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(null); + const [zoomed, setZoomed] = useState(false); const [selectedPlaceId, setSelectedPlaceId] = useState(null); const [isCreating, setIsCreating] = useState(false); const [created, setCreated] = useState(false); - const [map, setMap] = useState(null); const crosshairRef = useRef(null); const reactFGref = useRef(null); - const [localStorageBounds, setLocalStorageBounds] = useLocalStorage< - [LatLngTuple, LatLngTuple] | null - >(`mapBounds-${assignment.id}`, null); + const selectedPlace = places.find((place) => place.id == selectedPlaceId); const saveBounds = () => { const bounds = map?.getBounds(); @@ -103,10 +106,6 @@ const CanvassAssignmentMap: FC = ({ } }; - const [zoomed, setZoomed] = useState(false); - - const selectedPlace = places.find((place) => place.id == selectedPlaceId); - const updateSelection = useCallback(() => { let nearestPlace: string | null = null; let nearestDistance = Infinity; @@ -180,22 +179,22 @@ const CanvassAssignmentMap: FC = ({ useEffect(() => { if (map) { - map.on('click', (evt) => { + const handlePan = (evt: LeafletMouseEvent) => { panTo(evt.latlng); - }); + }; + map.on('click', handlePan); - map.on('move', () => { - updateSelection(); - }); + map.on('move', updateSelection); map.on('moveend', saveBounds); - map.on('zoomend', () => saveBounds); + map.on('zoomend', saveBounds); return () => { - map.off('move'); - map.off('moveend'); - map.off('movestart'); + map.off('click', handlePan); + map.off('move', updateSelection); + map.off('moveend', saveBounds); + map.off('zoomend', saveBounds); }; } }, [map, selectedPlaceId, places, panTo, updateSelection]); diff --git a/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx b/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx index b2336b9d34..cfda27e84b 100644 --- a/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx +++ b/src/features/canvassAssignments/components/CanvasserSidebar/index.tsx @@ -108,7 +108,7 @@ const CanvasserSidebar: FC = ({ assignment }) => { - + diff --git a/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx b/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx index 7ffebe87a0..b0514c13b1 100644 --- a/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx +++ b/src/features/canvassAssignments/components/MyCanvassAssignmentPage.tsx @@ -125,7 +125,7 @@ type MyCanvassAssignmentPageProps = { const MyCanvassAssignmentPage: FC = ({ canvassAssId, }) => { - const myAssignments = useMyCanvassAssignments().data || []; + const myAssignments = useMyCanvassAssignments(); const assignment = myAssignments.find( (assignment) => assignment.id == canvassAssId ); diff --git a/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx b/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx deleted file mode 100644 index a8748f652f..0000000000 --- a/src/features/canvassAssignments/components/MyCanvassAssignmentsPage.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'use client'; - -import { FC } from 'react'; -import { useRouter } from 'next/navigation'; -import { - Box, - Button, - Card, - CardActions, - CardContent, - Typography, -} from '@mui/material'; - -import useMyCanvassAssignments from '../hooks/useMyCanvassAssignments'; -import useOrganization from 'features/organizations/hooks/useOrganization'; -import ZUIFutures from 'zui/ZUIFutures'; -import { ZetkinCanvassAssignment } from '../types'; - -const CanvassAssignmentCard: FC<{ - assignment: ZetkinCanvassAssignment; - orgId: number; -}> = ({ orgId, assignment }) => { - const router = useRouter(); - const organizationFuture = useOrganization(orgId); - - return ( - - {({ data: { organization } }) => ( - - - - {assignment.title || 'Untitled assignment'} - - {organization.title} - - - - - - )} - - ); -}; - -const MyCanvassAssignmentsPage: FC = () => { - const myAssignments = useMyCanvassAssignments().data || []; - - return ( - - Canvassing - {myAssignments.map((assignment) => { - return ( - - ); - })} - - ); -}; - -export default MyCanvassAssignmentsPage; diff --git a/src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts b/src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts index ee55642426..a046ed90cd 100644 --- a/src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts +++ b/src/features/canvassAssignments/hooks/useMyCanvassAssignments.ts @@ -1,19 +1,28 @@ -import { loadListIfNecessary } from 'core/caching/cacheUtils'; -import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { useApiClient, useAppSelector } from 'core/hooks'; import { myAssignmentsLoad, myAssignmentsLoaded } from '../store'; import { AssignmentWithAreas } from '../types'; +import useRemoteList from 'core/hooks/useRemoteList'; export default function useMyCanvassAssignments() { const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - const mySessions = useAppSelector( + const list = useAppSelector( (state) => state.canvassAssignments.myAssignmentsWithAreasList ); - return loadListIfNecessary(mySessions, dispatch, { + const assignments = useRemoteList(list, { actionOnLoad: () => myAssignmentsLoad(), actionOnSuccess: (data) => myAssignmentsLoaded(data), loader: () => apiClient.get('/beta/users/me/canvassassignments'), }); + + 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/emails/hooks/useSendTestEmail.ts b/src/features/emails/hooks/useSendTestEmail.ts index 5c7a43b2ed..d8aa741238 100644 --- a/src/features/emails/hooks/useSendTestEmail.ts +++ b/src/features/emails/hooks/useSendTestEmail.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { useUser } from 'utils/hooks/useFocusDate'; +import useUser from 'core/hooks/useUser'; import { useApiClient, useNumericRouteParams } from 'core/hooks'; export default function useSendTestEmail() { diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index 40db1e3137..8efd2c6b60 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -22,7 +22,7 @@ export default makeMessages('feat.emails', { 'Your email has no targets. Go to the Targets section in the Overview tab to create a Smart Search that defines your targets.' ), targetsNotLocked: m( - 'The targets are not locked. Go to the Ready section in the Overview tab to do this.' + 'The targets are not locked. Go to the Targets section in the Overview tab to do this.' ), }, deliveryStatus: { diff --git a/src/features/events/components/EventTypeAutocomplete.tsx b/src/features/events/components/EventTypeAutocomplete.tsx index b2dd749f66..b891594058 100644 --- a/src/features/events/components/EventTypeAutocomplete.tsx +++ b/src/features/events/components/EventTypeAutocomplete.tsx @@ -10,6 +10,7 @@ import theme from 'theme'; import useCreateType from '../hooks/useCreateType'; import { useMessages } from 'core/i18n'; import useDeleteType from '../hooks/useDeleteType'; +import useOrganization from 'features/organizations/hooks/useOrganization'; import { ZetkinActivity, ZetkinEvent } from 'utils/types/zetkin'; import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; @@ -79,6 +80,7 @@ const EventTypeAutocomplete: FC = ({ const createType = useCreateType(orgId); const deleteType = useDeleteType(orgId); const messages = useMessages(messageIds); + const organization = useOrganization(orgId).data; const uncategorizedMsg = messages.type.uncategorized(); const [createdType, setCreatedType] = useState(''); const [text, setText] = useState(value?.title ?? uncategorizedMsg); @@ -248,8 +250,9 @@ const EventTypeAutocomplete: FC = ({ } } }, - warningText: messages.type.deleteMessage({ + warningText: messages.type.deleteWarning({ eventType: option.title, + orgTitle: organization?.title || '', }), }); }} diff --git a/src/features/events/hooks/useAllEvents.ts b/src/features/events/hooks/useAllEvents.ts new file mode 100644 index 0000000000..5287e4a562 --- /dev/null +++ b/src/features/events/hooks/useAllEvents.ts @@ -0,0 +1,34 @@ +import { useApiClient, useAppSelector } from 'core/hooks'; +import useRemoteList from 'core/hooks/useRemoteList'; +import { allEventsLoad, allEventsLoaded } from '../store'; +import getAllEvents from '../rpc/getAllEvents'; +import sortEventsByStartTime from '../utils/sortEventsByStartTime'; +import { ZetkinEventWithStatus } from 'features/home/types'; +import useMyEvents from './useMyEvents'; + +export default function useAllEvents() { + const apiClient = useApiClient(); + const allEventsList = useAppSelector((state) => state.events.allEventsList); + + const myEvents = useMyEvents(); + + const events = useRemoteList(allEventsList, { + actionOnLoad: () => allEventsLoad(), + actionOnSuccess: (data) => { + const sorted = data.sort(sortEventsByStartTime); + return allEventsLoaded(sorted); + }, + loader: () => apiClient.rpc(getAllEvents, {}), + }); + + return events.map((event) => { + const myEvent = myEvents.find((candidate) => candidate.id == event.id); + + return ( + myEvent || { + ...event, + status: null, + } + ); + }); +} diff --git a/src/features/events/hooks/useMyEvents.ts b/src/features/events/hooks/useMyEvents.ts new file mode 100644 index 0000000000..7ef2c522fd --- /dev/null +++ b/src/features/events/hooks/useMyEvents.ts @@ -0,0 +1,47 @@ +import { useApiClient, useAppSelector } from 'core/hooks'; +import useRemoteList from 'core/hooks/useRemoteList'; +import { userEventsLoad, userEventsLoaded } from '../store'; +import { ZetkinEvent } from 'utils/types/zetkin'; +import { ZetkinEventWithStatus } from 'features/home/types'; + +export default function useMyEvents() { + const apiClient = useApiClient(); + const list = useAppSelector((state) => state.events.userEventList); + + const now = new Date(); + const today = now.toISOString().slice(0, 10); + + return useRemoteList(list, { + actionOnLoad: () => userEventsLoad(), + actionOnSuccess: (data) => userEventsLoaded(data), + loader: async () => { + const bookedEventIds: number[] = []; + + const bookedEvents = await apiClient + .get(`/api/users/me/actions?filter=end_time>=${today}`) + .then((events) => + events.map((event) => { + bookedEventIds.push(event.id); + return { + ...event, + status: 'booked', + }; + }) + ); + + const signedUpEvents = await apiClient + .get<{ action: ZetkinEvent }[]>(`/api/users/me/action_responses`) + .then((events) => + events + .map((signup) => ({ + ...signup.action, + status: 'signedUp', + })) + .filter((event) => !bookedEventIds.includes(event.id)) + .filter(({ end_time }) => end_time >= today) + ); + + return [...bookedEvents, ...signedUpEvents]; + }, + }); +} diff --git a/src/features/events/l10n/messageIds.ts b/src/features/events/l10n/messageIds.ts index b04cf6fa7b..e0c5992d29 100644 --- a/src/features/events/l10n/messageIds.ts +++ b/src/features/events/l10n/messageIds.ts @@ -285,6 +285,9 @@ export default makeMessages('feat.events', { deleteMessage: m<{ eventType: string }>( 'Are you sure you want to delete the "{eventType}" event type for the whole organization?' ), + deleteWarning: m<{ eventType: string; orgTitle: string }>( + 'Are you sure you want to delete the "{eventType}" event type for all of {orgTitle}?' + ), tooltip: m('Click to change type'), uncategorized: m('Uncategorized'), }, diff --git a/src/features/events/rpc/getAllEvents.ts b/src/features/events/rpc/getAllEvents.ts new file mode 100644 index 0000000000..52b539a27d --- /dev/null +++ b/src/features/events/rpc/getAllEvents.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +import { makeRPCDef } from 'core/rpc/types'; +import { ZetkinEvent, ZetkinMembership } from 'utils/types/zetkin'; +import IApiClient from 'core/api/client/IApiClient'; +import getEventState from '../utils/getEventState'; +import { EventState } from '../hooks/useEventState'; + +const paramsSchema = z.object({}); + +type Params = z.input; +type Result = ZetkinEvent[]; + +export const getAllEventsDef = { + handler: handle, + name: 'getAllEvents', + schema: paramsSchema, +}; + +export default makeRPCDef(getAllEventsDef.name); + +async function handle(params: Params, apiClient: IApiClient): Promise { + const memberships = await apiClient.get( + '/api/users/me/memberships' + ); + + const now = new Date().toISOString(); + + const events: ZetkinEvent[] = []; + for (const membership of memberships) { + const eventsOfOrg = await apiClient.get( + `/api/orgs/${membership.organization.id}/actions?filter=start_time%3E=${now}` + ); + events.push(...eventsOfOrg); + } + + return events.filter((event) => { + const state = getEventState(event); + return state == EventState.OPEN || state == EventState.SCHEDULED; + }); +} diff --git a/src/features/events/store.ts b/src/features/events/store.ts index 7aec780b5d..faf53d73dc 100644 --- a/src/features/events/store.ts +++ b/src/features/events/store.ts @@ -15,6 +15,7 @@ import { ZetkinEventTypePostBody, ZetkinLocation, } from 'utils/types/zetkin'; +import { ZetkinEventWithStatus } from 'features/home/types'; export enum ACTION_FILTER_OPTIONS { CONTACT_MISSING = 'missing', @@ -50,6 +51,7 @@ export type FilterCategoryType = | 'selectedTypes'; export interface EventsStoreSlice { + allEventsList: RemoteList; eventList: RemoteList; eventsByCampaignId: Record>; eventsByDate: Record>; @@ -67,9 +69,11 @@ export interface EventsStoreSlice { selectedEventIds: number[]; statsByEventId: Record>; typeList: RemoteList; + userEventList: RemoteList; } const initialState: EventsStoreSlice = { + allEventsList: remoteList(), eventList: remoteList(), eventsByCampaignId: {}, eventsByDate: {}, @@ -87,12 +91,20 @@ const initialState: EventsStoreSlice = { selectedEventIds: [], statsByEventId: {}, typeList: remoteList(), + userEventList: remoteList(), }; const eventsSlice = createSlice({ initialState, name: 'events', reducers: { + allEventsLoad: (state) => { + state.allEventsList.isLoading = true; + }, + allEventsLoaded: (state, action: PayloadAction) => { + state.allEventsList = remoteList(action.payload); + state.allEventsList.loaded = new Date().toISOString(); + }, campaignEventsLoad: (state, action: PayloadAction) => { const id = action.payload; state.eventsByCampaignId[id] = remoteList(); @@ -583,6 +595,28 @@ const eventsSlice = createSlice({ state.typeList = remoteList(eventTypes); state.typeList.loaded = new Date().toISOString(); }, + userEventsLoad: (state) => { + state.userEventList.isLoading = true; + }, + userEventsLoaded: ( + state, + action: PayloadAction + ) => { + state.userEventList = remoteList(action.payload); + state.userEventList.loaded = new Date().toISOString(); + }, + userResponseAdded: (state, action: PayloadAction) => { + const event = action.payload; + state.userEventList.items.push( + remoteItem(event.id, { data: { ...event, status: 'signedUp' } }) + ); + }, + userResponseDeleted: (state, action: PayloadAction) => { + const eventId = action.payload; + state.userEventList.items = state.userEventList.items.filter( + (item) => item.id != eventId + ); + }, }, }); @@ -697,6 +731,8 @@ function updateAvailParticipantToState( export default eventsSlice; export const { + allEventsLoad, + allEventsLoaded, campaignEventsLoad, campaignEventsLoaded, eventCreate, @@ -747,4 +783,8 @@ export const { typeLoaded, typesLoad, typesLoaded, + userEventsLoad, + userEventsLoaded, + userResponseAdded, + userResponseDeleted, } = eventsSlice.actions; diff --git a/src/features/events/utils/sortEventsByStartTime.ts b/src/features/events/utils/sortEventsByStartTime.ts new file mode 100644 index 0000000000..97e39ea2fe --- /dev/null +++ b/src/features/events/utils/sortEventsByStartTime.ts @@ -0,0 +1,18 @@ +import { ZetkinEvent } from 'utils/types/zetkin'; + +export default function sortEventsByStartTime(a: ZetkinEvent, b: ZetkinEvent) { + 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(); +} diff --git a/src/features/home/components/AllEventsList.tsx b/src/features/home/components/AllEventsList.tsx new file mode 100644 index 0000000000..290deabd1b --- /dev/null +++ b/src/features/home/components/AllEventsList.tsx @@ -0,0 +1,410 @@ +import { FC, useState } from 'react'; +import { + Avatar, + Box, + Button, + Fade, + List, + ListItem, + ListItemAvatar, + ListItemText, + Switch, + Typography, +} from '@mui/material'; +import { CalendarMonthOutlined, Clear, Search } from '@mui/icons-material'; +import { + DateRange, + DateRangeCalendar, + DateRangePickerDay, +} from '@mui/x-date-pickers-pro'; +import dayjs, { Dayjs } from 'dayjs'; +import { FormattedDate, FormattedDateTimeRange } from 'react-intl'; + +import useAllEvents from 'features/events/hooks/useAllEvents'; +import EventListItem from './EventListItem'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; +import { ZetkinEventWithStatus } from '../types'; +import ZUIDate from 'zui/ZUIDate'; +import useIncrementalDelay from '../hooks/useIncrementalDelay'; +import FilterButton from './FilterButton'; +import DrawerModal from './DrawerModal'; +import { getContrastColor } from 'utils/colorUtils'; + +const DatesFilteredBy: FC<{ end: Dayjs | null; start: Dayjs }> = ({ + start, + end, +}) => { + if (!end) { + return ; + } else { + return ( + + ); + } +}; + +const AllEventsList: FC = () => { + const allEvents = useAllEvents(); + const nextDelay = useIncrementalDelay(); + + const [drawerContent, setDrawerContent] = useState< + 'orgs' | 'calendar' | null + >(null); + const [orgIdsToFilterBy, setOrgIdsToFilterBy] = useState([]); + const [customDatesToFilterBy, setCustomDatesToFilterBy] = useState< + DateRange + >([null, null]); + const [dateFilterState, setDateFilterState] = useState< + 'today' | 'tomorrow' | 'thisWeek' | 'custom' | null + >(null); + + const orgs = [ + ...new Map( + allEvents + .map((event) => event.organization) + .map((org) => [org['id'], org]) + ).values(), + ].sort((a, b) => a.title.localeCompare(b.title)); + + const getDateRange = (): [Dayjs | null, Dayjs | null] => { + const today = dayjs(); + if (!dateFilterState || dateFilterState == 'custom') { + return customDatesToFilterBy; + } else if (dateFilterState == 'today') { + return [today, null]; + } else if (dateFilterState == 'tomorrow') { + return [today.add(1, 'day'), null]; + } else { + //dateFilterState is 'thisWeek' + return [today.startOf('week'), today.endOf('week')]; + } + }; + + const filteredEvents = allEvents + .filter((event) => { + if (orgIdsToFilterBy.length == 0) { + return true; + } + return orgIdsToFilterBy.includes(event.organization.id); + }) + .filter((event) => { + if ( + !dateFilterState || + (dateFilterState == 'custom' && !customDatesToFilterBy[0]) + ) { + return true; + } + + const [start, end] = getDateRange(); + const eventStart = dayjs(event.start_time); + const eventEnd = dayjs(event.end_time); + + if (!end) { + const isOngoing = eventStart.isBefore(start) && eventEnd.isAfter(start); + const startsOnSelectedDay = eventStart.isSame(start, 'day'); + const endsOnSelectedDay = eventEnd.isSame(start, 'day'); + return isOngoing || startsOnSelectedDay || endsOnSelectedDay; + } else { + const isOngoing = + eventStart.isBefore(start, 'day') && eventEnd.isAfter(end, 'day'); + const startsInPeriod = + (eventStart.isSame(start, 'day') || + eventStart.isAfter(start, 'day')) && + (eventStart.isSame(end, 'day') || eventStart.isBefore(end, 'day')); + const endsInPeriod = + (eventEnd.isSame(start, 'day') || eventEnd.isAfter(start, 'day')) && + (eventEnd.isBefore(end, 'day') || eventEnd.isSame(end, 'day')); + return isOngoing || startsInPeriod || endsInPeriod; + } + }); + + const eventsByDate = filteredEvents.reduce< + Record + >((dates, event) => { + const eventDate = event.start_time.slice(0, 10); + const existingEvents = dates[eventDate] || []; + + const dateRange = getDateRange(); + const firstFilterDate = dateRange[0]?.format('YYYY-MM-DD'); + + const dateToSortAs = + firstFilterDate && eventDate < firstFilterDate + ? firstFilterDate + : eventDate; + + return { + ...dates, + [dateToSortAs]: [...existingEvents, event], + }; + }, {}); + + const dates = Object.keys(eventsByDate).sort(); + + const orgIdsWithEvents = allEvents.reduce((orgIds, event) => { + if (!orgIds.includes(event.organization.id)) { + orgIds = [...orgIds, event.organization.id]; + } + return orgIds; + }, []); + + const moreThanOneOrgHasEvents = orgIdsWithEvents.length > 1; + const isFiltered = orgIdsToFilterBy.length || !!dateFilterState; + + const filters = [ + { + active: dateFilterState == 'today', + key: 'today', + label: , + onClick: () => { + setCustomDatesToFilterBy([null, null]); + setDateFilterState('today'); + }, + }, + { + active: dateFilterState == 'tomorrow', + key: 'tomorrow', + label: , + onClick: () => { + setCustomDatesToFilterBy([null, null]); + setDateFilterState('tomorrow'); + }, + }, + { + active: dateFilterState == 'thisWeek', + key: 'thisWeek', + label: , + onClick: () => { + setCustomDatesToFilterBy([null, null]); + setDateFilterState('thisWeek'); + }, + }, + { + active: dateFilterState == 'custom', + key: 'custom', + label: + dateFilterState == 'custom' && customDatesToFilterBy[0] ? ( + + ) : ( + + ), + onClick: () => { + setDrawerContent('calendar'); + }, + }, + ...(moreThanOneOrgHasEvents + ? [ + { + active: !!orgIdsToFilterBy.length, + key: 'orgs', + label: ( + + ), + onClick: () => setDrawerContent('orgs'), + }, + ] + : []), + ].sort((a, b) => { + if (a.active && !b.active) { + return -1; + } else if (!a.active && b.active) { + return 1; + } else { + return 0; + } + }); + + return ( + + {allEvents.length != 0 && ( + + {isFiltered && ( + { + setDateFilterState(null); + setCustomDatesToFilterBy([null, null]); + setOrgIdsToFilterBy([]); + }} + round + > + + + )} + {filters.map((filter) => ( + + {filter.label} + + ))} + + )} + {filteredEvents.length == 0 && ( + + + + + + {isFiltered && ( + + )} + + )} + {dates.map((date) => ( + + +
+ + + +
+
+ + + {eventsByDate[date].map((event) => ( + + ))} + + +
+ ))} + setDrawerContent(null)} + open={drawerContent == 'calendar'} + > + + { + setDateFilterState('custom'); + setCustomDatesToFilterBy(newDateRange); + }} + slots={{ + day: (props) => { + const day = props.day; + + const hasEvents = !!allEvents.find((event) => { + const eventStart = dayjs(event.start_time); + const eventEnd = dayjs(event.end_time); + + const isOngoing = + eventStart.isBefore(day) && eventEnd.isAfter(day); + const startsOnSelectedDay = eventStart.isSame(day, 'day'); + const endsOnSelectedDay = eventEnd.isSame(day, 'day'); + return isOngoing || startsOnSelectedDay || endsOnSelectedDay; + }); + + const showHasEvents = hasEvents && !props.outsideCurrentMonth; + + return ( + ({ + '& .MuiDateRangePickerDay-day::before': showHasEvents + ? { + backgroundColor: props.selected + ? getContrastColor(theme.palette.primary.main) + : theme.palette.primary.main, + borderRadius: '1em', + bottom: 5, + content: '""', + height: '5px', + position: 'absolute', + width: '5px', + } + : '', + })} + /> + ); + }, + }} + value={getDateRange()} + /> + + + setDrawerContent(null)} + open={drawerContent == 'orgs'} + > + + {orgs.map((org) => ( + + + + + + {org.title} + + { + if (checked) { + setOrgIdsToFilterBy([...orgIdsToFilterBy, org.id]); + } else { + setOrgIdsToFilterBy( + orgIdsToFilterBy.filter((id) => id != org.id) + ); + } + }} + /> + + ))} + + +
+ ); +}; + +export default AllEventsList; diff --git a/src/features/home/components/DrawerModal.tsx b/src/features/home/components/DrawerModal.tsx new file mode 100644 index 0000000000..6580442d8b --- /dev/null +++ b/src/features/home/components/DrawerModal.tsx @@ -0,0 +1,87 @@ +import { KeyboardArrowDown } from '@mui/icons-material'; +import { Box } from '@mui/material'; +import { FC, ReactNode } from 'react'; + +import ZUIModalBackground from 'zui/ZUIModalBackground'; + +type Props = { + children: ReactNode; + onClose: () => void; + open?: boolean; +}; + +const DrawerModal: FC = ({ children, onClose, open }) => { + return ( + + onClose()} + sx={{ + height: '100%', + opacity: open ? 1 : 0, + transitionDelay: '0.2s', + transitionDuration: open ? '1s' : '0.5s', + transitionProperty: 'opacity', + width: '100%', + }} + > + + + + onClose()} + sx={(theme) => ({ + alignItems: 'center', + bgcolor: theme.palette.common.white, + borderRadius: '100%', + cursor: 'pointer', + display: 'flex', + height: '32px', + justifyContent: 'center', + left: '50%', + position: 'absolute', + top: -40, + transform: 'translateX(-50%)', + visibility: open ? 'visible' : 'hidden', + width: '32px', + })} + > + + + + {children} + + + + ); +}; + +export default DrawerModal; diff --git a/src/features/home/components/EventListItem.tsx b/src/features/home/components/EventListItem.tsx new file mode 100644 index 0000000000..de7094f69e --- /dev/null +++ b/src/features/home/components/EventListItem.tsx @@ -0,0 +1,134 @@ +import { FC, ReactNode } from 'react'; +import { Box, Button, Fade, Typography } from '@mui/material'; +import { + Event, + GroupWorkOutlined, + LocationOnOutlined, + WatchLaterOutlined, +} from '@mui/icons-material'; + +import MyActivityListItem from './MyActivityListItem'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; +import { ZetkinEventWithStatus } from '../types'; +import useEventActions from '../hooks/useEventActions'; +import ZUITimeSpan from 'zui/ZUITimeSpan'; +import { removeOffset } from 'utils/dateUtils'; + +type Props = { + event: ZetkinEventWithStatus; + showIcon?: boolean; +}; + +const EventListItem: FC = ({ event, showIcon = false }) => { + const messages = useMessages(messageIds); + const { signUp, undoSignup } = useEventActions( + event.organization.id, + event.id + ); + + const actions: ReactNode[] = []; + if (event.status == 'booked') { + actions.push( + + + + ); + } else if (event.status == 'signedUp') { + actions.push( + , + + + + + + + + ); + } else { + actions.push( + + ); + + if (event.num_participants_available < event.num_participants_required) { + actions.push( + + + + + + ); + } + } + + return ( + + + , + ], + }, + { + Icon: LocationOnOutlined, + labels: [ + event.location?.title || messages.defaultTitles.noLocation(), + ], + }, + ]} + title={ + event.title || event.activity?.title || messages.defaultTitles.event() + } + /> + ); +}; + +export default EventListItem; diff --git a/src/features/home/components/FilterButton.tsx b/src/features/home/components/FilterButton.tsx new file mode 100644 index 0000000000..89ff67c50e --- /dev/null +++ b/src/features/home/components/FilterButton.tsx @@ -0,0 +1,35 @@ +import { Box } from '@mui/material'; +import { FC, ReactNode } from 'react'; + +import { getContrastColor } from 'utils/colorUtils'; + +const FilterButton: FC<{ + active: boolean; + children: ReactNode; + onClick: () => void; + round?: boolean; +}> = ({ active, children, onClick, round }) => { + return ( + ({ + backgroundColor: active ? theme.palette.primary.main : '', + border: `1px solid ${theme.palette.primary.main}`, + borderRadius: '2em', + color: active + ? getContrastColor(theme.palette.primary.main) + : theme.palette.text.primary, + cursor: 'pointer', + display: 'inline-flex', + flexShrink: 0, + fontSize: '13px', + paddingX: round ? '3px' : '10px', + paddingY: '3px', + })} + > + {children} + + ); +}; + +export default FilterButton; diff --git a/src/features/home/components/HomeThemeProvider.tsx b/src/features/home/components/HomeThemeProvider.tsx new file mode 100644 index 0000000000..a5b8deefca --- /dev/null +++ b/src/features/home/components/HomeThemeProvider.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { ThemeProvider, useTheme } from '@mui/material'; +import { FC, ReactNode } from 'react'; + +type Props = { + children: ReactNode; +}; + +const HomeThemeProvider: FC = ({ children }) => { + const theme = useTheme(); + return ( + + {children} + + ); +}; + +export default HomeThemeProvider; diff --git a/src/features/home/components/MyActivitiesList.tsx b/src/features/home/components/MyActivitiesList.tsx new file mode 100644 index 0000000000..41aa9afe1b --- /dev/null +++ b/src/features/home/components/MyActivitiesList.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { FC, useState } from 'react'; +import { Box, Button, Fade, Typography } from '@mui/material'; +import { + GroupWorkOutlined, + Hotel, + MapsHomeWork, + PhoneInTalk, +} from '@mui/icons-material'; + +import useMyActivities from '../hooks/useMyActivities'; +import MyActivityListItem from './MyActivityListItem'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; +import EventListItem from './EventListItem'; +import useIncrementalDelay from '../hooks/useIncrementalDelay'; +import FilterButton from './FilterButton'; + +const MyActivitiesList: FC = () => { + const activities = useMyActivities(); + const messages = useMessages(messageIds); + const [filteredKinds, setFilteredKinds] = useState([]); + const nextDelay = useIncrementalDelay(); + + const kinds = Array.from( + new Set(activities.map((activity) => activity.kind)) + ); + + const filteredActivities = activities.filter((activity) => { + const notFiltering = filteredKinds.length == 0; + return notFiltering || filteredKinds.includes(activity.kind); + }); + + return ( + + {kinds.length > 1 && ( + + {kinds.map((kind) => { + const active = filteredKinds.includes(kind); + return ( + { + const newValue = filteredKinds.filter( + (prevKind) => prevKind != kind + ); + + if (!active) { + newValue.push(kind); + } + + setFilteredKinds(newValue); + }} + > + + + ); + })} + + )} + {filteredActivities.length == 0 && ( + + + + + + + )} + {filteredActivities.map((activity) => { + let elem, href; + + if (activity.kind == 'call') { + href = `/call/${activity.data.id}`; + elem = ( + + + , + ]} + href={href} + Icon={PhoneInTalk} + info={[ + { + Icon: GroupWorkOutlined, + labels: [ + activity.data.campaign?.title, + activity.data.organization.title, + ], + }, + ]} + title={ + activity.data.title || messages.defaultTitles.callAssignment() + } + /> + ); + } else if (activity.kind == 'canvass') { + href = `/canvass/${activity.data.id}`; + elem = ( + + + , + ]} + href={href} + Icon={MapsHomeWork} + info={[]} + title={ + activity.data.title || + messages.defaultTitles.canvassAssignment() + } + /> + ); + } else if (activity.kind == 'event') { + href = `/o/${activity.data.organization.id}/events/${activity.data.id}`; + elem = ; + } + + return ( + + {elem} + + ); + })} + + ); +}; + +export default MyActivitiesList; diff --git a/src/features/home/components/MyActivityListItem.tsx b/src/features/home/components/MyActivityListItem.tsx new file mode 100644 index 0000000000..2a0d571cc5 --- /dev/null +++ b/src/features/home/components/MyActivityListItem.tsx @@ -0,0 +1,115 @@ +import { + Box, + Card, + CardActions, + CardContent, + CardMedia, + SvgIconTypeMap, + Typography, +} from '@mui/material'; +import Image from 'next/image'; +import Link from 'next/link'; +import { FC, Fragment, ReactNode } from 'react'; +import { OverridableComponent } from '@mui/material/OverridableComponent'; + +type Props = { + Icon?: OverridableComponent> | null; + actions?: ReactNode[]; + href?: string; + image?: string; + info: { + Icon: OverridableComponent>; + labels: ReactNode[]; + }[]; + title: string; +}; + +const MyActivityListItem: FC = ({ + actions, + href, + Icon, + image, + info, + title, +}) => { + const card = ( + + {image && ( + + + + + + )} + + + {Icon && ( + + + + )} + {title} + + ({ + color: theme.palette.grey[600], + })} + > + {info.map((item, index) => { + return ( + + + + + {item.labels + .filter((label) => !!label) + .map((label, index) => { + const isFirst = index == 0; + + return ( + + {!isFirst && ·} + {typeof label == 'string' ? ( + + {label} + + ) : ( + label + )} + + ); + })} + + ); + })} + + + {actions && ( + + {actions} + + )} + + ); + + return href ? ( + + {card} + + ) : ( + card + ); +}; + +export default MyActivityListItem; diff --git a/src/features/home/hooks/useEventActions.ts b/src/features/home/hooks/useEventActions.ts new file mode 100644 index 0000000000..205a11cb16 --- /dev/null +++ b/src/features/home/hooks/useEventActions.ts @@ -0,0 +1,33 @@ +import { userResponseAdded, userResponseDeleted } from 'features/events/store'; +import useUserMemberships from './useUserMemberships'; +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinEvent } from 'utils/types/zetkin'; + +export default function useEventActions(orgId: number, eventId: number) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const memberships = useUserMemberships(); + const relevantMembership = memberships.find( + (membership) => membership.organization.id == orgId + ); + const personId = relevantMembership?.profile.id; + + return { + async signUp() { + if (personId) { + const response = await apiClient.put<{ action: ZetkinEvent }>( + `/api/orgs/${orgId}/actions/${eventId}/responses/${personId}` + ); + dispatch(userResponseAdded(response.action)); + } + }, + async undoSignup() { + if (personId) { + await apiClient.delete( + `/api/orgs/${orgId}/actions/${eventId}/responses/${personId}` + ); + } + dispatch(userResponseDeleted(eventId)); + }, + }; +} diff --git a/src/features/home/hooks/useIncrementalDelay.ts b/src/features/home/hooks/useIncrementalDelay.ts new file mode 100644 index 0000000000..4e7560f694 --- /dev/null +++ b/src/features/home/hooks/useIncrementalDelay.ts @@ -0,0 +1,23 @@ +type Props = { + increment?: number; + max?: number; + min?: number; +}; + +export default function useIncrementalDelay(props?: Props) { + const min = props?.min ?? 0; + const max = props?.max ?? 3; + const increment = props?.increment ?? 0.05; + + let delay = min; + + function nextDelay() { + if (delay < max) { + delay += increment; + } + + return delay + 's'; + } + + return nextDelay; +} diff --git a/src/features/home/hooks/useMyActivities.ts b/src/features/home/hooks/useMyActivities.ts new file mode 100644 index 0000000000..680daba27f --- /dev/null +++ b/src/features/home/hooks/useMyActivities.ts @@ -0,0 +1,30 @@ +import useMyCanvassAssignments from 'features/canvassAssignments/hooks/useMyCanvassAssignments'; +import useMyCallAssignments from 'features/callAssignments/hooks/useMyCallAssignments'; +import useMyEvents from 'features/events/hooks/useMyEvents'; +import { MyActivity } from '../types'; + +export default function useMyActivities() { + const canvassAssignments = useMyCanvassAssignments(); + const callAssignments = useMyCallAssignments(); + const events = useMyEvents(); + + const activities: MyActivity[] = [ + ...canvassAssignments.map((data) => ({ + data, + kind: 'canvass', + start: new Date(data.start_date || 0), + })), + ...callAssignments.map((data) => ({ + data, + kind: 'call', + start: new Date(data.start_date || 0), + })), + ...events.map((data) => ({ + data, + kind: 'event', + start: new Date(data.start_time || 0), + })), + ]; + + return activities.sort((a, b) => a.start.getTime() - b.start.getTime()); +} diff --git a/src/features/home/hooks/useUserMemberships.ts b/src/features/home/hooks/useUserMemberships.ts new file mode 100644 index 0000000000..f6292ada38 --- /dev/null +++ b/src/features/home/hooks/useUserMemberships.ts @@ -0,0 +1,15 @@ +import { useApiClient, useAppSelector } from 'core/hooks'; +import useRemoteList from 'core/hooks/useRemoteList'; +import { membershipsLoad, membershipsLoaded } from 'features/user/store'; +import { ZetkinMembership } from 'utils/types/zetkin'; + +export default function useUserMemberships() { + const apiClient = useApiClient(); + const list = useAppSelector((state) => state.user.membershipList); + return useRemoteList(list, { + actionOnLoad: () => membershipsLoad(), + actionOnSuccess: (data) => membershipsLoaded(data), + loader: () => + apiClient.get('/api/users/me/memberships'), + }); +} diff --git a/src/features/home/l10n/messageIds.ts b/src/features/home/l10n/messageIds.ts new file mode 100644 index 0000000000..9d5659fd7a --- /dev/null +++ b/src/features/home/l10n/messageIds.ts @@ -0,0 +1,53 @@ +import { m, makeMessages } from 'core/i18n/messages'; + +export default makeMessages('feat.home', { + activityList: { + actions: { + call: m('Start calling'), + canvass: m('Start canvassing'), + signUp: m('Sign up'), + undoSignup: m('Undo signup'), + }, + emptyListMessage: m('You are not signed up for any acitvities'), + eventStatus: { + booked: m<{ org: string }>( + 'You have been booked for this event and organizers are expecting you. Contact {org} if you need to cancel.' + ), + needed: m('You are needed'), + signedUp: m('You have signed up'), + }, + filters: { + call: m('Call'), + canvass: m('Canvass'), + event: m('Events'), + }, + }, + allEventsList: { + emptyList: { + message: m('Could not find any events'), + removeFiltersButton: m('Clear filters'), + }, + filterButtonLabels: { + organizations: m<{ numOrgs: number }>( + '{numOrgs, plural,=0 {Organizations} =1 {1 organization} other {# organizations}}' + ), + thisWeek: m('This week'), + today: m('Today'), + tomorrow: m('Tomorrow'), + }, + }, + defaultTitles: { + callAssignment: m('Untitled call assignment'), + canvassAssignment: m('Untitled canvass assignment'), + event: m('Untitled event'), + noLocation: m('No physical location'), + }, + footer: { + privacyPolicy: m('Privacy policy'), + }, + tabs: { + feed: m('All events'), + home: m('My activities'), + }, + title: m('My Zetkin'), +}); diff --git a/src/features/home/layouts/HomeLayout.tsx b/src/features/home/layouts/HomeLayout.tsx new file mode 100644 index 0000000000..ba50cfbbf6 --- /dev/null +++ b/src/features/home/layouts/HomeLayout.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { Box, Link, Tab, Tabs, Typography, useMediaQuery } from '@mui/material'; +import { FC, ReactNode } from 'react'; +import { usePathname } from 'next/navigation'; +import NextLink from 'next/link'; + +import { Msg, useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; +import ZUIAvatar from 'zui/ZUIAvatar'; +import useUser from 'core/hooks/useUser'; +import ZUILogo from 'zui/ZUILogo'; +import { useEnv } from 'core/hooks'; + +type Props = { + children: ReactNode; + title?: string; +}; + +const HomeLayout: FC = ({ children, title }) => { + const messages = useMessages(messageIds); + const env = useEnv(); + + const path = usePathname(); + const lastSegment = path?.split('/').pop() ?? 'home'; + + const isMobile = useMediaQuery('(max-width: 640px)'); + + const user = useUser(); + + return ( + + + {title || messages.title()} + {user && } + + ({ + bgcolor: theme.palette.background.default, + position: 'sticky', + top: 0, + zIndex: 1, + })} + > + span': { + backgroundColor: '#252525', + }, + }} + value={lastSegment} + variant={isMobile ? 'fullWidth' : 'standard'} + > + + + + + {children} + + + Zetkin + + + + + + + + ); +}; + +export default HomeLayout; diff --git a/src/features/home/pages/AllEventsPage.tsx b/src/features/home/pages/AllEventsPage.tsx new file mode 100644 index 0000000000..4ade7bda77 --- /dev/null +++ b/src/features/home/pages/AllEventsPage.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { Box } from '@mui/material'; +import { FC, Suspense } from 'react'; + +import ZUILogoLoadingIndicator from 'zui/ZUILogoLoadingIndicator'; +import AllEventsList from '../components/AllEventsList'; + +const AllEventsPage: FC = () => { + return ( + + + + } + > + + + ); +}; + +export default AllEventsPage; diff --git a/src/features/home/pages/HomePage.tsx b/src/features/home/pages/HomePage.tsx new file mode 100644 index 0000000000..da92d0895a --- /dev/null +++ b/src/features/home/pages/HomePage.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { FC, Suspense } from 'react'; +import { Box } from '@mui/material'; + +import MyActivitiesList from '../components/MyActivitiesList'; +import ZUILogoLoadingIndicator from 'zui/ZUILogoLoadingIndicator'; + +const HomePage: FC = () => { + return ( + + + + } + > + + + ); +}; + +export default HomePage; diff --git a/src/features/home/types.ts b/src/features/home/types.ts new file mode 100644 index 0000000000..a5b79e5bc7 --- /dev/null +++ b/src/features/home/types.ts @@ -0,0 +1,29 @@ +import { ZetkinCanvassAssignment } from 'features/canvassAssignments/types'; +import { ZetkinCallAssignment, ZetkinEvent } from 'utils/types/zetkin'; + +type MyEventActivity = { + data: ZetkinEventWithStatus; + kind: 'event'; + start: Date; +}; + +type MyCallAssignmentActivity = { + data: ZetkinCallAssignment; + kind: 'call'; + start: Date; +}; + +type MyCanvassAssignmentActivity = { + data: ZetkinCanvassAssignment; + kind: 'canvass'; + start: Date; +}; + +export type MyActivity = + | MyEventActivity + | MyCallAssignmentActivity + | MyCanvassAssignmentActivity; + +export type ZetkinEventWithStatus = ZetkinEvent & { + status: 'signedUp' | 'booked' | null; +}; diff --git a/src/features/import/components/ImportDialog/Configure/Configuration/GenderConfig.tsx b/src/features/import/components/ImportDialog/Configure/Configuration/GenderConfig.tsx new file mode 100644 index 0000000000..a6f96ce2f5 --- /dev/null +++ b/src/features/import/components/ImportDialog/Configure/Configuration/GenderConfig.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react'; +import { Box, Divider, Typography } from '@mui/material'; + +import messageIds from 'features/import/l10n/messageIds'; +import { GenderColumn } from 'features/import/utils/types'; +import { UIDataColumn } from 'features/import/hooks/useUIDataColumn'; +import { Msg, useMessages } from 'core/i18n'; +import GenderConfigRow from './GenderConfigRow'; +import useGenderMapping from 'features/import/hooks/useGenderMapping'; + +interface GenderConfigProps { + uiDataColumn: UIDataColumn; +} + +const GenderConfig: FC = ({ uiDataColumn }) => { + const messages = useMessages(messageIds); + const { selectGender, getSelectedGender, deselectGender } = useGenderMapping( + uiDataColumn.originalColumn, + uiDataColumn.columnIndex + ); + + return ( + + + + + + + + + + {uiDataColumn.title.toLocaleUpperCase()} + + + + + {messages.configuration.configure.genders + .label() + .toLocaleUpperCase()} + + + + {uiDataColumn.uniqueValues.map((uniqueValue, index) => ( + <> + {index != 0 && } + deselectGender(uniqueValue)} + onSelectGender={(gender) => selectGender(gender, uniqueValue)} + selectedGender={getSelectedGender(uniqueValue)} + title={uniqueValue.toString()} + /> + + ))} + + ); +}; + +export default GenderConfig; diff --git a/src/features/import/components/ImportDialog/Configure/Configuration/GenderConfigRow.tsx b/src/features/import/components/ImportDialog/Configure/Configuration/GenderConfigRow.tsx new file mode 100644 index 0000000000..1605afb70a --- /dev/null +++ b/src/features/import/components/ImportDialog/Configure/Configuration/GenderConfigRow.tsx @@ -0,0 +1,112 @@ +import { ArrowForward, Delete } from '@mui/icons-material'; +import { + Box, + FormControl, + IconButton, + InputLabel, + MenuItem, + Select, + Typography, +} from '@mui/material'; +import { FC } from 'react'; + +import messageIds from 'features/import/l10n/messageIds'; +import { Msg, useMessages } from 'core/i18n'; +import { Gender, genders } from '../../../../hooks/useGenderMapping'; + +interface GenderConfigRowProps { + italic?: boolean; + numRows: number; + onSelectGender: (gender: Gender | null) => void; + onDeselectGender: () => void; + selectedGender: Gender | 'unknown' | null; + title: string; +} + +const GenderConfigRow: FC = ({ + italic, + numRows, + onSelectGender, + onDeselectGender, + selectedGender, + title, +}) => { + const messages = useMessages(messageIds); + return ( + + + + + {title} + + + + + + + + + + + { + onDeselectGender(); + }} + > + + + + + + + + + ); +}; + +export default GenderConfigRow; diff --git a/src/features/import/components/ImportDialog/Configure/Configuration/index.tsx b/src/features/import/components/ImportDialog/Configure/Configuration/index.tsx index 43b449153f..acac538686 100644 --- a/src/features/import/components/ImportDialog/Configure/Configuration/index.tsx +++ b/src/features/import/components/ImportDialog/Configure/Configuration/index.tsx @@ -9,6 +9,7 @@ import { ColumnKind, DateColumn, EnumColumn, + GenderColumn, IDFieldColumn, OrgColumn, TagColumn, @@ -17,6 +18,7 @@ import useUIDataColumn, { UIDataColumn, } from 'features/import/hooks/useUIDataColumn'; import EnumConfig from './EnumConfig'; +import GenderConfig from './GenderConfig'; interface ConfigurationProps { columnIndexBeingConfigured: number; @@ -49,6 +51,12 @@ const Configuration: FC = ({ uiDataColumn.originalColumn.kind == ColumnKind.ORGANIZATION && ( } /> )} + {uiDataColumn && + uiDataColumn.originalColumn.kind == ColumnKind.GENDER && ( + } + /> + )} {uiDataColumn && uiDataColumn.originalColumn.kind == ColumnKind.DATE && ( } /> )} diff --git a/src/features/import/components/ImportDialog/Configure/Mapping/FieldSelect.tsx b/src/features/import/components/ImportDialog/Configure/Mapping/FieldSelect.tsx index a467c14526..c012eb7ebe 100644 --- a/src/features/import/components/ImportDialog/Configure/Mapping/FieldSelect.tsx +++ b/src/features/import/components/ImportDialog/Configure/Mapping/FieldSelect.tsx @@ -43,6 +43,10 @@ const FieldSelect: FC = ({ return `enum:${column.originalColumn.field}`; } + if (column.originalColumn.kind == ColumnKind.GENDER) { + return `field:gender`; + } + if (column.originalColumn.kind != ColumnKind.UNKNOWN) { return column.originalColumn.kind.toString(); } @@ -106,6 +110,14 @@ const FieldSelect: FC = ({ selected: true, }); onConfigureStart(); + } else if (event.target.value == 'field:gender') { + onChange({ + field: event.target.value, + kind: ColumnKind.GENDER, + mapping: [], + selected: true, + }); + onConfigureStart(); } else if (event.target.value.startsWith('field')) { onChange({ field: event.target.value.slice(6), diff --git a/src/features/import/components/ImportDialog/Configure/Mapping/MappingRow.tsx b/src/features/import/components/ImportDialog/Configure/Mapping/MappingRow.tsx index 99b97d3aeb..0c2fd80b20 100644 --- a/src/features/import/components/ImportDialog/Configure/Mapping/MappingRow.tsx +++ b/src/features/import/components/ImportDialog/Configure/Mapping/MappingRow.tsx @@ -29,6 +29,7 @@ const isConfigurableColumn = (column: Column): column is ConfigurableColumn => { ColumnKind.TAG, ColumnKind.DATE, ColumnKind.ENUM, + ColumnKind.GENDER, ].includes(column.kind); }; diff --git a/src/features/import/components/ImportDialog/Configure/Preview/GenderPreview.tsx b/src/features/import/components/ImportDialog/Configure/Preview/GenderPreview.tsx new file mode 100644 index 0000000000..41a5bcee1f --- /dev/null +++ b/src/features/import/components/ImportDialog/Configure/Preview/GenderPreview.tsx @@ -0,0 +1,57 @@ +import messageIds from 'features/import/l10n/messageIds'; +import PreviewGrid from './PreviewGrid'; +import { useMessages } from 'core/i18n'; +import { CellData, ColumnKind, Sheet } from 'features/import/utils/types'; + +interface GenderPreviewProps { + currentSheet: Sheet; + fieldKey: string; + fields: Record | undefined; +} + +const GenderPreview = ({ + currentSheet, + fieldKey, + fields, +}: GenderPreviewProps) => { + const messages = useMessages(messageIds); + + const map = currentSheet.columns.find( + (column) => column.kind === ColumnKind.GENDER && column.mapping.length > 0 + ); + + if (!map) { + return ( + + ); + } + + const value = fields?.[fieldKey]; + + if (value === 'o' || value === 'f' || value === 'm' || value === null) { + const key = value === null ? 'unknown' : value; + return ( + + ); + } + + // This should never happen + return ( + + ); +}; + +export default GenderPreview; diff --git a/src/features/import/components/ImportDialog/Configure/Preview/index.tsx b/src/features/import/components/ImportDialog/Configure/Preview/index.tsx index 5926051ffb..35b685c54f 100644 --- a/src/features/import/components/ImportDialog/Configure/Preview/index.tsx +++ b/src/features/import/components/ImportDialog/Configure/Preview/index.tsx @@ -14,6 +14,7 @@ import usePersonPreview from 'features/import/hooks/usePersonPreview'; import useSheets from 'features/import/hooks/useSheets'; import { ColumnKind, Sheet } from 'features/import/utils/types'; import EnumPreview from './EnumPreview'; +import GenderPreview from './GenderPreview'; const Preview = () => { const theme = useTheme(); @@ -155,6 +156,16 @@ const Preview = () => { /> ); } + if (column.kind === ColumnKind.GENDER) { + return ( + + ); + } } })} {orgColumnSelected && ( diff --git a/src/features/import/hooks/useGenderMapping.ts b/src/features/import/hooks/useGenderMapping.ts new file mode 100644 index 0000000000..add6b38526 --- /dev/null +++ b/src/features/import/hooks/useGenderMapping.ts @@ -0,0 +1,67 @@ +import { columnUpdate } from '../store'; +import { useAppDispatch } from 'core/hooks'; +import { CellData, Column, ColumnKind } from '../utils/types'; + +export const genders = ['f', 'm', 'o'] as const; +export type Gender = typeof genders[keyof typeof genders]; + +export default function useGenderMapping(column: Column, columnIndex: number) { + const dispatch = useAppDispatch(); + + const getSelectedGender = (value: CellData) => { + if (column.kind == ColumnKind.GENDER) { + const map = column.mapping.find((m) => m.value === value); + if (!map) { + return null; + } + return map.gender ?? 'unknown'; + } + return null; + }; + + const selectGender = (gender: Gender | null, value: CellData) => { + if (column.kind !== ColumnKind.GENDER) { + return; + } + + dispatch( + columnUpdate([ + columnIndex, + { + ...column, + mapping: [ + ...column.mapping.filter((m) => m.value !== value), + { gender, value }, + ], + }, + ]) + ); + }; + + const deselectGender = (value: CellData) => { + if (column.kind != ColumnKind.GENDER) { + return; + } + + const map = column.mapping.find((map) => map.value == value); + if (map) { + const filteredMapping = column.mapping.filter((m) => m.value != value); + + dispatch( + columnUpdate([ + columnIndex, + { + ...column, + mapping: filteredMapping, + }, + ]) + ); + } + }; + + return { + deselectGender, + getSelectedGender, + selectGender, + }; +} diff --git a/src/features/import/l10n/messageIds.ts b/src/features/import/l10n/messageIds.ts index 67152d5df6..969cb93b29 100644 --- a/src/features/import/l10n/messageIds.ts +++ b/src/features/import/l10n/messageIds.ts @@ -64,6 +64,15 @@ export default makeMessages('feat.import', { ), value: m('Value'), }, + genders: { + label: m('Gender'), + selectLabels: { + f: m('Female'), + m: m('Male'), + o: m('Other'), + unknown: m('Unknown'), + }, + }, ids: { externalID: m('External ID'), externalIDInfo: m( @@ -183,6 +192,7 @@ export default makeMessages('feat.import', { unfinished: { date: m('You need to configure date format'), enum: m('You need to map values'), + gender: m('You need to map values'), id: m('You need to configure the IDs'), org: m('You need to map values'), tag: m('You need to map values'), @@ -197,9 +207,16 @@ export default makeMessages('feat.import', { }, preview: { columnHeader: { + gender: m('Gender'), org: m('Organization'), tags: m('Tags'), }, + genders: { + f: m('Female'), + m: m('Male'), + o: m('Other'), + unknown: m('Unknown'), + }, next: m('Next'), noOrg: m('No organization'), noTags: m('No tags'), diff --git a/src/features/import/utils/createPreviewData.ts b/src/features/import/utils/createPreviewData.ts index a02b4d4d47..3d6a6d136f 100644 --- a/src/features/import/utils/createPreviewData.ts +++ b/src/features/import/utils/createPreviewData.ts @@ -90,6 +90,20 @@ export default function createPreviewData( }; } } + + if (column.kind === ColumnKind.GENDER) { + column.mapping.forEach((mappedColumn) => { + if ( + (!mappedColumn.value && !row[colIdx]) || + mappedColumn.value === row[colIdx] + ) { + personPreviewOp.data = { + ...personPreviewOp.data, + [column.field]: mappedColumn.gender as string, + }; + } + }); + } } }); diff --git a/src/features/import/utils/prepareImportOperations.ts b/src/features/import/utils/prepareImportOperations.ts index 6343b0319f..8b1165547b 100644 --- a/src/features/import/utils/prepareImportOperations.ts +++ b/src/features/import/utils/prepareImportOperations.ts @@ -149,6 +149,18 @@ export default function prepareImportOperations( } } } + + if (column.kind === ColumnKind.GENDER) { + const match = column.mapping.find( + (c) => c.value === row.data[colIdx] + ); + if (match !== undefined) { + personImportOps[rowIndex].data = { + ...personImportOps[rowIndex].data, + gender: match.gender as string, + }; + } + } }); } }); diff --git a/src/features/import/utils/types.ts b/src/features/import/utils/types.ts index 8f1bc1c628..1b8664676d 100644 --- a/src/features/import/utils/types.ts +++ b/src/features/import/utils/types.ts @@ -1,4 +1,5 @@ import { ZetkinPersonImportOp } from './prepareImportOperations'; +import { Gender } from '../hooks/useGenderMapping'; export type CellData = string | number | null | undefined; @@ -21,6 +22,7 @@ export type Row = { export enum ColumnKind { FIELD = 'field', + GENDER = 'gender', DATE = 'date', ID_FIELD = 'id', TAG = 'tag', @@ -48,6 +50,15 @@ export type DateColumn = BaseColumn & { kind: ColumnKind.DATE; }; +export type GenderColumn = BaseColumn & { + field: string; + kind: ColumnKind.GENDER; + mapping: { + gender: Gender | null; + value: CellData; + }[]; +}; + export type EnumColumn = BaseColumn & { field: string; kind: ColumnKind.ENUM; @@ -83,6 +94,7 @@ export type ConfigurableColumn = | IDFieldColumn | TagColumn | OrgColumn + | GenderColumn | EnumColumn; export type Column = UnknownColumn | FieldColumn | ConfigurableColumn; diff --git a/src/features/joinForms/components/JoinSubmissionTable.tsx b/src/features/joinForms/components/JoinSubmissionTable.tsx index 8b5c1f7fc9..ca74c1446f 100644 --- a/src/features/joinForms/components/JoinSubmissionTable.tsx +++ b/src/features/joinForms/components/JoinSubmissionTable.tsx @@ -28,7 +28,8 @@ const JoinSubmissionTable: FC = ({ onSelect, orgId, submissions }) => { const classes = useStyles(); const messages = useMessages(messageIds); const theme = useTheme(); - const { approveSubmission } = useJoinSubmissionMutations(orgId); + const { approveSubmission, deleteSubmission } = + useJoinSubmissionMutations(orgId); return ( @@ -37,7 +38,7 @@ const JoinSubmissionTable: FC = ({ onSelect, orgId, submissions }) => { { disableColumnMenu: true, field: 'state', - flex: 1, + flex: 2, headerName: messages.status(), renderCell: (params) => { return ( @@ -51,28 +52,28 @@ const JoinSubmissionTable: FC = ({ onSelect, orgId, submissions }) => { { disableColumnMenu: true, field: 'first_name', - flex: 1, + flex: 2, headerName: messages.submissionList.firstName(), valueGetter: (params) => params.row.person_data.first_name, }, { disableColumnMenu: true, field: 'last_name', - flex: 1, + flex: 2, headerName: messages.submissionList.lastName(), valueGetter: (params) => params.row.person_data.last_name, }, { disableColumnMenu: true, field: 'form', - flex: 1, + flex: 2, headerName: messages.submissionList.form(), valueGetter: (params) => params.row.form.title, }, { disableColumnMenu: true, field: 'submitted', - flex: 1, + flex: 2, headerName: messages.submissionList.timestamp(), type: 'dateTime', valueGetter: (params) => new Date(params.row.submitted), @@ -81,16 +82,21 @@ const JoinSubmissionTable: FC = ({ onSelect, orgId, submissions }) => { align: 'right', disableColumnMenu: true, field: 'actions', - flex: 1, + flex: 3, headerName: '', renderCell: (params) => { if (params.row.state !== 'accepted') { return ( - {/* TODO: Handle rejectButton click */} - {/* */} +