diff --git a/.env.development b/.env.development
index 6213cf79ef..1afcf45d8c 100644
--- a/.env.development
+++ b/.env.development
@@ -1,5 +1,6 @@
ZETKIN_APP_DOMAIN=http://www.dev.zetkin.org
ZETKIN_GEN2_ORGANIZE_URL=http://organize.dev.zetkin.org
+ZETKIN_GEN2_CALL_URL=http://call.dev.zetkin.org
ZETKIN_PRIVACY_POLICY_LINK=https://zetkin.org/privacy
diff --git a/docs/architecture/ui-model-cache.puml b/docs/architecture/ui-model-cache.puml
deleted file mode 100644
index 0c810b56d7..0000000000
--- a/docs/architecture/ui-model-cache.puml
+++ /dev/null
@@ -1,51 +0,0 @@
-@startuml ui-model-repo-store
-
-title "UI, models and cache"
-
-frame ui as "UI layer" {
- component SmartComponent
- component Child
-}
-
-frame models as "Model layer" {
- node Model
-}
-
-frame cache as "Cache layer" {
- node Repository
- database Store as "Redux store"
-}
-
-cloud API
-
-SmartComponent -> Child: pass model
-
-SmartComponent --> Model: creates
-Child --> Model: uses
-
-Model --> Repository: requests or\nupdates data
-Repository -> Store: uses as cache\nand mutates
-Store ~~> SmartComponent: triggers updates\nwhen mutated
-
-Repository --> API: requests (via ApiClient)
-
-note right of SmartComponent
-Some components create models using
-useModel(), and can then pass it to
-child components to use.
-endnote
-
-note right of Model
-Models contain most logic that isn't
-purely UI. They get their data via
-the cache layer.
-endnote
-
-note bottom of cache
-When data is requested from a
-repository, it will either get
-a cached version from the store,
-or retrieve it from the API.
-endnote
-
-@enduml
\ No newline at end of file
diff --git a/next.config.js b/next.config.js
index 9946118a2d..f39d094ad9 100644
--- a/next.config.js
+++ b/next.config.js
@@ -13,6 +13,11 @@ module.exports = {
},
async redirects() {
return [
+ {
+ source: '/my',
+ destination: '/my/home',
+ permanent: false,
+ },
{
source: '/:prevPath*/calendar/events',
destination: '/:prevPath*/calendar',
diff --git a/src/app/call/[callAssId]/page.tsx b/src/app/call/[callAssId]/page.tsx
new file mode 100644
index 0000000000..50e54100e7
--- /dev/null
+++ b/src/app/call/[callAssId]/page.tsx
@@ -0,0 +1,13 @@
+import { redirect } from 'next/navigation';
+
+interface PageProps {
+ params: {
+ callAssId: string;
+ };
+}
+
+export default async function Page({ params }: PageProps) {
+ const callUrl = process.env.ZETKIN_GEN2_CALL_URL;
+ const assignmentUrl = callUrl + '/assignments/' + params.callAssId;
+ redirect(assignmentUrl);
+}
diff --git a/src/app/my/canvassassignments/[canvassAssId]/page.tsx b/src/app/canvass/[canvassAssId]/page.tsx
similarity index 91%
rename from src/app/my/canvassassignments/[canvassAssId]/page.tsx
rename to src/app/canvass/[canvassAssId]/page.tsx
index fd2a1015e2..73a0780927 100644
--- a/src/app/my/canvassassignments/[canvassAssId]/page.tsx
+++ b/src/app/canvass/[canvassAssId]/page.tsx
@@ -24,6 +24,6 @@ export default async function Page({ params }: PageProps) {
return ;
} catch (err) {
- return redirect(`/login?redirect=/my/canvassassignments/${canvassAssId}`);
+ return redirect(`/login?redirect=/canvass/${canvassAssId}`);
}
}
diff --git a/src/app/my/canvassassignments/page.tsx b/src/app/my/canvassassignments/page.tsx
deleted file mode 100644
index 1d5def28cd..0000000000
--- a/src/app/my/canvassassignments/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { headers } from 'next/headers';
-import { redirect } from 'next/navigation';
-
-import BackendApiClient from 'core/api/client/BackendApiClient';
-import MyCanvassAssignmentsPage from 'features/canvassAssignments/components/MyCanvassAssignmentsPage';
-import { ZetkinOrganization } from 'utils/types/zetkin';
-
-export default async function Page() {
- const headersList = headers();
- const headersEntries = headersList.entries();
- const headersObject = Object.fromEntries(headersEntries);
- const apiClient = new BackendApiClient(headersObject);
-
- try {
- await apiClient.get(`/api/users/me`);
-
- return ;
- } catch (err) {
- return redirect(`/login?redirect=/my/canvassassignments`);
- }
-}
diff --git a/src/app/my/feed/page.tsx b/src/app/my/feed/page.tsx
new file mode 100644
index 0000000000..fe1a564302
--- /dev/null
+++ b/src/app/my/feed/page.tsx
@@ -0,0 +1,8 @@
+import redirectIfLoginNeeded from 'core/utils/redirectIfLoginNeeded';
+import AllEventsPage from 'features/home/pages/AllEventsPage';
+
+export default async function Page() {
+ await redirectIfLoginNeeded();
+
+ return ;
+}
diff --git a/src/app/my/home/page.tsx b/src/app/my/home/page.tsx
new file mode 100644
index 0000000000..d72deee325
--- /dev/null
+++ b/src/app/my/home/page.tsx
@@ -0,0 +1,8 @@
+import redirectIfLoginNeeded from 'core/utils/redirectIfLoginNeeded';
+import HomePage from 'features/home/pages/HomePage';
+
+export default async function Page() {
+ await redirectIfLoginNeeded();
+
+ return ;
+}
diff --git a/src/app/my/layout.tsx b/src/app/my/layout.tsx
new file mode 100644
index 0000000000..1047421be0
--- /dev/null
+++ b/src/app/my/layout.tsx
@@ -0,0 +1,35 @@
+import { FC, ReactNode } from 'react';
+import { Metadata } from 'next';
+import { headers } from 'next/headers';
+
+import HomeLayout from 'features/home/layouts/HomeLayout';
+import HomeThemeProvider from 'features/home/components/HomeThemeProvider';
+import { getBrowserLanguage } from 'utils/locale';
+import getServerMessages from 'core/i18n/server';
+import messageIds from 'features/home/l10n/messageIds';
+
+export async function generateMetadata(): Promise {
+ const lang = getBrowserLanguage(headers().get('accept-language') || '');
+ const messages = await getServerMessages(lang, messageIds);
+
+ return {
+ icons: [{ url: '/logo-zetkin.png' }],
+ title: process.env.HOME_TITLE || messages.title(),
+ };
+}
+
+type Props = {
+ children: ReactNode;
+};
+
+const MyHomeLayout: FC = ({ children }) => {
+ const homeTitle = process.env.HOME_TITLE;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default MyHomeLayout;
diff --git a/src/core/Providers.tsx b/src/core/Providers.tsx
index bd06124271..7a90b094d2 100644
--- a/src/core/Providers.tsx
+++ b/src/core/Providers.tsx
@@ -15,10 +15,10 @@ import { EventPopperProvider } from 'features/events/components/EventPopper/Even
import { MessageList } from 'utils/locale';
import { Store } from './store';
import { themeWithLocale } from '../theme';
-import { UserContext } from 'utils/hooks/useFocusDate';
import { ZetkinUser } from 'utils/types/zetkin';
import { ZUIConfirmDialogProvider } from 'zui/ZUIConfirmDialogProvider';
import { ZUISnackbarProvider } from 'zui/ZUISnackbarContext';
+import { UserProvider } from './env/UserContext';
type ProviderData = {
env: Environment;
@@ -63,7 +63,7 @@ const Providers: FC = ({
return (
-
+
@@ -87,7 +87,7 @@ const Providers: FC = ({
-
+
);
diff --git a/src/core/env/ClientContext.tsx b/src/core/env/ClientContext.tsx
index 8b765ea36f..998310ad83 100644
--- a/src/core/env/ClientContext.tsx
+++ b/src/core/env/ClientContext.tsx
@@ -11,6 +11,8 @@ import {
Theme,
ThemeProvider,
} from '@mui/material/styles';
+import { LicenseInfo, LocalizationProvider } from '@mui/x-date-pickers-pro';
+import { AdapterDayjs } from '@mui/x-date-pickers-pro/AdapterDayjs';
import BrowserApiClient from 'core/api/client/BrowserApiClient';
import Environment from 'core/env/Environment';
@@ -56,6 +58,11 @@ const ClientContext: FC = ({
const env = new Environment(apiClient, envVars);
const cache = createCache({ key: 'css', prepend: true });
+ // MUI-X license
+ if (env.vars.MUIX_LICENSE_KEY) {
+ LicenseInfo.setLicenseKey(env.vars.MUIX_LICENSE_KEY);
+ }
+
return (
@@ -63,14 +70,16 @@ const ClientContext: FC = ({
-
-
- {children}
-
+
+
+
+ {children}
+
+
diff --git a/src/core/env/Environment.ts b/src/core/env/Environment.ts
index 77ddcb6a4b..c3bead6b96 100644
--- a/src/core/env/Environment.ts
+++ b/src/core/env/Environment.ts
@@ -5,6 +5,7 @@ type EnvVars = {
MUIX_LICENSE_KEY: string | null;
ZETKIN_APP_DOMAIN: string | null;
ZETKIN_GEN2_ORGANIZE_URL?: string | null;
+ ZETKIN_PRIVACY_POLICY_LINK?: string | null;
};
export default class Environment {
@@ -22,6 +23,7 @@ export default class Environment {
MUIX_LICENSE_KEY: null,
ZETKIN_APP_DOMAIN: null,
ZETKIN_GEN2_ORGANIZE_URL: null,
+ ZETKIN_PRIVACY_POLICY_LINK: null,
};
}
diff --git a/src/core/hooks/useRemoteList.spec.tsx b/src/core/hooks/useRemoteList.spec.tsx
new file mode 100644
index 0000000000..1ea1f8e601
--- /dev/null
+++ b/src/core/hooks/useRemoteList.spec.tsx
@@ -0,0 +1,165 @@
+import { describe, expect, it, jest } from '@jest/globals';
+import { act, render } from '@testing-library/react';
+import { FC, Suspense } from 'react';
+import { Provider as ReduxProvider, useSelector } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+
+import useRemoteList from './useRemoteList';
+import { RemoteList, remoteList } from 'utils/storeUtils';
+
+type ListObjectForTest = { id: number; name: string };
+type StoreState = {
+ list: RemoteList;
+};
+
+describe('useRemoteList()', () => {
+ it('triggers a load when the data has not yet been loaded', async () => {
+ const { hooks, promise, render, store } = setupWrapperComponent();
+
+ const { queryByText } = render();
+
+ expect(queryByText('loading')).not.toBeNull();
+
+ await act(async () => {
+ await promise;
+ });
+
+ expect(hooks.loader).toHaveBeenCalled();
+ expect(store.dispatch).toHaveBeenCalledTimes(2);
+ expect(store.dispatch).toHaveBeenNthCalledWith(1, {
+ payload: undefined,
+ type: 'load',
+ });
+ expect(store.dispatch).toHaveBeenNthCalledWith(2, {
+ payload: [{ id: 1, name: 'Clara Zetkin' }],
+ type: 'loaded',
+ });
+
+ expect(queryByText('loading')).toBeNull();
+ expect(queryByText('loaded')).not.toBeNull();
+ });
+
+ it('returns data without load when the data has been loaded recently', async () => {
+ const { hooks, render, store } = setupWrapperComponent({
+ ...remoteList([
+ {
+ id: 1,
+ name: 'Clara Zetkin',
+ },
+ ]),
+ loaded: new Date().toISOString(),
+ });
+
+ const { queryByText } = render();
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ expect(hooks.loader).not.toHaveBeenCalled();
+ expect(queryByText('loading')).toBeNull();
+ expect(queryByText('loaded')).not.toBeNull();
+
+ const listItem = queryByText('Clara Zetkin');
+ expect(listItem?.tagName).toBe('LI');
+ });
+
+ it('returns stale data while re-loading', async () => {
+ const { hooks, promise, render, store } = setupWrapperComponent({
+ ...remoteList([
+ {
+ id: 1,
+ name: 'Clara Zetkin',
+ },
+ ]),
+ loaded: new Date(1857, 6, 5).toISOString(),
+ });
+
+ const { queryByText } = render();
+
+ expect(queryByText('Clara Zetkin')).not.toBeNull();
+
+ await act(async () => {
+ await promise;
+ });
+
+ expect(hooks.loader).toHaveBeenCalled();
+ expect(store.dispatch).toHaveBeenCalledTimes(2);
+ });
+});
+
+function setupWrapperComponent(initialList?: RemoteList) {
+ const store = configureStore({
+ preloadedState: {
+ list: initialList || remoteList(),
+ },
+ reducer: (state, action) => {
+ if (action.type == 'load') {
+ return {
+ list: {
+ ...remoteList(),
+ isLoading: true,
+ },
+ };
+ } else if (action.type == 'loaded') {
+ return {
+ list: {
+ ...remoteList(),
+ isLoading: false,
+ loaded: new Date().toISOString(),
+ },
+ };
+ }
+
+ return (
+ state || {
+ list: remoteList(),
+ }
+ );
+ },
+ });
+ jest.spyOn(store, 'dispatch');
+
+ const promise = Promise.resolve([{ id: 1, name: 'Clara Zetkin' }]);
+
+ const hooks = {
+ actionOnLoad: () => ({ payload: undefined, type: 'load' }),
+ actionOnSuccess: (data: ListObjectForTest[]) => ({
+ payload: data,
+ type: 'loaded',
+ }),
+ loader: () => promise,
+ };
+
+ jest.spyOn(hooks, 'loader');
+
+ const Component: FC = () => {
+ const list = useSelector>(
+ (state) => state.list
+ );
+
+ const items = useRemoteList(list, hooks);
+
+ return (
+
+
loaded
+
+ {items.map((item) => (
+ - {item.name}
+ ))}
+
+
+ );
+ };
+
+ return {
+ hooks,
+ promise,
+ render: () =>
+ render(
+
+ loading
}>
+
+
+
+ ),
+ store,
+ };
+}
diff --git a/src/core/hooks/useRemoteList.ts b/src/core/hooks/useRemoteList.ts
new file mode 100644
index 0000000000..5918ede288
--- /dev/null
+++ b/src/core/hooks/useRemoteList.ts
@@ -0,0 +1,52 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+
+import shouldLoad from 'core/caching/shouldLoad';
+import { RemoteList } from 'utils/storeUtils';
+import { useAppDispatch } from '.';
+
+export default function useRemoteList<
+ DataType,
+ OnLoadPayload = void,
+ OnSuccessPayload = DataType[]
+>(
+ remoteList: RemoteList | undefined,
+ hooks: {
+ actionOnError?: (err: unknown) => PayloadAction;
+ actionOnLoad: () => PayloadAction;
+ actionOnSuccess: (items: DataType[]) => PayloadAction;
+ isNecessary?: () => boolean;
+ loader: () => Promise;
+ }
+): DataType[] {
+ const dispatch = useAppDispatch();
+ const loadIsNecessary = hooks.isNecessary?.() ?? shouldLoad(remoteList);
+
+ if (!remoteList || loadIsNecessary) {
+ const promise = Promise.resolve()
+ .then(() => {
+ dispatch(hooks.actionOnLoad());
+ })
+ .then(() => hooks.loader())
+ .then((val) => {
+ dispatch(hooks.actionOnSuccess(val));
+ return val;
+ })
+ .catch((err: unknown) => {
+ if (hooks.actionOnError) {
+ dispatch(hooks.actionOnError(err));
+ return null;
+ } else {
+ throw err;
+ }
+ });
+
+ if (!remoteList?.items.length) {
+ throw promise;
+ }
+ }
+
+ return remoteList.items
+ .filter((item) => !item.deleted)
+ .map((item) => item.data)
+ .filter((data) => !!data) as DataType[];
+}
diff --git a/src/core/hooks/useUser.ts b/src/core/hooks/useUser.ts
new file mode 100644
index 0000000000..fd72bb7891
--- /dev/null
+++ b/src/core/hooks/useUser.ts
@@ -0,0 +1,7 @@
+import { useContext } from 'react';
+
+import { UserContext } from 'core/env/UserContext';
+
+export default function useUser() {
+ return useContext(UserContext);
+}
diff --git a/src/core/rpc/index.ts b/src/core/rpc/index.ts
index 7f9e03e57f..23daa7a1d1 100644
--- a/src/core/rpc/index.ts
+++ b/src/core/rpc/index.ts
@@ -21,10 +21,12 @@ import { renderEmailDef } from 'features/emails/rpc/renderEmail/server';
import { createCallAssignmentDef } from 'features/callAssignments/rpc/createCallAssignment';
import { getJoinFormEmbedDataDef } from 'features/joinForms/rpc/getJoinFormEmbedData';
import { createHouseholdsDef } from 'features/canvassAssignments/rpc/createHouseholds/server';
+import { getAllEventsDef } from 'features/events/rpc/getAllEvents';
export function createRPCRouter() {
const rpcRouter = new RPCRouter();
+ rpcRouter.register(getAllEventsDef);
rpcRouter.register(deleteFolderRouteDef);
rpcRouter.register(createNewViewRouteDef);
rpcRouter.register(copyViewRouteDef);
diff --git a/src/core/utils/redirectIfLoginNeeded.ts b/src/core/utils/redirectIfLoginNeeded.ts
new file mode 100644
index 0000000000..e12983c696
--- /dev/null
+++ b/src/core/utils/redirectIfLoginNeeded.ts
@@ -0,0 +1,30 @@
+import { headers } from 'next/headers';
+import { redirect } from 'next/navigation';
+
+import BackendApiClient from 'core/api/client/BackendApiClient';
+import { ZetkinSession } from 'utils/types/zetkin';
+
+export default async function redirectIfLoginNeeded(
+ requiredAuthLevel: number = 1
+) {
+ const headersList = headers();
+ const headersEntries = headersList.entries();
+ const headersObject = Object.fromEntries(headersEntries);
+ const apiClient = new BackendApiClient(headersObject);
+
+ let shouldRedirectToLogin = false;
+
+ try {
+ const session = await apiClient.get('/api/session');
+ if (session.level < requiredAuthLevel) {
+ shouldRedirectToLogin = true;
+ }
+ } catch (err) {
+ shouldRedirectToLogin = true;
+ }
+
+ if (shouldRedirectToLogin) {
+ const path = headersList.get('x-requested-path');
+ redirect(`/login?redirect=${path}`);
+ }
+}
diff --git a/src/features/callAssignments/hooks/useMyCallAssignments.ts b/src/features/callAssignments/hooks/useMyCallAssignments.ts
new file mode 100644
index 0000000000..c8530690d0
--- /dev/null
+++ b/src/features/callAssignments/hooks/useMyCallAssignments.ts
@@ -0,0 +1,28 @@
+import { useApiClient, useAppSelector } from 'core/hooks';
+import useRemoteList from 'core/hooks/useRemoteList';
+import { userAssignmentsLoad, userAssignmentsLoaded } from '../store';
+import { ZetkinCallAssignment } from 'utils/types/zetkin';
+
+export default function useMyCallAssignments() {
+ const apiClient = useApiClient();
+ const list = useAppSelector(
+ (state) => state.callAssignments.userAssignmentList
+ );
+
+ const assignments = useRemoteList(list, {
+ actionOnLoad: () => userAssignmentsLoad(),
+ actionOnSuccess: (data) => userAssignmentsLoaded(data),
+ loader: () =>
+ apiClient.get(`/api/users/me/call_assignments`),
+ });
+
+ const now = new Date();
+ const today = now.toISOString().slice(0, 10);
+
+ return assignments.filter(
+ ({ end_date, start_date }) =>
+ start_date &&
+ start_date <= today &&
+ (end_date == null || end_date >= today)
+ );
+}
diff --git a/src/features/callAssignments/l10n/messageIds.ts b/src/features/callAssignments/l10n/messageIds.ts
index f0153c8bb0..0910ec4818 100644
--- a/src/features/callAssignments/l10n/messageIds.ts
+++ b/src/features/callAssignments/l10n/messageIds.ts
@@ -32,7 +32,7 @@ export default makeMessages('feat.callAssignments', {
},
add: {
alreadyAdded: m('Already added'),
- placeholder: m('Add caller'),
+ placeholder: m('Start typing to search or add a new caller'),
},
customize: {
exclude: {
diff --git a/src/features/callAssignments/store.ts b/src/features/callAssignments/store.ts
index 62ec020c00..1cc6870dfc 100644
--- a/src/features/callAssignments/store.ts
+++ b/src/features/callAssignments/store.ts
@@ -22,6 +22,7 @@ export interface CallAssignmentSlice {
callersById: Record>;
callList: RemoteList;
statsById: Record>;
+ userAssignmentList: RemoteList;
}
const initialState: CallAssignmentSlice = {
@@ -30,6 +31,7 @@ const initialState: CallAssignmentSlice = {
callList: remoteList(),
callersById: {},
statsById: {},
+ userAssignmentList: remoteList(),
};
const callAssignmentsSlice = createSlice({
@@ -291,6 +293,16 @@ const callAssignmentsSlice = createSlice({
}
);
},
+ userAssignmentsLoad: (state) => {
+ state.userAssignmentList.isLoading = true;
+ },
+ userAssignmentsLoaded: (
+ state,
+ action: PayloadAction
+ ) => {
+ state.userAssignmentList = remoteList(action.payload);
+ state.userAssignmentList.loaded = new Date().toISOString();
+ },
},
});
@@ -319,4 +331,6 @@ export const {
campaignCallAssignmentsLoaded,
statsLoad,
statsLoaded,
+ userAssignmentsLoad,
+ userAssignmentsLoaded,
} = callAssignmentsSlice.actions;
diff --git a/src/features/campaigns/hooks/useClusteredActivities.ts b/src/features/campaigns/hooks/useClusteredActivities.ts
index 41225de452..2f29c87a49 100644
--- a/src/features/campaigns/hooks/useClusteredActivities.ts
+++ b/src/features/campaigns/hooks/useClusteredActivities.ts
@@ -10,6 +10,7 @@ import {
SurveyActivity,
TaskActivity,
} from '../types';
+import sortEventsByStartTime from 'features/events/utils/sortEventsByStartTime';
export enum CLUSTER_TYPE {
MULTI_LOCATION = 'multilocation',
@@ -51,22 +52,7 @@ export function clusterEvents(
): ClusteredEvent[] {
const sortedEvents = eventActivities
.map((activity) => activity.data)
- .sort((a, b) => {
- const aStart = new Date(a.start_time);
- const bStart = new Date(b.start_time);
- const diffStart = aStart.getTime() - bStart.getTime();
-
- // Primarily sort by start time
- if (diffStart != 0) {
- return diffStart;
- }
-
- // When start times are identical, sort by end time
- const aEnd = new Date(a.end_time);
- const bEnd = new Date(b.end_time);
-
- return aEnd.getTime() - bEnd.getTime();
- });
+ .sort(sortEventsByStartTime);
let allClusters: ClusteredEvent[] = [];
let pendingClusters: ClusteredEvent[] = [];
diff --git a/src/features/canvassAssignments/components/CanvassAssignmentMap.tsx b/src/features/canvassAssignments/components/CanvassAssignmentMap.tsx
index 2b9be4158f..403363ffbb 100644
--- a/src/features/canvassAssignments/components/CanvassAssignmentMap.tsx
+++ b/src/features/canvassAssignments/components/CanvassAssignmentMap.tsx
@@ -6,6 +6,7 @@ import {
latLngBounds,
LatLngBounds,
LatLngTuple,
+ LeafletMouseEvent,
} from 'leaflet';
import { makeStyles } from '@mui/styles';
import { GpsNotFixed } from '@mui/icons-material';
@@ -79,18 +80,20 @@ const CanvassAssignmentMap: FC = ({
assignment.organization.id,
assignment.id
);
+ const [localStorageBounds, setLocalStorageBounds] = useLocalStorage<
+ [LatLngTuple, LatLngTuple] | null
+ >(`mapBounds-${assignment.id}`, null);
+ const [map, setMap] = useState