From 78414d5b0f01f789f471c99a6a81d38c5e7d4b66 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 10 Nov 2023 10:24:07 +0100 Subject: [PATCH 001/369] Create boilerplate for activist portal pages --- src/pages/my/home/index.tsx | 21 ++++++++++++ src/pages/my/todo/index.tsx | 21 ++++++++++++ .../o/[orgId]/events/[eventId]/index.tsx | 33 +++++++++++++++++++ src/pages/o/[orgId]/index.tsx | 27 +++++++++++++++ src/pages/o/[orgId]/map/index.tsx | 27 +++++++++++++++ .../o/[orgId]/projects/[campId]/index.tsx | 33 +++++++++++++++++++ .../o/[orgId]/surveys/[surveyId]/index.tsx | 33 +++++++++++++++++++ 7 files changed, 195 insertions(+) create mode 100644 src/pages/my/home/index.tsx create mode 100644 src/pages/my/todo/index.tsx create mode 100644 src/pages/o/[orgId]/events/[eventId]/index.tsx create mode 100644 src/pages/o/[orgId]/index.tsx create mode 100644 src/pages/o/[orgId]/map/index.tsx create mode 100644 src/pages/o/[orgId]/projects/[campId]/index.tsx create mode 100644 src/pages/o/[orgId]/surveys/[surveyId]/index.tsx diff --git a/src/pages/my/home/index.tsx b/src/pages/my/home/index.tsx new file mode 100644 index 0000000000..30f48fa382 --- /dev/null +++ b/src/pages/my/home/index.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; +import { scaffold } from 'utils/next'; + +const scaffoldOptions = { + allowNonOfficials: true, + authLevelRequired: 1, +}; + +export const getServerSideProps = scaffold(async () => { + return { + props: {}, + }; +}, scaffoldOptions); + +type PageProps = void; + +const Page: FC = () => { + return

Home page for currently signed in user

; +}; + +export default Page; diff --git a/src/pages/my/todo/index.tsx b/src/pages/my/todo/index.tsx new file mode 100644 index 0000000000..dbc72780f2 --- /dev/null +++ b/src/pages/my/todo/index.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; +import { scaffold } from 'utils/next'; + +const scaffoldOptions = { + allowNonOfficials: true, + authLevelRequired: 1, +}; + +export const getServerSideProps = scaffold(async () => { + return { + props: {}, + }; +}, scaffoldOptions); + +type PageProps = void; + +const Page: FC = () => { + return

Todo page for currently signed in user

; +}; + +export default Page; diff --git a/src/pages/o/[orgId]/events/[eventId]/index.tsx b/src/pages/o/[orgId]/events/[eventId]/index.tsx new file mode 100644 index 0000000000..de17609a0b --- /dev/null +++ b/src/pages/o/[orgId]/events/[eventId]/index.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; +import { scaffold } from 'utils/next'; + +const scaffoldOptions = { + allowNonOfficials: true, + authLevelRequired: 1, +}; + +export const getServerSideProps = scaffold(async (ctx) => { + const { eventId, orgId } = ctx.params!; + + return { + props: { + eventId, + orgId, + }, + }; +}, scaffoldOptions); + +type PageProps = { + eventId: string; + orgId: string; +}; + +const Page: FC = ({ orgId, eventId }) => { + return ( +

+ Page for org {orgId}, event {eventId} +

+ ); +}; + +export default Page; diff --git a/src/pages/o/[orgId]/index.tsx b/src/pages/o/[orgId]/index.tsx new file mode 100644 index 0000000000..f01f175a3a --- /dev/null +++ b/src/pages/o/[orgId]/index.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { scaffold } from 'utils/next'; + +const scaffoldOptions = { + allowNonOfficials: true, + authLevelRequired: 1, +}; + +export const getServerSideProps = scaffold(async (ctx) => { + const { orgId } = ctx.params!; + + return { + props: { + orgId, + }, + }; +}, scaffoldOptions); + +type PageProps = { + orgId: string; +}; + +const Page: FC = ({ orgId }) => { + return

Home page for org {orgId}

; +}; + +export default Page; diff --git a/src/pages/o/[orgId]/map/index.tsx b/src/pages/o/[orgId]/map/index.tsx new file mode 100644 index 0000000000..50e740ac0f --- /dev/null +++ b/src/pages/o/[orgId]/map/index.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { scaffold } from 'utils/next'; + +const scaffoldOptions = { + allowNonOfficials: true, + authLevelRequired: 1, +}; + +export const getServerSideProps = scaffold(async (ctx) => { + const { orgId } = ctx.params!; + + return { + props: { + orgId, + }, + }; +}, scaffoldOptions); + +type PageProps = { + orgId: string; +}; + +const Page: FC = ({ orgId }) => { + return

Map page for org {orgId}

; +}; + +export default Page; diff --git a/src/pages/o/[orgId]/projects/[campId]/index.tsx b/src/pages/o/[orgId]/projects/[campId]/index.tsx new file mode 100644 index 0000000000..9d0a6a6249 --- /dev/null +++ b/src/pages/o/[orgId]/projects/[campId]/index.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; +import { scaffold } from 'utils/next'; + +const scaffoldOptions = { + allowNonOfficials: true, + authLevelRequired: 1, +}; + +export const getServerSideProps = scaffold(async (ctx) => { + const { campId, orgId } = ctx.params!; + + return { + props: { + campId, + orgId, + }, + }; +}, scaffoldOptions); + +type PageProps = { + campId: string; + orgId: string; +}; + +const Page: FC = ({ orgId, campId }) => { + return ( +

+ Page for org {orgId}, project {campId} +

+ ); +}; + +export default Page; diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx new file mode 100644 index 0000000000..a07f93054c --- /dev/null +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; +import { scaffold } from 'utils/next'; + +const scaffoldOptions = { + allowNonOfficials: true, + authLevelRequired: 1, +}; + +export const getServerSideProps = scaffold(async (ctx) => { + const { surveyId, orgId } = ctx.params!; + + return { + props: { + orgId, + surveyId, + }, + }; +}, scaffoldOptions); + +type PageProps = { + orgId: string; + surveyId: string; +}; + +const Page: FC = ({ orgId, surveyId }) => { + return ( +

+ Page for org {orgId}, survey {surveyId} +

+ ); +}; + +export default Page; From bb3a4f7440ff05fd06895368a5150d7df280acc9 Mon Sep 17 00:00:00 2001 From: kaulfield23 Date: Sat, 11 Nov 2023 10:24:59 +0100 Subject: [PATCH 002/369] WIP --- src/pages/o/[orgId]/surveys/[surveyId]/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index a07f93054c..aeb5550235 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -1,5 +1,6 @@ import { FC } from 'react'; import { scaffold } from 'utils/next'; +import useSurvey from 'features/surveys/hooks/useSurvey'; const scaffoldOptions = { allowNonOfficials: true, @@ -23,6 +24,7 @@ type PageProps = { }; const Page: FC = ({ orgId, surveyId }) => { + const surveys = useSurvey(parseInt(orgId), parseInt(surveyId)); return (

Page for org {orgId}, survey {surveyId} From 372d698750b722b66bf294e7bab00a15030a3b72 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 11 Nov 2023 10:22:02 +0100 Subject: [PATCH 003/369] Render survey elements --- .../o/[orgId]/surveys/[surveyId]/index.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index aeb5550235..229785e75e 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { scaffold } from 'utils/next'; -import useSurvey from 'features/surveys/hooks/useSurvey'; +import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; const scaffoldOptions = { allowNonOfficials: true, @@ -24,11 +24,26 @@ type PageProps = { }; const Page: FC = ({ orgId, surveyId }) => { - const surveys = useSurvey(parseInt(orgId), parseInt(surveyId)); + const elements = useSurveyElements(parseInt(orgId, 10), parseInt(surveyId, 10)); return ( -

- Page for org {orgId}, survey {surveyId} -

+ <> +

+ Page for org {orgId}, survey {surveyId} +

+ +
+ {(elements.data || []).map((element) => ( +
+ {element.type === 'question' && ( + + )} + {element.type === 'text' && ( +

{element.text_block.content}

+ )} +
+ ))} +
+ ); }; From 48041b1ce63c6eaae8fb3cb1c20e20fa669969ca Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 11 Nov 2023 10:55:18 +0100 Subject: [PATCH 004/369] Add org name + avatar, form title + desc, and render form inputs --- .../o/[orgId]/surveys/[surveyId]/index.tsx | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 229785e75e..a7e5990d5d 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -1,6 +1,9 @@ +import Box from '@mui/system/Box'; import { FC } from 'react'; import { scaffold } from 'utils/next'; +import useSurvey from 'features/surveys/hooks/useSurvey'; import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; +import ZUIAvatar from 'zui/ZUIAvatar'; const scaffoldOptions = { allowNonOfficials: true, @@ -24,22 +27,45 @@ type PageProps = { }; const Page: FC = ({ orgId, surveyId }) => { - const elements = useSurveyElements(parseInt(orgId, 10), parseInt(surveyId, 10)); + const survey = useSurvey(parseInt(orgId, 10), parseInt(surveyId, 10)); + const elements = useSurveyElements( + parseInt(orgId, 10), + parseInt(surveyId, 10) + ); return ( <> -

- Page for org {orgId}, survey {surveyId} -

+

{survey.data?.title}

+ + {survey.data?.info_text &&

{survey.data?.info_text}

} + + + + {survey.data?.organization.title} +
{(elements.data || []).map((element) => (
{element.type === 'question' && ( - - )} - {element.type === 'text' && ( -

{element.text_block.content}

+ + + {element.question.response_type === 'text' && ( + + )} + {element.question.response_type === 'options' && ( + + )} + )} + {element.type === 'text' &&

{element.text_block.content}

}
))}
From 077debbfb31e1a05a3d426f6cfe2d3a7c7fa2ffe Mon Sep 17 00:00:00 2001 From: kaulfield23 Date: Sat, 11 Nov 2023 12:46:30 +0100 Subject: [PATCH 005/369] create signUpOptions --- .../components/surveyForm/SignUpOptions.tsx | 33 +++++++++++++++++++ src/features/surveys/l10n/messageIds.ts | 6 ++++ .../o/[orgId]/surveys/[surveyId]/index.tsx | 2 ++ 3 files changed, 41 insertions(+) create mode 100644 src/features/surveys/components/surveyForm/SignUpOptions.tsx diff --git a/src/features/surveys/components/surveyForm/SignUpOptions.tsx b/src/features/surveys/components/surveyForm/SignUpOptions.tsx new file mode 100644 index 0000000000..9718a9f581 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SignUpOptions.tsx @@ -0,0 +1,33 @@ +import messageIds from 'features/surveys/l10n/messageIds'; +import { useMessages } from 'core/i18n'; +import { FormControlLabel, Radio, RadioGroup } from '@mui/material'; + +interface SignUpOptionsProps { + signature: + | 'require_signature' + | 'allow_anonymous' + | 'force_anonymous' + | undefined; +} +const SignUpOptions = ({ signature }: SignUpOptionsProps) => { + const messages = useMessages(messageIds); + + return ( + + } + label={messages.surveyForm.sign.nameAndEmail()} + value={'name-and-mail'} + /> + {signature === 'allow_anonymous' && ( + } + label={messages.surveyForm.sign.anonymous()} + value={'anonymous'} + /> + )} + + ); +}; + +export default SignUpOptions; diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 202e32226d..8b564e48eb 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -159,6 +159,12 @@ export default makeMessages('feat.surveys', { suggestedPeople: m('Suggested people'), unlink: m('Unlink'), }, + surveyForm: { + sign: { + anonymous: m('Submit anonymously'), + nameAndEmail: m('Sign with name and e-mail'), + }, + }, tabs: { overview: m('Overview'), questions: m('Questions'), diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index a7e5990d5d..1d0dffddad 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -1,6 +1,7 @@ import Box from '@mui/system/Box'; import { FC } from 'react'; import { scaffold } from 'utils/next'; +import SignUpOptions from 'features/surveys/components/surveyForm/SignUpOptions'; import useSurvey from 'features/surveys/hooks/useSurvey'; import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; import ZUIAvatar from 'zui/ZUIAvatar'; @@ -68,6 +69,7 @@ const Page: FC = ({ orgId, surveyId }) => { {element.type === 'text' &&

{element.text_block.content}

} ))} + ); From 4d7632e4bc6cf91a54bf25334170e371bb43f699 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 11 Nov 2023 12:37:19 +0100 Subject: [PATCH 006/369] Survey submit button --- .../o/[orgId]/surveys/[surveyId]/index.tsx | 115 +++++++++++++++++- .../surveys/[surveyId]/submitted/index.tsx | 37 ++++++ 2 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 src/pages/o/[orgId]/surveys/[surveyId]/submitted/index.tsx diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 1d0dffddad..d2a589e0bd 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -1,7 +1,10 @@ +import BackendApiClient from 'core/api/client/BackendApiClient'; import Box from '@mui/system/Box'; +import Button from '@mui/material/Button'; import { FC } from 'react'; +import { IncomingMessage } from 'http'; +import { parse } from 'querystring'; import { scaffold } from 'utils/next'; -import SignUpOptions from 'features/surveys/components/surveyForm/SignUpOptions'; import useSurvey from 'features/surveys/hooks/useSurvey'; import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; import ZUIAvatar from 'zui/ZUIAvatar'; @@ -11,9 +14,98 @@ const scaffoldOptions = { authLevelRequired: 1, }; +function parseRequest( + req: IncomingMessage +): Promise> { + return new Promise((resolve) => { + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on('end', () => { + resolve(parse(body)); + }); + }); +} + export const getServerSideProps = scaffold(async (ctx) => { + const { req, res } = ctx; const { surveyId, orgId } = ctx.params!; + if (req.method === 'POST') { + const form = await parseRequest(req); + const responses: Record< + string, + { + options?: number[]; + question_id: number; + response?: string; + } + > = {}; + + for (const name in form) { + const isSignature = name.startsWith('sig'); + const isPrivacy = name.startsWith('privacy'); + const isMetadata = isSignature || isPrivacy; + if (isMetadata) { + continue; + } + + const fields = name.split('.'); + const questionId = fields[0]; + const questionType = fields[1]; + + if (typeof responses[questionId] === 'undefined') { + responses[questionId] = { + question_id: parseInt(fields[0]), + }; + } + + if (questionType == 'options') { + if (Array.isArray(form[name])) { + responses[questionId].options = (form[name] as string[]).map((o) => + parseInt(o) + ); + } else { + responses[questionId].options = [parseInt((form[name] as string)!)]; + } + } else if (questionType == 'text') { + responses[questionId].response = form[name] as string; + } + } + + let signature: null | { + email: string; + first_name: string; + last_name: string; + } = null; + // TODO: handle other signature types + if (form.sig == 'email') { + signature = { + email: form['sig.email'] as string, + first_name: form['sig.first_name'] as string, + last_name: form['sig.last_name'] as string, + }; + } + + const apiClient = new BackendApiClient(req.headers); + const requestUrl = `/api/orgs/${orgId}/surveys/${surveyId}/submissions`; + try { + await apiClient.post(requestUrl, { + responses: Object.values(responses), + signature, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e); + } + + res.writeHead(302, { + Location: `/o/${orgId}/surveys/${surveyId}/submitted`, + }); + res.end(); + } + return { props: { orgId, @@ -33,6 +125,7 @@ const Page: FC = ({ orgId, surveyId }) => { parseInt(orgId, 10), parseInt(surveyId, 10) ); + return ( <>

{survey.data?.title}

@@ -44,19 +137,26 @@ const Page: FC = ({ orgId, surveyId }) => { {survey.data?.organization.title} -
+ {(elements.data || []).map((element) => (
{element.type === 'question' && ( -
))} - + + ); diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/submitted/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/submitted/index.tsx new file mode 100644 index 0000000000..0bb15ac5ea --- /dev/null +++ b/src/pages/o/[orgId]/surveys/[surveyId]/submitted/index.tsx @@ -0,0 +1,37 @@ +import { FC } from 'react'; +import { scaffold } from 'utils/next'; +import useSurvey from 'features/surveys/hooks/useSurvey'; + +const scaffoldOptions = { + allowNonOfficials: true, + authLevelRequired: 1, +}; + +export const getServerSideProps = scaffold(async (ctx) => { + const { surveyId, orgId } = ctx.params!; + return { + props: { + orgId, + surveyId, + }, + }; +}, scaffoldOptions); + +type PageProps = { + orgId: string; + surveyId: string; +}; + +const Page: FC = ({ orgId, surveyId }) => { + const survey = useSurvey(parseInt(orgId, 10), parseInt(surveyId, 10)); + return ( + <> +

Survey Submitted

+

+ Your responses to "{survey.data?.title}" have been submitted. +

+ + ); +}; + +export default Page; From eaf3dffcfe7f64cb0394e045ab53625074eb9af0 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 11 Nov 2023 16:16:38 +0100 Subject: [PATCH 007/369] Add playwright test for submitting a survey --- .../KPD/surveys/MembershipSurvey/index.ts | 65 ++++++++++++++++++ .../surveys/submitting-survey.spec.ts | 68 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts create mode 100644 integrationTesting/tests/organize/surveys/submitting-survey.spec.ts diff --git a/integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts b/integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts new file mode 100644 index 0000000000..3d8e28a335 --- /dev/null +++ b/integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts @@ -0,0 +1,65 @@ +import KPD from '../..'; +import { + ELEMENT_TYPE, + RESPONSE_TYPE, + ZetkinSurveyExtended, +} from 'utils/types/zetkin'; + +const KPDMembershipSurvey: ZetkinSurveyExtended = { + access: 'open', + callers_only: false, + campaign: null, + elements: [ + { + hidden: false, + id: 1, + question: { + description: '', + options: [ + { + id: 1, + text: 'Yes', + }, + { + id: 1, + text: 'No', + }, + ], + question: 'Do you want to be active?', + required: false, + response_config: { + widget_type: 'radio', + }, + response_type: RESPONSE_TYPE.OPTIONS, + }, + type: ELEMENT_TYPE.QUESTION, + }, + { + hidden: false, + id: 2, + question: { + description: '', + question: 'What would you like to do?', + required: false, + response_config: { + multiline: true, + }, + response_type: RESPONSE_TYPE.TEXT, + }, + type: ELEMENT_TYPE.QUESTION, + }, + ], + expires: null, + id: 1, + info_text: '', + org_access: 'sameorg', + organization: { + id: KPD.id, + title: KPD.title, + }, + published: '1857-05-07T13:37:00.000Z', + signature: 'require_signature', + title: 'Membership survey', +}; + +export default KPDMembershipSurvey; diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts new file mode 100644 index 0000000000..e504196d4f --- /dev/null +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -0,0 +1,68 @@ +import { expect } from '@playwright/test'; + +import KPD from '../../../mockData/orgs/KPD'; +import KPDMembershipSurvey from '../../../mockData/orgs/KPD/surveys/MembershipSurvey'; +import RosaLuxemburg from '../../../mockData/orgs/KPD/people/RosaLuxemburg'; +import RosaLuxemburgUser from '../../../mockData/users/RosaLuxemburgUser'; +import test from '../../../fixtures/next'; + +test.describe.only('User submitting a survey', () => { + test.afterEach(({ moxy }) => { + moxy.teardown(); + }); + + test('submits data successfully', async ({ appUri, login, moxy, page }) => { + const apiPostPath = `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`; + + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + KPDMembershipSurvey + ); + + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, + 'post', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + const memberships = [ + { + organization: KPD, + profile: { + id: RosaLuxemburg.id, + name: RosaLuxemburg.first_name + ' ' + RosaLuxemburg.last_name, + }, + role: null, + }, + ]; + + login(RosaLuxemburgUser, memberships); + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + await page.fill('input', 'Topple capitalism'); + await Promise.all([ + page.click('text=Submit'), + page.waitForResponse((res) => res.request().method() == 'POST'), + ]); + + const reqLog = moxy.log(`/v1${apiPostPath}`); + expect(reqLog.length).toBe(1); + expect(reqLog[0].data).toMatchObject({ + responses: [ + { + options: [1], + question_id: KPDMembershipSurvey.elements[0].id, + }, + { + question_id: KPDMembershipSurvey.elements[1].id, + response: 'Topple capitalism', + }, + ], + }); + }); +}); From 8b3a9697798509b031484f517f5b45867a66e5ad Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 11 Nov 2023 16:19:58 +0100 Subject: [PATCH 008/369] Remove test.only flag --- .../tests/organize/surveys/submitting-survey.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index e504196d4f..d815aa3221 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -6,7 +6,7 @@ import RosaLuxemburg from '../../../mockData/orgs/KPD/people/RosaLuxemburg'; import RosaLuxemburgUser from '../../../mockData/users/RosaLuxemburgUser'; import test from '../../../fixtures/next'; -test.describe.only('User submitting a survey', () => { +test.describe('User submitting a survey', () => { test.afterEach(({ moxy }) => { moxy.teardown(); }); From c1a31280343922063f1d80b428aa6b184ccea6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sat, 11 Nov 2023 16:03:34 +0100 Subject: [PATCH 009/369] Adds checkbox and description of privacy policy. Localization for link and descriptions to Swedish --- src/features/surveys/l10n/messageIds.ts | 11 ++++++-- src/locale/sv.yml | 5 ++++ .../o/[orgId]/surveys/[surveyId]/index.tsx | 28 +++++++++++++++++-- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 8b564e48eb..22736f5243 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -1,5 +1,4 @@ import { ReactElement } from 'react'; - import { m, makeMessages } from 'core/i18n'; export default makeMessages('feat.surveys', { @@ -160,10 +159,18 @@ export default makeMessages('feat.surveys', { unlink: m('Unlink'), }, surveyForm: { + accept: m('I accept the terms stated below'), + policy: { + link: m('https://zetkin.org/privacy'), + text: m('Click to read the full Zetkin Privacy Policy'), + }, sign: { anonymous: m('Submit anonymously'), nameAndEmail: m('Sign with name and e-mail'), }, + termsDescription: m<{ organization: string }>( + 'When you submit this survey, the information you provide will be stored and processed in Zetkin by {organization} in order to organize activism and in accordance with the Zetkin privacy policy.' + ), }, tabs: { overview: m('Overview'), @@ -172,7 +179,7 @@ export default makeMessages('feat.surveys', { }, unlinkedCard: { description: m( - 'When someone submits a survey without logging in,that survey will be unlinked. Searching for people in Zetkin based on their survey responses will not work on unlinked submissions.' + 'When someone submits a survey without logging in, that survey will be unlinked. Searching for people in Zetkin based on their survey responses will not work on unlinked submissions.' ), header: m('Unlinked submissions'), openLink: m<{ numUnlink: number }>( diff --git a/src/locale/sv.yml b/src/locale/sv.yml index 339776bc8d..af167434df 100644 --- a/src/locale/sv.yml +++ b/src/locale/sv.yml @@ -1416,6 +1416,11 @@ feat: personRecordColumn: Svarande suggestedPeople: Föreslagna personer unlink: Avlänka + surveyForm: + accept: Jag accepterar nedanstående villkor + policyLink: https://zetkin.org/sv/sekretess + policyText: Klicka här för att läsa Zetkins sekretesspolicy + termsDescription: När du skickar in enkäten kommer informationen att lagras i Zetkin och hanteras av {organization} i deras verksamhet, i enlighet med Zetkins sekretesspolicy. tabs: overview: Översikt questions: Frågor diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index d2a589e0bd..3291267ba9 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -1,13 +1,17 @@ import BackendApiClient from 'core/api/client/BackendApiClient'; import Box from '@mui/system/Box'; import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; import { FC } from 'react'; import { IncomingMessage } from 'http'; +import messageIds from 'features/surveys/l10n/messageIds'; import { parse } from 'querystring'; import { scaffold } from 'utils/next'; import useSurvey from 'features/surveys/hooks/useSurvey'; import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; import ZUIAvatar from 'zui/ZUIAvatar'; +import { FormControlLabel, Link, Typography } from '@mui/material'; +import { Msg, useMessages } from 'core/i18n'; const scaffoldOptions = { allowNonOfficials: true, @@ -120,12 +124,12 @@ type PageProps = { }; const Page: FC = ({ orgId, surveyId }) => { - const survey = useSurvey(parseInt(orgId, 10), parseInt(surveyId, 10)); const elements = useSurveyElements( parseInt(orgId, 10), parseInt(surveyId, 10) ); - + const messages = useMessages(messageIds); + const survey = useSurvey(parseInt(orgId, 10), parseInt(surveyId, 10)); return ( <>

{survey.data?.title}

@@ -169,7 +173,25 @@ const Page: FC = ({ orgId, surveyId }) => { {element.type === 'text' &&

{element.text_block.content}

} ))} - + } + label={} + /> + + + + + + + + {' '} From fdc2ec35a76dd2a452064e0a534f271e1355b0c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sat, 11 Nov 2023 17:02:18 +0100 Subject: [PATCH 010/369] Added additional localization for submission button --- src/features/surveys/l10n/messageIds.ts | 1 + src/locale/sv.yml | 9 +++++++-- src/pages/o/[orgId]/surveys/[surveyId]/index.tsx | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 22736f5243..e8343b6525 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -168,6 +168,7 @@ export default makeMessages('feat.surveys', { anonymous: m('Submit anonymously'), nameAndEmail: m('Sign with name and e-mail'), }, + submit: m('Submit'), termsDescription: m<{ organization: string }>( 'When you submit this survey, the information you provide will be stored and processed in Zetkin by {organization} in order to organize activism and in accordance with the Zetkin privacy policy.' ), diff --git a/src/locale/sv.yml b/src/locale/sv.yml index af167434df..2b3405bdf6 100644 --- a/src/locale/sv.yml +++ b/src/locale/sv.yml @@ -1418,8 +1418,13 @@ feat: unlink: Avlänka surveyForm: accept: Jag accepterar nedanstående villkor - policyLink: https://zetkin.org/sv/sekretess - policyText: Klicka här för att läsa Zetkins sekretesspolicy + policy: + link: https://zetkin.org/sv/sekretess + text: Klicka här för att läsa Zetkins sekretesspolicy + sign: + anonymous: Skicka anonymt + nameAndEmail: Skicka med namn och e-mail + submit: Skicka termsDescription: När du skickar in enkäten kommer informationen att lagras i Zetkin och hanteras av {organization} i deras verksamhet, i enlighet med Zetkins sekretesspolicy. tabs: overview: Översikt diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 3291267ba9..274690c401 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -191,9 +191,9 @@ const Page: FC = ({ orgId, surveyId }) => { > - {' '} + From ae915ae3ff1b35c52f47ac7912344c06e2092548 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 11 Nov 2023 18:02:23 +0100 Subject: [PATCH 011/369] Fix playwright test --- .../tests/organize/surveys/submitting-survey.spec.ts | 3 ++- src/pages/o/[orgId]/surveys/[surveyId]/index.tsx | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index d815aa3221..6278ebe7f2 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -45,8 +45,9 @@ test.describe('User submitting a survey', () => { ); await page.fill('input', 'Topple capitalism'); + await page.click('data-testid=Survey-acceptTerms'); await Promise.all([ - page.click('text=Submit'), + await page.click('data-testid=Survey-submit'), page.waitForResponse((res) => res.request().method() == 'POST'), ]); diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 274690c401..2ceb9c49ce 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -175,6 +175,7 @@ const Page: FC = ({ orgId, surveyId }) => { ))} } + data-testid="Survey-acceptTerms" label={} /> @@ -192,7 +193,12 @@ const Page: FC = ({ orgId, surveyId }) => { - From 810bf6b10b22c7b00531f00ba6b9877bbc2195b5 Mon Sep 17 00:00:00 2001 From: xuan tran Date: Sat, 11 Nov 2023 17:09:16 +0100 Subject: [PATCH 012/369] add sign in options for user --- src/features/surveys/l10n/messageIds.ts | 6 ++ .../o/[orgId]/surveys/[surveyId]/index.tsx | 101 +++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index e8343b6525..865b700e58 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -160,6 +160,11 @@ export default makeMessages('feat.surveys', { }, surveyForm: { accept: m('I accept the terms stated below'), + anonymousOption: m('Sign anonymously'), + authenticatedOption: m<{ email: string; person: string }>( + 'Sign as {person} with email {email}' + ), + nameEmailOption: m('Sign with name and email'), policy: { link: m('https://zetkin.org/privacy'), text: m('Click to read the full Zetkin Privacy Policy'), @@ -168,6 +173,7 @@ export default makeMessages('feat.surveys', { anonymous: m('Submit anonymously'), nameAndEmail: m('Sign with name and e-mail'), }, + signOptions: m('Choose 1 of these signing options below'), submit: m('Submit'), termsDescription: m<{ organization: string }>( 'When you submit this survey, the information you provide will be stored and processed in Zetkin by {organization} in order to organize activism and in accordance with the Zetkin privacy policy.' diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 2ceb9c49ce..7d4d6f2c73 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -2,17 +2,26 @@ import BackendApiClient from 'core/api/client/BackendApiClient'; import Box from '@mui/system/Box'; import Button from '@mui/material/Button'; import Checkbox from '@mui/material/Checkbox'; -import { FC } from 'react'; import { IncomingMessage } from 'http'; -import messageIds from 'features/surveys/l10n/messageIds'; import { parse } from 'querystring'; import { scaffold } from 'utils/next'; +import useCurrentUser from 'features/user/hooks/useCurrentUser'; import useSurvey from 'features/surveys/hooks/useSurvey'; import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; import ZUIAvatar from 'zui/ZUIAvatar'; -import { FormControlLabel, Link, Typography } from '@mui/material'; +import { FC, useState } from 'react'; import { Msg, useMessages } from 'core/i18n'; +import { + FormControlLabel, + Radio, + RadioGroup, + TextField, + Typography, +} from '@mui/material'; + +import messageIds from 'features/surveys/l10n/messageIds'; + const scaffoldOptions = { allowNonOfficials: true, authLevelRequired: 1, @@ -130,6 +139,26 @@ const Page: FC = ({ orgId, surveyId }) => { ); const messages = useMessages(messageIds); const survey = useSurvey(parseInt(orgId, 10), parseInt(surveyId, 10)); + + const [selectedOption, setSelectedOption] = useState(null); + const [customInput, setCustomInput] = useState({ + email: '', + name: '', + }); + + const handleRadioChange = (option) => { + setSelectedOption(option); + }; + + const handleCustomInputChange = (field, value) => { + setCustomInput((prevInput) => ({ + ...prevInput, + [field]: value, + })); + }; + + const currentUser = useCurrentUser(); + return ( <>

{survey.data?.title}

@@ -201,6 +230,72 @@ const Page: FC = ({ orgId, surveyId }) => { > {messages.surveyForm.submit()} + + + + + + handleRadioChange(e.target.value)} + value={selectedOption} + > + } + label={ + + + + } + value="authenticated" + /> + + } + label={ +
+ + + + {selectedOption === 'name+email' && ( + <> + + handleCustomInputChange('name', e.target.value) + } + value={customInput.name} + /> + + handleCustomInputChange('email', e.target.value) + } + value={customInput.email} + /> + + )} +
+ } + value="name+email" + /> + {survey.data?.signature === 'allow_anonymous' && ( + } + label={ + + + + } + value="anonymous" + /> + )} +
); From 6b0e75afc7419c68f54ea65e836e25a724d6b7b8 Mon Sep 17 00:00:00 2001 From: xuan tran Date: Sat, 11 Nov 2023 17:55:30 +0100 Subject: [PATCH 013/369] fix error --- src/pages/o/[orgId]/surveys/[surveyId]/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 7d4d6f2c73..010a5d9fcf 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -10,10 +10,10 @@ import useSurvey from 'features/surveys/hooks/useSurvey'; import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; import ZUIAvatar from 'zui/ZUIAvatar'; import { FC, useState } from 'react'; -import { Msg, useMessages } from 'core/i18n'; import { FormControlLabel, + Link, Radio, RadioGroup, TextField, @@ -21,6 +21,7 @@ import { } from '@mui/material'; import messageIds from 'features/surveys/l10n/messageIds'; +import { Msg, useMessages } from 'core/i18n'; const scaffoldOptions = { allowNonOfficials: true, From 3808bc31aeecf73b52cebd515c710ff1cff53245 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 11 Nov 2023 18:21:10 +0100 Subject: [PATCH 014/369] Fixes --- .../o/[orgId]/surveys/[surveyId]/index.tsx | 83 ++++++++++--------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 010a5d9fcf..fb383d8670 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -3,6 +3,7 @@ import Box from '@mui/system/Box'; import Button from '@mui/material/Button'; import Checkbox from '@mui/material/Checkbox'; import { IncomingMessage } from 'http'; +import messageIds from 'features/surveys/l10n/messageIds'; import { parse } from 'querystring'; import { scaffold } from 'utils/next'; import useCurrentUser from 'features/user/hooks/useCurrentUser'; @@ -20,7 +21,6 @@ import { Typography, } from '@mui/material'; -import messageIds from 'features/surveys/l10n/messageIds'; import { Msg, useMessages } from 'core/i18n'; const scaffoldOptions = { @@ -141,17 +141,21 @@ const Page: FC = ({ orgId, surveyId }) => { const messages = useMessages(messageIds); const survey = useSurvey(parseInt(orgId, 10), parseInt(surveyId, 10)); - const [selectedOption, setSelectedOption] = useState(null); + const [selectedOption, setSelectedOption] = useState< + null | 'authenticated' | 'name+email' | 'anonymous' + >(null); const [customInput, setCustomInput] = useState({ email: '', name: '', }); - const handleRadioChange = (option) => { - setSelectedOption(option); + const handleRadioChange = ( + value: 'authenticated' | 'name+email' | 'anonymous' + ) => { + setSelectedOption(value); }; - const handleCustomInputChange = (field, value) => { + const handleCustomInputChange = (field: 'name' | 'email', value: string) => { setCustomInput((prevInput) => ({ ...prevInput, [field]: value, @@ -203,41 +207,16 @@ const Page: FC = ({ orgId, surveyId }) => { {element.type === 'text' &&

{element.text_block.content}

} ))} - } - data-testid="Survey-acceptTerms" - label={} - /> - - - - - - - - - - handleRadioChange(e.target.value)} + onChange={(e) => + handleRadioChange( + e.target.value as 'authenticated' | 'name+email' | 'anonymous' + ) + } value={selectedOption} > = ({ orgId, surveyId }) => { @@ -297,6 +276,36 @@ const Page: FC = ({ orgId, surveyId }) => { /> )} + + } + data-testid="Survey-acceptTerms" + label={} + /> + + + + + + + + + + ); From 83a10a0763e70f4dacea4d27a323d8b69de55150 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 11 Nov 2023 16:35:23 +0100 Subject: [PATCH 015/369] Create components for each survey element --- .../surveys/submitting-survey.spec.ts | 2 +- .../components/surveyForm/OptionsQuestion.tsx | 70 +++++++++++++++++++ .../components/surveyForm/TextBlock.tsx | 8 +++ .../components/surveyForm/TextQuestion.tsx | 22 ++++++ .../o/[orgId]/surveys/[surveyId]/index.tsx | 39 +++++------ 5 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 src/features/surveys/components/surveyForm/OptionsQuestion.tsx create mode 100644 src/features/surveys/components/surveyForm/TextBlock.tsx create mode 100644 src/features/surveys/components/surveyForm/TextQuestion.tsx diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 6278ebe7f2..0423f17ed6 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -44,7 +44,7 @@ test.describe('User submitting a survey', () => { `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` ); - await page.fill('input', 'Topple capitalism'); + await page.fill('input[name="2.text"]', 'Topple capitalism'); await page.click('data-testid=Survey-acceptTerms'); await Promise.all([ await page.click('data-testid=Survey-submit'), diff --git a/src/features/surveys/components/surveyForm/OptionsQuestion.tsx b/src/features/surveys/components/surveyForm/OptionsQuestion.tsx new file mode 100644 index 0000000000..c36a161cde --- /dev/null +++ b/src/features/surveys/components/surveyForm/OptionsQuestion.tsx @@ -0,0 +1,70 @@ +import { FC } from 'react'; +import { + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + FormLabel, + MenuItem, + Radio, + RadioGroup, + Select, +} from '@mui/material'; +import { + ZetkinSurveyOption, + ZetkinSurveyOptionsQuestionElement, +} from 'utils/types/zetkin'; + +const OptionsQuestion: FC<{ element: ZetkinSurveyOptionsQuestionElement }> = ({ + element, +}) => { + return ( + + + {element.question.question} + + {element.question.response_config.widget_type === 'checkbox' && ( + + {element.question.options!.map((option: ZetkinSurveyOption) => ( + } + label={option.text} + name={`${element.id}.options`} + value={option.id} + /> + ))} + + )} + {element.question.response_config.widget_type === 'radio' && ( + + {element.question.options!.map((option: ZetkinSurveyOption) => ( + } + label={option.text} + value={option.id} + /> + ))} + + )} + {element.question.response_config.widget_type === 'select' && ( + + )} + + ); +}; + +export default OptionsQuestion; diff --git a/src/features/surveys/components/surveyForm/TextBlock.tsx b/src/features/surveys/components/surveyForm/TextBlock.tsx new file mode 100644 index 0000000000..d1f79f352d --- /dev/null +++ b/src/features/surveys/components/surveyForm/TextBlock.tsx @@ -0,0 +1,8 @@ +import { FC } from 'react'; +import { ZetkinSurveyTextElement } from 'utils/types/zetkin'; + +const TextBlock: FC<{ element: ZetkinSurveyTextElement }> = ({ element }) => { + return

{element.text_block.content}

; +}; + +export default TextBlock; diff --git a/src/features/surveys/components/surveyForm/TextQuestion.tsx b/src/features/surveys/components/surveyForm/TextQuestion.tsx new file mode 100644 index 0000000000..7e305702bb --- /dev/null +++ b/src/features/surveys/components/surveyForm/TextQuestion.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react'; +import { ZetkinSurveyTextQuestionElement } from 'utils/types/zetkin'; +import { FormControl, FormLabel, TextField } from '@mui/material'; + +const OptionsQuestion: FC<{ element: ZetkinSurveyTextQuestionElement }> = ({ + element, +}) => { + return ( + + + {element.question.question} + + + + ); +}; + +export default OptionsQuestion; diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index fb383d8670..7ccf043f23 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -7,6 +7,15 @@ import messageIds from 'features/surveys/l10n/messageIds'; import { parse } from 'querystring'; import { scaffold } from 'utils/next'; import useCurrentUser from 'features/user/hooks/useCurrentUser'; +import { + ZetkinSurveyOptionsQuestionElement, + ZetkinSurveyTextElement, + ZetkinSurveyTextQuestionElement, +} from 'utils/types/zetkin'; + +import OptionsQuestion from 'features/surveys/components/surveyForm/OptionsQuestion'; +import TextBlock from 'features/surveys/components/surveyForm/TextBlock'; +import TextQuestion from 'features/surveys/components/surveyForm/TextQuestion'; import useSurvey from 'features/surveys/hooks/useSurvey'; import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; import ZUIAvatar from 'zui/ZUIAvatar'; @@ -179,32 +188,22 @@ const Page: FC = ({ orgId, surveyId }) => { {(elements.data || []).map((element) => (
{element.type === 'question' && ( - - + <> {element.question.response_type === 'text' && ( - )} {element.question.response_type === 'options' && ( - + )} - + + )} + {element.type === 'text' && ( + )} - {element.type === 'text' &&

{element.text_block.content}

}
))} From 67403fcf9a56885a32eceeeb89577572c83560f2 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 12 Nov 2023 09:49:11 +0100 Subject: [PATCH 016/369] Fix the playwright test for the new survey page --- .../mockData/orgs/KPD/surveys/MembershipSurvey/index.ts | 2 +- .../tests/organize/surveys/submitting-survey.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts b/integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts index 3d8e28a335..eedea651ff 100644 --- a/integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts +++ b/integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts @@ -21,7 +21,7 @@ const KPDMembershipSurvey: ZetkinSurveyExtended = { text: 'Yes', }, { - id: 1, + id: 2, text: 'No', }, ], diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 0423f17ed6..38d9a0d03b 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -44,11 +44,12 @@ test.describe('User submitting a survey', () => { `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` ); + await page.click('input[name="1.options"]'); await page.fill('input[name="2.text"]', 'Topple capitalism'); await page.click('data-testid=Survey-acceptTerms'); await Promise.all([ - await page.click('data-testid=Survey-submit'), page.waitForResponse((res) => res.request().method() == 'POST'), + await page.click('data-testid=Survey-submit'), ]); const reqLog = moxy.log(`/v1${apiPostPath}`); From df4815ca4804e2db0c567d9efbe6b363895177be Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 12 Nov 2023 10:30:52 +0100 Subject: [PATCH 017/369] Align new survey signature formdata with old survey --- .../o/[orgId]/surveys/[surveyId]/index.tsx | 56 ++++++------------- 1 file changed, 16 insertions(+), 40 deletions(-) diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 7ccf043f23..a0d45eadcd 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -142,6 +142,8 @@ type PageProps = { surveyId: string; }; +type SignatureOption = 'authenticated' | 'email' | 'anonymous'; + const Page: FC = ({ orgId, surveyId }) => { const elements = useSurveyElements( parseInt(orgId, 10), @@ -150,27 +152,14 @@ const Page: FC = ({ orgId, surveyId }) => { const messages = useMessages(messageIds); const survey = useSurvey(parseInt(orgId, 10), parseInt(surveyId, 10)); - const [selectedOption, setSelectedOption] = useState< - null | 'authenticated' | 'name+email' | 'anonymous' - >(null); - const [customInput, setCustomInput] = useState({ - email: '', - name: '', - }); + const [selectedOption, setSelectedOption] = useState( + null + ); - const handleRadioChange = ( - value: 'authenticated' | 'name+email' | 'anonymous' - ) => { + const handleRadioChange = (value: SignatureOption) => { setSelectedOption(value); }; - const handleCustomInputChange = (field: 'name' | 'email', value: string) => { - setCustomInput((prevInput) => ({ - ...prevInput, - [field]: value, - })); - }; - const currentUser = useCurrentUser(); return ( @@ -211,11 +200,8 @@ const Page: FC = ({ orgId, surveyId }) => { - handleRadioChange( - e.target.value as 'authenticated' | 'name+email' | 'anonymous' - ) - } + name="sig" + onChange={(e) => handleRadioChange(e.target.value as SignatureOption)} value={selectedOption} > = ({ orgId, surveyId }) => { - {selectedOption === 'name+email' && ( - <> - - handleCustomInputChange('name', e.target.value) - } - value={customInput.name} - /> - - handleCustomInputChange('email', e.target.value) - } - value={customInput.email} - /> - + {selectedOption === 'email' && ( + + + + + )} } - value="name+email" + value="email" /> {survey.data?.signature === 'allow_anonymous' && ( = ({ orgId, surveyId }) => { control={} data-testid="Survey-acceptTerms" label={} + name="privacy.approval" /> Date: Sun, 12 Nov 2023 10:08:22 +0100 Subject: [PATCH 018/369] Add email:string to ZetkinUser --- integrationTesting/mockData/users/RosaLuxemburgUser.ts | 1 + src/pages/o/[orgId]/surveys/[surveyId]/index.tsx | 2 +- src/utils/types/zetkin.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/integrationTesting/mockData/users/RosaLuxemburgUser.ts b/integrationTesting/mockData/users/RosaLuxemburgUser.ts index 93531f9f2e..aea8a49785 100644 --- a/integrationTesting/mockData/users/RosaLuxemburgUser.ts +++ b/integrationTesting/mockData/users/RosaLuxemburgUser.ts @@ -1,6 +1,7 @@ import { ZetkinUser } from 'utils/types/zetkin'; const RosaLuxemburgUser: ZetkinUser = { + email: 'rosa@example.org', first_name: 'Rosa', id: 1, lang: null, diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index a0d45eadcd..49a886120d 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -211,7 +211,7 @@ const Page: FC = ({ orgId, surveyId }) => { diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index 890fcb68e4..b033cc5c64 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -135,6 +135,7 @@ export interface ZetkinUser { lang: string | null; last_name: string; username: string; + email: string; } export interface ZetkinOrganization { From 4f7c6e8986b9445288419c13052f2ca2bc47888e Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 12 Nov 2023 11:18:41 +0100 Subject: [PATCH 019/369] Load survey in getServerSideProps and add error handling --- .../components/surveyForm/ErrorMessage.tsx | 14 ++++ src/features/surveys/l10n/messageIds.ts | 3 + .../o/[orgId]/surveys/[surveyId]/index.tsx | 72 +++++++++++-------- 3 files changed, 61 insertions(+), 28 deletions(-) create mode 100644 src/features/surveys/components/surveyForm/ErrorMessage.tsx diff --git a/src/features/surveys/components/surveyForm/ErrorMessage.tsx b/src/features/surveys/components/surveyForm/ErrorMessage.tsx new file mode 100644 index 0000000000..3b40e872d1 --- /dev/null +++ b/src/features/surveys/components/surveyForm/ErrorMessage.tsx @@ -0,0 +1,14 @@ +import Box from '@mui/material/Box'; +import { FC } from 'react'; +import messageIds from 'features/surveys/l10n/messageIds'; +import { Msg } from 'core/i18n'; + +const ErrorMessage: FC = () => { + return ( + + + + ); +}; + +export default ErrorMessage; diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 865b700e58..181b3a6163 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -164,6 +164,9 @@ export default makeMessages('feat.surveys', { authenticatedOption: m<{ email: string; person: string }>( 'Sign as {person} with email {email}' ), + error: m( + 'Something went wrong when submitting your answers. Please try again later.' + ), nameEmailOption: m('Sign with name and email'), policy: { link: m('https://zetkin.org/privacy'), diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 49a886120d..9120c5f617 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -8,16 +8,16 @@ import { parse } from 'querystring'; import { scaffold } from 'utils/next'; import useCurrentUser from 'features/user/hooks/useCurrentUser'; import { + ZetkinSurveyExtended, ZetkinSurveyOptionsQuestionElement, ZetkinSurveyTextElement, ZetkinSurveyTextQuestionElement, } from 'utils/types/zetkin'; +import ErrorMessage from 'features/surveys/components/surveyForm/ErrorMessage'; import OptionsQuestion from 'features/surveys/components/surveyForm/OptionsQuestion'; import TextBlock from 'features/surveys/components/surveyForm/TextBlock'; import TextQuestion from 'features/surveys/components/surveyForm/TextQuestion'; -import useSurvey from 'features/surveys/hooks/useSurvey'; -import useSurveyElements from 'features/surveys/hooks/useSurveyElements'; import ZUIAvatar from 'zui/ZUIAvatar'; import { FC, useState } from 'react'; @@ -54,6 +54,17 @@ function parseRequest( export const getServerSideProps = scaffold(async (ctx) => { const { req, res } = ctx; const { surveyId, orgId } = ctx.params!; + let status: FormStatus = 'editing'; + + const apiClient = new BackendApiClient(req.headers); + let survey: ZetkinSurveyExtended; + try { + survey = await apiClient.get( + `/api/orgs/${orgId}/surveys/${surveyId}` + ); + } catch (e) { + return { notFound: true }; + } if (req.method === 'POST') { const form = await parseRequest(req); @@ -111,46 +122,49 @@ export const getServerSideProps = scaffold(async (ctx) => { }; } - const apiClient = new BackendApiClient(req.headers); - const requestUrl = `/api/orgs/${orgId}/surveys/${surveyId}/submissions`; try { - await apiClient.post(requestUrl, { - responses: Object.values(responses), - signature, - }); + await apiClient.post( + `/api/orgs/${orgId}/surveys/${surveyId}/submissions`, + { + responses: Object.values(responses), + signature, + } + ); } catch (e) { - // eslint-disable-next-line no-console - console.log(e); + status = 'error'; + } + if (status !== 'error') { + status = 'submitted'; } - res.writeHead(302, { - Location: `/o/${orgId}/surveys/${surveyId}/submitted`, - }); - res.end(); + if (status === 'submitted') { + res.writeHead(302, { + Location: `/o/${orgId}/surveys/${surveyId}/submitted`, + }); + res.end(); + } } return { props: { orgId, - surveyId, + status, + survey, }, }; }, scaffoldOptions); type PageProps = { orgId: string; - surveyId: string; + status: FormStatus; + survey: ZetkinSurveyExtended; }; +type FormStatus = 'editing' | 'invalid' | 'error' | 'submitted'; type SignatureOption = 'authenticated' | 'email' | 'anonymous'; -const Page: FC = ({ orgId, surveyId }) => { - const elements = useSurveyElements( - parseInt(orgId, 10), - parseInt(surveyId, 10) - ); +const Page: FC = ({ orgId, status, survey }) => { const messages = useMessages(messageIds); - const survey = useSurvey(parseInt(orgId, 10), parseInt(surveyId, 10)); const [selectedOption, setSelectedOption] = useState( null @@ -164,17 +178,19 @@ const Page: FC = ({ orgId, surveyId }) => { return ( <> -

{survey.data?.title}

+

{survey.title}

+ + {status === 'error' && } - {survey.data?.info_text &&

{survey.data?.info_text}

} + {survey.info_text &&

{survey.info_text}

} - {survey.data?.organization.title} + {survey.organization.title}
- {(elements.data || []).map((element) => ( + {survey.elements.map((element) => (
{element.type === 'question' && ( <> @@ -238,7 +254,7 @@ const Page: FC = ({ orgId, surveyId }) => { } value="email" /> - {survey.data?.signature === 'allow_anonymous' && ( + {survey.signature === 'allow_anonymous' && ( } label={ @@ -260,7 +276,7 @@ const Page: FC = ({ orgId, surveyId }) => { From eefa86aa7b5aec19b9ae2dc18323717b9410c813 Mon Sep 17 00:00:00 2001 From: xuan tran Date: Sun, 12 Nov 2023 12:14:10 +0100 Subject: [PATCH 020/369] Form styling --- .../components/surveyForm/OptionsQuestion.tsx | 11 +++- .../components/surveyForm/TextQuestion.tsx | 14 ++++- src/features/surveys/l10n/messageIds.ts | 2 +- .../o/[orgId]/surveys/[surveyId]/index.tsx | 54 ++++++++++++++++--- 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/features/surveys/components/surveyForm/OptionsQuestion.tsx b/src/features/surveys/components/surveyForm/OptionsQuestion.tsx index c36a161cde..549ba1105b 100644 --- a/src/features/surveys/components/surveyForm/OptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/OptionsQuestion.tsx @@ -20,7 +20,16 @@ const OptionsQuestion: FC<{ element: ZetkinSurveyOptionsQuestionElement }> = ({ }) => { return ( - + {element.question.question} {element.question.response_config.widget_type === 'checkbox' && ( diff --git a/src/features/surveys/components/surveyForm/TextQuestion.tsx b/src/features/surveys/components/surveyForm/TextQuestion.tsx index 7e305702bb..ea19646066 100644 --- a/src/features/surveys/components/surveyForm/TextQuestion.tsx +++ b/src/features/surveys/components/surveyForm/TextQuestion.tsx @@ -6,11 +6,21 @@ const OptionsQuestion: FC<{ element: ZetkinSurveyTextQuestionElement }> = ({ element, }) => { return ( - - + + {element.question.question} ( 'When you submit this survey, the information you provide will be stored and processed in Zetkin by {organization} in order to organize activism and in accordance with the Zetkin privacy policy.' diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index 9120c5f617..bd76fe3e02 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -22,12 +22,15 @@ import ZUIAvatar from 'zui/ZUIAvatar'; import { FC, useState } from 'react'; import { + Container, FormControlLabel, + FormControlLabelProps, Link, Radio, RadioGroup, TextField, Typography, + useRadioGroup, } from '@mui/material'; import { Msg, useMessages } from 'core/i18n'; @@ -163,6 +166,31 @@ type PageProps = { type FormStatus = 'editing' | 'invalid' | 'error' | 'submitted'; type SignatureOption = 'authenticated' | 'email' | 'anonymous'; +function RadioFormControlLabel(props: FormControlLabelProps) { + const radioGroup = useRadioGroup(); + + let checked = false; + + if (radioGroup) { + checked = radioGroup.value === props.value; + } + + if (checked) { + return ( + + ); + } + + return ; +} + const Page: FC = ({ orgId, status, survey }) => { const messages = useMessages(messageIds); @@ -177,7 +205,7 @@ const Page: FC = ({ orgId, status, survey }) => { const currentUser = useCurrentUser(); return ( - <> +

{survey.title}

{status === 'error' && } @@ -211,7 +239,15 @@ const Page: FC = ({ orgId, status, survey }) => { )}
))} - + @@ -220,7 +256,7 @@ const Page: FC = ({ orgId, status, survey }) => { onChange={(e) => handleRadioChange(e.target.value as SignatureOption)} value={selectedOption} > - } label={ @@ -236,7 +272,7 @@ const Page: FC = ({ orgId, status, survey }) => { value="authenticated" /> - } label={
@@ -254,8 +290,9 @@ const Page: FC = ({ orgId, status, survey }) => { } value="email" /> + {survey.signature === 'allow_anonymous' && ( - } label={ @@ -273,13 +310,13 @@ const Page: FC = ({ orgId, status, survey }) => { label={} name="privacy.approval" /> - + - + = ({ orgId, status, survey }) => { - + ); }; From cec25a907cb9f65dfb25a2ed7ba002bf51077575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sun, 12 Nov 2023 12:06:05 +0100 Subject: [PATCH 021/369] Merging conflicts --- .../surveys/submitting-survey.spec.ts | 1 + src/features/surveys/l10n/messageIds.ts | 11 +- src/locale/sv.yml | 8 +- .../o/[orgId]/surveys/[surveyId]/index.tsx | 108 +++++++++--------- 4 files changed, 69 insertions(+), 59 deletions(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 38d9a0d03b..219d5066a9 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -46,6 +46,7 @@ test.describe('User submitting a survey', () => { await page.click('input[name="1.options"]'); await page.fill('input[name="2.text"]', 'Topple capitalism'); + await page.click('input[name="sig"][value="authenticated"]'); await page.click('data-testid=Survey-acceptTerms'); await Promise.all([ page.waitForResponse((res) => res.request().method() == 'POST'), diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index a9a0892bcc..f78bab07d3 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -173,14 +173,17 @@ export default makeMessages('feat.surveys', { text: m('Click to read the full Zetkin Privacy Policy'), }, sign: { - anonymous: m('Submit anonymously'), + anonymous: m('Sign anonymously'), nameAndEmail: m('Sign with name and e-mail'), }, signOptions: m('Choose how to sign'), submit: m('Submit'), - termsDescription: m<{ organization: string }>( - 'When you submit this survey, the information you provide will be stored and processed in Zetkin by {organization} in order to organize activism and in accordance with the Zetkin privacy policy.' - ), + terms: { + description: m<{ organization: string }>( + 'When you submit this survey, the information you provide will be stored and processed in Zetkin by {organization} in order to organize activism and in accordance with the Zetkin privacy policy.' + ), + title: m('Privacy Policy'), + }, }, tabs: { overview: m('Overview'), diff --git a/src/locale/sv.yml b/src/locale/sv.yml index 2b3405bdf6..dcb81bf873 100644 --- a/src/locale/sv.yml +++ b/src/locale/sv.yml @@ -1422,10 +1422,12 @@ feat: link: https://zetkin.org/sv/sekretess text: Klicka här för att läsa Zetkins sekretesspolicy sign: - anonymous: Skicka anonymt - nameAndEmail: Skicka med namn och e-mail + anonymous: Signera anonymt + nameAndEmail: Signera med namn och e-mail submit: Skicka - termsDescription: När du skickar in enkäten kommer informationen att lagras i Zetkin och hanteras av {organization} i deras verksamhet, i enlighet med Zetkins sekretesspolicy. + terms: + description: När du skickar in enkäten kommer informationen att lagras i Zetkin och hanteras av {organization} i deras verksamhet, i enlighet med Zetkins sekretesspolicy. + title: Sekretesspolicy tabs: overview: Översikt questions: Frågor diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index bd76fe3e02..d49549237a 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -1,27 +1,18 @@ import BackendApiClient from 'core/api/client/BackendApiClient'; -import Box from '@mui/system/Box'; -import Button from '@mui/material/Button'; -import Checkbox from '@mui/material/Checkbox'; +import ErrorMessage from 'features/surveys/components/surveyForm/ErrorMessage'; import { IncomingMessage } from 'http'; import messageIds from 'features/surveys/l10n/messageIds'; +import OptionsQuestion from 'features/surveys/components/surveyForm/OptionsQuestion'; import { parse } from 'querystring'; import { scaffold } from 'utils/next'; -import useCurrentUser from 'features/user/hooks/useCurrentUser'; -import { - ZetkinSurveyExtended, - ZetkinSurveyOptionsQuestionElement, - ZetkinSurveyTextElement, - ZetkinSurveyTextQuestionElement, -} from 'utils/types/zetkin'; - -import ErrorMessage from 'features/surveys/components/surveyForm/ErrorMessage'; -import OptionsQuestion from 'features/surveys/components/surveyForm/OptionsQuestion'; import TextBlock from 'features/surveys/components/surveyForm/TextBlock'; import TextQuestion from 'features/surveys/components/surveyForm/TextQuestion'; +import useCurrentUser from 'features/user/hooks/useCurrentUser'; import ZUIAvatar from 'zui/ZUIAvatar'; -import { FC, useState } from 'react'; - import { + Box, + Button, + Checkbox, Container, FormControlLabel, FormControlLabelProps, @@ -32,8 +23,14 @@ import { Typography, useRadioGroup, } from '@mui/material'; - +import { FC, useState } from 'react'; import { Msg, useMessages } from 'core/i18n'; +import { + ZetkinSurveyExtended, + ZetkinSurveyOptionsQuestionElement, + ZetkinSurveyTextElement, + ZetkinSurveyTextQuestionElement, +} from 'utils/types/zetkin'; const scaffoldOptions = { allowNonOfficials: true, @@ -206,17 +203,17 @@ const Page: FC = ({ orgId, status, survey }) => { return ( -

{survey.title}

- - {status === 'error' && } - - {survey.info_text &&

{survey.info_text}

} - {survey.organization.title} + {status === 'error' && } + +

{survey.title}

+ + {survey.info_text &&

{survey.info_text}

} +
{survey.elements.map((element) => (
@@ -257,7 +254,7 @@ const Page: FC = ({ orgId, status, survey }) => { value={selectedOption} > } + control={} label={ = ({ orgId, status, survey }) => { /> } + control={} label={
- {selectedOption === 'email' && ( - - - - - - )}
} value="email" /> + {selectedOption === 'email' && ( + + + + + + )} + {survey.signature === 'allow_anonymous' && ( } + control={} label={ @@ -304,27 +302,33 @@ const Page: FC = ({ orgId, status, survey }) => { )} - } - data-testid="Survey-acceptTerms" - label={} - name="privacy.approval" - /> - - + + + + + } + data-testid="Survey-acceptTerms" + label={} + name="privacy.approval" /> - - - - - - + + + + + + + + + + ); +}; + +export default SurveySubmitButton; diff --git a/src/features/surveys/components/surveyForm/SurveySuccess.tsx b/src/features/surveys/components/surveyForm/SurveySuccess.tsx new file mode 100644 index 0000000000..bc2c968c95 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveySuccess.tsx @@ -0,0 +1,27 @@ +import { Box } from '@mui/material'; +import { FC } from 'react'; +import messageIds from 'features/surveys/l10n/messageIds'; +import { Msg } from 'core/i18n'; +import { ZetkinSurveyExtended } from 'utils/types/zetkin'; + +export type SurveySuccessProps = { + survey: ZetkinSurveyExtended; +}; + +const SurveySuccess: FC = ({ survey }) => { + return ( + +

+ +

+

+ +

+
+ ); +}; + +export default SurveySuccess; diff --git a/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx new file mode 100644 index 0000000000..836f3d161a --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx @@ -0,0 +1,12 @@ +import { FC } from 'react'; +import { ZetkinSurveyTextElement } from 'utils/types/zetkin'; + +export type SurveyTextBlockProps = { + element: ZetkinSurveyTextElement; +}; + +const SurveyTextBlock: FC = ({ element }) => { + return

{element.text_block.content}

; +}; + +export default SurveyTextBlock; diff --git a/src/features/surveys/components/surveyForm/TextQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx similarity index 60% rename from src/features/surveys/components/surveyForm/TextQuestion.tsx rename to src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx index 2fd950549b..09be695478 100644 --- a/src/features/surveys/components/surveyForm/TextQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx @@ -2,13 +2,19 @@ import { FC } from 'react'; import { ZetkinSurveyTextQuestionElement } from 'utils/types/zetkin'; import { FormControl, FormLabel, TextField } from '@mui/material'; -const OptionsQuestion: FC<{ - defaultValue?: string; +export type SurveyOptionsQuestionProps = { element: ZetkinSurveyTextQuestionElement; -}> = ({ element, defaultValue = '' }) => { + formData: NodeJS.Dict; +}; + +const SurveyOptionsQuestion: FC = ({ + element, + formData, +}) => { return ( = ({ element }) => { - return

{element.text_block.content}

; -}; - -export default TextBlock; diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx index cb1f631107..6548f059f0 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/index.tsx @@ -1,32 +1,19 @@ import BackendApiClient from 'core/api/client/BackendApiClient'; -import ErrorMessage from 'features/surveys/components/surveyForm/ErrorMessage'; +import { Container } from '@mui/material'; import { FC } from 'react'; import { IncomingMessage } from 'http'; -import messageIds from 'features/surveys/l10n/messageIds'; -import OptionsQuestion from 'features/surveys/components/surveyForm/OptionsQuestion'; import { parse } from 'querystring'; import { scaffold } from 'utils/next'; +import SurveyElements from 'features/surveys/components/surveyForm/SurveyElements'; +import SurveyHeading from 'features/surveys/components/surveyForm/SurveyHeading'; +import SurveyPrivacyPolicy from 'features/surveys/components/surveyForm/SurveyPrivacyPolicy'; import SurveySignature from 'features/surveys/components/surveyForm/SurveySignature'; -import TextBlock from 'features/surveys/components/surveyForm/TextBlock'; -import TextQuestion from 'features/surveys/components/surveyForm/TextQuestion'; -import ZUIAvatar from 'zui/ZUIAvatar'; -import { - Box, - Button, - Checkbox, - Container, - FormControlLabel, - Link, - Typography, -} from '@mui/material'; -import { Msg, useMessages } from 'core/i18n'; +import SurveySubmitButton from 'features/surveys/components/surveyForm/SurveySubmitButton'; import { ZetkinSurveyExtended, - ZetkinSurveyOptionsQuestionElement, + ZetkinSurveyFormStatus, ZetkinSurveyQuestionResponse, ZetkinSurveySignaturePayload, - ZetkinSurveyTextElement, - ZetkinSurveyTextQuestionElement, } from 'utils/types/zetkin'; const scaffoldOptions = { @@ -51,7 +38,7 @@ function parseRequest( export const getServerSideProps = scaffold(async (ctx) => { const { req } = ctx; const { surveyId, orgId } = ctx.params!; - let status: FormStatus = 'editing'; + let status: ZetkinSurveyFormStatus = 'editing'; let formData: NodeJS.Dict = {}; const apiClient = new BackendApiClient(req.headers); @@ -153,96 +140,19 @@ export const getServerSideProps = scaffold(async (ctx) => { type PageProps = { formData: NodeJS.Dict; - orgId: string; - status: FormStatus; + status: ZetkinSurveyFormStatus; survey: ZetkinSurveyExtended; }; -type FormStatus = 'editing' | 'invalid' | 'error' | 'submitted'; - -const Page: FC = ({ formData, orgId, status, survey }) => { - const messages = useMessages(messageIds); - +const Page: FC = ({ formData, status, survey }) => { return ( - - - {survey.organization.title} - - - {status === 'error' && } - -

{survey.title}

- - {survey.info_text &&

{survey.info_text}

} - + - {survey.elements.map((element) => ( -
- {element.type === 'question' && ( - <> - {element.question.response_type === 'text' && ( - - )} - {element.question.response_type === 'options' && ( - - )} - - )} - {element.type === 'text' && ( - - )} -
- ))} - + - - - - - - - } - data-testid="Survey-acceptTerms" - label={} - name="privacy.approval" - /> - - - - - - - - - - - + +
); diff --git a/src/pages/o/[orgId]/surveys/[surveyId]/submitted/index.tsx b/src/pages/o/[orgId]/surveys/[surveyId]/submitted/index.tsx index c6c08e8575..bbb4b2376e 100644 --- a/src/pages/o/[orgId]/surveys/[surveyId]/submitted/index.tsx +++ b/src/pages/o/[orgId]/surveys/[surveyId]/submitted/index.tsx @@ -1,8 +1,7 @@ import BackendApiClient from 'core/api/client/BackendApiClient'; import { FC } from 'react'; -import messageIds from 'features/surveys/l10n/messageIds'; -import { Msg } from 'core/i18n'; import { scaffold } from 'utils/next'; +import SurveySuccess from 'features/surveys/components/surveyForm/SurveySuccess'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; const scaffoldOptions = { @@ -36,19 +35,7 @@ type PageProps = { }; const Page: FC = ({ survey }) => { - return ( - <> -

- -

-

- -

- - ); + return ; }; export default Page; diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index b5c78b046d..91a804260e 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -282,6 +282,12 @@ export type ZetkinSurveyElement = | ZetkinSurveyTextElement | ZetkinSurveyQuestionElement; +export type ZetkinSurveyFormStatus = + | 'editing' + | 'invalid' + | 'error' + | 'submitted'; + export enum RESPONSE_TYPE { OPTIONS = 'options', TEXT = 'text', From 832e194e6f3769df7b4cc329f393cc4d37d8adff Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Thu, 7 Dec 2023 21:07:56 +0100 Subject: [PATCH 034/369] Tweak according to PR feedback --- .../components/surveyForm/SurveyOptionsQuestion.tsx | 8 +++++--- .../surveys/components/surveyForm/SurveySuccess.tsx | 6 +++--- .../surveys/components/surveyForm/SurveyTextBlock.tsx | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index 70824629a8..f25a1799c9 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -32,7 +32,7 @@ const OptionsQuestion: FC = ({ element, formData }) => { return ( - {element.question.response_config.widget_type === 'checkbox' ? ( + {element.question.response_config.widget_type === 'checkbox' && ( = ({ element, formData }) => { /> ))} - ) : element.question.response_config.widget_type === 'radio' ? ( + )} + {element.question.response_config.widget_type === 'radio' && ( = ({ element, formData }) => { /> ))} - ) : ( + )} + {element.question.response_config.widget_type === 'select' && ( {element.question.options!.map((option: ZetkinSurveyOption) => ( diff --git a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx index 731b485314..177b43d6c2 100644 --- a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx +++ b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx @@ -14,14 +14,10 @@ import { import { Msg, useMessages } from 'core/i18n'; export type SurveyPrivacyPolicyProps = { - formData: NodeJS.Dict; survey: ZetkinSurveyExtended; }; -const SurveyPrivacyPolicy: FC = ({ - formData, - survey, -}) => { +const SurveyPrivacyPolicy: FC = ({ survey }) => { const messages = useMessages(messageIds); return ( @@ -32,12 +28,7 @@ const SurveyPrivacyPolicy: FC = ({
- } + control={} data-testid="Survey-acceptTerms" label={} name="privacy.approval" diff --git a/src/features/surveys/components/surveyForm/SurveyQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyQuestion.tsx index 64b7851da9..82a0e3116f 100644 --- a/src/features/surveys/components/surveyForm/SurveyQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyQuestion.tsx @@ -9,22 +9,19 @@ import { export type SurveyQuestionProps = { element: ZetkinSurveyQuestionElement; - formData: NodeJS.Dict; }; -const SurveyQuestion: FC = ({ element, formData }) => { +const SurveyQuestion: FC = ({ element }) => { return ( <> {element.question.response_type === 'text' && ( )} {element.question.response_type === 'options' && ( )} diff --git a/src/features/surveys/components/surveyForm/SurveySignature.tsx b/src/features/surveys/components/surveyForm/SurveySignature.tsx index 6f6e4ef934..a910c376b3 100644 --- a/src/features/surveys/components/surveyForm/SurveySignature.tsx +++ b/src/features/surveys/components/surveyForm/SurveySignature.tsx @@ -21,17 +21,16 @@ import { } from 'utils/types/zetkin'; export type SurveySignatureProps = { - formData: NodeJS.Dict; survey: ZetkinSurveyExtended; }; -const SurveySignature: FC = ({ formData, survey }) => { +const SurveySignature: FC = ({ survey }) => { // const currentUser = useCurrentUser(); const theme = useTheme(); const [signatureType, setSignatureType] = useState< ZetkinSurveySignatureType | undefined - >(formData['sig'] as ZetkinSurveySignatureType | undefined); + >(undefined); const handleRadioChange = useCallback( (value: ZetkinSurveySignatureType) => { @@ -44,7 +43,6 @@ const SurveySignature: FC = ({ formData, survey }) => { handleRadioChange(e.target.value as ZetkinSurveySignatureType) @@ -91,19 +89,16 @@ const SurveySignature: FC = ({ formData, survey }) => { style={{ rowGap: theme.spacing(1) }} > } name="sig.first_name" required /> } name="sig.last_name" required /> } name="sig.email" required diff --git a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx index d41c957c44..c3295720bb 100644 --- a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx @@ -5,24 +5,15 @@ import { FormControl, FormLabel, TextField } from '@mui/material'; export type SurveyOptionsQuestionProps = { element: ZetkinSurveyTextQuestionElement; - formData: NodeJS.Dict; }; -const SurveyOptionsQuestion: FC = ({ - element, - formData, -}) => { +const SurveyOptionsQuestion: FC = ({ element }) => { return ( {element.question.question} { - const { req } = ctx; - const { surveyId, orgId } = ctx.params!; - - const apiClient = new BackendApiClient(req.headers); - let survey: ZetkinSurveyExtended; - try { - survey = await apiClient.get( - `/api/orgs/${orgId}/surveys/${surveyId}` - ); - } catch (e) { - return { notFound: true }; - } - - return { - props: { - survey, - }, - }; -}, scaffoldOptions); - -type PageProps = { - survey: ZetkinSurveyExtended; -}; - -const Page: FC = ({ survey }) => { - return ; -}; - -export default Page; From e7df6b474013285068cc9b9dbd8568e8ae553698 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 7 Jan 2024 08:52:16 +0100 Subject: [PATCH 049/369] Try out server actions --- .../surveys/[surveyId]/submissions/route.tsx | 32 ----------- src/app/o/[orgId]/surveys/[surveyId]/page.tsx | 55 ++++++------------- .../components/surveyForm/SurveyForm.tsx | 29 ++++++---- .../utils/prepareSurveyApiSubmission.ts | 2 +- 4 files changed, 38 insertions(+), 80 deletions(-) delete mode 100644 src/app/api/orgs/[orgId]/surveys/[surveyId]/submissions/route.tsx diff --git a/src/app/api/orgs/[orgId]/surveys/[surveyId]/submissions/route.tsx b/src/app/api/orgs/[orgId]/surveys/[surveyId]/submissions/route.tsx deleted file mode 100644 index 6f84c9ce16..0000000000 --- a/src/app/api/orgs/[orgId]/surveys/[surveyId]/submissions/route.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import BackendApiClient from 'core/api/client/BackendApiClient'; -import prepareSurveyApiSubmission from 'features/surveys/utils/prepareSurveyApiSubmission'; -import { ZetkinSurveyExtended } from 'utils/types/zetkin'; - -type Params = { - orgId: string; - surveyId: string; -}; - -export async function POST(request: Request, { params }: { params: Params }) { - const apiClient = new BackendApiClient( - Object.fromEntries(request.headers.entries()) - ); - - try { - await apiClient.get( - `/api/orgs/${params.orgId}/surveys/${params.surveyId}` - ); - } catch (e) { - return { notFound: true }; - } - - const formData = await request.json(); - const submission = prepareSurveyApiSubmission(formData, false); - - await apiClient.post( - `/api/orgs/${params.orgId}/surveys/${params.surveyId}/submissions`, - submission - ); - - return new Response(null, { status: 201 }); -} diff --git a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx index 753a9e9bbe..0822f62d60 100644 --- a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx +++ b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx @@ -1,15 +1,11 @@ -'use client'; - +import BackendApiClient from 'core/api/client/BackendApiClient'; import { Container } from '@mui/material'; +import { FC } from 'react'; +import prepareSurveyApiSubmission from 'features/surveys/utils/prepareSurveyApiSubmission'; import SurveyForm from 'features/surveys/components/surveyForm/SurveyForm'; -import SurveyHeading from 'features/surveys/components/surveyForm/SurveyHeading'; -import { useRouter } from 'next/navigation'; +// import { useRouter } from 'next/navigation'; import useSurvey from 'features/surveys/hooks/useSurvey'; -import { FC, FormEvent, useCallback, useState } from 'react'; -import { - ZetkinSurveyExtended, - ZetkinSurveyFormStatus, -} from 'utils/types/zetkin'; +import { ZetkinSurveyExtended } from 'utils/types/zetkin'; type PageProps = { params: { @@ -19,38 +15,24 @@ type PageProps = { }; const Page: FC = ({ params }) => { - const router = useRouter(); + // const router = useRouter(); const { data: survey } = useSurvey( parseInt(params.orgId, 10), parseInt(params.surveyId, 10) ); - const [status, setStatus] = useState('editing'); - - const onSubmit = useCallback(async (e: FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.target as HTMLFormElement); - const entries = [...formData.entries()]; - const data = Object.fromEntries(entries); - try { - await fetch( - `/api/orgs/${params.orgId}/surveys/${params.surveyId}/submissions`, - { - body: JSON.stringify(data), - headers: { - 'Content-Type': 'application/json', - }, - method: 'POST', - } - ); - } catch (e) { - setStatus('error'); - window.scrollTo(0, 0); - return; - } - router.push(`/o/${params.orgId}/surveys/${params.surveyId}/submitted`); - }, []); + const submit = async (formData: FormData): Promise => { + const submission = prepareSurveyApiSubmission( + Object.fromEntries([...formData.entries()]), + false + ); + const apiClient = new BackendApiClient({}); + await apiClient.post( + `/api/orgs/${params.orgId}/surveys/${params.surveyId}/submissions`, + submission + ); + }; if (!survey) { return null; @@ -58,8 +40,7 @@ const Page: FC = ({ params }) => { return ( - - + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index 336b8cee4c..1fac06429b 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -1,25 +1,34 @@ 'use client'; +import { FC } from 'react'; import SurveyElements from './SurveyElements'; +import SurveyHeading from './SurveyHeading'; import SurveyPrivacyPolicy from './SurveyPrivacyPolicy'; import SurveySignature from './SurveySignature'; import SurveySubmitButton from './SurveySubmitButton'; -import { ZetkinSurveyExtended } from 'utils/types/zetkin'; -import { FC, FormEvent } from 'react'; +import { + ZetkinSurveyExtended, + ZetkinSurveyFormStatus, +} from 'utils/types/zetkin'; export type SurveyFormProps = { - onSubmit: (e: FormEvent) => void; + action: (formData: FormData) => Promise; survey: ZetkinSurveyExtended; }; -const SurveyForm: FC = ({ onSubmit, survey }) => { +const SurveyForm: FC = ({ action, survey }) => { + const status = 'editing' as ZetkinSurveyFormStatus; + return ( -
- - - - - + <> + +
+ + + + + + ); }; diff --git a/src/features/surveys/utils/prepareSurveyApiSubmission.ts b/src/features/surveys/utils/prepareSurveyApiSubmission.ts index 24f2b678e4..52cb31d64b 100644 --- a/src/features/surveys/utils/prepareSurveyApiSubmission.ts +++ b/src/features/surveys/utils/prepareSurveyApiSubmission.ts @@ -5,7 +5,7 @@ import { } from 'utils/types/zetkin'; export default function prepareSurveyApiSubmission( - formData: NodeJS.Dict, + formData: Record, isLoggedIn?: boolean ): ZetkinSurveyApiSubmission { const responses: ZetkinSurveyQuestionResponse[] = []; From 39d33c54a06406c2a2a918542fd50e26386a14a3 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 21 Jan 2024 14:50:08 +0100 Subject: [PATCH 050/369] Get server action working --- next.config.js | 5 ++- src/app/o/[orgId]/surveys/[surveyId]/page.tsx | 40 ++++--------------- src/features/surveys/actions/submit.ts | 16 ++++++++ .../components/surveyForm/SurveyForm.tsx | 23 ++++++++--- 4 files changed, 46 insertions(+), 38 deletions(-) create mode 100644 src/features/surveys/actions/submit.ts diff --git a/next.config.js b/next.config.js index 285fbd5fe6..97d0ffb247 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,7 @@ module.exports = { + experimental: { + serverActions: true, + }, images: { domains: [ `files.${process.env.ZETKIN_API_DOMAIN}`, @@ -59,7 +62,7 @@ module.exports = { }, // all paths with /o redirected to Gen2 { - source: '/o/:path*', + source: '/o/(!.+\\/surveys):path*', destination: `http://${process.env.ZETKIN_API_DOMAIN}/o/:path*`, permanent: false, }, diff --git a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx index 0822f62d60..9c8904ab5d 100644 --- a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx +++ b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx @@ -1,11 +1,8 @@ -import BackendApiClient from 'core/api/client/BackendApiClient'; -import { Container } from '@mui/material'; +'use server'; + import { FC } from 'react'; -import prepareSurveyApiSubmission from 'features/surveys/utils/prepareSurveyApiSubmission'; +import { submit } from 'features/surveys/actions/submit'; import SurveyForm from 'features/surveys/components/surveyForm/SurveyForm'; -// import { useRouter } from 'next/navigation'; -import useSurvey from 'features/surveys/hooks/useSurvey'; -import { ZetkinSurveyExtended } from 'utils/types/zetkin'; type PageProps = { params: { @@ -15,33 +12,12 @@ type PageProps = { }; const Page: FC = ({ params }) => { - // const router = useRouter(); - - const { data: survey } = useSurvey( - parseInt(params.orgId, 10), - parseInt(params.surveyId, 10) - ); - - const submit = async (formData: FormData): Promise => { - const submission = prepareSurveyApiSubmission( - Object.fromEntries([...formData.entries()]), - false - ); - const apiClient = new BackendApiClient({}); - await apiClient.post( - `/api/orgs/${params.orgId}/surveys/${params.surveyId}/submissions`, - submission - ); - }; - - if (!survey) { - return null; - } - return ( - - - + ); }; diff --git a/src/features/surveys/actions/submit.ts b/src/features/surveys/actions/submit.ts new file mode 100644 index 0000000000..4a96436afc --- /dev/null +++ b/src/features/surveys/actions/submit.ts @@ -0,0 +1,16 @@ +'use server'; + +import BackendApiClient from 'core/api/client/BackendApiClient'; +import prepareSurveyApiSubmission from 'features/surveys/utils/prepareSurveyApiSubmission'; +import { redirect } from 'next/navigation'; + +export async function submit(formData: FormData) { + const data = Object.fromEntries([...formData.entries()]); + const submission = prepareSurveyApiSubmission(data, false); + const apiClient = new BackendApiClient({}); + await apiClient.post( + `/api/orgs/${data.orgId}/surveys/${data.surveyId}/submissions`, + submission + ); + redirect(`/o/${data.orgId}/surveys/${data.surveyId}/submitted`); +} diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index 1fac06429b..540811e991 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -6,6 +6,7 @@ import SurveyHeading from './SurveyHeading'; import SurveyPrivacyPolicy from './SurveyPrivacyPolicy'; import SurveySignature from './SurveySignature'; import SurveySubmitButton from './SurveySubmitButton'; +import useSurvey from 'features/surveys/hooks/useSurvey'; import { ZetkinSurveyExtended, ZetkinSurveyFormStatus, @@ -13,19 +14,31 @@ import { export type SurveyFormProps = { action: (formData: FormData) => Promise; - survey: ZetkinSurveyExtended; + orgId: string; + surveyId: string; }; -const SurveyForm: FC = ({ action, survey }) => { +const SurveyForm: FC = ({ action, orgId, surveyId }) => { const status = 'editing' as ZetkinSurveyFormStatus; + const { data: survey } = useSurvey( + parseInt(orgId, 10), + parseInt(surveyId, 10) + ); + + if (!survey) { + return null; + } + return ( <>
- - - + + + + + From 69a7e3985ca55778d039dee0da6a548ac18fe759 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Tue, 23 Jan 2024 19:51:45 +0100 Subject: [PATCH 051/369] Fetch survey in server component, pass to client component --- .../surveys/submitting-survey.spec.ts | 8 ++++---- src/app/o/[orgId]/surveys/[surveyId]/page.tsx | 19 +++++++++++-------- .../components/surveyForm/SurveyForm.tsx | 15 ++++----------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 503be7713e..70074d6489 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -51,7 +51,7 @@ test.describe('User submitting a survey', () => { await page.click('input[name="1.options"]'); await page.fill('input[name="2.text"]', 'Topple capitalism'); - await page.click('input[name="sig"][value="user"]'); + await page.click('input[name="sig"][value="anonymous"]'); await page.click('data-testid=Survey-acceptTerms'); await Promise.all([ page.waitForResponse((res) => res.request().method() == 'POST'), @@ -111,7 +111,7 @@ test.describe('User submitting a survey', () => { }); }); - test('submits user signature', async ({ moxy, page }) => { + test.skip('submits user signature', async ({ moxy, page }) => { moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', @@ -138,7 +138,7 @@ test.describe('User submitting a survey', () => { expect(data.signature).toBe('user'); }); - test('submits anonymous signature', async ({ moxy, page }) => { + test.skip('submits anonymous signature', async ({ moxy, page }) => { moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', @@ -165,7 +165,7 @@ test.describe('User submitting a survey', () => { expect(data.signature).toBe(null); }); - test('preserves inputs on error', async ({ page }) => { + test.skip('preserves inputs on error', async ({ page }) => { await page.click('input[name="1.options"][value="1"]'); await page.fill('input[name="2.text"]', 'Topple capitalism'); await page.click('input[name="sig"][value="anonymous"]'); diff --git a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx index 9c8904ab5d..244b27e12e 100644 --- a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx +++ b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx @@ -1,8 +1,10 @@ 'use server'; -import { FC } from 'react'; +import BackendApiClient from 'core/api/client/BackendApiClient'; import { submit } from 'features/surveys/actions/submit'; import SurveyForm from 'features/surveys/components/surveyForm/SurveyForm'; +import { ZetkinSurveyExtended } from 'utils/types/zetkin'; +import { FC, ReactElement } from 'react'; type PageProps = { params: { @@ -11,14 +13,15 @@ type PageProps = { }; }; -const Page: FC = ({ params }) => { - return ( - +/* @ts-expect-error Server Component */ +const Page: FC = async ({ params }): Promise => { + const { orgId, surveyId } = params; + const apiClient = new BackendApiClient({}); + const survey = await apiClient.get( + `/api/orgs/${orgId}/surveys/${surveyId}` ); + + return ; }; export default Page; diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index 540811e991..deb74d99aa 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -6,7 +6,6 @@ import SurveyHeading from './SurveyHeading'; import SurveyPrivacyPolicy from './SurveyPrivacyPolicy'; import SurveySignature from './SurveySignature'; import SurveySubmitButton from './SurveySubmitButton'; -import useSurvey from 'features/surveys/hooks/useSurvey'; import { ZetkinSurveyExtended, ZetkinSurveyFormStatus, @@ -14,18 +13,12 @@ import { export type SurveyFormProps = { action: (formData: FormData) => Promise; - orgId: string; - surveyId: string; + survey: ZetkinSurveyExtended; }; -const SurveyForm: FC = ({ action, orgId, surveyId }) => { +const SurveyForm: FC = ({ action, survey }) => { const status = 'editing' as ZetkinSurveyFormStatus; - const { data: survey } = useSurvey( - parseInt(orgId, 10), - parseInt(surveyId, 10) - ); - if (!survey) { return null; } @@ -34,8 +27,8 @@ const SurveyForm: FC = ({ action, orgId, surveyId }) => { <>
- - + + From 4ea40f220686e0894be3f93f66cd1a709b3eed6c Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Wed, 24 Jan 2024 19:42:43 +0100 Subject: [PATCH 052/369] Enable + fix submits anonymous signature playwright test for survey page --- .../surveys/submitting-survey.spec.ts | 2 +- .../surveys/[surveyId]/submitted/page.tsx | 27 +++++++------------ .../components/surveyForm/SurveySuccess.tsx | 2 ++ 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 70074d6489..7979be7a65 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -138,7 +138,7 @@ test.describe('User submitting a survey', () => { expect(data.signature).toBe('user'); }); - test.skip('submits anonymous signature', async ({ moxy, page }) => { + test('submits anonymous signature', async ({ moxy, page }) => { moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', diff --git a/src/app/o/[orgId]/surveys/[surveyId]/submitted/page.tsx b/src/app/o/[orgId]/surveys/[surveyId]/submitted/page.tsx index 92f71e8103..365ba0b430 100644 --- a/src/app/o/[orgId]/surveys/[surveyId]/submitted/page.tsx +++ b/src/app/o/[orgId]/surveys/[surveyId]/submitted/page.tsx @@ -1,10 +1,9 @@ -'use client'; +'use server'; -import { Container } from '@mui/material'; -import { FC } from 'react'; +import BackendApiClient from 'core/api/client/BackendApiClient'; import SurveySuccess from 'features/surveys/components/surveyForm/SurveySuccess'; -import useSurvey from 'features/surveys/hooks/useSurvey'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; +import { FC, ReactElement } from 'react'; type PageProps = { params: { @@ -13,21 +12,15 @@ type PageProps = { }; }; -const Page: FC = ({ params }) => { - const { data: survey } = useSurvey( - parseInt(params.orgId, 10), - parseInt(params.surveyId, 10) +/* @ts-expect-error Server Component */ +const Page: FC = async ({ params }): Promise => { + const { orgId, surveyId } = params; + const apiClient = new BackendApiClient({}); + const survey = await apiClient.get( + `/api/orgs/${orgId}/surveys/${surveyId}` ); - if (!survey) { - return null; - } - - return ( - - - - ); + return ; }; export default Page; diff --git a/src/features/surveys/components/surveyForm/SurveySuccess.tsx b/src/features/surveys/components/surveyForm/SurveySuccess.tsx index f395484ba9..7e5f75f16e 100644 --- a/src/features/surveys/components/surveyForm/SurveySuccess.tsx +++ b/src/features/surveys/components/surveyForm/SurveySuccess.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC } from 'react'; import messageIds from 'features/surveys/l10n/messageIds'; import { Msg } from 'core/i18n'; From c3058429f15c588d137947f3cfb4303d12773dd2 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Fri, 26 Jan 2024 21:19:33 +0100 Subject: [PATCH 053/369] Make the error handling playwright test pass in the new survey form --- .../surveys/submitting-survey.spec.ts | 2 +- next.config.js | 3 -- src/app/o/[orgId]/surveys/[surveyId]/page.tsx | 5 +-- .../surveys/[surveyId]/submitted/page.tsx | 26 ------------ src/features/surveys/actions/submit.ts | 21 ++++++---- .../components/surveyForm/SurveyForm.tsx | 40 +++++++++++++------ yarn.lock | 8 ++-- 7 files changed, 49 insertions(+), 56 deletions(-) delete mode 100644 src/app/o/[orgId]/surveys/[surveyId]/submitted/page.tsx diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 7979be7a65..89f5ab0441 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -165,7 +165,7 @@ test.describe('User submitting a survey', () => { expect(data.signature).toBe(null); }); - test.skip('preserves inputs on error', async ({ page }) => { + test('preserves inputs on error', async ({ page }) => { await page.click('input[name="1.options"][value="1"]'); await page.fill('input[name="2.text"]', 'Topple capitalism'); await page.click('input[name="sig"][value="anonymous"]'); diff --git a/next.config.js b/next.config.js index 97d0ffb247..1773374af8 100644 --- a/next.config.js +++ b/next.config.js @@ -1,7 +1,4 @@ module.exports = { - experimental: { - serverActions: true, - }, images: { domains: [ `files.${process.env.ZETKIN_API_DOMAIN}`, diff --git a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx index 244b27e12e..c49cd06a51 100644 --- a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx +++ b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx @@ -1,7 +1,6 @@ 'use server'; import BackendApiClient from 'core/api/client/BackendApiClient'; -import { submit } from 'features/surveys/actions/submit'; import SurveyForm from 'features/surveys/components/surveyForm/SurveyForm'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; import { FC, ReactElement } from 'react'; @@ -13,7 +12,7 @@ type PageProps = { }; }; -/* @ts-expect-error Server Component */ +// @ts-expect-error Async support missing const Page: FC = async ({ params }): Promise => { const { orgId, surveyId } = params; const apiClient = new BackendApiClient({}); @@ -21,7 +20,7 @@ const Page: FC = async ({ params }): Promise => { `/api/orgs/${orgId}/surveys/${surveyId}` ); - return ; + return ; }; export default Page; diff --git a/src/app/o/[orgId]/surveys/[surveyId]/submitted/page.tsx b/src/app/o/[orgId]/surveys/[surveyId]/submitted/page.tsx deleted file mode 100644 index 365ba0b430..0000000000 --- a/src/app/o/[orgId]/surveys/[surveyId]/submitted/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use server'; - -import BackendApiClient from 'core/api/client/BackendApiClient'; -import SurveySuccess from 'features/surveys/components/surveyForm/SurveySuccess'; -import { ZetkinSurveyExtended } from 'utils/types/zetkin'; -import { FC, ReactElement } from 'react'; - -type PageProps = { - params: { - orgId: string; - surveyId: string; - }; -}; - -/* @ts-expect-error Server Component */ -const Page: FC = async ({ params }): Promise => { - const { orgId, surveyId } = params; - const apiClient = new BackendApiClient({}); - const survey = await apiClient.get( - `/api/orgs/${orgId}/surveys/${surveyId}` - ); - - return ; -}; - -export default Page; diff --git a/src/features/surveys/actions/submit.ts b/src/features/surveys/actions/submit.ts index 4a96436afc..07ef75b323 100644 --- a/src/features/surveys/actions/submit.ts +++ b/src/features/surveys/actions/submit.ts @@ -2,15 +2,22 @@ import BackendApiClient from 'core/api/client/BackendApiClient'; import prepareSurveyApiSubmission from 'features/surveys/utils/prepareSurveyApiSubmission'; -import { redirect } from 'next/navigation'; +import { ZetkinSurveyFormStatus } from 'utils/types/zetkin'; -export async function submit(formData: FormData) { +export async function submit( + prevState: ZetkinSurveyFormStatus, + formData: FormData +): Promise { const data = Object.fromEntries([...formData.entries()]); const submission = prepareSurveyApiSubmission(data, false); const apiClient = new BackendApiClient({}); - await apiClient.post( - `/api/orgs/${data.orgId}/surveys/${data.surveyId}/submissions`, - submission - ); - redirect(`/o/${data.orgId}/surveys/${data.surveyId}/submitted`); + try { + await apiClient.post( + `/api/orgs/${data.orgId}/surveys/${data.surveyId}/submissions`, + submission + ); + } catch (e) { + return 'error'; + } + return 'submitted'; } diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index deb74d99aa..bec8a78dff 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -1,23 +1,31 @@ 'use client'; import { FC } from 'react'; +import { submit } from 'features/surveys/actions/submit'; import SurveyElements from './SurveyElements'; import SurveyHeading from './SurveyHeading'; import SurveyPrivacyPolicy from './SurveyPrivacyPolicy'; import SurveySignature from './SurveySignature'; import SurveySubmitButton from './SurveySubmitButton'; +import SurveySuccess from './SurveySuccess'; import { ZetkinSurveyExtended, ZetkinSurveyFormStatus, } from 'utils/types/zetkin'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { useFormState } from 'react-dom'; + export type SurveyFormProps = { - action: (formData: FormData) => Promise; survey: ZetkinSurveyExtended; }; -const SurveyForm: FC = ({ action, survey }) => { - const status = 'editing' as ZetkinSurveyFormStatus; +const SurveyForm: FC = ({ survey }) => { + const [status, action] = useFormState( + submit, + 'editing' + ); if (!survey) { return null; @@ -25,15 +33,23 @@ const SurveyForm: FC = ({ action, survey }) => { return ( <> - - - - - - - - - + {(status === 'editing' || status === 'error') && ( + <> + +
+ + + + + + + + + )} + {status === 'submitted' && } ); }; diff --git a/yarn.lock b/yarn.lock index b2922c6c59..abd0d66312 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6414,9 +6414,9 @@ caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001280, caniuse-lite@^1.0.300013 integrity sha512-iaIZ8gVrWfemh5DG3T9/YqarVZoYf0r188IjaGwx68j4Pf0SGY6CQkmJUIE+NZHkkecQGohzXmBGEwWDr9aM3Q== caniuse-lite@^1.0.30001579: - version "1.0.30001581" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001581.tgz#0dfd4db9e94edbdca67d57348ebc070dece279f4" - integrity sha512-whlTkwhqV2tUmP3oYhtNfaWGYHDdS3JYFQBKXxcUR9qqPWsRhFHhoISO2Xnl/g0xyKzht9mI1LZpiNWfMzHixQ== + version "1.0.30001580" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001580.tgz#e3c76bc6fe020d9007647044278954ff8cd17d1e" + integrity sha512-mtj5ur2FFPZcCEpXFy8ADXbDACuNFXg6mxVDqp7tqooX6l3zwm+d8EPoeOSIFRDvHs8qu7/SLFOGniULkcH2iA== capture-exit@^2.0.0: version "2.0.0" @@ -12738,7 +12738,7 @@ next-transpile-modules@^4.1.0: micromatch "^4.0.2" slash "^3.0.0" -next@^14.1.0: +next@14.1.0: version "14.1.0" resolved "https://registry.yarnpkg.com/next/-/next-14.1.0.tgz#b31c0261ff9caa6b4a17c5af019ed77387174b69" integrity sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q== From 74a749bb36e64f8c6e88fa6f0dfa8ec42b73674c Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sun, 25 Feb 2024 12:14:41 +0100 Subject: [PATCH 054/369] Update lockfile after merge --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index abd0d66312..9af9389996 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12738,7 +12738,7 @@ next-transpile-modules@^4.1.0: micromatch "^4.0.2" slash "^3.0.0" -next@14.1.0: +next@^14.1.0: version "14.1.0" resolved "https://registry.yarnpkg.com/next/-/next-14.1.0.tgz#b31c0261ff9caa6b4a17c5af019ed77387174b69" integrity sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q== From e270d419fe9e300677f52b4592a49b8ab1b2b7b1 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Mon, 26 Feb 2024 19:12:29 +0100 Subject: [PATCH 055/369] Default to radio buttons when no question type is specified --- .../surveys/components/surveyForm/SurveyOptionsQuestion.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index ecd7db9384..abb19bcf9a 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -41,7 +41,9 @@ const OptionsQuestion: FC = ({ element }) => { )} - {element.question.response_config.widget_type === 'radio' && ( + {(element.question.response_config.widget_type === 'radio' || + typeof element.question.response_config.widget_type === + 'undefined') && ( Date: Sat, 16 Mar 2024 11:00:15 +0100 Subject: [PATCH 056/369] Implement mobile survey design from Figma --- .../components/surveyForm/SurveyElements.tsx | 4 +- .../components/surveyForm/SurveyForm.tsx | 20 ++-- .../components/surveyForm/SurveyHeading.tsx | 23 ++++- .../components/surveyForm/SurveyOption.tsx | 13 +++ .../surveyForm/SurveyOptionsQuestion.tsx | 26 ++--- .../surveyForm/SurveyPrivacyPolicy.tsx | 55 ++++++----- .../components/surveyForm/SurveySignature.tsx | 98 ++++++++++--------- .../surveyForm/SurveySubheading.tsx | 11 +-- .../surveyForm/SurveySubmitButton.tsx | 23 +++-- .../surveyForm/SurveyTextQuestion.tsx | 22 +++-- 10 files changed, 169 insertions(+), 126 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyElements.tsx b/src/features/surveys/components/surveyForm/SurveyElements.tsx index 3cb8014e1d..55e49ef6e9 100644 --- a/src/features/surveys/components/surveyForm/SurveyElements.tsx +++ b/src/features/surveys/components/surveyForm/SurveyElements.tsx @@ -13,7 +13,7 @@ export type SurveyElementsProps = { const SurveyElements: FC = ({ survey }) => { return ( - <> + {survey.elements.map((element) => ( {element.type === 'question' && } @@ -22,7 +22,7 @@ const SurveyElements: FC = ({ survey }) => { )} ))} - + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index bec8a78dff..da87f57166 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -1,5 +1,6 @@ 'use client'; +import { Box } from '@mui/material'; import { FC } from 'react'; import { submit } from 'features/surveys/actions/submit'; import SurveyElements from './SurveyElements'; @@ -32,7 +33,12 @@ const SurveyForm: FC = ({ survey }) => { } return ( - <> + {(status === 'editing' || status === 'error') && ( <> = ({ survey }) => {
- - - - + + + + + + )} {status === 'submitted' && } - +
); }; diff --git a/src/features/surveys/components/surveyForm/SurveyHeading.tsx b/src/features/surveys/components/surveyForm/SurveyHeading.tsx index bf6a69a445..faa9203650 100644 --- a/src/features/surveys/components/surveyForm/SurveyHeading.tsx +++ b/src/features/surveys/components/surveyForm/SurveyHeading.tsx @@ -1,7 +1,7 @@ -import { Box } from '@mui/material'; import { FC } from 'react'; import SurveyErrorMessage from './SurveyErrorMessage'; import ZUIAvatar from 'zui/ZUIAvatar'; +import { Box, Typography } from '@mui/material'; import { ZetkinSurveyExtended, ZetkinSurveyFormStatus, @@ -15,7 +15,13 @@ export type SurveyHeadingProps = { const SurveyHeading: FC = ({ status, survey }) => { return ( - + = ({ status, survey }) => { {status === 'error' && } -

{survey.title}

- - {survey.info_text &&

{survey.info_text}

} + + + {survey.title} + + {survey.info_text && ( + + {survey.info_text} + + )} +
); }; diff --git a/src/features/surveys/components/surveyForm/SurveyOption.tsx b/src/features/surveys/components/surveyForm/SurveyOption.tsx index 0cc90652b3..d9edaa7899 100644 --- a/src/features/surveys/components/surveyForm/SurveyOption.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOption.tsx @@ -8,12 +8,25 @@ const SurveyOption: FC = ({ ...formControlLabelProps }) => { ); diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index abb19bcf9a..3ced623a1c 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -23,7 +23,7 @@ export type OptionsQuestionProps = { const OptionsQuestion: FC = ({ element }) => { return ( - + {element.question.response_config.widget_type === 'checkbox' && ( @@ -48,18 +48,20 @@ const OptionsQuestion: FC = ({ element }) => { aria-labelledby={`label-${element.id}`} name={`${element.id}.options`} > - - {element.question.question} - - {element.question.options!.map((option: ZetkinSurveyOption) => ( - } - label={option.text} - value={option.id} - /> - ))} + + {element.question.question} + + + {element.question.options!.map((option: ZetkinSurveyOption) => ( + } + label={option.text} + value={option.id} + /> + ))} +
)} diff --git a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx index 177b43d6c2..e805d83ebd 100644 --- a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx +++ b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx @@ -4,6 +4,7 @@ import SurveyOption from './SurveyOption'; import SurveySubheading from './SurveySubheading'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; import { + Box, Checkbox, FormControl, FormGroup, @@ -20,34 +21,36 @@ export type SurveyPrivacyPolicyProps = { const SurveyPrivacyPolicy: FC = ({ survey }) => { const messages = useMessages(messageIds); return ( - + - - - - - - } - data-testid="Survey-acceptTerms" - label={} - name="privacy.approval" - /> - - + + + + + + } + data-testid="Survey-acceptTerms" + label={} + name="privacy.approval" /> - - - - - - + + + + + + + + + ); diff --git a/src/features/surveys/components/surveyForm/SurveySignature.tsx b/src/features/surveys/components/surveyForm/SurveySignature.tsx index a910c376b3..488a506bb9 100644 --- a/src/features/surveys/components/surveyForm/SurveySignature.tsx +++ b/src/features/surveys/components/surveyForm/SurveySignature.tsx @@ -40,7 +40,7 @@ const SurveySignature: FC = ({ survey }) => { ); return ( - + = ({ survey }) => { handleRadioChange(e.target.value as ZetkinSurveySignatureType) } > - - - - - + + + + + + - - {/* + {/* } label={ @@ -71,52 +72,55 @@ const SurveySignature: FC = ({ survey }) => { value="user" /> */} - } - label={ - - - - } - value="email" - /> - - {signatureType === 'email' && ( - - } - name="sig.first_name" - required - /> - } - name="sig.last_name" - required - /> - } - name="sig.email" - required - /> - - )} - - {survey.signature === 'allow_anonymous' && ( } label={ - + } - value="anonymous" + value="email" /> - )} + + {signatureType === 'email' && ( + + + } + name="sig.first_name" + required + /> + } + name="sig.last_name" + required + /> + } + name="sig.email" + required + /> + + )} + + {survey.signature === 'allow_anonymous' && ( + } + label={ + + + + } + value="anonymous" + /> + )} + diff --git a/src/features/surveys/components/surveyForm/SurveySubheading.tsx b/src/features/surveys/components/surveyForm/SurveySubheading.tsx index ed740b5bfc..0558130791 100644 --- a/src/features/surveys/components/surveyForm/SurveySubheading.tsx +++ b/src/features/surveys/components/surveyForm/SurveySubheading.tsx @@ -8,15 +8,10 @@ export type SurveySubheadingProps = { const SurveySubheading: FC = ({ children }) => { return ( {children} diff --git a/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx b/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx index 05850f298f..ebb02f0cda 100644 --- a/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx +++ b/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx @@ -1,20 +1,23 @@ -import { Button } from '@mui/material'; import { FC } from 'react'; import messageIds from 'features/surveys/l10n/messageIds'; import { useMessages } from 'core/i18n'; +import { Box, Button } from '@mui/material'; const SurveySubmitButton: FC = () => { const messages = useMessages(messageIds); + return ( - + + + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx index c3295720bb..4da4fa16b1 100644 --- a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import SurveySubheading from './SurveySubheading'; import { ZetkinSurveyTextQuestionElement } from 'utils/types/zetkin'; -import { FormControl, FormLabel, TextField } from '@mui/material'; +import { Box, FormControl, FormLabel, TextField } from '@mui/material'; export type SurveyOptionsQuestionProps = { element: ZetkinSurveyTextQuestionElement; @@ -10,15 +10,17 @@ export type SurveyOptionsQuestionProps = { const SurveyOptionsQuestion: FC = ({ element }) => { return ( - - {element.question.question} - - + + + {element.question.question} + + + ); }; From d5741b7da1a965b74766e6e7ca14b91fa524a875 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 16 Mar 2024 14:16:12 +0100 Subject: [PATCH 057/369] Implement desktop layout --- .../components/surveyForm/SurveyContainer.tsx | 26 ++++ .../surveyForm/SurveyErrorMessage.tsx | 23 ++-- .../components/surveyForm/SurveyHeading.tsx | 36 +++--- .../surveyForm/SurveyOptionsQuestion.tsx | 85 ++++++------ .../surveyForm/SurveyPrivacyPolicy.tsx | 61 ++++----- .../components/surveyForm/SurveySignature.tsx | 121 +++++++++--------- .../surveyForm/SurveySubmitButton.tsx | 7 +- .../components/surveyForm/SurveyTextBlock.tsx | 7 +- .../surveyForm/SurveyTextQuestion.tsx | 27 ++-- 9 files changed, 219 insertions(+), 174 deletions(-) create mode 100644 src/features/surveys/components/surveyForm/SurveyContainer.tsx diff --git a/src/features/surveys/components/surveyForm/SurveyContainer.tsx b/src/features/surveys/components/surveyForm/SurveyContainer.tsx new file mode 100644 index 0000000000..3552dd0299 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyContainer.tsx @@ -0,0 +1,26 @@ +import { Box, BoxProps } from '@mui/material'; +import { FC, ReactNode } from 'react'; + +export type SurveyContainerProps = BoxProps & { + children: ReactNode; +}; + +const SurveyContainer: FC = ({ + children, + ...boxProps +}) => { + return ( + + + {children} + + + ); +}; + +export default SurveyContainer; diff --git a/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx b/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx index 2f5cfe8296..9d067d085e 100644 --- a/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx +++ b/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx @@ -2,22 +2,23 @@ import Box from '@mui/material/Box'; import { FC } from 'react'; import messageIds from 'features/surveys/l10n/messageIds'; import { Msg } from 'core/i18n'; +import SurveyContainer from './SurveyContainer'; import { useTheme } from '@mui/material'; const SurveyErrorMessage: FC = () => { const theme = useTheme(); return ( - - - + + + + + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveyHeading.tsx b/src/features/surveys/components/surveyForm/SurveyHeading.tsx index faa9203650..3cef1d220a 100644 --- a/src/features/surveys/components/surveyForm/SurveyHeading.tsx +++ b/src/features/surveys/components/surveyForm/SurveyHeading.tsx @@ -1,4 +1,5 @@ import { FC } from 'react'; +import SurveyContainer from './SurveyContainer'; import SurveyErrorMessage from './SurveyErrorMessage'; import ZUIAvatar from 'zui/ZUIAvatar'; import { Box, Typography } from '@mui/material'; @@ -15,23 +16,22 @@ export type SurveyHeadingProps = { const SurveyHeading: FC = ({ status, survey }) => { return ( - - - {survey.organization.title} - + + + + {survey.organization.title} + + - {status === 'error' && } - - + {survey.title} @@ -40,7 +40,9 @@ const SurveyHeading: FC = ({ status, survey }) => { {survey.info_text}
)} - + + + {status === 'error' && } ); }; diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index 3ced623a1c..cfdffacdd0 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -1,4 +1,5 @@ import { FC } from 'react'; +import SurveyContainer from './SurveyContainer'; import SurveyOption from './SurveyOption'; import SurveySubheading from './SurveySubheading'; import { @@ -24,31 +25,9 @@ export type OptionsQuestionProps = { const OptionsQuestion: FC = ({ element }) => { return ( - {element.question.response_config.widget_type === 'checkbox' && ( - - - {element.question.question} - - - {element.question.options!.map((option: ZetkinSurveyOption) => ( - } - label={option.text} - value={option.id} - /> - ))} - - - )} - {(element.question.response_config.widget_type === 'radio' || - typeof element.question.response_config.widget_type === - 'undefined') && ( - - + + {element.question.response_config.widget_type === 'checkbox' && ( + {element.question.question} @@ -56,27 +35,51 @@ const OptionsQuestion: FC = ({ element }) => { {element.question.options!.map((option: ZetkinSurveyOption) => ( } + control={} label={option.text} value={option.id} /> ))} - - - )} - {element.question.response_config.widget_type === 'select' && ( - - )} + + )} + {(element.question.response_config.widget_type === 'radio' || + typeof element.question.response_config.widget_type === + 'undefined') && ( + + + + {element.question.question} + + + {element.question.options!.map((option: ZetkinSurveyOption) => ( + } + label={option.text} + value={option.id} + /> + ))} + + + + )} + {element.question.response_config.widget_type === 'select' && ( + + )} + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx index e805d83ebd..9625bf2222 100644 --- a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx +++ b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx @@ -1,5 +1,6 @@ import { FC } from 'react'; import messageIds from 'features/surveys/l10n/messageIds'; +import SurveyContainer from './SurveyContainer'; import SurveyOption from './SurveyOption'; import SurveySubheading from './SurveySubheading'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; @@ -22,36 +23,38 @@ const SurveyPrivacyPolicy: FC = ({ survey }) => { const messages = useMessages(messageIds); return ( - - - - - - - - } - data-testid="Survey-acceptTerms" - label={} - name="privacy.approval" - /> - - + + + + + + + + } + data-testid="Survey-acceptTerms" + label={} + name="privacy.approval" /> - - - - - - - - + + + + + + + + + + + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveySignature.tsx b/src/features/surveys/components/surveyForm/SurveySignature.tsx index 488a506bb9..2eb1e698e5 100644 --- a/src/features/surveys/components/surveyForm/SurveySignature.tsx +++ b/src/features/surveys/components/surveyForm/SurveySignature.tsx @@ -1,5 +1,6 @@ import messageIds from 'features/surveys/l10n/messageIds'; import { Msg } from 'core/i18n'; +import SurveyContainer from './SurveyContainer'; import SurveyOption from './SurveyOption'; import SurveySubheading from './SurveySubheading'; // import useCurrentUser from 'features/user/hooks/useCurrentUser'; @@ -41,22 +42,23 @@ const SurveySignature: FC = ({ survey }) => { return ( - - handleRadioChange(e.target.value as ZetkinSurveySignatureType) - } - > - - - - - - + + + handleRadioChange(e.target.value as ZetkinSurveySignatureType) + } + > + + + + + + - - {/* + {/* } label={ @@ -72,57 +74,60 @@ const SurveySignature: FC = ({ survey }) => { value="user" /> */} - } - label={ - - - - } - value="email" - /> - - {signatureType === 'email' && ( - - - } - name="sig.first_name" - required - /> - } - name="sig.last_name" - required - /> - } - name="sig.email" - required - /> - - )} - - {survey.signature === 'allow_anonymous' && ( } label={ - + } - value="anonymous" + value="email" /> - )} + + {signatureType === 'email' && ( + + + } + name="sig.first_name" + required + /> + + } + name="sig.last_name" + required + /> + } + name="sig.email" + required + /> + + )} + + {survey.signature === 'allow_anonymous' && ( + } + label={ + + + + } + value="anonymous" + /> + )} + - - + + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx b/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx index ebb02f0cda..9f7b570f5d 100644 --- a/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx +++ b/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx @@ -1,13 +1,14 @@ +import { Button } from '@mui/material'; import { FC } from 'react'; import messageIds from 'features/surveys/l10n/messageIds'; +import SurveyContainer from './SurveyContainer'; import { useMessages } from 'core/i18n'; -import { Box, Button } from '@mui/material'; const SurveySubmitButton: FC = () => { const messages = useMessages(messageIds); return ( - + - + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx index c568952ab6..9eb695bba8 100644 --- a/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; +import SurveyContainer from './SurveyContainer'; +import { Typography } from '@mui/material'; import { ZetkinSurveyTextElement } from 'utils/types/zetkin'; -import { Box, Typography } from '@mui/material'; export type SurveyTextBlockProps = { element: ZetkinSurveyTextElement; @@ -8,10 +9,10 @@ export type SurveyTextBlockProps = { const SurveyTextBlock: FC = ({ element }) => { return ( - + {element.text_block.header} {element.text_block.content} - + ); }; diff --git a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx index 4da4fa16b1..9a0b525bf9 100644 --- a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx @@ -1,4 +1,5 @@ import { FC } from 'react'; +import SurveyContainer from './SurveyContainer'; import SurveySubheading from './SurveySubheading'; import { ZetkinSurveyTextQuestionElement } from 'utils/types/zetkin'; import { Box, FormControl, FormLabel, TextField } from '@mui/material'; @@ -9,18 +10,20 @@ export type SurveyOptionsQuestionProps = { const SurveyOptionsQuestion: FC = ({ element }) => { return ( - - - - {element.question.question} - - - + + + + + {element.question.question} + + + + ); }; From 62b1adc8360d8960ca3d2cfc96679ba5f8d4bfe3 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 16 Mar 2024 14:26:04 +0100 Subject: [PATCH 058/369] Tidy up survey submitted page --- .../components/surveyForm/SurveyForm.tsx | 27 ++++++++----------- .../components/surveyForm/SurveySuccess.tsx | 9 ++++--- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index da87f57166..04bdb38ea0 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -39,23 +39,18 @@ const SurveyForm: FC = ({ survey }) => { flexDirection="column" gap={4} > + {(status === 'editing' || status === 'error') && ( - <> - -
- - - - - - - - -
- +
+ + + + + + + + +
)} {status === 'submitted' && } diff --git a/src/features/surveys/components/surveyForm/SurveySuccess.tsx b/src/features/surveys/components/surveyForm/SurveySuccess.tsx index 7e5f75f16e..881129dd6a 100644 --- a/src/features/surveys/components/surveyForm/SurveySuccess.tsx +++ b/src/features/surveys/components/surveyForm/SurveySuccess.tsx @@ -3,8 +3,9 @@ import { FC } from 'react'; import messageIds from 'features/surveys/l10n/messageIds'; import { Msg } from 'core/i18n'; +import SurveyContainer from './SurveyContainer'; +import { Typography } from '@mui/material'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; -import { Box, Typography } from '@mui/material'; export type SurveySuccessProps = { survey: ZetkinSurveyExtended; @@ -12,8 +13,8 @@ export type SurveySuccessProps = { const SurveySuccess: FC = ({ survey }) => { return ( - - + + @@ -22,7 +23,7 @@ const SurveySuccess: FC = ({ survey }) => { values={{ title: survey.title }} /> - + ); }; From 7e6263194f46c20539fb0b81e87b066970783525 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 16 Mar 2024 15:27:51 +0100 Subject: [PATCH 059/369] Implement missing functionality --- .../surveyForm/SurveyOptionsQuestion.tsx | 96 ++++++++++++++----- .../surveyForm/SurveyQuestionDescription.tsx | 27 ++++++ .../surveyForm/SurveySubheading.tsx | 10 +- .../components/surveyForm/SurveyTextBlock.tsx | 5 +- .../surveyForm/SurveyTextQuestion.tsx | 19 +++- 5 files changed, 124 insertions(+), 33 deletions(-) create mode 100644 src/features/surveys/components/surveyForm/SurveyQuestionDescription.tsx diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index cfdffacdd0..22315eab9c 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; import SurveyContainer from './SurveyContainer'; import SurveyOption from './SurveyOption'; +import SurveyQuestionDescription from './SurveyQuestionDescription'; import SurveySubheading from './SurveySubheading'; import { Box, @@ -27,19 +28,33 @@ const OptionsQuestion: FC = ({ element }) => { {element.question.response_config.widget_type === 'checkbox' && ( - - - {element.question.question} - - - {element.question.options!.map((option: ZetkinSurveyOption) => ( - } - label={option.text} - value={option.id} - /> - ))} + + + + + + {element.question.question} + + + {element.question.description && ( + + {element.question.description} + + )} + + + {element.question.options!.map((option: ZetkinSurveyOption) => ( + } + label={option.text} + value={option.id} + /> + ))} + )} @@ -47,13 +62,25 @@ const OptionsQuestion: FC = ({ element }) => { typeof element.question.response_config.widget_type === 'undefined') && ( - - - {element.question.question} - + + + + + + {element.question.question} + + + {element.question.description && ( + + {element.question.description} + + )} + + {element.question.options!.map((option: ZetkinSurveyOption) => ( = ({ element }) => { )} {element.question.response_config.widget_type === 'select' && ( - + + + + + {element.question.question} + + + {element.question.description && ( + + {element.question.description} + + )} + + + + )} diff --git a/src/features/surveys/components/surveyForm/SurveyQuestionDescription.tsx b/src/features/surveys/components/surveyForm/SurveyQuestionDescription.tsx new file mode 100644 index 0000000000..a6f6848aba --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyQuestionDescription.tsx @@ -0,0 +1,27 @@ +import { Typography } from '@mui/material'; +import { ElementType, FC, ReactElement } from 'react'; + +export type SurveyQuestionDescriptionProps = { + children: ReactElement | string; + component?: ElementType; + id?: string; +}; + +const SurveyQuestionDescription: FC = ({ + children, + component = 'p', + id, +}) => { + return ( + + {children} + + ); +}; + +export default SurveyQuestionDescription; diff --git a/src/features/surveys/components/surveyForm/SurveySubheading.tsx b/src/features/surveys/components/surveyForm/SurveySubheading.tsx index 0558130791..1130188995 100644 --- a/src/features/surveys/components/surveyForm/SurveySubheading.tsx +++ b/src/features/surveys/components/surveyForm/SurveySubheading.tsx @@ -1,15 +1,19 @@ import { Typography } from '@mui/material'; -import { FC, ReactElement } from 'react'; +import { ElementType, FC, ReactElement } from 'react'; export type SurveySubheadingProps = { children: ReactElement | string; + component?: ElementType; }; -const SurveySubheading: FC = ({ children }) => { +const SurveySubheading: FC = ({ + children, + component = 'span', +}) => { return ( diff --git a/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx index 9eb695bba8..2106812794 100644 --- a/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx @@ -1,5 +1,6 @@ import { FC } from 'react'; import SurveyContainer from './SurveyContainer'; +import SurveySubheading from './SurveySubheading'; import { Typography } from '@mui/material'; import { ZetkinSurveyTextElement } from 'utils/types/zetkin'; @@ -10,7 +11,9 @@ export type SurveyTextBlockProps = { const SurveyTextBlock: FC = ({ element }) => { return ( - {element.text_block.header} + + {element.text_block.header} + {element.text_block.content} ); diff --git a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx index 9a0b525bf9..61c2cbe76c 100644 --- a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx @@ -1,5 +1,6 @@ import { FC } from 'react'; import SurveyContainer from './SurveyContainer'; +import SurveyQuestionDescription from './SurveyQuestionDescription'; import SurveySubheading from './SurveySubheading'; import { ZetkinSurveyTextQuestionElement } from 'utils/types/zetkin'; import { Box, FormControl, FormLabel, TextField } from '@mui/material'; @@ -12,14 +13,24 @@ const SurveyOptionsQuestion: FC = ({ element }) => { return ( - - - {element.question.question} - + + + + {element.question.question} + + {element.question.description && ( + + {element.question.description} + + )} + From 1f051ef090df7a3da7d6af5da420d2470a626cf6 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 16 Mar 2024 15:56:40 +0100 Subject: [PATCH 060/369] Fix playwright tests Adding support for the multiline response config in text questions had broken the input[name="2.text"] selector so I've made it less specific. --- .../tests/organize/surveys/submitting-survey.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 89f5ab0441..6410e74c75 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -50,7 +50,7 @@ test.describe('User submitting a survey', () => { ); await page.click('input[name="1.options"]'); - await page.fill('input[name="2.text"]', 'Topple capitalism'); + await page.fill('[name="2.text"]', 'Topple capitalism'); await page.click('input[name="sig"][value="anonymous"]'); await page.click('data-testid=Survey-acceptTerms'); await Promise.all([ @@ -87,7 +87,7 @@ test.describe('User submitting a survey', () => { ); await page.click('input[name="1.options"]'); - await page.fill('input[name="2.text"]', 'Topple capitalism'); + await page.fill('[name="2.text"]', 'Topple capitalism'); await page.click('input[name="sig"][value="email"]'); await page.fill('input[name="sig.email"]', 'testuser@example.org'); await page.fill('input[name="sig.first_name"]', 'Test'); @@ -121,7 +121,7 @@ test.describe('User submitting a survey', () => { ); await page.click('input[name="1.options"][value="1"]'); - await page.fill('input[name="2.text"]', 'Topple capitalism'); + await page.fill('[name="2.text"]', 'Topple capitalism'); await page.click('input[name="sig"][value="user"]'); await page.click('data-testid=Survey-acceptTerms'); await Promise.all([ @@ -148,7 +148,7 @@ test.describe('User submitting a survey', () => { ); await page.click('input[name="1.options"][value="1"]'); - await page.fill('input[name="2.text"]', 'Topple capitalism'); + await page.fill('[name="2.text"]', 'Topple capitalism'); await page.click('input[name="sig"][value="anonymous"]'); await page.click('data-testid=Survey-acceptTerms'); await Promise.all([ @@ -167,7 +167,7 @@ test.describe('User submitting a survey', () => { test('preserves inputs on error', async ({ page }) => { await page.click('input[name="1.options"][value="1"]'); - await page.fill('input[name="2.text"]', 'Topple capitalism'); + await page.fill('[name="2.text"]', 'Topple capitalism'); await page.click('input[name="sig"][value="anonymous"]'); await page.click('data-testid=Survey-acceptTerms'); @@ -180,7 +180,7 @@ test.describe('User submitting a survey', () => { await expect( page.locator('input[name="1.options"][value="1"]') ).toBeChecked(); - await expect(page.locator('input[name="2.text"]')).toHaveValue( + await expect(page.locator('[name="2.text"]')).toHaveValue( 'Topple capitalism' ); await expect( From c00f14ea4fb6fcf52c3494fb0c01da8d21b15d6a Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 16 Mar 2024 16:35:39 +0100 Subject: [PATCH 061/369] Fix layout when options are very very long --- .../surveyForm/SurveyOptionsQuestion.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index 22315eab9c..8b7d90beae 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -32,7 +32,14 @@ const OptionsQuestion: FC = ({ element }) => { aria-describedby={`description-${element.id}`} aria-labelledby={`label-${element.id}`} > - + @@ -66,7 +73,14 @@ const OptionsQuestion: FC = ({ element }) => { aria-labelledby={`label-${element.id}`} name={`${element.id}.options`} > - + From 2f3d49c38e34645fa3bccffb5c9b29a95971524f Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 16 Mar 2024 17:01:04 +0100 Subject: [PATCH 062/369] Add hideOrganization parameter for iframe use case --- .../components/surveyForm/SurveyHeading.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyHeading.tsx b/src/features/surveys/components/surveyForm/SurveyHeading.tsx index 3cef1d220a..e6356cd29d 100644 --- a/src/features/surveys/components/surveyForm/SurveyHeading.tsx +++ b/src/features/surveys/components/surveyForm/SurveyHeading.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; import SurveyContainer from './SurveyContainer'; import SurveyErrorMessage from './SurveyErrorMessage'; +import { useSearchParams } from 'next/navigation'; import ZUIAvatar from 'zui/ZUIAvatar'; import { Box, Typography } from '@mui/material'; import { @@ -14,22 +15,26 @@ export type SurveyHeadingProps = { }; const SurveyHeading: FC = ({ status, survey }) => { + const searchParams = useSearchParams(); + const hideOrganization = searchParams?.get('hideOrganization'); return ( - - - - {survey.organization.title} - - + {hideOrganization !== 'true' && ( + + + + {survey.organization.title} + + + )} From 509aea000fd79430f9d7332d21a6afc67f3e32cb Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 16 Mar 2024 17:29:36 +0100 Subject: [PATCH 063/369] Fix out-of-range error about default dropdown value --- .../components/surveyForm/SurveyOptionsQuestion.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index 8b7d90beae..e4f7cc05ca 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -1,4 +1,3 @@ -import { FC } from 'react'; import SurveyContainer from './SurveyContainer'; import SurveyOption from './SurveyOption'; import SurveyQuestionDescription from './SurveyQuestionDescription'; @@ -13,7 +12,9 @@ import { Radio, RadioGroup, Select, + SelectChangeEvent, } from '@mui/material'; +import { FC, useCallback, useState } from 'react'; import { ZetkinSurveyOption, ZetkinSurveyOptionsQuestionElement, @@ -24,6 +25,11 @@ export type OptionsQuestionProps = { }; const OptionsQuestion: FC = ({ element }) => { + const [dropdownValue, setDropdownValue] = useState(''); + const handleDropdownChange = useCallback((event: SelectChangeEvent) => { + setDropdownValue(event.target.value); + }, []); + return ( @@ -129,6 +135,8 @@ const OptionsQuestion: FC = ({ element }) => { - + diff --git a/src/features/surveys/components/surveyForm/SurveySignature.tsx b/src/features/surveys/components/surveyForm/SurveySignature.tsx index 2eb1e698e5..3367e9029f 100644 --- a/src/features/surveys/components/surveyForm/SurveySignature.tsx +++ b/src/features/surveys/components/surveyForm/SurveySignature.tsx @@ -19,13 +19,15 @@ import { FC, useCallback, useState } from 'react'; import { ZetkinSurveyExtended, ZetkinSurveySignatureType, + ZetkinUser, } from 'utils/types/zetkin'; export type SurveySignatureProps = { survey: ZetkinSurveyExtended; + user: ZetkinUser | null; }; -const SurveySignature: FC = ({ survey }) => { +const SurveySignature: FC = ({ survey, user }) => { // const currentUser = useCurrentUser(); const theme = useTheme(); @@ -58,21 +60,23 @@ const SurveySignature: FC = ({ survey }) => { - {/* } - label={ - - } + label={ + + + + } + value="user" /> - - } - value="user" - /> */} + )} } diff --git a/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts b/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts index 85cd73bb2a..6078e5c017 100644 --- a/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts +++ b/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts @@ -9,7 +9,7 @@ describe('prepareSurveyApiSubmission()', () => { it('formats a text response', () => { formData['123.text'] = 'Lorem ipsum dolor sit amet'; - const submission = prepareSurveyApiSubmission(formData); + const submission = prepareSurveyApiSubmission(formData, null); expect(submission.responses).toMatchObject([ { question_id: 123, @@ -20,7 +20,7 @@ describe('prepareSurveyApiSubmission()', () => { it('formats a radio button response', () => { formData['123.options'] = '456'; - const submission = prepareSurveyApiSubmission(formData); + const submission = prepareSurveyApiSubmission(formData, null); expect(submission.responses).toMatchObject([ { options: [456], @@ -31,7 +31,7 @@ describe('prepareSurveyApiSubmission()', () => { it('formats a checkbox response', () => { formData['123.options'] = ['456', '789']; - const submission = prepareSurveyApiSubmission(formData); + const submission = prepareSurveyApiSubmission(formData, null); expect(submission.responses).toMatchObject([ { options: [456, 789], @@ -42,8 +42,19 @@ describe('prepareSurveyApiSubmission()', () => { it('signs as the logged-in account when a logged-in user requests to sign as themself', () => { formData['sig'] = 'user'; - const submission = prepareSurveyApiSubmission(formData, true); - expect(submission.signature).toEqual('user'); + const submission = prepareSurveyApiSubmission(formData, { + email: 'testadmin@example.com', + first_name: 'Angela', + id: 2, + lang: null, + last_name: 'Davis', + username: 'test', + }); + expect(submission.signature).toEqual({ + email: 'testadmin@example.com', + first_name: 'Angela', + last_name: 'Davis', + }); }); it('signs with custom contact details when a name and email are given', () => { @@ -51,7 +62,7 @@ describe('prepareSurveyApiSubmission()', () => { formData['sig.email'] = 'testuser@example.org'; formData['sig.first_name'] = 'test'; formData['sig.last_name'] = 'user'; - const submission = prepareSurveyApiSubmission(formData); + const submission = prepareSurveyApiSubmission(formData, null); expect(submission.signature).toMatchObject({ email: 'testuser@example.org', first_name: 'test', diff --git a/src/features/surveys/utils/prepareSurveyApiSubmission.ts b/src/features/surveys/utils/prepareSurveyApiSubmission.ts index 52cb31d64b..b1ff21c5cd 100644 --- a/src/features/surveys/utils/prepareSurveyApiSubmission.ts +++ b/src/features/surveys/utils/prepareSurveyApiSubmission.ts @@ -2,11 +2,12 @@ import { ZetkinSurveyApiSubmission, ZetkinSurveyQuestionResponse, ZetkinSurveySignaturePayload, + ZetkinUser, } from 'utils/types/zetkin'; export default function prepareSurveyApiSubmission( formData: Record, - isLoggedIn?: boolean + user: ZetkinUser | null ): ZetkinSurveyApiSubmission { const responses: ZetkinSurveyQuestionResponse[] = []; const responseEntries = Object.fromEntries( @@ -44,8 +45,12 @@ export default function prepareSurveyApiSubmission( let signature: ZetkinSurveySignaturePayload = null; - if (formData.sig === 'user' && isLoggedIn) { - signature = 'user'; + if (formData.sig === 'user' && user) { + signature = { + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + }; } if (formData.sig == 'email') { From de1a5bf81b5a8f4fc3967e662ba982c7d1aab369 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 17 Mar 2024 11:23:26 +0100 Subject: [PATCH 070/369] Replace signature hack with original implementation which has mysteriously started working --- .../surveys/submitting-survey.spec.ts | 8 ++----- src/features/surveys/actions/submit.ts | 2 +- .../utils/prepareSurveyApiSubmission.spec.ts | 23 +++++-------------- .../utils/prepareSurveyApiSubmission.ts | 11 +++------ 4 files changed, 12 insertions(+), 32 deletions(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index ca7085f61a..83e749d7ca 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -111,7 +111,7 @@ test.describe('User submitting a survey', () => { }); }); - test('submits user signature', async ({ moxy, page }) => { + test.only('submits user signature', async ({ moxy, page }) => { moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', @@ -135,11 +135,7 @@ test.describe('User submitting a survey', () => { const data = request.data as { signature: ZetkinSurveySignaturePayload; }; - expect(data.signature).toEqual({ - email: 'rosa@example.org', - first_name: 'Rosa', - last_name: 'Luxemburg', - }); + expect(data.signature).toBe('user'); }); test('submits anonymous signature', async ({ moxy, page }) => { diff --git a/src/features/surveys/actions/submit.ts b/src/features/surveys/actions/submit.ts index 965dcf4750..38058c1c8b 100644 --- a/src/features/surveys/actions/submit.ts +++ b/src/features/surveys/actions/submit.ts @@ -22,7 +22,7 @@ export async function submit( } const data = Object.fromEntries([...formData.entries()]); - const submission = prepareSurveyApiSubmission(data, user); + const submission = prepareSurveyApiSubmission(data, !!user); try { await apiClient.post( `/api/orgs/${data.orgId}/surveys/${data.surveyId}/submissions`, diff --git a/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts b/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts index 6078e5c017..85cd73bb2a 100644 --- a/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts +++ b/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts @@ -9,7 +9,7 @@ describe('prepareSurveyApiSubmission()', () => { it('formats a text response', () => { formData['123.text'] = 'Lorem ipsum dolor sit amet'; - const submission = prepareSurveyApiSubmission(formData, null); + const submission = prepareSurveyApiSubmission(formData); expect(submission.responses).toMatchObject([ { question_id: 123, @@ -20,7 +20,7 @@ describe('prepareSurveyApiSubmission()', () => { it('formats a radio button response', () => { formData['123.options'] = '456'; - const submission = prepareSurveyApiSubmission(formData, null); + const submission = prepareSurveyApiSubmission(formData); expect(submission.responses).toMatchObject([ { options: [456], @@ -31,7 +31,7 @@ describe('prepareSurveyApiSubmission()', () => { it('formats a checkbox response', () => { formData['123.options'] = ['456', '789']; - const submission = prepareSurveyApiSubmission(formData, null); + const submission = prepareSurveyApiSubmission(formData); expect(submission.responses).toMatchObject([ { options: [456, 789], @@ -42,19 +42,8 @@ describe('prepareSurveyApiSubmission()', () => { it('signs as the logged-in account when a logged-in user requests to sign as themself', () => { formData['sig'] = 'user'; - const submission = prepareSurveyApiSubmission(formData, { - email: 'testadmin@example.com', - first_name: 'Angela', - id: 2, - lang: null, - last_name: 'Davis', - username: 'test', - }); - expect(submission.signature).toEqual({ - email: 'testadmin@example.com', - first_name: 'Angela', - last_name: 'Davis', - }); + const submission = prepareSurveyApiSubmission(formData, true); + expect(submission.signature).toEqual('user'); }); it('signs with custom contact details when a name and email are given', () => { @@ -62,7 +51,7 @@ describe('prepareSurveyApiSubmission()', () => { formData['sig.email'] = 'testuser@example.org'; formData['sig.first_name'] = 'test'; formData['sig.last_name'] = 'user'; - const submission = prepareSurveyApiSubmission(formData, null); + const submission = prepareSurveyApiSubmission(formData); expect(submission.signature).toMatchObject({ email: 'testuser@example.org', first_name: 'test', diff --git a/src/features/surveys/utils/prepareSurveyApiSubmission.ts b/src/features/surveys/utils/prepareSurveyApiSubmission.ts index b1ff21c5cd..52cb31d64b 100644 --- a/src/features/surveys/utils/prepareSurveyApiSubmission.ts +++ b/src/features/surveys/utils/prepareSurveyApiSubmission.ts @@ -2,12 +2,11 @@ import { ZetkinSurveyApiSubmission, ZetkinSurveyQuestionResponse, ZetkinSurveySignaturePayload, - ZetkinUser, } from 'utils/types/zetkin'; export default function prepareSurveyApiSubmission( formData: Record, - user: ZetkinUser | null + isLoggedIn?: boolean ): ZetkinSurveyApiSubmission { const responses: ZetkinSurveyQuestionResponse[] = []; const responseEntries = Object.fromEntries( @@ -45,12 +44,8 @@ export default function prepareSurveyApiSubmission( let signature: ZetkinSurveySignaturePayload = null; - if (formData.sig === 'user' && user) { - signature = { - email: user.email, - first_name: user.first_name, - last_name: user.last_name, - }; + if (formData.sig === 'user' && isLoggedIn) { + signature = 'user'; } if (formData.sig == 'email') { From f04d3905f418117b86f024b5cf5f111913c05dfc Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 17 Mar 2024 11:26:13 +0100 Subject: [PATCH 071/369] Remove spurious redirect --- next.config.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/next.config.js b/next.config.js index aad400f001..7c0cd30d61 100644 --- a/next.config.js +++ b/next.config.js @@ -67,11 +67,6 @@ module.exports = { destination: `http://${process.env.ZETKIN_API_DOMAIN}/o/:orgId`, permanent: false, }, - { - source: '/o/:orgId/map', - destination: `http://${process.env.ZETKIN_API_DOMAIN}/o/:orgId/map`, - permanent: false, - }, { source: '/o/:orgId/projects/:campId', destination: `http://${process.env.ZETKIN_API_DOMAIN}/o/:orgId/campaigns/:campId`, From a14c2427935cedea6546f8e3e3c9dcae18d8c72e Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 17 Mar 2024 11:32:00 +0100 Subject: [PATCH 072/369] Remove .only --- .../tests/organize/surveys/submitting-survey.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 83e749d7ca..566f87d4ae 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -111,7 +111,7 @@ test.describe('User submitting a survey', () => { }); }); - test.only('submits user signature', async ({ moxy, page }) => { + test('submits user signature', async ({ moxy, page }) => { moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', From 445e60f745d801cbaf9509b7f6c90e96074395c8 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 17 Mar 2024 12:24:48 +0100 Subject: [PATCH 073/369] Scroll to survey error on mount --- .../components/surveyForm/SurveyContainer.tsx | 13 +++++++------ .../components/surveyForm/SurveyErrorMessage.tsx | 10 ++++++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyContainer.tsx b/src/features/surveys/components/surveyForm/SurveyContainer.tsx index 3552dd0299..a4171ea68d 100644 --- a/src/features/surveys/components/surveyForm/SurveyContainer.tsx +++ b/src/features/surveys/components/surveyForm/SurveyContainer.tsx @@ -1,16 +1,17 @@ import { Box, BoxProps } from '@mui/material'; -import { FC, ReactNode } from 'react'; +import { forwardRef, ForwardRefRenderFunction, ReactNode } from 'react'; export type SurveyContainerProps = BoxProps & { children: ReactNode; }; -const SurveyContainer: FC = ({ - children, - ...boxProps -}) => { +const SurveyContainer: ForwardRefRenderFunction< + unknown, + SurveyContainerProps +> = ({ children, ...boxProps }, ref) => { return ( = ({ ); }; -export default SurveyContainer; +export default forwardRef(SurveyContainer); diff --git a/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx b/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx index 9d067d085e..66d00180de 100644 --- a/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx +++ b/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx @@ -1,14 +1,20 @@ import Box from '@mui/material/Box'; -import { FC } from 'react'; import messageIds from 'features/surveys/l10n/messageIds'; import { Msg } from 'core/i18n'; import SurveyContainer from './SurveyContainer'; import { useTheme } from '@mui/material'; +import { FC, useEffect, useRef } from 'react'; const SurveyErrorMessage: FC = () => { + const element = useRef(null); const theme = useTheme(); + useEffect(() => { + if (element.current) { + element.current.scrollIntoView({ behavior: 'smooth' }); + } + }, []); return ( - + Date: Sun, 17 Mar 2024 11:47:00 +0100 Subject: [PATCH 074/369] Fix server-rendered CSS in app router --- package.json | 4 + src/app/layout.tsx | 9 +- yarn.lock | 1207 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 1196 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index f0e9a2b6a3..986afdb94d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dependencies": { "@date-io/date-fns": "1.x", "@date-io/dayjs": "1.x", + "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@messageformat/parser": "^5.1.0", @@ -34,6 +35,7 @@ "@mui/icons-material": "^5.10.6", "@mui/lab": "^5.0.0-alpha.100", "@mui/material": "^5.10.7", + "@mui/material-nextjs": "^5.15.11", "@mui/styles": "^5.10.6", "@mui/system": "^5.10.7", "@mui/x-data-grid-pro": "^6.14.0", @@ -49,6 +51,7 @@ "dayjs": "^1.10.6", "final-form": "^4.20.2", "fuse.js": "^6.5.3", + "install": "^0.13.0", "intl-messageformat": "^10.3.1", "iron-session": "^8.0.1", "is-url": "^1.2.4", @@ -62,6 +65,7 @@ "negotiator": "^0.6.2", "next": "^14.1.0", "node-xlsx": "^0.21.0", + "npm": "^10.5.0", "nprogress": "^0.2.0", "papaparse": "^5.4.1", "random-seed": "^0.3.0", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0dbe9ab69b..8c0a8e5850 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter'; import BackendApiClient from 'core/api/client/BackendApiClient'; import ClientContext from 'core/env/ClientContext'; import { headers } from 'next/headers'; @@ -27,9 +28,11 @@ export default async function RootLayout({ return ( - - {children} - + + + {children} + + ); diff --git a/yarn.lock b/yarn.lock index 9af9389996..91f3afcab7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1356,6 +1356,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.23.9": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.0.tgz#584c450063ffda59697021430cb47101b085951e" + integrity sha512-Chk32uHMg6TnQdvw2e9IlqPpFX/6NLuK0Ys2PqLb7/gL5uFn9mXvK715FGLlOLQrcO4qIkNHkvPGktzzXexsFw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/runtime@~7.5.4": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132" @@ -2054,6 +2061,11 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" +"@isaacs/string-locale-compare@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz#291c227e93fd407a96ecd59879a35809120e432b" + integrity sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -2421,6 +2433,13 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/material-nextjs@^5.15.11": + version "5.15.11" + resolved "https://registry.yarnpkg.com/@mui/material-nextjs/-/material-nextjs-5.15.11.tgz#bf75eece88fb088e74eb5f0eef01f9f64f8ec7f4" + integrity sha512-cp5RWYbBngyi7NKP91R9QITllfxumCVPFjqe4AKzNROVuCot0VpgkafxXqfbv0uFsyUU0ROs0O2M3r17q604Aw== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/material@^5.10.7": version "5.10.7" resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.10.7.tgz#08e72c554bd528f9f5fef604eea542551f9e5986" @@ -2885,6 +2904,77 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@npmcli/agent@^2.0.0": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@npmcli/agent/-/agent-2.2.1.tgz#8aa677d0a4136d57524336a35d5679aedf2d56f7" + integrity sha512-H4FrOVtNyWC8MUwL3UfjOsAihHvT1Pe8POj3JvjXhSTJipsZMtgUALCT4mGyYZNxymkUfOw3PUj6dE4QPp6osQ== + dependencies: + agent-base "^7.1.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.1" + lru-cache "^10.0.1" + socks-proxy-agent "^8.0.1" + +"@npmcli/arborist@^7.2.1": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-7.4.0.tgz#6be8e6562945cdf87097f8f8c50d72c37b9eb832" + integrity sha512-VFsUaTrV8NR+0E2I+xhp6pPC5eAbMmSMSMZbS57aogLc6du6HWBPATFOaiNWwp1QTFVeP4aLhYixQM9hHfaAsA== + dependencies: + "@isaacs/string-locale-compare" "^1.1.0" + "@npmcli/fs" "^3.1.0" + "@npmcli/installed-package-contents" "^2.0.2" + "@npmcli/map-workspaces" "^3.0.2" + "@npmcli/metavuln-calculator" "^7.0.0" + "@npmcli/name-from-folder" "^2.0.0" + "@npmcli/node-gyp" "^3.0.0" + "@npmcli/package-json" "^5.0.0" + "@npmcli/query" "^3.1.0" + "@npmcli/run-script" "^7.0.2" + bin-links "^4.0.1" + cacache "^18.0.0" + common-ancestor-path "^1.0.1" + hosted-git-info "^7.0.1" + json-parse-even-better-errors "^3.0.0" + json-stringify-nice "^1.1.4" + minimatch "^9.0.0" + nopt "^7.0.0" + npm-install-checks "^6.2.0" + npm-package-arg "^11.0.1" + npm-pick-manifest "^9.0.0" + npm-registry-fetch "^16.0.0" + npmlog "^7.0.1" + pacote "^17.0.4" + parse-conflict-json "^3.0.0" + proc-log "^3.0.0" + promise-all-reject-late "^1.0.0" + promise-call-limit "^3.0.1" + read-package-json-fast "^3.0.2" + semver "^7.3.7" + ssri "^10.0.5" + treeverse "^3.0.0" + walk-up-path "^3.0.1" + +"@npmcli/config@^8.0.2": + version "8.2.0" + resolved "https://registry.yarnpkg.com/@npmcli/config/-/config-8.2.0.tgz#18774fc7239cfcc124ca9fdc48b1f65bb7bee191" + integrity sha512-YoEYZFg0hRSRP/Chmq+J4FvULFvji6SORUYWQc10FiJ+ReAnViXcDCENg6kM6dID04bAoKNUygrby798+gYBbQ== + dependencies: + "@npmcli/map-workspaces" "^3.0.2" + ci-info "^4.0.0" + ini "^4.1.0" + nopt "^7.0.0" + proc-log "^3.0.0" + read-package-json-fast "^3.0.2" + semver "^7.3.5" + walk-up-path "^3.0.1" + +"@npmcli/disparity-colors@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/disparity-colors/-/disparity-colors-3.0.0.tgz#60ea8c6eb5ba9de2d1950e15b06205b2c3ab7833" + integrity sha512-5R/z157/f20Fi0Ou4ZttL51V0xz0EdPEOauFtPCEYOLInDBRCj1/TxOJ5aGTrtShxEshN2d+hXb9ZKSi5RLBcg== + dependencies: + ansi-styles "^4.3.0" + "@npmcli/fs@^1.0.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.1.1.tgz#72f719fe935e687c56a4faecf3c03d06ba593257" @@ -2893,6 +2983,55 @@ "@gar/promisify" "^1.0.1" semver "^7.3.5" +"@npmcli/fs@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-3.1.0.tgz#233d43a25a91d68c3a863ba0da6a3f00924a173e" + integrity sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w== + dependencies: + semver "^7.3.5" + +"@npmcli/git@^5.0.0", "@npmcli/git@^5.0.3": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-5.0.4.tgz#d18c50f99649e6e89e8b427318134f582498700c" + integrity sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ== + dependencies: + "@npmcli/promise-spawn" "^7.0.0" + lru-cache "^10.0.1" + npm-pick-manifest "^9.0.0" + proc-log "^3.0.0" + promise-inflight "^1.0.1" + promise-retry "^2.0.1" + semver "^7.3.5" + which "^4.0.0" + +"@npmcli/installed-package-contents@^2.0.1", "@npmcli/installed-package-contents@^2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz#bfd817eccd9e8df200919e73f57f9e3d9e4f9e33" + integrity sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ== + dependencies: + npm-bundled "^3.0.0" + npm-normalize-package-bin "^3.0.0" + +"@npmcli/map-workspaces@^3.0.2", "@npmcli/map-workspaces@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@npmcli/map-workspaces/-/map-workspaces-3.0.4.tgz#15ad7d854292e484f7ba04bc30187a8320dba799" + integrity sha512-Z0TbvXkRbacjFFLpVpV0e2mheCh+WzQpcqL+4xp49uNJOxOnIAPZyXtUxZ5Qn3QBTGKA11Exjd9a5411rBrhDg== + dependencies: + "@npmcli/name-from-folder" "^2.0.0" + glob "^10.2.2" + minimatch "^9.0.0" + read-package-json-fast "^3.0.0" + +"@npmcli/metavuln-calculator@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/metavuln-calculator/-/metavuln-calculator-7.0.0.tgz#fb59245926d7f677db904177f9aca15ac883d6cb" + integrity sha512-Pw0tyX02VkpqlIQlG2TeiJNsdrecYeUU0ubZZa9pi3N37GCsxI+en43u4hYFdq+eSx1A9a9vwFAUyqEtKFsbHQ== + dependencies: + cacache "^18.0.0" + json-parse-even-better-errors "^3.0.0" + pacote "^17.0.0" + semver "^7.3.5" + "@npmcli/move-file@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.1.2.tgz#1a82c3e372f7cae9253eb66d72543d6b8685c674" @@ -2901,6 +3040,54 @@ mkdirp "^1.0.4" rimraf "^3.0.2" +"@npmcli/name-from-folder@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/name-from-folder/-/name-from-folder-2.0.0.tgz#c44d3a7c6d5c184bb6036f4d5995eee298945815" + integrity sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg== + +"@npmcli/node-gyp@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz#101b2d0490ef1aa20ed460e4c0813f0db560545a" + integrity sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA== + +"@npmcli/package-json@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-5.0.0.tgz#77d0f8b17096763ccbd8af03b7117ba6e34d6e91" + integrity sha512-OI2zdYBLhQ7kpNPaJxiflofYIpkNLi+lnGdzqUOfRmCF3r2l1nadcjtCYMJKv/Utm/ZtlffaUuTiAktPHbc17g== + dependencies: + "@npmcli/git" "^5.0.0" + glob "^10.2.2" + hosted-git-info "^7.0.0" + json-parse-even-better-errors "^3.0.0" + normalize-package-data "^6.0.0" + proc-log "^3.0.0" + semver "^7.5.3" + +"@npmcli/promise-spawn@^7.0.0", "@npmcli/promise-spawn@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz#a836de2f42a2245d629cf6fbb8dd6c74c74c55af" + integrity sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg== + dependencies: + which "^4.0.0" + +"@npmcli/query@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@npmcli/query/-/query-3.1.0.tgz#bc202c59e122a06cf8acab91c795edda2cdad42c" + integrity sha512-C/iR0tk7KSKGldibYIB9x8GtO/0Bd0I2mhOaDb8ucQL/bQVTmGoeREaFj64Z5+iCBRf3dQfed0CjJL7I8iTkiQ== + dependencies: + postcss-selector-parser "^6.0.10" + +"@npmcli/run-script@^7.0.0", "@npmcli/run-script@^7.0.2", "@npmcli/run-script@^7.0.4": + version "7.0.4" + resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-7.0.4.tgz#9f29aaf4bfcf57f7de2a9e28d1ef091d14b2e6eb" + integrity sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg== + dependencies: + "@npmcli/node-gyp" "^3.0.0" + "@npmcli/package-json" "^5.0.0" + "@npmcli/promise-spawn" "^7.0.0" + node-gyp "^10.0.0" + which "^4.0.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -3030,6 +3217,50 @@ resolved "https://registry.yarnpkg.com/@servie/events/-/events-1.0.0.tgz#8258684b52d418ab7b86533e861186638ecc5dc1" integrity sha512-sBSO19KzdrJCM3gdx6eIxV8M9Gxfgg6iDQmH5TIAGaUu+X9VDdsINXJOnoiZ1Kx3TrHdH4bt5UVglkjsEGBcvw== +"@sigstore/bundle@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-2.2.0.tgz#e3f555a5c503fe176d8d1e0e829b00f842502e46" + integrity sha512-5VI58qgNs76RDrwXNhpmyN/jKpq9evV/7f1XrcqcAfvxDl5SeVY/I5Rmfe96ULAV7/FK5dge9RBKGBJPhL1WsQ== + dependencies: + "@sigstore/protobuf-specs" "^0.3.0" + +"@sigstore/core@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-1.0.0.tgz#0fcdb32d191d4145a70cb837061185353b3b08e3" + integrity sha512-dW2qjbWLRKGu6MIDUTBuJwXCnR8zivcSpf5inUzk7y84zqy/dji0/uahppoIgMoKeR+6pUZucrwHfkQQtiG9Rw== + +"@sigstore/protobuf-specs@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@sigstore/protobuf-specs/-/protobuf-specs-0.3.0.tgz#bdcc773671f625bb81591bca86ec5314d57297f3" + integrity sha512-zxiQ66JFOjVvP9hbhGj/F/qNdsZfkGb/dVXSanNRNuAzMlr4MC95voPUBX8//ZNnmv3uSYzdfR/JSkrgvZTGxA== + +"@sigstore/sign@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-2.2.3.tgz#f07bcd2cfee654fade867db44ae260f1a0142ba4" + integrity sha512-LqlA+ffyN02yC7RKszCdMTS6bldZnIodiox+IkT8B2f8oRYXCB3LQ9roXeiEL21m64CVH1wyveYAORfD65WoSw== + dependencies: + "@sigstore/bundle" "^2.2.0" + "@sigstore/core" "^1.0.0" + "@sigstore/protobuf-specs" "^0.3.0" + make-fetch-happen "^13.0.0" + +"@sigstore/tuf@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-2.3.1.tgz#86ff3c3c907e271696c88de0108d9063a8cbcc45" + integrity sha512-9Iv40z652td/QbV0o5n/x25H9w6IYRt2pIGbTX55yFDYlApDQn/6YZomjz6+KBx69rXHLzHcbtTS586mDdFD+Q== + dependencies: + "@sigstore/protobuf-specs" "^0.3.0" + tuf-js "^2.2.0" + +"@sigstore/verify@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-1.1.0.tgz#ab617c5dc0bc09ead7f101a848f4870af2d84374" + integrity sha512-1fTqnqyTBWvV7cftUUFtDcHPdSox0N3Ub7C0lRyReYx4zZUlNTZjCV+HPy4Lre+r45dV7Qx5JLKvqqsgxuyYfg== + dependencies: + "@sigstore/bundle" "^2.2.0" + "@sigstore/core" "^1.0.0" + "@sigstore/protobuf-specs" "^0.3.0" + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -4218,6 +4449,19 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== +"@tufjs/canonical-json@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz#a52f61a3d7374833fca945b2549bc30a2dd40d0a" + integrity sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA== + +"@tufjs/models@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-2.0.0.tgz#c7ab241cf11dd29deb213d6817dabb8c99ce0863" + integrity sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg== + dependencies: + "@tufjs/canonical-json" "2.0.0" + minimatch "^9.0.3" + "@types/aria-query@^4.2.0": version "4.2.2" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" @@ -5146,6 +5390,11 @@ abbrev@^1.0.0: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + accepts@~1.3.5: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -5235,6 +5484,13 @@ agent-base@6: dependencies: debug "4" +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434" + integrity sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg== + dependencies: + debug "^4.3.4" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -5347,7 +5603,7 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" -ansi-styles@^4.0.0, ansi-styles@^4.1.0: +ansi-styles@^4.0.0, ansi-styles@^4.1.0, ansi-styles@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -5392,7 +5648,7 @@ app-root-dir@^1.0.2: resolved "https://registry.yarnpkg.com/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118" integrity sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg= -"aproba@^1.0.3 || ^2.0.0": +"aproba@^1.0.3 || ^2.0.0", aproba@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== @@ -5402,6 +5658,11 @@ aproba@^1.1.1: resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== +archy@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + integrity sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw== + are-we-there-yet@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" @@ -5410,6 +5671,11 @@ are-we-there-yet@^2.0.0: delegates "^1.0.0" readable-stream "^3.6.0" +are-we-there-yet@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-4.0.2.tgz#aed25dd0eae514660d49ac2b2366b175c614785a" + integrity sha512-ncSWAawFhKMJDTdoAeOV+jyW1VCMj5QIAwULIBV0SSR7B/RLPPEQiknKcg/RIIZlUQrxELpsxMiTUoAQ4sIUyg== + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -5992,6 +6258,16 @@ big.js@^5.2.2: resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== +bin-links@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/bin-links/-/bin-links-4.0.3.tgz#9e4a3c5900830aee3d7f52178b65e01dcdde64a5" + integrity sha512-obsRaULtJurnfox/MDwgq6Yo9kzbv1CPTk/1/s7Z/61Lezc8IKkFCOXNeVLXz0456WRzBQmSsDWlai2tIhBsfA== + dependencies: + cmd-shim "^6.0.0" + npm-normalize-package-bin "^3.0.0" + read-cmd-shim "^4.0.0" + write-file-atomic "^5.0.0" + binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" @@ -6002,6 +6278,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +binary-extensions@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== + bindings@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" @@ -6245,6 +6526,13 @@ builtin-status-codes@^3.0.0: resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= +builtins@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9" + integrity sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ== + dependencies: + semver "^7.0.0" + busboy@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" @@ -6330,6 +6618,24 @@ cacache@^15.0.5: tar "^6.0.2" unique-filename "^1.1.1" +cacache@^18.0.0, cacache@^18.0.2: + version "18.0.2" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-18.0.2.tgz#fd527ea0f03a603be5c0da5805635f8eef00c60c" + integrity sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw== + dependencies: + "@npmcli/fs" "^3.1.0" + fs-minipass "^3.0.0" + glob "^10.2.2" + lru-cache "^10.0.1" + minipass "^7.0.3" + minipass-collect "^2.0.1" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + p-map "^4.0.0" + ssri "^10.0.0" + tar "^6.1.11" + unique-filename "^3.0.0" + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -6465,6 +6771,11 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" + integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== + chance@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/chance/-/chance-1.1.8.tgz#5d6c2b78c9170bf6eb9df7acdda04363085be909" @@ -6559,6 +6870,18 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.2.0.tgz#2876cb948a498797b5236f0095bc057d0dca38b6" integrity sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A== +ci-info@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.0.0.tgz#65466f8b280fc019b9f50a5388115d17a63a44f2" + integrity sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg== + +cidr-regex@4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-4.0.3.tgz#07b52c9762d1ff546a50740e92fc2b5b13a6d871" + integrity sha512-HOwDIy/rhKeMf6uOzxtv7FAbrz8zPjmVKfSpM+U7/bNBXC5rtOyr758jxcptiSx6ZZn5LOhPJT5WWxPAGDV8dw== + dependencies: + ip-regex "^5.0.0" + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -6606,6 +6929,14 @@ cli-boxes@^2.2.1: resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== +cli-columns@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-columns/-/cli-columns-4.0.0.tgz#9fe4d65975238d55218c41bd2ed296a7fa555646" + integrity sha512-XW2Vg+w+L9on9wtwKpyzluIPCWXjaBahI7mTcYjx+BVIYD9c3yqcv/yKC7CmdCZat4rq2yiE1UMSJC5ivKfMtQ== + dependencies: + string-width "^4.2.3" + strip-ansi "^6.0.1" + cli-table3@^0.6.1: version "0.6.2" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.2.tgz#aaf5df9d8b5bf12634dc8b3040806a0c07120d2a" @@ -6615,6 +6946,15 @@ cli-table3@^0.6.1: optionalDependencies: "@colors/colors" "1.5.0" +cli-table3@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2" + integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg== + dependencies: + string-width "^4.2.0" + optionalDependencies: + "@colors/colors" "1.5.0" + client-oauth2@^4.1.0: version "4.3.3" resolved "https://registry.yarnpkg.com/client-oauth2/-/client-oauth2-4.3.3.tgz#7a700e6f4bf412c1f96da0d6b50e07676561e086" @@ -6646,6 +6986,11 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== + clsx@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702" @@ -6666,6 +7011,11 @@ clsx@^2.0.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== +cmd-shim@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-6.0.2.tgz#435fd9e5c95340e61715e19f90209ed6fcd9e0a4" + integrity sha512-+FFYbB0YLaAkhkcrjkyNLYDiOsFSfRjwjY19LXk/psmMx1z00xlCv7hhQoTGXXIKi+YXHL/iiFo8NqMVQX9nOw== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -6726,7 +7076,7 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-support@^1.1.2: +color-support@^1.1.2, color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== @@ -6736,6 +7086,14 @@ colorette@^1.2.2: resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== +columnify@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/columnify/-/columnify-1.6.0.tgz#6989531713c9008bb29735e61e37acf5bd553cf3" + integrity sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q== + dependencies: + strip-ansi "^6.0.1" + wcwidth "^1.0.0" + combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -6788,6 +7146,11 @@ commander@~2.17.1: resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== +common-ancestor-path@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz#4f7d2d1394d91b7abdf51871c62f71eadb0182a7" + integrity sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w== + common-path-prefix@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" @@ -7432,6 +7795,13 @@ default-browser-id@^1.0.4: meow "^3.1.0" untildify "^2.0.0" +defaults@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" + integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== + dependencies: + clone "^1.0.2" + define-data-property@^1.0.1, define-data-property@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3" @@ -7572,6 +7942,11 @@ diff@^5.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== +diff@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -7807,6 +8182,13 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= +encoding@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -7868,6 +8250,16 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +env-paths@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + +err-code@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" + integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== + errno@^0.1.3, errno@~0.1.7: version "0.1.8" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" @@ -8556,6 +8948,11 @@ expect@^27.3.1: jest-message-util "^27.3.1" jest-regex-util "^27.0.6" +exponential-backoff@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" + integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== + express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -8691,6 +9088,11 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fastest-levenshtein@^1.0.16: + version "1.0.16" + resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" + integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== + fastq@^1.6.0: version "1.13.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" @@ -9044,6 +9446,13 @@ fs-minipass@^2.0.0: dependencies: minipass "^3.0.0" +fs-minipass@^3.0.0, fs-minipass@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54" + integrity sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw== + dependencies: + minipass "^7.0.3" + fs-monkey@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3" @@ -9137,6 +9546,20 @@ gauge@^3.0.0: strip-ansi "^6.0.1" wide-align "^1.1.2" +gauge@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-5.0.1.tgz#1efc801b8ff076b86ef3e9a7a280a975df572112" + integrity sha512-CmykPMJGuNan/3S4kZOpvvPYSNqSHANiWnh9XcMU2pSjtBfF0XzZ2p1bFAxTbnFxyBuPxQYHhzwaoOmUdqzvxQ== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.3" + console-control-strings "^1.1.0" + has-unicode "^2.0.1" + signal-exit "^4.0.1" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.5" + gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -9245,7 +9668,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@10.3.10: +glob@10.3.10, glob@^10.2.2, glob@^10.3.10: version "10.3.10" resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== @@ -9368,7 +9791,7 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== -graceful-fs@^4.2.11: +graceful-fs@^4.2.11, graceful-fs@^4.2.6: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -9628,6 +10051,13 @@ hosted-git-info@^2.1.4: resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== +hosted-git-info@^7.0.0, hosted-git-info@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-7.0.1.tgz#9985fcb2700467fecf7f33a4d4874e30680b5322" + integrity sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA== + dependencies: + lru-cache "^10.0.1" + html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" @@ -9724,6 +10154,11 @@ htmlparser2@^6.1.0: domutils "^2.5.2" entities "^2.0.0" +http-cache-semantics@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" + integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== + http-errors@1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" @@ -9764,6 +10199,14 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" +http-proxy-agent@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + http-proxy-middleware@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.1.tgz#7ef3417a479fb7666a571e09966c66a39bd2c15f" @@ -9797,6 +10240,14 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" +https-proxy-agent@^7.0.1: + version "7.0.4" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz#8e97b841a029ad8ddc8731f26595bad868cb4168" + integrity sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg== + dependencies: + agent-base "^7.0.2" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -9814,7 +10265,7 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.6.3: +iconv-lite@0.6.3, iconv-lite@^0.6.2: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -9843,6 +10294,13 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= +ignore-walk@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-6.0.4.tgz#89950be94b4f522225eb63a13c56badb639190e9" + integrity sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw== + dependencies: + minimatch "^9.0.0" + ignore@^4.0.3, ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -9934,11 +10392,34 @@ ini@^1.3.4: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ini@^4.1.0, ini@^4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.2.tgz#7f646dbd9caea595e61f88ef60bfff8b01f8130a" + integrity sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw== + +init-package-json@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-6.0.2.tgz#0d780b752dd1dd83b8649945df38a07df4f990a6" + integrity sha512-ZQ9bxt6PkqIH6fPU69HPheOMoUqIqVqwZj0qlCBfoSCG4lplQhVM/qB3RS4f0RALK3WZZSrNQxNtCZgphuf3IA== + dependencies: + "@npmcli/package-json" "^5.0.0" + npm-package-arg "^11.0.0" + promzard "^1.0.0" + read "^3.0.1" + semver "^7.3.5" + validate-npm-package-license "^3.0.4" + validate-npm-package-name "^5.0.0" + inline-style-parser@0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== +install@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/install/-/install-0.13.0.tgz#6af6e9da9dd0987de2ab420f78e60d9c17260776" + integrity sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA== + internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" @@ -9987,11 +10468,24 @@ intl-messageformat@^10.3.1: "@formatjs/icu-messageformat-parser" "2.3.0" tslib "^2.4.0" +ip-address@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" + integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g== + dependencies: + jsbn "1.1.0" + sprintf-js "^1.1.3" + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= +ip-regex@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-5.0.0.tgz#cd313b2ae9c80c07bd3851e12bf4fa4dc5480632" + integrity sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw== + ip@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" @@ -10133,6 +10627,13 @@ is-ci@^2.0.0: dependencies: ci-info "^2.0.0" +is-cidr@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/is-cidr/-/is-cidr-5.0.3.tgz#fcf817c0146dd4a318f27938af89e98a9b21bdd5" + integrity sha512-lKkM0tmz07dAxNsr8Ii9MGreExa9ZR34N9j8mTG5op824kcwBqinZPowNjcVWWc7j+jR8XAMMItOmBkniN0jOA== + dependencies: + cidr-regex "4.0.3" + is-core-module@^2.11.0, is-core-module@^2.13.0, is-core-module@^2.13.1: version "2.13.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" @@ -10291,6 +10792,11 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU= +is-lambda@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" + integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== + is-map@^2.0.1, is-map@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" @@ -10507,6 +11013,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +isexe@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" + integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -11144,6 +11655,11 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +jsbn@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" + integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== + jsdom@^16.6.0: version "16.7.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" @@ -11230,6 +11746,11 @@ json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== +json-parse-even-better-errors@^3.0.0, json-parse-even-better-errors@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz#02bb29fb5da90b5444581749c22cedd3597c6cb0" + integrity sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg== + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -11245,6 +11766,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= +json-stringify-nice@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz#2c937962b80181d3f317dd39aa323e14f5a60a67" + integrity sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw== + json-stringify-safe@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -11304,6 +11830,11 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" +jsonparse@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" + integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== + jss-plugin-camel-case@^10.10.0: version "10.10.0" resolved "https://registry.yarnpkg.com/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz#27ea159bab67eb4837fa0260204eb7925d4daa1c" @@ -11467,6 +11998,16 @@ junk@^3.1.0: resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== +just-diff-apply@^5.2.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-5.5.0.tgz#771c2ca9fa69f3d2b54e7c3f5c1dfcbcc47f9f0f" + integrity sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw== + +just-diff@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-6.0.2.tgz#03b65908543ac0521caf6d8eb85035f7d27ea285" + integrity sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA== + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -11589,6 +12130,119 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +libnpmaccess@^8.0.1: + version "8.0.2" + resolved "https://registry.yarnpkg.com/libnpmaccess/-/libnpmaccess-8.0.2.tgz#a13a72fd5b71a1063ea54973fa56d61ec38f718f" + integrity sha512-4K+nsg3OYt4rjryP/3D5zGWluLbZaKozwj6YdtvAyxNhLhUrjCoyxHVoL5AkTJcAnjsd6/ATei52QPVvpSX9Ug== + dependencies: + npm-package-arg "^11.0.1" + npm-registry-fetch "^16.0.0" + +libnpmdiff@^6.0.3: + version "6.0.7" + resolved "https://registry.yarnpkg.com/libnpmdiff/-/libnpmdiff-6.0.7.tgz#5fd7df1c4b8ff58160fa59d5eb97686a00f8fdd3" + integrity sha512-Erca7NHh+MGk4O14mM4yv9S1S+Wc5TgFg6yr8r/g5ykn34dZdAP/GkzhQNJiOpzfD8j1HBhbTpkbGJHVDdgG5Q== + dependencies: + "@npmcli/arborist" "^7.2.1" + "@npmcli/disparity-colors" "^3.0.0" + "@npmcli/installed-package-contents" "^2.0.2" + binary-extensions "^2.2.0" + diff "^5.1.0" + minimatch "^9.0.0" + npm-package-arg "^11.0.1" + pacote "^17.0.4" + tar "^6.2.0" + +libnpmexec@^7.0.4: + version "7.0.8" + resolved "https://registry.yarnpkg.com/libnpmexec/-/libnpmexec-7.0.8.tgz#2bc6ab0468dde95803745ced1fea48bd43b112fc" + integrity sha512-xDzWoYpV1Ok0TIdrY4wuWGxriEv/O3/d8QG924yErBE0sMkkzKsin2dAmlEBsSlR7YRilObs8q+5uNtxKNQHAQ== + dependencies: + "@npmcli/arborist" "^7.2.1" + "@npmcli/run-script" "^7.0.2" + ci-info "^4.0.0" + npm-package-arg "^11.0.1" + npmlog "^7.0.1" + pacote "^17.0.4" + proc-log "^3.0.0" + read "^2.0.0" + read-package-json-fast "^3.0.2" + semver "^7.3.7" + walk-up-path "^3.0.1" + +libnpmfund@^5.0.1: + version "5.0.5" + resolved "https://registry.yarnpkg.com/libnpmfund/-/libnpmfund-5.0.5.tgz#f874005a2f9a92a4c6c4ae7a489ceb16f48690ce" + integrity sha512-BUu2l9Kn4u6nce1Ay8a1uRN1fyU7lbVmtsMYxWcFpcbF+ZPN7qIiPksfcnY9/NDKIRGJYwwv0IXgQQStHDx6Tg== + dependencies: + "@npmcli/arborist" "^7.2.1" + +libnpmhook@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/libnpmhook/-/libnpmhook-10.0.1.tgz#3cb9516645f0d6891b4a59c72ffe026bdbb9bd6b" + integrity sha512-FnXCweDpoAko6mnLPSW8qrRYicjfh+GrvY5PuYHQRPvaW4BFtHDUmK3K3aYx4yD3TeGAKpj4IigrEDfUfWuSkA== + dependencies: + aproba "^2.0.0" + npm-registry-fetch "^16.0.0" + +libnpmorg@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/libnpmorg/-/libnpmorg-6.0.2.tgz#6e5e37ecc5a391082e83c599512689c78e60dc70" + integrity sha512-zK4r6cjVsfXf7hWzWGB6R0LBJidVhKaeMWMZL/1eyZS6ixxAxVijfsPacoEnBRCFaXsNjAtwV3b2RCmYU6+usA== + dependencies: + aproba "^2.0.0" + npm-registry-fetch "^16.0.0" + +libnpmpack@^6.0.3: + version "6.0.7" + resolved "https://registry.yarnpkg.com/libnpmpack/-/libnpmpack-6.0.7.tgz#0b1cdd7c250f929e77ece95f2a738e9670dcb8ad" + integrity sha512-aVX5ZLiYAioShh5wzoBOGs25GvPskry7SxCpx76gMCjOrd/wKcNtbTOMqStvizd3c+vzq5a1b7FMP09XAtgRFg== + dependencies: + "@npmcli/arborist" "^7.2.1" + "@npmcli/run-script" "^7.0.2" + npm-package-arg "^11.0.1" + pacote "^17.0.4" + +libnpmpublish@^9.0.2: + version "9.0.4" + resolved "https://registry.yarnpkg.com/libnpmpublish/-/libnpmpublish-9.0.4.tgz#0222c14578088ca9a758585c36d8133b828c87ad" + integrity sha512-330o6pVsCCg77jQ/+kidyG/RiohXYQKpqmzOC4BjUDWcimb+mXptRBh1Kvy27/Zb/CStZLVrfgGc6tXf5+PE3Q== + dependencies: + ci-info "^4.0.0" + normalize-package-data "^6.0.0" + npm-package-arg "^11.0.1" + npm-registry-fetch "^16.0.0" + proc-log "^3.0.0" + semver "^7.3.7" + sigstore "^2.2.0" + ssri "^10.0.5" + +libnpmsearch@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/libnpmsearch/-/libnpmsearch-7.0.1.tgz#8fa803a8e5837a33ce750a8cc1c70820d728b91d" + integrity sha512-XyKi6Y94t6PGd5Lk2Ma3+fgiHWD3KSCvXmHOrcLkAOEP7oUejbNjL0Bb/HUDZXgBj6gP1Qk7pJ6jZPFBc2hmXQ== + dependencies: + npm-registry-fetch "^16.0.0" + +libnpmteam@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/libnpmteam/-/libnpmteam-6.0.1.tgz#daa1b2e7e4ccef0469bdef661737ca823b53468b" + integrity sha512-1YytqVk1gSkKFNMe4kkCKN49y5rlABrRSx5TrYShQtt2Lb4uQaed49dGE7Ue8TJGxbIkHzvyyVtb3PBiGACVqw== + dependencies: + aproba "^2.0.0" + npm-registry-fetch "^16.0.0" + +libnpmversion@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/libnpmversion/-/libnpmversion-5.0.2.tgz#aea7b09bc270c778cbc8be7bf02e4b60566989cf" + integrity sha512-6JBnLhd6SYgKRekJ4cotxpURLGbEtKxzw+a8p5o+wNwrveJPMH8yW/HKjeewyHzWmxzzwn9EQ3TkF2onkrwstA== + dependencies: + "@npmcli/git" "^5.0.3" + "@npmcli/run-script" "^7.0.2" + json-parse-even-better-errors "^3.0.0" + proc-log "^3.0.0" + semver "^7.3.7" + libphonenumber-js@^1.10.51: version "1.10.51" resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.51.tgz#a3b8c15db2721c3e5f7fe6759e2a524712b578e6" @@ -11745,6 +12399,11 @@ lowlight@^1.14.0, lowlight@^1.17.0: fault "^1.0.0" highlight.js "~10.7.0" +lru-cache@^10.0.1, "lru-cache@^9.1.1 || ^10.0.0": + version "10.2.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" + integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== + lru-cache@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -11767,11 +12426,6 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -"lru-cache@^9.1.1 || ^10.0.0": - version "10.2.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" - integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== - lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -11804,6 +12458,23 @@ make-error@1.x, make-error@^1.1.1, make-error@^1.3.5: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== +make-fetch-happen@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz#705d6f6cbd7faecb8eac2432f551e49475bfedf0" + integrity sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A== + dependencies: + "@npmcli/agent" "^2.0.0" + cacache "^18.0.0" + http-cache-semantics "^4.1.1" + is-lambda "^1.0.1" + minipass "^7.0.2" + minipass-fetch "^3.0.0" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^0.6.3" + promise-retry "^2.0.1" + ssri "^10.0.0" + makeerror@1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" @@ -12475,7 +13146,7 @@ minimalistic-crypto-utils@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -minimatch@9.0.3, minimatch@^9.0.1: +minimatch@9.0.3, minimatch@^9.0.0, minimatch@^9.0.1, minimatch@^9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== @@ -12520,6 +13191,24 @@ minipass-collect@^1.0.2: dependencies: minipass "^3.0.0" +minipass-collect@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863" + integrity sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw== + dependencies: + minipass "^7.0.3" + +minipass-fetch@^3.0.0: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-3.0.4.tgz#4d4d9b9f34053af6c6e597a64be8e66e42bf45b7" + integrity sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg== + dependencies: + minipass "^7.0.3" + minipass-sized "^1.0.3" + minizlib "^2.1.2" + optionalDependencies: + encoding "^0.1.13" + minipass-flush@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" @@ -12527,13 +13216,28 @@ minipass-flush@^1.0.5: dependencies: minipass "^3.0.0" -minipass-pipeline@^1.2.2: +minipass-json-stream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz#7edbb92588fbfc2ff1db2fc10397acb7b6b44aa7" + integrity sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg== + dependencies: + jsonparse "^1.3.1" + minipass "^3.0.0" + +minipass-pipeline@^1.2.2, minipass-pipeline@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== dependencies: minipass "^3.0.0" +minipass-sized@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" + integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== + dependencies: + minipass "^3.0.0" + minipass@^3.0.0, minipass@^3.1.1: version "3.1.6" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.6.tgz#3b8150aa688a711a1521af5e8779c1d3bb4f45ee" @@ -12541,12 +13245,17 @@ minipass@^3.0.0, minipass@^3.1.1: dependencies: yallist "^4.0.0" -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0": +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.2, minipass@^7.0.3, minipass@^7.0.4: version "7.0.4" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== -minizlib@^2.1.1: +minizlib@^2.1.1, minizlib@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== @@ -12643,7 +13352,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: +ms@^2.1.1, ms@^2.1.2: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -12658,6 +13367,11 @@ mui-rff@^6.1.2: date-fns "^2.25.0" yup "^0.32.11" +mute-stream@^1.0.0, mute-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e" + integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA== + nan@^2.12.1: version "2.15.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" @@ -12705,7 +13419,7 @@ negotiator@0.6.2, negotiator@^0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -negotiator@0.6.3: +negotiator@0.6.3, negotiator@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== @@ -12788,6 +13502,22 @@ node-fetch@^2.6.1, node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-gyp@^10.0.0, node-gyp@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-10.0.1.tgz#205514fc19e5830fa991e4a689f9e81af377a966" + integrity sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg== + dependencies: + env-paths "^2.2.0" + exponential-backoff "^3.1.1" + glob "^10.3.10" + graceful-fs "^4.2.6" + make-fetch-happen "^13.0.0" + nopt "^7.0.0" + proc-log "^3.0.0" + semver "^7.3.5" + tar "^6.1.2" + which "^4.0.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -12851,6 +13581,13 @@ nopt@^6.0.0: dependencies: abbrev "^1.0.0" +nopt@^7.0.0, nopt@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.0.tgz#067378c68116f602f552876194fd11f1292503d7" + integrity sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA== + dependencies: + abbrev "^2.0.0" + normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" @@ -12861,6 +13598,16 @@ normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package- semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" +normalize-package-data@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-6.0.0.tgz#68a96b3c11edd462af7189c837b6b1064a484196" + integrity sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg== + dependencies: + hosted-git-info "^7.0.0" + is-core-module "^2.8.1" + semver "^7.3.5" + validate-npm-package-license "^3.0.4" + normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" @@ -12878,6 +13625,78 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= +npm-audit-report@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/npm-audit-report/-/npm-audit-report-5.0.0.tgz#83ac14aeff249484bde81eff53c3771d5048cf95" + integrity sha512-EkXrzat7zERmUhHaoren1YhTxFwsOu5jypE84k6632SXTHcQE1z8V51GC6GVZt8LxkC+tbBcKMUBZAgk8SUSbw== + +npm-bundled@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-3.0.0.tgz#7e8e2f8bb26b794265028491be60321a25a39db7" + integrity sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ== + dependencies: + npm-normalize-package-bin "^3.0.0" + +npm-install-checks@^6.0.0, npm-install-checks@^6.2.0, npm-install-checks@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-6.3.0.tgz#046552d8920e801fa9f919cad569545d60e826fe" + integrity sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw== + dependencies: + semver "^7.1.1" + +npm-normalize-package-bin@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz#25447e32a9a7de1f51362c61a559233b89947832" + integrity sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ== + +npm-package-arg@^11.0.0, npm-package-arg@^11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-11.0.1.tgz#f208b0022c29240a1c532a449bdde3f0a4708ebc" + integrity sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ== + dependencies: + hosted-git-info "^7.0.0" + proc-log "^3.0.0" + semver "^7.3.5" + validate-npm-package-name "^5.0.0" + +npm-packlist@^8.0.0: + version "8.0.2" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-8.0.2.tgz#5b8d1d906d96d21c85ebbeed2cf54147477c8478" + integrity sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA== + dependencies: + ignore-walk "^6.0.4" + +npm-pick-manifest@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz#f87a4c134504a2c7931f2bb8733126e3c3bb7e8f" + integrity sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg== + dependencies: + npm-install-checks "^6.0.0" + npm-normalize-package-bin "^3.0.0" + npm-package-arg "^11.0.0" + semver "^7.3.5" + +npm-profile@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/npm-profile/-/npm-profile-9.0.0.tgz#ffcfa4e3e1b1cb44b17c192f75b44b24b4aae645" + integrity sha512-qv43ixsJ7vndzfxD3XsPNu1Njck6dhO7q1efksTo+0DiOQysKSOsIhK/qDD1/xO2o+2jDOA4Rv/zOJ9KQFs9nw== + dependencies: + npm-registry-fetch "^16.0.0" + proc-log "^3.0.0" + +npm-registry-fetch@^16.0.0, npm-registry-fetch@^16.1.0: + version "16.1.0" + resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz#10227b7b36c97bc1cf2902a24e4f710cfe62803c" + integrity sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw== + dependencies: + make-fetch-happen "^13.0.0" + minipass "^7.0.2" + minipass-fetch "^3.0.0" + minipass-json-stream "^1.0.1" + minizlib "^2.1.2" + npm-package-arg "^11.0.0" + proc-log "^3.0.0" + npm-run-all2@^6.0.4: version "6.0.4" resolved "https://registry.yarnpkg.com/npm-run-all2/-/npm-run-all2-6.0.4.tgz#2623097fcc8b43d051a364146f060978d3e36baa" @@ -12905,6 +13724,87 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +npm-user-validate@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/npm-user-validate/-/npm-user-validate-2.0.0.tgz#7b69bbbff6f7992a1d9a8968d52fd6b6db5431b6" + integrity sha512-sSWeqAYJ2dUPStJB+AEj0DyLRltr/f6YNcvCA7phkB8/RMLMnVsQ41GMwHo/ERZLYNDsyB2wPm7pZo1mqPOl7Q== + +npm@^10.5.0: + version "10.5.0" + resolved "https://registry.yarnpkg.com/npm/-/npm-10.5.0.tgz#726f91df5b1b14d9637c8819d7e71cb873c395a1" + integrity sha512-Ejxwvfh9YnWVU2yA5FzoYLTW52vxHCz+MHrOFg9Cc8IFgF/6f5AGPAvb5WTay5DIUP1NIfN3VBZ0cLlGO0Ys+A== + dependencies: + "@isaacs/string-locale-compare" "^1.1.0" + "@npmcli/arborist" "^7.2.1" + "@npmcli/config" "^8.0.2" + "@npmcli/fs" "^3.1.0" + "@npmcli/map-workspaces" "^3.0.4" + "@npmcli/package-json" "^5.0.0" + "@npmcli/promise-spawn" "^7.0.1" + "@npmcli/run-script" "^7.0.4" + "@sigstore/tuf" "^2.3.1" + abbrev "^2.0.0" + archy "~1.0.0" + cacache "^18.0.2" + chalk "^5.3.0" + ci-info "^4.0.0" + cli-columns "^4.0.0" + cli-table3 "^0.6.3" + columnify "^1.6.0" + fastest-levenshtein "^1.0.16" + fs-minipass "^3.0.3" + glob "^10.3.10" + graceful-fs "^4.2.11" + hosted-git-info "^7.0.1" + ini "^4.1.1" + init-package-json "^6.0.0" + is-cidr "^5.0.3" + json-parse-even-better-errors "^3.0.1" + libnpmaccess "^8.0.1" + libnpmdiff "^6.0.3" + libnpmexec "^7.0.4" + libnpmfund "^5.0.1" + libnpmhook "^10.0.0" + libnpmorg "^6.0.1" + libnpmpack "^6.0.3" + libnpmpublish "^9.0.2" + libnpmsearch "^7.0.0" + libnpmteam "^6.0.0" + libnpmversion "^5.0.1" + make-fetch-happen "^13.0.0" + minimatch "^9.0.3" + minipass "^7.0.4" + minipass-pipeline "^1.2.4" + ms "^2.1.2" + node-gyp "^10.0.1" + nopt "^7.2.0" + normalize-package-data "^6.0.0" + npm-audit-report "^5.0.0" + npm-install-checks "^6.3.0" + npm-package-arg "^11.0.1" + npm-pick-manifest "^9.0.0" + npm-profile "^9.0.0" + npm-registry-fetch "^16.1.0" + npm-user-validate "^2.0.0" + npmlog "^7.0.1" + p-map "^4.0.0" + pacote "^17.0.6" + parse-conflict-json "^3.0.1" + proc-log "^3.0.0" + qrcode-terminal "^0.12.0" + read "^2.1.0" + semver "^7.6.0" + spdx-expression-parse "^3.0.1" + ssri "^10.0.5" + supports-color "^9.4.0" + tar "^6.2.0" + text-table "~0.2.0" + tiny-relative-date "^1.3.0" + treeverse "^3.0.0" + validate-npm-package-name "^5.0.0" + which "^4.0.0" + write-file-atomic "^5.0.1" + npmlog@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" @@ -12915,6 +13815,16 @@ npmlog@^5.0.1: gauge "^3.0.0" set-blocking "^2.0.0" +npmlog@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-7.0.1.tgz#7372151a01ccb095c47d8bf1d0771a4ff1f53ac8" + integrity sha512-uJ0YFk/mCQpLBt+bxN88AKd+gyqZvZDbtiNxk6Waqcj2aPRyfVx8ITawkyQynxUagInjdYT1+qj4NfA5KJJUxg== + dependencies: + are-we-there-yet "^4.0.0" + console-control-strings "^1.1.0" + gauge "^5.0.0" + set-blocking "^2.0.0" + nprogress@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" @@ -13283,6 +14193,30 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pacote@^17.0.0, pacote@^17.0.4, pacote@^17.0.6: + version "17.0.6" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-17.0.6.tgz#874bb59cda5d44ab784d0b6530fcb4a7d9b76a60" + integrity sha512-cJKrW21VRE8vVTRskJo78c/RCvwJCn1f4qgfxL4w77SOWrTCRcmfkYHlHtS0gqpgjv3zhXflRtgsrUCX5xwNnQ== + dependencies: + "@npmcli/git" "^5.0.0" + "@npmcli/installed-package-contents" "^2.0.1" + "@npmcli/promise-spawn" "^7.0.0" + "@npmcli/run-script" "^7.0.0" + cacache "^18.0.0" + fs-minipass "^3.0.0" + minipass "^7.0.2" + npm-package-arg "^11.0.0" + npm-packlist "^8.0.0" + npm-pick-manifest "^9.0.0" + npm-registry-fetch "^16.0.0" + proc-log "^3.0.0" + promise-retry "^2.0.1" + read-package-json "^7.0.0" + read-package-json-fast "^3.0.0" + sigstore "^2.2.0" + ssri "^10.0.0" + tar "^6.1.11" + pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" @@ -13328,6 +14262,15 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.5: pbkdf2 "^3.0.3" safe-buffer "^5.1.1" +parse-conflict-json@^3.0.0, parse-conflict-json@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz#67dc55312781e62aa2ddb91452c7606d1969960c" + integrity sha512-01TvEktc68vwbJOtWZluyWeVGWjP+bZwXtPDMQVbBKzbJ/vZBif0L69KH1+cHv1SZ6e0FKLvjyHe8mqsIqYOmw== + dependencies: + json-parse-even-better-errors "^3.0.0" + just-diff "^6.0.0" + just-diff-apply "^5.2.0" + parse-entities@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8" @@ -13727,6 +14670,14 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2, postcss-selector cssesc "^3.0.0" util-deprecate "^1.0.2" +postcss-selector-parser@^6.0.10: + version "6.0.16" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz#3b88b9f5c5abd989ef4e2fc9ec8eedd34b20fb04" + integrity sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + postcss-value-parser@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" @@ -13824,6 +14775,11 @@ prismjs@~1.27.0: resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.27.0.tgz#bb6ee3138a0b438a3653dd4d6ce0cc6510a45057" integrity sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA== +proc-log@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-3.0.0.tgz#fb05ef83ccd64fd7b20bbe9c8c1070fc08338dd8" + integrity sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -13839,11 +14795,29 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +promise-all-reject-late@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz#f8ebf13483e5ca91ad809ccc2fcf25f26f8643c2" + integrity sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw== + +promise-call-limit@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/promise-call-limit/-/promise-call-limit-3.0.1.tgz#3570f7a3f2aaaf8e703623a552cd74749688cf19" + integrity sha512-utl+0x8gIDasV5X+PI5qWEPqH6fJS0pFtQ/4gZ95xfEFb/89dmh+/b895TbFDBLiafBvxD/PGTKfvxl4kH/pQg== + promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= +promise-retry@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" + integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== + dependencies: + err-code "^2.0.2" + retry "^0.12.0" + promise.allsettled@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/promise.allsettled/-/promise.allsettled-1.0.5.tgz#2443f3d4b2aa8dfa560f6ac2aa6c4ea999d75f53" @@ -13873,6 +14847,13 @@ prompts@^2.0.1, prompts@^2.4.0: kleur "^3.0.3" sisteransi "^1.0.5" +promzard@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/promzard/-/promzard-1.0.0.tgz#3246f8e6c9895a77c0549cefb65828ac0f6c006b" + integrity sha512-KQVDEubSUHGSt5xLakaToDFrSoZhStB8dXLzk2xvwR67gJktrHFvpR63oZgHyK19WKbHFLXJqCPXdVR3aBP8Ig== + dependencies: + read "^2.0.0" + prop-types@^15.0.0, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" @@ -13983,6 +14964,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qrcode-terminal@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" + integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== + qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" @@ -14324,6 +15310,29 @@ react@18.3.0-canary-763612647-20240126: dependencies: loose-envify "^1.1.0" +read-cmd-shim@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz#640a08b473a49043e394ae0c7a34dd822c73b9bb" + integrity sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q== + +read-package-json-fast@^3.0.0, read-package-json-fast@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz#394908a9725dc7a5f14e70c8e7556dff1d2b1049" + integrity sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw== + dependencies: + json-parse-even-better-errors "^3.0.0" + npm-normalize-package-bin "^3.0.0" + +read-package-json@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-7.0.0.tgz#d605c9dcf6bc5856da24204aa4e9518ee9714be0" + integrity sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg== + dependencies: + glob "^10.2.2" + json-parse-even-better-errors "^3.0.0" + normalize-package-data "^6.0.0" + npm-normalize-package-bin "^3.0.0" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -14360,6 +15369,20 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" +read@^2.0.0, read@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/read/-/read-2.1.0.tgz#69409372c54fe3381092bc363a00650b6ac37218" + integrity sha512-bvxi1QLJHcaywCAEsAk4DG3nVoqiY2Csps3qzWalhj5hFqRn1d/OixkFXtLO1PrgHUcAP0FNaSY/5GYNfENFFQ== + dependencies: + mute-stream "~1.0.0" + +read@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/read/-/read-3.0.1.tgz#926808f0f7c83fa95f1ef33c0e2c09dbb28fd192" + integrity sha512-SLBrDU/Srs/9EoWhU5GdbAoxG1GzpQHo/6qiGItaoLJ1thmYpcNIM1qISEUvyHBzfGlWIyd6p2DNi1oV1VmAuw== + dependencies: + mute-stream "^1.0.0" + "readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" @@ -14795,6 +15818,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -14993,6 +16021,13 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@^7.0.0, semver@^7.1.1, semver@^7.3.7, semver@^7.5.3, semver@^7.6.0: + version "7.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== + dependencies: + lru-cache "^6.0.0" + semver@^7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" @@ -15188,6 +16223,18 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +sigstore@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-2.2.2.tgz#5e4ff39febeae9e0679bafa22180cb0f445a7e35" + integrity sha512-2A3WvXkQurhuMgORgT60r6pOWiCOO5LlEqY2ADxGBDGVYLSo5HN0uLtb68YpVpuL/Vi8mLTe7+0Dx2Fq8lLqEg== + dependencies: + "@sigstore/bundle" "^2.2.0" + "@sigstore/core" "^1.0.0" + "@sigstore/protobuf-specs" "^0.3.0" + "@sigstore/sign" "^2.2.3" + "@sigstore/tuf" "^2.3.1" + "@sigstore/verify" "^1.1.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -15248,6 +16295,11 @@ slugify@^1.6.5: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.5.tgz#c8f5c072bf2135b80703589b39a3d41451fbe8c8" integrity sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ== +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -15278,6 +16330,23 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" +socks-proxy-agent@^8.0.1: + version "8.0.2" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz#5acbd7be7baf18c46a3f293a840109a430a640ad" + integrity sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g== + dependencies: + agent-base "^7.0.2" + debug "^4.3.4" + socks "^2.7.1" + +socks@^2.7.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.1.tgz#22c7d9dd7882649043cba0eafb49ae144e3457af" + integrity sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ== + dependencies: + ip-address "^9.0.5" + smart-buffer "^4.2.0" + source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" @@ -15345,7 +16414,7 @@ spdx-exceptions@^2.1.0: resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== -spdx-expression-parse@^3.0.0: +spdx-expression-parse@^3.0.0, spdx-expression-parse@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== @@ -15365,6 +16434,11 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" +sprintf-js@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -15377,6 +16451,13 @@ ssf@~0.11.2: dependencies: frac "~1.1.2" +ssri@^10.0.0, ssri@^10.0.5: + version "10.0.5" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-10.0.5.tgz#e49efcd6e36385196cb515d3a2ad6c3f0265ef8c" + integrity sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A== + dependencies: + minipass "^7.0.3" + ssri@^6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5" @@ -15778,6 +16859,11 @@ supports-color@^8.0.0: dependencies: has-flag "^4.0.0" +supports-color@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.4.0.tgz#17bfcf686288f531db3dea3215510621ccb55954" + integrity sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw== + supports-hyperlinks@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz#4f77b42488765891774b70c79babd87f9bd594bb" @@ -15844,6 +16930,18 @@ tar@^6.0.2: mkdirp "^1.0.3" yallist "^4.0.0" +tar@^6.1.11, tar@^6.1.2, tar@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73" + integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^5.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + telejson@^5.3.2: version "5.3.3" resolved "https://registry.yarnpkg.com/telejson/-/telejson-5.3.3.tgz#fa8ca84543e336576d8734123876a9f02bf41d2e" @@ -15969,7 +17067,7 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -text-table@^0.2.0: +text-table@^0.2.0, text-table@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= @@ -16004,6 +17102,11 @@ tiny-invariant@1.0.6: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== +tiny-relative-date@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/tiny-relative-date/-/tiny-relative-date-1.3.0.tgz#fa08aad501ed730f31cc043181d995c39a935e07" + integrity sha512-MOQHpzllWxDCHHaDno30hhLfbouoYlOI8YlMNtvKe1zXbjEVhbcEovQxvZrPvtiYW630GQDoMMarCnjfyfHA+A== + tiny-warning@^1.0.2, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" @@ -16108,6 +17211,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= +treeverse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-3.0.0.tgz#dd82de9eb602115c6ebd77a574aae67003cb48c8" + integrity sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ== + trim-newlines@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" @@ -16268,6 +17376,15 @@ tty-browserify@0.0.0: resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= +tuf-js@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-2.2.0.tgz#4daaa8620ba7545501d04dfa933c98abbcc959b9" + integrity sha512-ZSDngmP1z6zw+FIkIBjvOp/II/mIub/O7Pp12j1WNsiCpg5R5wAc//i555bBQsE44O94btLt0xM/Zr2LQjwdCg== + dependencies: + "@tufjs/models" "2.0.0" + debug "^4.3.4" + make-fetch-happen "^13.0.0" + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -16484,6 +17601,13 @@ unique-filename@^1.1.1: dependencies: unique-slug "^2.0.0" +unique-filename@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea" + integrity sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g== + dependencies: + unique-slug "^4.0.0" + unique-slug@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" @@ -16491,6 +17615,13 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" +unique-slug@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3" + integrity sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ== + dependencies: + imurmurhash "^0.1.4" + unist-builder@2.0.3, unist-builder@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-2.0.3.tgz#77648711b5d86af0942f334397a33c5e91516436" @@ -16754,7 +17885,7 @@ v8-to-istanbul@^9.0.0: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" -validate-npm-package-license@^3.0.1: +validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== @@ -16762,6 +17893,13 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +validate-npm-package-name@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz#f16afd48318e6f90a1ec101377fa0384cfc8c713" + integrity sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ== + dependencies: + builtins "^5.0.0" + validator@^13.6.0: version "13.7.0" resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" @@ -16839,6 +17977,11 @@ w3c-xmlserializer@^3.0.0: dependencies: xml-name-validator "^4.0.0" +walk-up-path@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/walk-up-path/-/walk-up-path-3.0.1.tgz#c8d78d5375b4966c717eb17ada73dbd41490e886" + integrity sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA== + walker@^1.0.7, walker@~1.0.5: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" @@ -16887,6 +18030,13 @@ watchpack@^2.3.1: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" +wcwidth@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== + dependencies: + defaults "^1.0.3" + web-namespaces@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" @@ -17228,7 +18378,14 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -wide-align@^1.1.2: +which@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/which/-/which-4.0.0.tgz#cd60b5e74503a3fbcfbf6cd6b4138a8bae644c1a" + integrity sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg== + dependencies: + isexe "^3.1.1" + +wide-align@^1.1.2, wide-align@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== @@ -17309,6 +18466,14 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" +write-file-atomic@^5.0.0, write-file-atomic@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" + integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^4.0.1" + ws@^7.4.6: version "7.5.5" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" From da3c16d9bbd97a8f58265cb8b7e986bb4c7a11ee Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 17 Mar 2024 12:52:25 +0100 Subject: [PATCH 075/369] Undo accidental package installs --- package.json | 2 - yarn.lock | 1193 +------------------------------------------------- 2 files changed, 21 insertions(+), 1174 deletions(-) diff --git a/package.json b/package.json index 986afdb94d..e10fa07540 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "dayjs": "^1.10.6", "final-form": "^4.20.2", "fuse.js": "^6.5.3", - "install": "^0.13.0", "intl-messageformat": "^10.3.1", "iron-session": "^8.0.1", "is-url": "^1.2.4", @@ -65,7 +64,6 @@ "negotiator": "^0.6.2", "next": "^14.1.0", "node-xlsx": "^0.21.0", - "npm": "^10.5.0", "nprogress": "^0.2.0", "papaparse": "^5.4.1", "random-seed": "^0.3.0", diff --git a/yarn.lock b/yarn.lock index 91f3afcab7..7400026545 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2061,11 +2061,6 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" -"@isaacs/string-locale-compare@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz#291c227e93fd407a96ecd59879a35809120e432b" - integrity sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ== - "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -2904,77 +2899,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@npmcli/agent@^2.0.0": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@npmcli/agent/-/agent-2.2.1.tgz#8aa677d0a4136d57524336a35d5679aedf2d56f7" - integrity sha512-H4FrOVtNyWC8MUwL3UfjOsAihHvT1Pe8POj3JvjXhSTJipsZMtgUALCT4mGyYZNxymkUfOw3PUj6dE4QPp6osQ== - dependencies: - agent-base "^7.1.0" - http-proxy-agent "^7.0.0" - https-proxy-agent "^7.0.1" - lru-cache "^10.0.1" - socks-proxy-agent "^8.0.1" - -"@npmcli/arborist@^7.2.1": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@npmcli/arborist/-/arborist-7.4.0.tgz#6be8e6562945cdf87097f8f8c50d72c37b9eb832" - integrity sha512-VFsUaTrV8NR+0E2I+xhp6pPC5eAbMmSMSMZbS57aogLc6du6HWBPATFOaiNWwp1QTFVeP4aLhYixQM9hHfaAsA== - dependencies: - "@isaacs/string-locale-compare" "^1.1.0" - "@npmcli/fs" "^3.1.0" - "@npmcli/installed-package-contents" "^2.0.2" - "@npmcli/map-workspaces" "^3.0.2" - "@npmcli/metavuln-calculator" "^7.0.0" - "@npmcli/name-from-folder" "^2.0.0" - "@npmcli/node-gyp" "^3.0.0" - "@npmcli/package-json" "^5.0.0" - "@npmcli/query" "^3.1.0" - "@npmcli/run-script" "^7.0.2" - bin-links "^4.0.1" - cacache "^18.0.0" - common-ancestor-path "^1.0.1" - hosted-git-info "^7.0.1" - json-parse-even-better-errors "^3.0.0" - json-stringify-nice "^1.1.4" - minimatch "^9.0.0" - nopt "^7.0.0" - npm-install-checks "^6.2.0" - npm-package-arg "^11.0.1" - npm-pick-manifest "^9.0.0" - npm-registry-fetch "^16.0.0" - npmlog "^7.0.1" - pacote "^17.0.4" - parse-conflict-json "^3.0.0" - proc-log "^3.0.0" - promise-all-reject-late "^1.0.0" - promise-call-limit "^3.0.1" - read-package-json-fast "^3.0.2" - semver "^7.3.7" - ssri "^10.0.5" - treeverse "^3.0.0" - walk-up-path "^3.0.1" - -"@npmcli/config@^8.0.2": - version "8.2.0" - resolved "https://registry.yarnpkg.com/@npmcli/config/-/config-8.2.0.tgz#18774fc7239cfcc124ca9fdc48b1f65bb7bee191" - integrity sha512-YoEYZFg0hRSRP/Chmq+J4FvULFvji6SORUYWQc10FiJ+ReAnViXcDCENg6kM6dID04bAoKNUygrby798+gYBbQ== - dependencies: - "@npmcli/map-workspaces" "^3.0.2" - ci-info "^4.0.0" - ini "^4.1.0" - nopt "^7.0.0" - proc-log "^3.0.0" - read-package-json-fast "^3.0.2" - semver "^7.3.5" - walk-up-path "^3.0.1" - -"@npmcli/disparity-colors@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/disparity-colors/-/disparity-colors-3.0.0.tgz#60ea8c6eb5ba9de2d1950e15b06205b2c3ab7833" - integrity sha512-5R/z157/f20Fi0Ou4ZttL51V0xz0EdPEOauFtPCEYOLInDBRCj1/TxOJ5aGTrtShxEshN2d+hXb9ZKSi5RLBcg== - dependencies: - ansi-styles "^4.3.0" - "@npmcli/fs@^1.0.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.1.1.tgz#72f719fe935e687c56a4faecf3c03d06ba593257" @@ -2983,55 +2907,6 @@ "@gar/promisify" "^1.0.1" semver "^7.3.5" -"@npmcli/fs@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-3.1.0.tgz#233d43a25a91d68c3a863ba0da6a3f00924a173e" - integrity sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w== - dependencies: - semver "^7.3.5" - -"@npmcli/git@^5.0.0", "@npmcli/git@^5.0.3": - version "5.0.4" - resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-5.0.4.tgz#d18c50f99649e6e89e8b427318134f582498700c" - integrity sha512-nr6/WezNzuYUppzXRaYu/W4aT5rLxdXqEFupbh6e/ovlYFQ8hpu1UUPV3Ir/YTl+74iXl2ZOMlGzudh9ZPUchQ== - dependencies: - "@npmcli/promise-spawn" "^7.0.0" - lru-cache "^10.0.1" - npm-pick-manifest "^9.0.0" - proc-log "^3.0.0" - promise-inflight "^1.0.1" - promise-retry "^2.0.1" - semver "^7.3.5" - which "^4.0.0" - -"@npmcli/installed-package-contents@^2.0.1", "@npmcli/installed-package-contents@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz#bfd817eccd9e8df200919e73f57f9e3d9e4f9e33" - integrity sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ== - dependencies: - npm-bundled "^3.0.0" - npm-normalize-package-bin "^3.0.0" - -"@npmcli/map-workspaces@^3.0.2", "@npmcli/map-workspaces@^3.0.4": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@npmcli/map-workspaces/-/map-workspaces-3.0.4.tgz#15ad7d854292e484f7ba04bc30187a8320dba799" - integrity sha512-Z0TbvXkRbacjFFLpVpV0e2mheCh+WzQpcqL+4xp49uNJOxOnIAPZyXtUxZ5Qn3QBTGKA11Exjd9a5411rBrhDg== - dependencies: - "@npmcli/name-from-folder" "^2.0.0" - glob "^10.2.2" - minimatch "^9.0.0" - read-package-json-fast "^3.0.0" - -"@npmcli/metavuln-calculator@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/metavuln-calculator/-/metavuln-calculator-7.0.0.tgz#fb59245926d7f677db904177f9aca15ac883d6cb" - integrity sha512-Pw0tyX02VkpqlIQlG2TeiJNsdrecYeUU0ubZZa9pi3N37GCsxI+en43u4hYFdq+eSx1A9a9vwFAUyqEtKFsbHQ== - dependencies: - cacache "^18.0.0" - json-parse-even-better-errors "^3.0.0" - pacote "^17.0.0" - semver "^7.3.5" - "@npmcli/move-file@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.1.2.tgz#1a82c3e372f7cae9253eb66d72543d6b8685c674" @@ -3040,54 +2915,6 @@ mkdirp "^1.0.4" rimraf "^3.0.2" -"@npmcli/name-from-folder@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/name-from-folder/-/name-from-folder-2.0.0.tgz#c44d3a7c6d5c184bb6036f4d5995eee298945815" - integrity sha512-pwK+BfEBZJbKdNYpHHRTNBwBoqrN/iIMO0AiGvYsp3Hoaq0WbgGSWQR6SCldZovoDpY3yje5lkFUe6gsDgJ2vg== - -"@npmcli/node-gyp@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz#101b2d0490ef1aa20ed460e4c0813f0db560545a" - integrity sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA== - -"@npmcli/package-json@^5.0.0": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-5.0.0.tgz#77d0f8b17096763ccbd8af03b7117ba6e34d6e91" - integrity sha512-OI2zdYBLhQ7kpNPaJxiflofYIpkNLi+lnGdzqUOfRmCF3r2l1nadcjtCYMJKv/Utm/ZtlffaUuTiAktPHbc17g== - dependencies: - "@npmcli/git" "^5.0.0" - glob "^10.2.2" - hosted-git-info "^7.0.0" - json-parse-even-better-errors "^3.0.0" - normalize-package-data "^6.0.0" - proc-log "^3.0.0" - semver "^7.5.3" - -"@npmcli/promise-spawn@^7.0.0", "@npmcli/promise-spawn@^7.0.1": - version "7.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/promise-spawn/-/promise-spawn-7.0.1.tgz#a836de2f42a2245d629cf6fbb8dd6c74c74c55af" - integrity sha512-P4KkF9jX3y+7yFUxgcUdDtLy+t4OlDGuEBLNs57AZsfSfg+uV6MLndqGpnl4831ggaEdXwR50XFoZP4VFtHolg== - dependencies: - which "^4.0.0" - -"@npmcli/query@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@npmcli/query/-/query-3.1.0.tgz#bc202c59e122a06cf8acab91c795edda2cdad42c" - integrity sha512-C/iR0tk7KSKGldibYIB9x8GtO/0Bd0I2mhOaDb8ucQL/bQVTmGoeREaFj64Z5+iCBRf3dQfed0CjJL7I8iTkiQ== - dependencies: - postcss-selector-parser "^6.0.10" - -"@npmcli/run-script@^7.0.0", "@npmcli/run-script@^7.0.2", "@npmcli/run-script@^7.0.4": - version "7.0.4" - resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-7.0.4.tgz#9f29aaf4bfcf57f7de2a9e28d1ef091d14b2e6eb" - integrity sha512-9ApYM/3+rBt9V80aYg6tZfzj3UWdiYyCt7gJUD1VJKvWF5nwKDSICXbYIQbspFTq6TOpbsEtIC0LArB8d9PFmg== - dependencies: - "@npmcli/node-gyp" "^3.0.0" - "@npmcli/package-json" "^5.0.0" - "@npmcli/promise-spawn" "^7.0.0" - node-gyp "^10.0.0" - which "^4.0.0" - "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -3217,50 +3044,6 @@ resolved "https://registry.yarnpkg.com/@servie/events/-/events-1.0.0.tgz#8258684b52d418ab7b86533e861186638ecc5dc1" integrity sha512-sBSO19KzdrJCM3gdx6eIxV8M9Gxfgg6iDQmH5TIAGaUu+X9VDdsINXJOnoiZ1Kx3TrHdH4bt5UVglkjsEGBcvw== -"@sigstore/bundle@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-2.2.0.tgz#e3f555a5c503fe176d8d1e0e829b00f842502e46" - integrity sha512-5VI58qgNs76RDrwXNhpmyN/jKpq9evV/7f1XrcqcAfvxDl5SeVY/I5Rmfe96ULAV7/FK5dge9RBKGBJPhL1WsQ== - dependencies: - "@sigstore/protobuf-specs" "^0.3.0" - -"@sigstore/core@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-1.0.0.tgz#0fcdb32d191d4145a70cb837061185353b3b08e3" - integrity sha512-dW2qjbWLRKGu6MIDUTBuJwXCnR8zivcSpf5inUzk7y84zqy/dji0/uahppoIgMoKeR+6pUZucrwHfkQQtiG9Rw== - -"@sigstore/protobuf-specs@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@sigstore/protobuf-specs/-/protobuf-specs-0.3.0.tgz#bdcc773671f625bb81591bca86ec5314d57297f3" - integrity sha512-zxiQ66JFOjVvP9hbhGj/F/qNdsZfkGb/dVXSanNRNuAzMlr4MC95voPUBX8//ZNnmv3uSYzdfR/JSkrgvZTGxA== - -"@sigstore/sign@^2.2.3": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-2.2.3.tgz#f07bcd2cfee654fade867db44ae260f1a0142ba4" - integrity sha512-LqlA+ffyN02yC7RKszCdMTS6bldZnIodiox+IkT8B2f8oRYXCB3LQ9roXeiEL21m64CVH1wyveYAORfD65WoSw== - dependencies: - "@sigstore/bundle" "^2.2.0" - "@sigstore/core" "^1.0.0" - "@sigstore/protobuf-specs" "^0.3.0" - make-fetch-happen "^13.0.0" - -"@sigstore/tuf@^2.3.1": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-2.3.1.tgz#86ff3c3c907e271696c88de0108d9063a8cbcc45" - integrity sha512-9Iv40z652td/QbV0o5n/x25H9w6IYRt2pIGbTX55yFDYlApDQn/6YZomjz6+KBx69rXHLzHcbtTS586mDdFD+Q== - dependencies: - "@sigstore/protobuf-specs" "^0.3.0" - tuf-js "^2.2.0" - -"@sigstore/verify@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-1.1.0.tgz#ab617c5dc0bc09ead7f101a848f4870af2d84374" - integrity sha512-1fTqnqyTBWvV7cftUUFtDcHPdSox0N3Ub7C0lRyReYx4zZUlNTZjCV+HPy4Lre+r45dV7Qx5JLKvqqsgxuyYfg== - dependencies: - "@sigstore/bundle" "^2.2.0" - "@sigstore/core" "^1.0.0" - "@sigstore/protobuf-specs" "^0.3.0" - "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -4449,19 +4232,6 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== -"@tufjs/canonical-json@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz#a52f61a3d7374833fca945b2549bc30a2dd40d0a" - integrity sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA== - -"@tufjs/models@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-2.0.0.tgz#c7ab241cf11dd29deb213d6817dabb8c99ce0863" - integrity sha512-c8nj8BaOExmZKO2DXhDfegyhSGcG9E/mPN3U13L+/PsoWm1uaGiHHjxqSHQiasDBQwDA3aHuw9+9spYAP1qvvg== - dependencies: - "@tufjs/canonical-json" "2.0.0" - minimatch "^9.0.3" - "@types/aria-query@^4.2.0": version "4.2.2" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" @@ -5390,11 +5160,6 @@ abbrev@^1.0.0: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== -abbrev@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" - integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== - accepts@~1.3.5: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -5484,13 +5249,6 @@ agent-base@6: dependencies: debug "4" -agent-base@^7.0.2, agent-base@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434" - integrity sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg== - dependencies: - debug "^4.3.4" - aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -5603,7 +5361,7 @@ ansi-styles@^3.2.1: dependencies: color-convert "^1.9.0" -ansi-styles@^4.0.0, ansi-styles@^4.1.0, ansi-styles@^4.3.0: +ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -5648,7 +5406,7 @@ app-root-dir@^1.0.2: resolved "https://registry.yarnpkg.com/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118" integrity sha1-OBh+wt6nV3//Az/8sSFyaS/24Rg= -"aproba@^1.0.3 || ^2.0.0", aproba@^2.0.0: +"aproba@^1.0.3 || ^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== @@ -5658,11 +5416,6 @@ aproba@^1.1.1: resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== -archy@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" - integrity sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw== - are-we-there-yet@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" @@ -5671,11 +5424,6 @@ are-we-there-yet@^2.0.0: delegates "^1.0.0" readable-stream "^3.6.0" -are-we-there-yet@^4.0.0: - version "4.0.2" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-4.0.2.tgz#aed25dd0eae514660d49ac2b2366b175c614785a" - integrity sha512-ncSWAawFhKMJDTdoAeOV+jyW1VCMj5QIAwULIBV0SSR7B/RLPPEQiknKcg/RIIZlUQrxELpsxMiTUoAQ4sIUyg== - arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -6258,16 +6006,6 @@ big.js@^5.2.2: resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== -bin-links@^4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/bin-links/-/bin-links-4.0.3.tgz#9e4a3c5900830aee3d7f52178b65e01dcdde64a5" - integrity sha512-obsRaULtJurnfox/MDwgq6Yo9kzbv1CPTk/1/s7Z/61Lezc8IKkFCOXNeVLXz0456WRzBQmSsDWlai2tIhBsfA== - dependencies: - cmd-shim "^6.0.0" - npm-normalize-package-bin "^3.0.0" - read-cmd-shim "^4.0.0" - write-file-atomic "^5.0.0" - binary-extensions@^1.0.0: version "1.13.1" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" @@ -6278,11 +6016,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== -binary-extensions@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" - integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== - bindings@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" @@ -6526,13 +6259,6 @@ builtin-status-codes@^3.0.0: resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug= -builtins@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9" - integrity sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ== - dependencies: - semver "^7.0.0" - busboy@1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" @@ -6618,24 +6344,6 @@ cacache@^15.0.5: tar "^6.0.2" unique-filename "^1.1.1" -cacache@^18.0.0, cacache@^18.0.2: - version "18.0.2" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-18.0.2.tgz#fd527ea0f03a603be5c0da5805635f8eef00c60c" - integrity sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw== - dependencies: - "@npmcli/fs" "^3.1.0" - fs-minipass "^3.0.0" - glob "^10.2.2" - lru-cache "^10.0.1" - minipass "^7.0.3" - minipass-collect "^2.0.1" - minipass-flush "^1.0.5" - minipass-pipeline "^1.2.4" - p-map "^4.0.0" - ssri "^10.0.0" - tar "^6.1.11" - unique-filename "^3.0.0" - cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -6771,11 +6479,6 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" - integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== - chance@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/chance/-/chance-1.1.8.tgz#5d6c2b78c9170bf6eb9df7acdda04363085be909" @@ -6870,18 +6573,6 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.2.0.tgz#2876cb948a498797b5236f0095bc057d0dca38b6" integrity sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A== -ci-info@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.0.0.tgz#65466f8b280fc019b9f50a5388115d17a63a44f2" - integrity sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg== - -cidr-regex@4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-4.0.3.tgz#07b52c9762d1ff546a50740e92fc2b5b13a6d871" - integrity sha512-HOwDIy/rhKeMf6uOzxtv7FAbrz8zPjmVKfSpM+U7/bNBXC5rtOyr758jxcptiSx6ZZn5LOhPJT5WWxPAGDV8dw== - dependencies: - ip-regex "^5.0.0" - cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -6929,14 +6620,6 @@ cli-boxes@^2.2.1: resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== -cli-columns@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/cli-columns/-/cli-columns-4.0.0.tgz#9fe4d65975238d55218c41bd2ed296a7fa555646" - integrity sha512-XW2Vg+w+L9on9wtwKpyzluIPCWXjaBahI7mTcYjx+BVIYD9c3yqcv/yKC7CmdCZat4rq2yiE1UMSJC5ivKfMtQ== - dependencies: - string-width "^4.2.3" - strip-ansi "^6.0.1" - cli-table3@^0.6.1: version "0.6.2" resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.2.tgz#aaf5df9d8b5bf12634dc8b3040806a0c07120d2a" @@ -6946,15 +6629,6 @@ cli-table3@^0.6.1: optionalDependencies: "@colors/colors" "1.5.0" -cli-table3@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2" - integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg== - dependencies: - string-width "^4.2.0" - optionalDependencies: - "@colors/colors" "1.5.0" - client-oauth2@^4.1.0: version "4.3.3" resolved "https://registry.yarnpkg.com/client-oauth2/-/client-oauth2-4.3.3.tgz#7a700e6f4bf412c1f96da0d6b50e07676561e086" @@ -6986,11 +6660,6 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" -clone@^1.0.2: - version "1.0.4" - resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" - integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== - clsx@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702" @@ -7011,11 +6680,6 @@ clsx@^2.0.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.0.0.tgz#12658f3fd98fafe62075595a5c30e43d18f3d00b" integrity sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q== -cmd-shim@^6.0.0: - version "6.0.2" - resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-6.0.2.tgz#435fd9e5c95340e61715e19f90209ed6fcd9e0a4" - integrity sha512-+FFYbB0YLaAkhkcrjkyNLYDiOsFSfRjwjY19LXk/psmMx1z00xlCv7hhQoTGXXIKi+YXHL/iiFo8NqMVQX9nOw== - co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -7076,7 +6740,7 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-support@^1.1.2, color-support@^1.1.3: +color-support@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== @@ -7086,14 +6750,6 @@ colorette@^1.2.2: resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== -columnify@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/columnify/-/columnify-1.6.0.tgz#6989531713c9008bb29735e61e37acf5bd553cf3" - integrity sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q== - dependencies: - strip-ansi "^6.0.1" - wcwidth "^1.0.0" - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -7146,11 +6802,6 @@ commander@~2.17.1: resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== -common-ancestor-path@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz#4f7d2d1394d91b7abdf51871c62f71eadb0182a7" - integrity sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w== - common-path-prefix@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" @@ -7795,13 +7446,6 @@ default-browser-id@^1.0.4: meow "^3.1.0" untildify "^2.0.0" -defaults@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" - integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== - dependencies: - clone "^1.0.2" - define-data-property@^1.0.1, define-data-property@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.1.tgz#c35f7cd0ab09883480d12ac5cb213715587800b3" @@ -7942,11 +7586,6 @@ diff@^5.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== -diff@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" - integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== - diffie-hellman@^5.0.0: version "5.0.3" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" @@ -8182,13 +7821,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= -encoding@^0.1.13: - version "0.1.13" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" - integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== - dependencies: - iconv-lite "^0.6.2" - end-of-stream@^1.0.0, end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -8250,16 +7882,6 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== -env-paths@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" - integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== - -err-code@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9" - integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA== - errno@^0.1.3, errno@~0.1.7: version "0.1.8" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f" @@ -8948,11 +8570,6 @@ expect@^27.3.1: jest-message-util "^27.3.1" jest-regex-util "^27.0.6" -exponential-backoff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" - integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== - express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -9088,11 +8705,6 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= -fastest-levenshtein@^1.0.16: - version "1.0.16" - resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" - integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== - fastq@^1.6.0: version "1.13.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" @@ -9446,13 +9058,6 @@ fs-minipass@^2.0.0: dependencies: minipass "^3.0.0" -fs-minipass@^3.0.0, fs-minipass@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54" - integrity sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw== - dependencies: - minipass "^7.0.3" - fs-monkey@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3" @@ -9546,20 +9151,6 @@ gauge@^3.0.0: strip-ansi "^6.0.1" wide-align "^1.1.2" -gauge@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-5.0.1.tgz#1efc801b8ff076b86ef3e9a7a280a975df572112" - integrity sha512-CmykPMJGuNan/3S4kZOpvvPYSNqSHANiWnh9XcMU2pSjtBfF0XzZ2p1bFAxTbnFxyBuPxQYHhzwaoOmUdqzvxQ== - dependencies: - aproba "^1.0.3 || ^2.0.0" - color-support "^1.1.3" - console-control-strings "^1.1.0" - has-unicode "^2.0.1" - signal-exit "^4.0.1" - string-width "^4.2.3" - strip-ansi "^6.0.1" - wide-align "^1.1.5" - gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -9668,7 +9259,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@10.3.10, glob@^10.2.2, glob@^10.3.10: +glob@10.3.10: version "10.3.10" resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== @@ -9791,7 +9382,7 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== -graceful-fs@^4.2.11, graceful-fs@^4.2.6: +graceful-fs@^4.2.11: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -10051,13 +9642,6 @@ hosted-git-info@^2.1.4: resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== -hosted-git-info@^7.0.0, hosted-git-info@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-7.0.1.tgz#9985fcb2700467fecf7f33a4d4874e30680b5322" - integrity sha512-+K84LB1DYwMHoHSgaOY/Jfhw3ucPmSET5v98Ke/HdNSw4a0UktWzyW1mjhjpuxxTqOOsfWT/7iVshHmVZ4IpOA== - dependencies: - lru-cache "^10.0.1" - html-encoding-sniffer@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3" @@ -10154,11 +9738,6 @@ htmlparser2@^6.1.0: domutils "^2.5.2" entities "^2.0.0" -http-cache-semantics@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" - integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== - http-errors@1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" @@ -10199,14 +9778,6 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" -http-proxy-agent@^7.0.0: - version "7.0.2" - resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" - integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== - dependencies: - agent-base "^7.1.0" - debug "^4.3.4" - http-proxy-middleware@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.1.tgz#7ef3417a479fb7666a571e09966c66a39bd2c15f" @@ -10240,14 +9811,6 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" -https-proxy-agent@^7.0.1: - version "7.0.4" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz#8e97b841a029ad8ddc8731f26595bad868cb4168" - integrity sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg== - dependencies: - agent-base "^7.0.2" - debug "4" - human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -10265,7 +9828,7 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.6.3, iconv-lite@^0.6.2: +iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -10294,13 +9857,6 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^6.0.4: - version "6.0.4" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-6.0.4.tgz#89950be94b4f522225eb63a13c56badb639190e9" - integrity sha512-t7sv42WkwFkyKbivUCglsQW5YWMskWtbEf4MNKX5u/CCWHKSPzN4FtBQGsQZgCLbxOzpVlcbWVK5KB3auIOjSw== - dependencies: - minimatch "^9.0.0" - ignore@^4.0.3, ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -10392,34 +9948,11 @@ ini@^1.3.4: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -ini@^4.1.0, ini@^4.1.1: - version "4.1.2" - resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.2.tgz#7f646dbd9caea595e61f88ef60bfff8b01f8130a" - integrity sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw== - -init-package-json@^6.0.0: - version "6.0.2" - resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-6.0.2.tgz#0d780b752dd1dd83b8649945df38a07df4f990a6" - integrity sha512-ZQ9bxt6PkqIH6fPU69HPheOMoUqIqVqwZj0qlCBfoSCG4lplQhVM/qB3RS4f0RALK3WZZSrNQxNtCZgphuf3IA== - dependencies: - "@npmcli/package-json" "^5.0.0" - npm-package-arg "^11.0.0" - promzard "^1.0.0" - read "^3.0.1" - semver "^7.3.5" - validate-npm-package-license "^3.0.4" - validate-npm-package-name "^5.0.0" - inline-style-parser@0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== -install@^0.13.0: - version "0.13.0" - resolved "https://registry.yarnpkg.com/install/-/install-0.13.0.tgz#6af6e9da9dd0987de2ab420f78e60d9c17260776" - integrity sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA== - internal-slot@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c" @@ -10468,24 +10001,11 @@ intl-messageformat@^10.3.1: "@formatjs/icu-messageformat-parser" "2.3.0" tslib "^2.4.0" -ip-address@^9.0.5: - version "9.0.5" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" - integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g== - dependencies: - jsbn "1.1.0" - sprintf-js "^1.1.3" - ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= -ip-regex@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-5.0.0.tgz#cd313b2ae9c80c07bd3851e12bf4fa4dc5480632" - integrity sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw== - ip@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" @@ -10627,13 +10147,6 @@ is-ci@^2.0.0: dependencies: ci-info "^2.0.0" -is-cidr@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/is-cidr/-/is-cidr-5.0.3.tgz#fcf817c0146dd4a318f27938af89e98a9b21bdd5" - integrity sha512-lKkM0tmz07dAxNsr8Ii9MGreExa9ZR34N9j8mTG5op824kcwBqinZPowNjcVWWc7j+jR8XAMMItOmBkniN0jOA== - dependencies: - cidr-regex "4.0.3" - is-core-module@^2.11.0, is-core-module@^2.13.0, is-core-module@^2.13.1: version "2.13.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" @@ -10792,11 +10305,6 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.yarnpkg.com/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU= -is-lambda@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" - integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== - is-map@^2.0.1, is-map@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" @@ -11013,11 +10521,6 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= -isexe@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-3.1.1.tgz#4a407e2bd78ddfb14bea0c27c6f7072dde775f0d" - integrity sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ== - isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -11655,11 +11158,6 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -jsbn@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" - integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== - jsdom@^16.6.0: version "16.7.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710" @@ -11746,11 +11244,6 @@ json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== -json-parse-even-better-errors@^3.0.0, json-parse-even-better-errors@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.1.tgz#02bb29fb5da90b5444581749c22cedd3597c6cb0" - integrity sha512-aatBvbL26wVUCLmbWdCpeu9iF5wOyWpagiKkInA+kfws3sWdBrTnsvN2CKcyCYyUrc7rebNBlK6+kteg7ksecg== - json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -11766,11 +11259,6 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= -json-stringify-nice@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz#2c937962b80181d3f317dd39aa323e14f5a60a67" - integrity sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw== - json-stringify-safe@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" @@ -11830,11 +11318,6 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsonparse@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" - integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== - jss-plugin-camel-case@^10.10.0: version "10.10.0" resolved "https://registry.yarnpkg.com/jss-plugin-camel-case/-/jss-plugin-camel-case-10.10.0.tgz#27ea159bab67eb4837fa0260204eb7925d4daa1c" @@ -11998,16 +11481,6 @@ junk@^3.1.0: resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== -just-diff-apply@^5.2.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-5.5.0.tgz#771c2ca9fa69f3d2b54e7c3f5c1dfcbcc47f9f0f" - integrity sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw== - -just-diff@^6.0.0: - version "6.0.2" - resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-6.0.2.tgz#03b65908543ac0521caf6d8eb85035f7d27ea285" - integrity sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA== - kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -12130,119 +11603,6 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -libnpmaccess@^8.0.1: - version "8.0.2" - resolved "https://registry.yarnpkg.com/libnpmaccess/-/libnpmaccess-8.0.2.tgz#a13a72fd5b71a1063ea54973fa56d61ec38f718f" - integrity sha512-4K+nsg3OYt4rjryP/3D5zGWluLbZaKozwj6YdtvAyxNhLhUrjCoyxHVoL5AkTJcAnjsd6/ATei52QPVvpSX9Ug== - dependencies: - npm-package-arg "^11.0.1" - npm-registry-fetch "^16.0.0" - -libnpmdiff@^6.0.3: - version "6.0.7" - resolved "https://registry.yarnpkg.com/libnpmdiff/-/libnpmdiff-6.0.7.tgz#5fd7df1c4b8ff58160fa59d5eb97686a00f8fdd3" - integrity sha512-Erca7NHh+MGk4O14mM4yv9S1S+Wc5TgFg6yr8r/g5ykn34dZdAP/GkzhQNJiOpzfD8j1HBhbTpkbGJHVDdgG5Q== - dependencies: - "@npmcli/arborist" "^7.2.1" - "@npmcli/disparity-colors" "^3.0.0" - "@npmcli/installed-package-contents" "^2.0.2" - binary-extensions "^2.2.0" - diff "^5.1.0" - minimatch "^9.0.0" - npm-package-arg "^11.0.1" - pacote "^17.0.4" - tar "^6.2.0" - -libnpmexec@^7.0.4: - version "7.0.8" - resolved "https://registry.yarnpkg.com/libnpmexec/-/libnpmexec-7.0.8.tgz#2bc6ab0468dde95803745ced1fea48bd43b112fc" - integrity sha512-xDzWoYpV1Ok0TIdrY4wuWGxriEv/O3/d8QG924yErBE0sMkkzKsin2dAmlEBsSlR7YRilObs8q+5uNtxKNQHAQ== - dependencies: - "@npmcli/arborist" "^7.2.1" - "@npmcli/run-script" "^7.0.2" - ci-info "^4.0.0" - npm-package-arg "^11.0.1" - npmlog "^7.0.1" - pacote "^17.0.4" - proc-log "^3.0.0" - read "^2.0.0" - read-package-json-fast "^3.0.2" - semver "^7.3.7" - walk-up-path "^3.0.1" - -libnpmfund@^5.0.1: - version "5.0.5" - resolved "https://registry.yarnpkg.com/libnpmfund/-/libnpmfund-5.0.5.tgz#f874005a2f9a92a4c6c4ae7a489ceb16f48690ce" - integrity sha512-BUu2l9Kn4u6nce1Ay8a1uRN1fyU7lbVmtsMYxWcFpcbF+ZPN7qIiPksfcnY9/NDKIRGJYwwv0IXgQQStHDx6Tg== - dependencies: - "@npmcli/arborist" "^7.2.1" - -libnpmhook@^10.0.0: - version "10.0.1" - resolved "https://registry.yarnpkg.com/libnpmhook/-/libnpmhook-10.0.1.tgz#3cb9516645f0d6891b4a59c72ffe026bdbb9bd6b" - integrity sha512-FnXCweDpoAko6mnLPSW8qrRYicjfh+GrvY5PuYHQRPvaW4BFtHDUmK3K3aYx4yD3TeGAKpj4IigrEDfUfWuSkA== - dependencies: - aproba "^2.0.0" - npm-registry-fetch "^16.0.0" - -libnpmorg@^6.0.1: - version "6.0.2" - resolved "https://registry.yarnpkg.com/libnpmorg/-/libnpmorg-6.0.2.tgz#6e5e37ecc5a391082e83c599512689c78e60dc70" - integrity sha512-zK4r6cjVsfXf7hWzWGB6R0LBJidVhKaeMWMZL/1eyZS6ixxAxVijfsPacoEnBRCFaXsNjAtwV3b2RCmYU6+usA== - dependencies: - aproba "^2.0.0" - npm-registry-fetch "^16.0.0" - -libnpmpack@^6.0.3: - version "6.0.7" - resolved "https://registry.yarnpkg.com/libnpmpack/-/libnpmpack-6.0.7.tgz#0b1cdd7c250f929e77ece95f2a738e9670dcb8ad" - integrity sha512-aVX5ZLiYAioShh5wzoBOGs25GvPskry7SxCpx76gMCjOrd/wKcNtbTOMqStvizd3c+vzq5a1b7FMP09XAtgRFg== - dependencies: - "@npmcli/arborist" "^7.2.1" - "@npmcli/run-script" "^7.0.2" - npm-package-arg "^11.0.1" - pacote "^17.0.4" - -libnpmpublish@^9.0.2: - version "9.0.4" - resolved "https://registry.yarnpkg.com/libnpmpublish/-/libnpmpublish-9.0.4.tgz#0222c14578088ca9a758585c36d8133b828c87ad" - integrity sha512-330o6pVsCCg77jQ/+kidyG/RiohXYQKpqmzOC4BjUDWcimb+mXptRBh1Kvy27/Zb/CStZLVrfgGc6tXf5+PE3Q== - dependencies: - ci-info "^4.0.0" - normalize-package-data "^6.0.0" - npm-package-arg "^11.0.1" - npm-registry-fetch "^16.0.0" - proc-log "^3.0.0" - semver "^7.3.7" - sigstore "^2.2.0" - ssri "^10.0.5" - -libnpmsearch@^7.0.0: - version "7.0.1" - resolved "https://registry.yarnpkg.com/libnpmsearch/-/libnpmsearch-7.0.1.tgz#8fa803a8e5837a33ce750a8cc1c70820d728b91d" - integrity sha512-XyKi6Y94t6PGd5Lk2Ma3+fgiHWD3KSCvXmHOrcLkAOEP7oUejbNjL0Bb/HUDZXgBj6gP1Qk7pJ6jZPFBc2hmXQ== - dependencies: - npm-registry-fetch "^16.0.0" - -libnpmteam@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/libnpmteam/-/libnpmteam-6.0.1.tgz#daa1b2e7e4ccef0469bdef661737ca823b53468b" - integrity sha512-1YytqVk1gSkKFNMe4kkCKN49y5rlABrRSx5TrYShQtt2Lb4uQaed49dGE7Ue8TJGxbIkHzvyyVtb3PBiGACVqw== - dependencies: - aproba "^2.0.0" - npm-registry-fetch "^16.0.0" - -libnpmversion@^5.0.1: - version "5.0.2" - resolved "https://registry.yarnpkg.com/libnpmversion/-/libnpmversion-5.0.2.tgz#aea7b09bc270c778cbc8be7bf02e4b60566989cf" - integrity sha512-6JBnLhd6SYgKRekJ4cotxpURLGbEtKxzw+a8p5o+wNwrveJPMH8yW/HKjeewyHzWmxzzwn9EQ3TkF2onkrwstA== - dependencies: - "@npmcli/git" "^5.0.3" - "@npmcli/run-script" "^7.0.2" - json-parse-even-better-errors "^3.0.0" - proc-log "^3.0.0" - semver "^7.3.7" - libphonenumber-js@^1.10.51: version "1.10.51" resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.10.51.tgz#a3b8c15db2721c3e5f7fe6759e2a524712b578e6" @@ -12399,11 +11759,6 @@ lowlight@^1.14.0, lowlight@^1.17.0: fault "^1.0.0" highlight.js "~10.7.0" -lru-cache@^10.0.1, "lru-cache@^9.1.1 || ^10.0.0": - version "10.2.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" - integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== - lru-cache@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -12426,6 +11781,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +"lru-cache@^9.1.1 || ^10.0.0": + version "10.2.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" + integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" @@ -12458,23 +11818,6 @@ make-error@1.x, make-error@^1.1.1, make-error@^1.3.5: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== -make-fetch-happen@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz#705d6f6cbd7faecb8eac2432f551e49475bfedf0" - integrity sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A== - dependencies: - "@npmcli/agent" "^2.0.0" - cacache "^18.0.0" - http-cache-semantics "^4.1.1" - is-lambda "^1.0.1" - minipass "^7.0.2" - minipass-fetch "^3.0.0" - minipass-flush "^1.0.5" - minipass-pipeline "^1.2.4" - negotiator "^0.6.3" - promise-retry "^2.0.1" - ssri "^10.0.0" - makeerror@1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" @@ -13146,7 +12489,7 @@ minimalistic-crypto-utils@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -minimatch@9.0.3, minimatch@^9.0.0, minimatch@^9.0.1, minimatch@^9.0.3: +minimatch@9.0.3, minimatch@^9.0.1: version "9.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== @@ -13191,24 +12534,6 @@ minipass-collect@^1.0.2: dependencies: minipass "^3.0.0" -minipass-collect@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-2.0.1.tgz#1621bc77e12258a12c60d34e2276ec5c20680863" - integrity sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw== - dependencies: - minipass "^7.0.3" - -minipass-fetch@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-3.0.4.tgz#4d4d9b9f34053af6c6e597a64be8e66e42bf45b7" - integrity sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg== - dependencies: - minipass "^7.0.3" - minipass-sized "^1.0.3" - minizlib "^2.1.2" - optionalDependencies: - encoding "^0.1.13" - minipass-flush@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373" @@ -13216,28 +12541,13 @@ minipass-flush@^1.0.5: dependencies: minipass "^3.0.0" -minipass-json-stream@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz#7edbb92588fbfc2ff1db2fc10397acb7b6b44aa7" - integrity sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg== - dependencies: - jsonparse "^1.3.1" - minipass "^3.0.0" - -minipass-pipeline@^1.2.2, minipass-pipeline@^1.2.4: +minipass-pipeline@^1.2.2: version "1.2.4" resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c" integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A== dependencies: minipass "^3.0.0" -minipass-sized@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/minipass-sized/-/minipass-sized-1.0.3.tgz#70ee5a7c5052070afacfbc22977ea79def353b70" - integrity sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g== - dependencies: - minipass "^3.0.0" - minipass@^3.0.0, minipass@^3.1.1: version "3.1.6" resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.6.tgz#3b8150aa688a711a1521af5e8779c1d3bb4f45ee" @@ -13245,17 +12555,12 @@ minipass@^3.0.0, minipass@^3.1.1: dependencies: yallist "^4.0.0" -minipass@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" - integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== - -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.2, minipass@^7.0.3, minipass@^7.0.4: +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0": version "7.0.4" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== -minizlib@^2.1.1, minizlib@^2.1.2: +minizlib@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== @@ -13352,7 +12657,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1, ms@^2.1.2: +ms@^2.1.1: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -13367,11 +12672,6 @@ mui-rff@^6.1.2: date-fns "^2.25.0" yup "^0.32.11" -mute-stream@^1.0.0, mute-stream@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e" - integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA== - nan@^2.12.1: version "2.15.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee" @@ -13419,7 +12719,7 @@ negotiator@0.6.2, negotiator@^0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== -negotiator@0.6.3, negotiator@^0.6.3: +negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== @@ -13502,22 +12802,6 @@ node-fetch@^2.6.1, node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" -node-gyp@^10.0.0, node-gyp@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-10.0.1.tgz#205514fc19e5830fa991e4a689f9e81af377a966" - integrity sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg== - dependencies: - env-paths "^2.2.0" - exponential-backoff "^3.1.1" - glob "^10.3.10" - graceful-fs "^4.2.6" - make-fetch-happen "^13.0.0" - nopt "^7.0.0" - proc-log "^3.0.0" - semver "^7.3.5" - tar "^6.1.2" - which "^4.0.0" - node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -13581,13 +12865,6 @@ nopt@^6.0.0: dependencies: abbrev "^1.0.0" -nopt@^7.0.0, nopt@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.0.tgz#067378c68116f602f552876194fd11f1292503d7" - integrity sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA== - dependencies: - abbrev "^2.0.0" - normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" @@ -13598,16 +12875,6 @@ normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package- semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" -normalize-package-data@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-6.0.0.tgz#68a96b3c11edd462af7189c837b6b1064a484196" - integrity sha512-UL7ELRVxYBHBgYEtZCXjxuD5vPxnmvMGq0jp/dGPKKrN7tfsBh2IY7TlJ15WWwdjRWD3RJbnsygUurTK3xkPkg== - dependencies: - hosted-git-info "^7.0.0" - is-core-module "^2.8.1" - semver "^7.3.5" - validate-npm-package-license "^3.0.4" - normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" @@ -13625,78 +12892,6 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= -npm-audit-report@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/npm-audit-report/-/npm-audit-report-5.0.0.tgz#83ac14aeff249484bde81eff53c3771d5048cf95" - integrity sha512-EkXrzat7zERmUhHaoren1YhTxFwsOu5jypE84k6632SXTHcQE1z8V51GC6GVZt8LxkC+tbBcKMUBZAgk8SUSbw== - -npm-bundled@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-3.0.0.tgz#7e8e2f8bb26b794265028491be60321a25a39db7" - integrity sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ== - dependencies: - npm-normalize-package-bin "^3.0.0" - -npm-install-checks@^6.0.0, npm-install-checks@^6.2.0, npm-install-checks@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-6.3.0.tgz#046552d8920e801fa9f919cad569545d60e826fe" - integrity sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw== - dependencies: - semver "^7.1.1" - -npm-normalize-package-bin@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz#25447e32a9a7de1f51362c61a559233b89947832" - integrity sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ== - -npm-package-arg@^11.0.0, npm-package-arg@^11.0.1: - version "11.0.1" - resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-11.0.1.tgz#f208b0022c29240a1c532a449bdde3f0a4708ebc" - integrity sha512-M7s1BD4NxdAvBKUPqqRW957Xwcl/4Zvo8Aj+ANrzvIPzGJZElrH7Z//rSaec2ORcND6FHHLnZeY8qgTpXDMFQQ== - dependencies: - hosted-git-info "^7.0.0" - proc-log "^3.0.0" - semver "^7.3.5" - validate-npm-package-name "^5.0.0" - -npm-packlist@^8.0.0: - version "8.0.2" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-8.0.2.tgz#5b8d1d906d96d21c85ebbeed2cf54147477c8478" - integrity sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA== - dependencies: - ignore-walk "^6.0.4" - -npm-pick-manifest@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-9.0.0.tgz#f87a4c134504a2c7931f2bb8733126e3c3bb7e8f" - integrity sha512-VfvRSs/b6n9ol4Qb+bDwNGUXutpy76x6MARw/XssevE0TnctIKcmklJZM5Z7nqs5z5aW+0S63pgCNbpkUNNXBg== - dependencies: - npm-install-checks "^6.0.0" - npm-normalize-package-bin "^3.0.0" - npm-package-arg "^11.0.0" - semver "^7.3.5" - -npm-profile@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/npm-profile/-/npm-profile-9.0.0.tgz#ffcfa4e3e1b1cb44b17c192f75b44b24b4aae645" - integrity sha512-qv43ixsJ7vndzfxD3XsPNu1Njck6dhO7q1efksTo+0DiOQysKSOsIhK/qDD1/xO2o+2jDOA4Rv/zOJ9KQFs9nw== - dependencies: - npm-registry-fetch "^16.0.0" - proc-log "^3.0.0" - -npm-registry-fetch@^16.0.0, npm-registry-fetch@^16.1.0: - version "16.1.0" - resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-16.1.0.tgz#10227b7b36c97bc1cf2902a24e4f710cfe62803c" - integrity sha512-PQCELXKt8Azvxnt5Y85GseQDJJlglTFM9L9U9gkv2y4e9s0k3GVDdOx3YoB6gm2Do0hlkzC39iCGXby+Wve1Bw== - dependencies: - make-fetch-happen "^13.0.0" - minipass "^7.0.2" - minipass-fetch "^3.0.0" - minipass-json-stream "^1.0.1" - minizlib "^2.1.2" - npm-package-arg "^11.0.0" - proc-log "^3.0.0" - npm-run-all2@^6.0.4: version "6.0.4" resolved "https://registry.yarnpkg.com/npm-run-all2/-/npm-run-all2-6.0.4.tgz#2623097fcc8b43d051a364146f060978d3e36baa" @@ -13724,87 +12919,6 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -npm-user-validate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/npm-user-validate/-/npm-user-validate-2.0.0.tgz#7b69bbbff6f7992a1d9a8968d52fd6b6db5431b6" - integrity sha512-sSWeqAYJ2dUPStJB+AEj0DyLRltr/f6YNcvCA7phkB8/RMLMnVsQ41GMwHo/ERZLYNDsyB2wPm7pZo1mqPOl7Q== - -npm@^10.5.0: - version "10.5.0" - resolved "https://registry.yarnpkg.com/npm/-/npm-10.5.0.tgz#726f91df5b1b14d9637c8819d7e71cb873c395a1" - integrity sha512-Ejxwvfh9YnWVU2yA5FzoYLTW52vxHCz+MHrOFg9Cc8IFgF/6f5AGPAvb5WTay5DIUP1NIfN3VBZ0cLlGO0Ys+A== - dependencies: - "@isaacs/string-locale-compare" "^1.1.0" - "@npmcli/arborist" "^7.2.1" - "@npmcli/config" "^8.0.2" - "@npmcli/fs" "^3.1.0" - "@npmcli/map-workspaces" "^3.0.4" - "@npmcli/package-json" "^5.0.0" - "@npmcli/promise-spawn" "^7.0.1" - "@npmcli/run-script" "^7.0.4" - "@sigstore/tuf" "^2.3.1" - abbrev "^2.0.0" - archy "~1.0.0" - cacache "^18.0.2" - chalk "^5.3.0" - ci-info "^4.0.0" - cli-columns "^4.0.0" - cli-table3 "^0.6.3" - columnify "^1.6.0" - fastest-levenshtein "^1.0.16" - fs-minipass "^3.0.3" - glob "^10.3.10" - graceful-fs "^4.2.11" - hosted-git-info "^7.0.1" - ini "^4.1.1" - init-package-json "^6.0.0" - is-cidr "^5.0.3" - json-parse-even-better-errors "^3.0.1" - libnpmaccess "^8.0.1" - libnpmdiff "^6.0.3" - libnpmexec "^7.0.4" - libnpmfund "^5.0.1" - libnpmhook "^10.0.0" - libnpmorg "^6.0.1" - libnpmpack "^6.0.3" - libnpmpublish "^9.0.2" - libnpmsearch "^7.0.0" - libnpmteam "^6.0.0" - libnpmversion "^5.0.1" - make-fetch-happen "^13.0.0" - minimatch "^9.0.3" - minipass "^7.0.4" - minipass-pipeline "^1.2.4" - ms "^2.1.2" - node-gyp "^10.0.1" - nopt "^7.2.0" - normalize-package-data "^6.0.0" - npm-audit-report "^5.0.0" - npm-install-checks "^6.3.0" - npm-package-arg "^11.0.1" - npm-pick-manifest "^9.0.0" - npm-profile "^9.0.0" - npm-registry-fetch "^16.1.0" - npm-user-validate "^2.0.0" - npmlog "^7.0.1" - p-map "^4.0.0" - pacote "^17.0.6" - parse-conflict-json "^3.0.1" - proc-log "^3.0.0" - qrcode-terminal "^0.12.0" - read "^2.1.0" - semver "^7.6.0" - spdx-expression-parse "^3.0.1" - ssri "^10.0.5" - supports-color "^9.4.0" - tar "^6.2.0" - text-table "~0.2.0" - tiny-relative-date "^1.3.0" - treeverse "^3.0.0" - validate-npm-package-name "^5.0.0" - which "^4.0.0" - write-file-atomic "^5.0.1" - npmlog@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" @@ -13815,16 +12929,6 @@ npmlog@^5.0.1: gauge "^3.0.0" set-blocking "^2.0.0" -npmlog@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-7.0.1.tgz#7372151a01ccb095c47d8bf1d0771a4ff1f53ac8" - integrity sha512-uJ0YFk/mCQpLBt+bxN88AKd+gyqZvZDbtiNxk6Waqcj2aPRyfVx8ITawkyQynxUagInjdYT1+qj4NfA5KJJUxg== - dependencies: - are-we-there-yet "^4.0.0" - console-control-strings "^1.1.0" - gauge "^5.0.0" - set-blocking "^2.0.0" - nprogress@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" @@ -14193,30 +13297,6 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== -pacote@^17.0.0, pacote@^17.0.4, pacote@^17.0.6: - version "17.0.6" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-17.0.6.tgz#874bb59cda5d44ab784d0b6530fcb4a7d9b76a60" - integrity sha512-cJKrW21VRE8vVTRskJo78c/RCvwJCn1f4qgfxL4w77SOWrTCRcmfkYHlHtS0gqpgjv3zhXflRtgsrUCX5xwNnQ== - dependencies: - "@npmcli/git" "^5.0.0" - "@npmcli/installed-package-contents" "^2.0.1" - "@npmcli/promise-spawn" "^7.0.0" - "@npmcli/run-script" "^7.0.0" - cacache "^18.0.0" - fs-minipass "^3.0.0" - minipass "^7.0.2" - npm-package-arg "^11.0.0" - npm-packlist "^8.0.0" - npm-pick-manifest "^9.0.0" - npm-registry-fetch "^16.0.0" - proc-log "^3.0.0" - promise-retry "^2.0.1" - read-package-json "^7.0.0" - read-package-json-fast "^3.0.0" - sigstore "^2.2.0" - ssri "^10.0.0" - tar "^6.1.11" - pako@~1.0.5: version "1.0.11" resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" @@ -14262,15 +13342,6 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.5: pbkdf2 "^3.0.3" safe-buffer "^5.1.1" -parse-conflict-json@^3.0.0, parse-conflict-json@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/parse-conflict-json/-/parse-conflict-json-3.0.1.tgz#67dc55312781e62aa2ddb91452c7606d1969960c" - integrity sha512-01TvEktc68vwbJOtWZluyWeVGWjP+bZwXtPDMQVbBKzbJ/vZBif0L69KH1+cHv1SZ6e0FKLvjyHe8mqsIqYOmw== - dependencies: - json-parse-even-better-errors "^3.0.0" - just-diff "^6.0.0" - just-diff-apply "^5.2.0" - parse-entities@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8" @@ -14670,14 +13741,6 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2, postcss-selector cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss-selector-parser@^6.0.10: - version "6.0.16" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz#3b88b9f5c5abd989ef4e2fc9ec8eedd34b20fb04" - integrity sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - postcss-value-parser@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" @@ -14775,11 +13838,6 @@ prismjs@~1.27.0: resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.27.0.tgz#bb6ee3138a0b438a3653dd4d6ce0cc6510a45057" integrity sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA== -proc-log@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-3.0.0.tgz#fb05ef83ccd64fd7b20bbe9c8c1070fc08338dd8" - integrity sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A== - process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -14795,29 +13853,11 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -promise-all-reject-late@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz#f8ebf13483e5ca91ad809ccc2fcf25f26f8643c2" - integrity sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw== - -promise-call-limit@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/promise-call-limit/-/promise-call-limit-3.0.1.tgz#3570f7a3f2aaaf8e703623a552cd74749688cf19" - integrity sha512-utl+0x8gIDasV5X+PI5qWEPqH6fJS0pFtQ/4gZ95xfEFb/89dmh+/b895TbFDBLiafBvxD/PGTKfvxl4kH/pQg== - promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= -promise-retry@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" - integrity sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g== - dependencies: - err-code "^2.0.2" - retry "^0.12.0" - promise.allsettled@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/promise.allsettled/-/promise.allsettled-1.0.5.tgz#2443f3d4b2aa8dfa560f6ac2aa6c4ea999d75f53" @@ -14847,13 +13887,6 @@ prompts@^2.0.1, prompts@^2.4.0: kleur "^3.0.3" sisteransi "^1.0.5" -promzard@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/promzard/-/promzard-1.0.0.tgz#3246f8e6c9895a77c0549cefb65828ac0f6c006b" - integrity sha512-KQVDEubSUHGSt5xLakaToDFrSoZhStB8dXLzk2xvwR67gJktrHFvpR63oZgHyK19WKbHFLXJqCPXdVR3aBP8Ig== - dependencies: - read "^2.0.0" - prop-types@^15.0.0, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" @@ -14964,11 +13997,6 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -qrcode-terminal@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819" - integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== - qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" @@ -15310,29 +14338,6 @@ react@18.3.0-canary-763612647-20240126: dependencies: loose-envify "^1.1.0" -read-cmd-shim@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz#640a08b473a49043e394ae0c7a34dd822c73b9bb" - integrity sha512-yILWifhaSEEytfXI76kB9xEEiG1AiozaCJZ83A87ytjRiN+jVibXjedjCRNjoZviinhG+4UkalO3mWTd8u5O0Q== - -read-package-json-fast@^3.0.0, read-package-json-fast@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz#394908a9725dc7a5f14e70c8e7556dff1d2b1049" - integrity sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw== - dependencies: - json-parse-even-better-errors "^3.0.0" - npm-normalize-package-bin "^3.0.0" - -read-package-json@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-7.0.0.tgz#d605c9dcf6bc5856da24204aa4e9518ee9714be0" - integrity sha512-uL4Z10OKV4p6vbdvIXB+OzhInYtIozl/VxUBPgNkBuUi2DeRonnuspmaVAMcrkmfjKGNmRndyQAbE7/AmzGwFg== - dependencies: - glob "^10.2.2" - json-parse-even-better-errors "^3.0.0" - normalize-package-data "^6.0.0" - npm-normalize-package-bin "^3.0.0" - read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -15369,20 +14374,6 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" -read@^2.0.0, read@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/read/-/read-2.1.0.tgz#69409372c54fe3381092bc363a00650b6ac37218" - integrity sha512-bvxi1QLJHcaywCAEsAk4DG3nVoqiY2Csps3qzWalhj5hFqRn1d/OixkFXtLO1PrgHUcAP0FNaSY/5GYNfENFFQ== - dependencies: - mute-stream "~1.0.0" - -read@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/read/-/read-3.0.1.tgz#926808f0f7c83fa95f1ef33c0e2c09dbb28fd192" - integrity sha512-SLBrDU/Srs/9EoWhU5GdbAoxG1GzpQHo/6qiGItaoLJ1thmYpcNIM1qISEUvyHBzfGlWIyd6p2DNi1oV1VmAuw== - dependencies: - mute-stream "^1.0.0" - "readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" @@ -15818,11 +14809,6 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== -retry@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" - integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== - reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -16021,13 +15007,6 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.1.1, semver@^7.3.7, semver@^7.5.3, semver@^7.6.0: - version "7.6.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" - integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== - dependencies: - lru-cache "^6.0.0" - semver@^7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" @@ -16223,18 +15202,6 @@ signal-exit@^4.0.1: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -sigstore@^2.2.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-2.2.2.tgz#5e4ff39febeae9e0679bafa22180cb0f445a7e35" - integrity sha512-2A3WvXkQurhuMgORgT60r6pOWiCOO5LlEqY2ADxGBDGVYLSo5HN0uLtb68YpVpuL/Vi8mLTe7+0Dx2Fq8lLqEg== - dependencies: - "@sigstore/bundle" "^2.2.0" - "@sigstore/core" "^1.0.0" - "@sigstore/protobuf-specs" "^0.3.0" - "@sigstore/sign" "^2.2.3" - "@sigstore/tuf" "^2.3.1" - "@sigstore/verify" "^1.1.0" - sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -16295,11 +15262,6 @@ slugify@^1.6.5: resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.5.tgz#c8f5c072bf2135b80703589b39a3d41451fbe8c8" integrity sha512-8mo9bslnBO3tr5PEVFzMPIWwWnipGS0xVbYf65zxDqfNwmzYn1LpiKNrR6DlClusuvo+hDHd1zKpmfAe83NQSQ== -smart-buffer@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" - integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== - snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -16330,23 +15292,6 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -socks-proxy-agent@^8.0.1: - version "8.0.2" - resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz#5acbd7be7baf18c46a3f293a840109a430a640ad" - integrity sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g== - dependencies: - agent-base "^7.0.2" - debug "^4.3.4" - socks "^2.7.1" - -socks@^2.7.1: - version "2.8.1" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.1.tgz#22c7d9dd7882649043cba0eafb49ae144e3457af" - integrity sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ== - dependencies: - ip-address "^9.0.5" - smart-buffer "^4.2.0" - source-list-map@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" @@ -16414,7 +15359,7 @@ spdx-exceptions@^2.1.0: resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== -spdx-expression-parse@^3.0.0, spdx-expression-parse@^3.0.1: +spdx-expression-parse@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== @@ -16434,11 +15379,6 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" -sprintf-js@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" - integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== - sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -16451,13 +15391,6 @@ ssf@~0.11.2: dependencies: frac "~1.1.2" -ssri@^10.0.0, ssri@^10.0.5: - version "10.0.5" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-10.0.5.tgz#e49efcd6e36385196cb515d3a2ad6c3f0265ef8c" - integrity sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A== - dependencies: - minipass "^7.0.3" - ssri@^6.0.1: version "6.0.2" resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5" @@ -16859,11 +15792,6 @@ supports-color@^8.0.0: dependencies: has-flag "^4.0.0" -supports-color@^9.4.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.4.0.tgz#17bfcf686288f531db3dea3215510621ccb55954" - integrity sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw== - supports-hyperlinks@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz#4f77b42488765891774b70c79babd87f9bd594bb" @@ -16930,18 +15858,6 @@ tar@^6.0.2: mkdirp "^1.0.3" yallist "^4.0.0" -tar@^6.1.11, tar@^6.1.2, tar@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.0.tgz#b14ce49a79cb1cd23bc9b016302dea5474493f73" - integrity sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^5.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" - telejson@^5.3.2: version "5.3.3" resolved "https://registry.yarnpkg.com/telejson/-/telejson-5.3.3.tgz#fa8ca84543e336576d8734123876a9f02bf41d2e" @@ -17067,7 +15983,7 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -text-table@^0.2.0, text-table@~0.2.0: +text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= @@ -17102,11 +16018,6 @@ tiny-invariant@1.0.6: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== -tiny-relative-date@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/tiny-relative-date/-/tiny-relative-date-1.3.0.tgz#fa08aad501ed730f31cc043181d995c39a935e07" - integrity sha512-MOQHpzllWxDCHHaDno30hhLfbouoYlOI8YlMNtvKe1zXbjEVhbcEovQxvZrPvtiYW630GQDoMMarCnjfyfHA+A== - tiny-warning@^1.0.2, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" @@ -17211,11 +16122,6 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= -treeverse@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/treeverse/-/treeverse-3.0.0.tgz#dd82de9eb602115c6ebd77a574aae67003cb48c8" - integrity sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ== - trim-newlines@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613" @@ -17376,15 +16282,6 @@ tty-browserify@0.0.0: resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY= -tuf-js@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-2.2.0.tgz#4daaa8620ba7545501d04dfa933c98abbcc959b9" - integrity sha512-ZSDngmP1z6zw+FIkIBjvOp/II/mIub/O7Pp12j1WNsiCpg5R5wAc//i555bBQsE44O94btLt0xM/Zr2LQjwdCg== - dependencies: - "@tufjs/models" "2.0.0" - debug "^4.3.4" - make-fetch-happen "^13.0.0" - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -17601,13 +16498,6 @@ unique-filename@^1.1.1: dependencies: unique-slug "^2.0.0" -unique-filename@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea" - integrity sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g== - dependencies: - unique-slug "^4.0.0" - unique-slug@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c" @@ -17615,13 +16505,6 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" -unique-slug@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3" - integrity sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ== - dependencies: - imurmurhash "^0.1.4" - unist-builder@2.0.3, unist-builder@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-2.0.3.tgz#77648711b5d86af0942f334397a33c5e91516436" @@ -17885,7 +16768,7 @@ v8-to-istanbul@^9.0.0: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^1.6.0" -validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: +validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== @@ -17893,13 +16776,6 @@ validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.4: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -validate-npm-package-name@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz#f16afd48318e6f90a1ec101377fa0384cfc8c713" - integrity sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ== - dependencies: - builtins "^5.0.0" - validator@^13.6.0: version "13.7.0" resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" @@ -17977,11 +16853,6 @@ w3c-xmlserializer@^3.0.0: dependencies: xml-name-validator "^4.0.0" -walk-up-path@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/walk-up-path/-/walk-up-path-3.0.1.tgz#c8d78d5375b4966c717eb17ada73dbd41490e886" - integrity sha512-9YlCL/ynK3CTlrSRrDxZvUauLzAswPCrsaCgilqFevUYpeEW0/3ScEjaa3kbW/T0ghhkEr7mv+fpjqn1Y1YuTA== - walker@^1.0.7, walker@~1.0.5: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" @@ -18030,13 +16901,6 @@ watchpack@^2.3.1: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" -wcwidth@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" - integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== - dependencies: - defaults "^1.0.3" - web-namespaces@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" @@ -18378,14 +17242,7 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -which@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/which/-/which-4.0.0.tgz#cd60b5e74503a3fbcfbf6cd6b4138a8bae644c1a" - integrity sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg== - dependencies: - isexe "^3.1.1" - -wide-align@^1.1.2, wide-align@^1.1.5: +wide-align@^1.1.2: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== @@ -18466,14 +17323,6 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -write-file-atomic@^5.0.0, write-file-atomic@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" - integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw== - dependencies: - imurmurhash "^0.1.4" - signal-exit "^4.0.1" - ws@^7.4.6: version "7.5.5" resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" From b5cc44cc81ae94e446f15a222b805956c6def911 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 27 Mar 2024 15:37:39 +0100 Subject: [PATCH 076/369] Do navigation in test cases, not in beforeEach() --- .../surveys/submitting-survey.spec.ts | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 566f87d4ae..1c952bea9f 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -13,7 +13,7 @@ import { test.describe('User submitting a survey', () => { const apiPostPath = `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`; - test.beforeEach(async ({ appUri, login, moxy, page }) => { + test.beforeEach(async ({ login, moxy }) => { moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, 'get', @@ -30,17 +30,17 @@ test.describe('User submitting a survey', () => { role: null, }, ]); - - await page.goto( - `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` - ); }); test.afterEach(({ moxy }) => { moxy.teardown(); }); - test('submits responses', async ({ moxy, page }) => { + test('submits responses', async ({ appUri, moxy, page }) => { + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', @@ -77,7 +77,11 @@ test.describe('User submitting a survey', () => { ]); }); - test('submits email signature', async ({ moxy, page }) => { + test('submits email signature', async ({ appUri, moxy, page }) => { + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', @@ -111,7 +115,11 @@ test.describe('User submitting a survey', () => { }); }); - test('submits user signature', async ({ moxy, page }) => { + test('submits user signature', async ({ appUri, moxy, page }) => { + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', @@ -138,7 +146,11 @@ test.describe('User submitting a survey', () => { expect(data.signature).toBe('user'); }); - test('submits anonymous signature', async ({ moxy, page }) => { + test('submits anonymous signature', async ({ appUri, moxy, page }) => { + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + moxy.setZetkinApiMock( `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, 'post', @@ -165,7 +177,11 @@ test.describe('User submitting a survey', () => { expect(data.signature).toBe(null); }); - test('preserves inputs on error', async ({ page }) => { + test('preserves inputs on error', async ({ appUri, page }) => { + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + await page.click('input[name="1.options"][value="1"]'); await page.fill('[name="2.text"]', 'Topple capitalism'); await page.click('input[name="sig"][value="anonymous"]'); From 5a23beafc17151f7e11578d310214abb18024c23 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 27 Mar 2024 15:38:12 +0100 Subject: [PATCH 077/369] Add test case and fix bug that occurred when not interacting with Date: Sun, 12 May 2024 15:43:06 +0200 Subject: [PATCH 083/369] Allow matching for a higher number of tags than selected --- .../smartSearch/components/filters/PersonTags/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/smartSearch/components/filters/PersonTags/index.tsx b/src/features/smartSearch/components/filters/PersonTags/index.tsx index 1f640fa70a..c7090efce0 100644 --- a/src/features/smartSearch/components/filters/PersonTags/index.tsx +++ b/src/features/smartSearch/components/filters/PersonTags/index.tsx @@ -153,7 +153,7 @@ const PersonTags = ({ minMatchingInput: ( From 7258c1ca2c571d7d815bf0a82a4a0dee778e4432 Mon Sep 17 00:00:00 2001 From: awarn Date: Sun, 12 May 2024 17:21:28 +0200 Subject: [PATCH 084/369] Allow erasing the value of number of tags to match in SmartSearch --- .../components/filters/PersonTags/index.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/features/smartSearch/components/filters/PersonTags/index.tsx b/src/features/smartSearch/components/filters/PersonTags/index.tsx index c7090efce0..efcaed5623 100644 --- a/src/features/smartSearch/components/filters/PersonTags/index.tsx +++ b/src/features/smartSearch/components/filters/PersonTags/index.tsx @@ -1,4 +1,4 @@ -import { FormEvent } from 'react'; +import { FormEvent, useState } from 'react'; import { Box, Chip, MenuItem } from '@mui/material'; import FilterForm from '../../FilterForm'; @@ -51,6 +51,10 @@ const PersonTags = ({ tags: [], }); + const [selected, setSelected] = useState( + filter.config.condition + ); + // preserve the order of the tag array const selectedTags = filter.config.tags.reduce((acc: ZetkinTag[], id) => { const tag = tags.find((tag) => tag.id === id); @@ -60,10 +64,6 @@ const PersonTags = ({ return acc; }, []); - const selected = filter.config.min_matching - ? MIN_MATCHING - : filter.config.condition; - // only submit if at least one tag has been added const submittable = !!filter.config.tags.length; @@ -80,12 +80,14 @@ const PersonTags = ({ condition: CONDITION_OPERATOR.ANY, min_matching: 1, }); + setSelected(MIN_MATCHING); } else { setConfig({ ...filter.config, condition: conditionValue as CONDITION_OPERATOR, min_matching: undefined, }); + setSelected(conditionValue as CONDITION_OPERATOR); } }; @@ -160,7 +162,7 @@ const PersonTags = ({ setConfig({ ...filter.config, condition: CONDITION_OPERATOR.ANY, - min_matching: +e.target.value, + min_matching: +e.target.value || undefined, }) } value={filter.config.min_matching} From 0265ec7684595c43aecbcdb40ffbcaf119383013 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Fri, 24 May 2024 20:15:54 +0200 Subject: [PATCH 085/369] Fix survey URL generation --- .../surveys/components/SurveyURLCard.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/features/surveys/components/SurveyURLCard.tsx b/src/features/surveys/components/SurveyURLCard.tsx index d219a448f3..2f570e8c52 100644 --- a/src/features/surveys/components/SurveyURLCard.tsx +++ b/src/features/surveys/components/SurveyURLCard.tsx @@ -1,4 +1,5 @@ import { OpenInNew } from '@mui/icons-material'; +import { useMemo } from 'react'; import { Box, Link, useTheme } from '@mui/material'; import ZUICard from 'zui/ZUICard'; @@ -6,6 +7,7 @@ import ZUITextfieldToClipboard from 'zui/ZUITextfieldToClipboard'; import { Msg, useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; +import useSurvey from '../hooks/useSurvey'; interface SurveyURLCardProps { isOpen: boolean; @@ -14,8 +16,16 @@ interface SurveyURLCardProps { } const SurveyURLCard = ({ isOpen, orgId, surveyId }: SurveyURLCardProps) => { + const survey = useSurvey(parseInt(orgId), parseInt(surveyId)); const messages = useMessages(messageIds); const theme = useTheme(); + const surveyUrl = useMemo( + () => + survey.data + ? `${location.protocol}//${location.host}/o/${survey.data.organization.id}/surveys/${surveyId}` + : '', + [survey.data, surveyId] + ); return ( { } > - - {`${location.protocol}//${location.host}/o/${orgId}/surveys/${surveyId}`} + + {surveyUrl} Date: Sun, 30 Jun 2024 16:26:27 +0200 Subject: [PATCH 086/369] Fix linter errors and remove unused commented-out code --- src/app/o/[orgId]/surveys/[surveyId]/page.tsx | 5 +++-- src/features/surveys/actions/submit.ts | 3 ++- .../components/surveyForm/SurveyElements.tsx | 1 + .../components/surveyForm/SurveyErrorMessage.tsx | 5 +++-- .../surveys/components/surveyForm/SurveyForm.tsx | 3 ++- .../components/surveyForm/SurveyHeading.tsx | 5 +++-- .../surveyForm/SurveyOptionsQuestion.tsx | 13 +++++++------ .../components/surveyForm/SurveyPrivacyPolicy.tsx | 11 ++++++----- .../components/surveyForm/SurveyQuestion.tsx | 1 + .../components/surveyForm/SurveySignature.tsx | 14 ++++++-------- .../components/surveyForm/SurveySubmitButton.tsx | 1 + .../components/surveyForm/SurveySuccess.tsx | 3 ++- .../components/surveyForm/SurveyTextBlock.tsx | 3 ++- .../components/surveyForm/SurveyTextQuestion.tsx | 3 ++- src/features/surveys/l10n/messageIds.ts | 1 + .../surveys/utils/prepareSurveyApiSubmission.ts | 1 + src/utils/locale.spec.ts | 3 ++- 17 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx index 76ad6e83a6..34387f2368 100644 --- a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx +++ b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx @@ -1,10 +1,11 @@ 'use server'; -import BackendApiClient from 'core/api/client/BackendApiClient'; import { headers } from 'next/headers'; import { Metadata } from 'next'; -import SurveyForm from 'features/surveys/components/surveyForm/SurveyForm'; import { FC, ReactElement } from 'react'; + +import SurveyForm from 'features/surveys/components/surveyForm/SurveyForm'; +import BackendApiClient from 'core/api/client/BackendApiClient'; import { ZetkinSurveyExtended, ZetkinUser } from 'utils/types/zetkin'; type PageProps = { diff --git a/src/features/surveys/actions/submit.ts b/src/features/surveys/actions/submit.ts index 41280d69a7..ac1110c914 100644 --- a/src/features/surveys/actions/submit.ts +++ b/src/features/surveys/actions/submit.ts @@ -1,7 +1,8 @@ 'use server'; -import BackendApiClient from 'core/api/client/BackendApiClient'; import { headers } from 'next/headers'; + +import BackendApiClient from 'core/api/client/BackendApiClient'; import prepareSurveyApiSubmission from 'features/surveys/utils/prepareSurveyApiSubmission'; import { ZetkinSurveyFormStatus, ZetkinUser } from 'utils/types/zetkin'; diff --git a/src/features/surveys/components/surveyForm/SurveyElements.tsx b/src/features/surveys/components/surveyForm/SurveyElements.tsx index 6d5219df21..5aeb32dbbe 100644 --- a/src/features/surveys/components/surveyForm/SurveyElements.tsx +++ b/src/features/surveys/components/surveyForm/SurveyElements.tsx @@ -1,5 +1,6 @@ import { Box } from '@mui/system'; import { FC } from 'react'; + import SurveyQuestion from './SurveyQuestion'; import SurveyTextBlock from './SurveyTextBlock'; import { diff --git a/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx b/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx index 66d00180de..8864355de7 100644 --- a/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx +++ b/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx @@ -1,9 +1,10 @@ import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material'; +import { FC, useEffect, useRef } from 'react'; + import messageIds from 'features/surveys/l10n/messageIds'; import { Msg } from 'core/i18n'; import SurveyContainer from './SurveyContainer'; -import { useTheme } from '@mui/material'; -import { FC, useEffect, useRef } from 'react'; const SurveyErrorMessage: FC = () => { const element = useRef(null); diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index 6ed53c42ae..7d98039cf1 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -2,6 +2,8 @@ import { Box } from '@mui/material'; import { FC } from 'react'; +import { useFormState } from 'react-dom'; + import { submit } from 'features/surveys/actions/submit'; import SurveyElements from './SurveyElements'; import SurveyHeading from './SurveyHeading'; @@ -21,7 +23,6 @@ import { // import this. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -import { useFormState } from 'react-dom'; export type SurveyFormProps = { survey: ZetkinSurveyExtended; diff --git a/src/features/surveys/components/surveyForm/SurveyHeading.tsx b/src/features/surveys/components/surveyForm/SurveyHeading.tsx index e6356cd29d..b520c1de0a 100644 --- a/src/features/surveys/components/surveyForm/SurveyHeading.tsx +++ b/src/features/surveys/components/surveyForm/SurveyHeading.tsx @@ -1,9 +1,10 @@ import { FC } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Box, Typography } from '@mui/material'; + import SurveyContainer from './SurveyContainer'; import SurveyErrorMessage from './SurveyErrorMessage'; -import { useSearchParams } from 'next/navigation'; import ZUIAvatar from 'zui/ZUIAvatar'; -import { Box, Typography } from '@mui/material'; import { ZetkinSurveyExtended, ZetkinSurveyFormStatus, diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index 23e2f603ef..66e93c3c19 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -1,9 +1,3 @@ -import messageIds from 'features/surveys/l10n/messageIds'; -import SurveyContainer from './SurveyContainer'; -import SurveyOption from './SurveyOption'; -import SurveyQuestionDescription from './SurveyQuestionDescription'; -import SurveySubheading from './SurveySubheading'; -import { useMessages } from 'core/i18n'; import { Box, Checkbox, @@ -17,6 +11,13 @@ import { SelectChangeEvent, } from '@mui/material'; import { FC, useCallback, useState } from 'react'; + +import messageIds from 'features/surveys/l10n/messageIds'; +import SurveyContainer from './SurveyContainer'; +import SurveyOption from './SurveyOption'; +import SurveyQuestionDescription from './SurveyQuestionDescription'; +import SurveySubheading from './SurveySubheading'; +import { useMessages } from 'core/i18n'; import { ZetkinSurveyOption, ZetkinSurveyOptionsQuestionElement, diff --git a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx index 9625bf2222..5222051536 100644 --- a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx +++ b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx @@ -1,9 +1,4 @@ import { FC } from 'react'; -import messageIds from 'features/surveys/l10n/messageIds'; -import SurveyContainer from './SurveyContainer'; -import SurveyOption from './SurveyOption'; -import SurveySubheading from './SurveySubheading'; -import { ZetkinSurveyExtended } from 'utils/types/zetkin'; import { Box, Checkbox, @@ -13,6 +8,12 @@ import { Link, Typography, } from '@mui/material'; + +import messageIds from 'features/surveys/l10n/messageIds'; +import SurveyContainer from './SurveyContainer'; +import SurveyOption from './SurveyOption'; +import SurveySubheading from './SurveySubheading'; +import { ZetkinSurveyExtended } from 'utils/types/zetkin'; import { Msg, useMessages } from 'core/i18n'; export type SurveyPrivacyPolicyProps = { diff --git a/src/features/surveys/components/surveyForm/SurveyQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyQuestion.tsx index 82a0e3116f..894e7f653b 100644 --- a/src/features/surveys/components/surveyForm/SurveyQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyQuestion.tsx @@ -1,4 +1,5 @@ import { FC } from 'react'; + import SurveyOptionsQuestion from './SurveyOptionsQuestion'; import SurveyTextQuestion from './SurveyTextQuestion'; import { diff --git a/src/features/surveys/components/surveyForm/SurveySignature.tsx b/src/features/surveys/components/surveyForm/SurveySignature.tsx index 3367e9029f..7acc638af2 100644 --- a/src/features/surveys/components/surveyForm/SurveySignature.tsx +++ b/src/features/surveys/components/surveyForm/SurveySignature.tsx @@ -1,10 +1,3 @@ -import messageIds from 'features/surveys/l10n/messageIds'; -import { Msg } from 'core/i18n'; -import SurveyContainer from './SurveyContainer'; -import SurveyOption from './SurveyOption'; -import SurveySubheading from './SurveySubheading'; -// import useCurrentUser from 'features/user/hooks/useCurrentUser'; - import { Box, FormControl, @@ -16,6 +9,12 @@ import { useTheme, } from '@mui/material'; import { FC, useCallback, useState } from 'react'; + +import messageIds from 'features/surveys/l10n/messageIds'; +import { Msg } from 'core/i18n'; +import SurveyContainer from './SurveyContainer'; +import SurveyOption from './SurveyOption'; +import SurveySubheading from './SurveySubheading'; import { ZetkinSurveyExtended, ZetkinSurveySignatureType, @@ -28,7 +27,6 @@ export type SurveySignatureProps = { }; const SurveySignature: FC = ({ survey, user }) => { - // const currentUser = useCurrentUser(); const theme = useTheme(); const [signatureType, setSignatureType] = useState< diff --git a/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx b/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx index 9f7b570f5d..0f2a5aacbd 100644 --- a/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx +++ b/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx @@ -1,5 +1,6 @@ import { Button } from '@mui/material'; import { FC } from 'react'; + import messageIds from 'features/surveys/l10n/messageIds'; import SurveyContainer from './SurveyContainer'; import { useMessages } from 'core/i18n'; diff --git a/src/features/surveys/components/surveyForm/SurveySuccess.tsx b/src/features/surveys/components/surveyForm/SurveySuccess.tsx index 881129dd6a..e80bf6b49a 100644 --- a/src/features/surveys/components/surveyForm/SurveySuccess.tsx +++ b/src/features/surveys/components/surveyForm/SurveySuccess.tsx @@ -1,10 +1,11 @@ 'use client'; import { FC } from 'react'; +import { Typography } from '@mui/material'; + import messageIds from 'features/surveys/l10n/messageIds'; import { Msg } from 'core/i18n'; import SurveyContainer from './SurveyContainer'; -import { Typography } from '@mui/material'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; export type SurveySuccessProps = { diff --git a/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx index 2106812794..cf74da8c51 100644 --- a/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx @@ -1,7 +1,8 @@ import { FC } from 'react'; +import { Typography } from '@mui/material'; + import SurveyContainer from './SurveyContainer'; import SurveySubheading from './SurveySubheading'; -import { Typography } from '@mui/material'; import { ZetkinSurveyTextElement } from 'utils/types/zetkin'; export type SurveyTextBlockProps = { diff --git a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx index cac056af88..dc62e50f73 100644 --- a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx @@ -1,11 +1,12 @@ import { FC } from 'react'; +import { Box, FormControl, FormLabel, TextField } from '@mui/material'; + import messageIds from 'features/surveys/l10n/messageIds'; import SurveyContainer from './SurveyContainer'; import SurveyQuestionDescription from './SurveyQuestionDescription'; import SurveySubheading from './SurveySubheading'; import { useMessages } from 'core/i18n'; import { ZetkinSurveyTextQuestionElement } from 'utils/types/zetkin'; -import { Box, FormControl, FormLabel, TextField } from '@mui/material'; export type SurveyOptionsQuestionProps = { element: ZetkinSurveyTextQuestionElement; diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 6f03219f5b..054c19c136 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -1,4 +1,5 @@ import { ReactElement } from 'react'; + import { m, makeMessages } from 'core/i18n'; export default makeMessages('feat.surveys', { diff --git a/src/features/surveys/utils/prepareSurveyApiSubmission.ts b/src/features/surveys/utils/prepareSurveyApiSubmission.ts index f67f14a7f8..fa6f57800c 100644 --- a/src/features/surveys/utils/prepareSurveyApiSubmission.ts +++ b/src/features/surveys/utils/prepareSurveyApiSubmission.ts @@ -1,4 +1,5 @@ import uniq from 'lodash/uniq'; + import { ZetkinSurveyApiSubmission, ZetkinSurveyQuestionResponse, diff --git a/src/utils/locale.spec.ts b/src/utils/locale.spec.ts index 3895bca210..8c3d3d4214 100644 --- a/src/utils/locale.spec.ts +++ b/src/utils/locale.spec.ts @@ -1,6 +1,7 @@ -import { getBrowserLanguage } from './locale'; import { NextApiRequest } from 'next'; +import { getBrowserLanguage } from './locale'; + describe('getBrowserLanguage', () => { it('returns the preferred language of the user if available', () => { const request: Partial = { From 9373c75853af9ff3be77ac39671f3483194ff8f1 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 30 Jun 2024 21:47:06 +0200 Subject: [PATCH 087/369] Suppress erroneous TypeScript error about nonexistent useFormState export --- src/features/surveys/components/surveyForm/SurveyForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index 7d98039cf1..386ee670a1 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -2,6 +2,7 @@ import { Box } from '@mui/material'; import { FC } from 'react'; +// @ts-expect-error Erroneous `Module '"react-dom"' has no exported member 'useFormState'` import { useFormState } from 'react-dom'; import { submit } from 'features/surveys/actions/submit'; From ac213e0c6003d563fe34c5f10c02a0b0f3fae5d7 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Mon, 1 Jul 2024 22:15:19 +0200 Subject: [PATCH 088/369] Move old @ts-ignore comment back alongside the import it was originally for --- .../surveys/components/surveyForm/SurveyForm.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index 386ee670a1..08a08b030d 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -2,7 +2,12 @@ import { Box } from '@mui/material'; import { FC } from 'react'; -// @ts-expect-error Erroneous `Module '"react-dom"' has no exported member 'useFormState'` +// Type definitions for the new experimental stuff like useFormState in +// react-dom are lagging behind the implementation so it's necessary to silence +// the TypeScript error about the lack of type definitions here in order to +// import this. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore import { useFormState } from 'react-dom'; import { submit } from 'features/surveys/actions/submit'; @@ -18,13 +23,6 @@ import { ZetkinUser, } from 'utils/types/zetkin'; -// Type definitions for the new experimental stuff like useFormState in -// react-dom are lagging behind the implementation so it's necessary to silence -// the TypeScript error about the lack of type definitions here in order to -// import this. -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore - export type SurveyFormProps = { survey: ZetkinSurveyExtended; user: ZetkinUser | null; From 8c9f516f1c2408dab24be761b5a623375c0b1468 Mon Sep 17 00:00:00 2001 From: river-bbc Date: Tue, 2 Jul 2024 15:57:45 -0700 Subject: [PATCH 089/369] move all time scale logic to own hook --- src/features/calendar/components/index.tsx | 47 +++++++-------------- src/features/calendar/hooks/useTimeScale.ts | 37 ++++++++++++++++ 2 files changed, 52 insertions(+), 32 deletions(-) create mode 100644 src/features/calendar/hooks/useTimeScale.ts diff --git a/src/features/calendar/components/index.tsx b/src/features/calendar/components/index.tsx index e395868bc7..dc27924ec6 100644 --- a/src/features/calendar/components/index.tsx +++ b/src/features/calendar/components/index.tsx @@ -10,6 +10,7 @@ import CalendarNavBar from './CalendarNavBar'; import CalendarWeekView from './CalendarWeekView'; import SelectionBar from '../../events/components/SelectionBar'; import useDayCalendarNav from '../hooks/useDayCalendarNav'; +import useTimeScale from '../hooks/useTimeScale'; dayjs.extend(utc); @@ -19,17 +20,6 @@ export enum TimeScale { MONTH = 'month', } -function getTimeScale(timeScaleStr: string) { - let timeScale = TimeScale.MONTH; - if ( - timeScaleStr !== undefined && - Object.values(TimeScale).includes(timeScaleStr as TimeScale) - ) { - timeScale = timeScaleStr as TimeScale; - } - return timeScale; -} - function getDateFromString(focusDateStr: string) { let date = new Date(); if (focusDateStr) { @@ -51,19 +41,14 @@ const Calendar = () => { const [focusDate, setFocusDate] = useState(getDateFromString(focusDateStr)); const { nextActivityDay, prevActivityDay } = useDayCalendarNav(focusDate); - const timeScaleStr = router.query.timeScale as string; - const [selectedTimeScale, setSelectedTimeScale] = useState( - getTimeScale(timeScaleStr) + const { setPersistentTimeScale, timeScale } = useTimeScale( + router.query.timeScale ); useEffect(() => { setFocusDate(getDateFromString(focusDateStr)); }, [focusDateStr]); - useEffect(() => { - setSelectedTimeScale(getTimeScale(timeScaleStr)); - }, [timeScaleStr]); - useEffect(() => { const focusedDate = dayjs.utc(focusDate).format('YYYY-MM-DD'); router.replace( @@ -73,16 +58,16 @@ const Calendar = () => { ...(campId && { campId: campId }), focusDate: focusedDate, orgId: orgId, - timeScale: selectedTimeScale, + timeScale: timeScale, }, }, undefined, { shallow: true } ); - }, [focusDate, selectedTimeScale]); + }, [focusDate, timeScale]); function navigateTo(timeScale: TimeScale, date: Date) { - setSelectedTimeScale(timeScale); + setPersistentTimeScale(timeScale); setFocusDate(date); } @@ -94,28 +79,26 @@ const Calendar = () => { setFocusDate(date); }} onChangeTimeScale={(timeScale) => { - setSelectedTimeScale(timeScale); + setPersistentTimeScale(timeScale); }} onStepBackward={() => { // Steps back to the last day with an event on day view - if (selectedTimeScale === TimeScale.DAY && prevActivityDay) { + if (timeScale === TimeScale.DAY && prevActivityDay) { setFocusDate(prevActivityDay[0]); } else { - setFocusDate( - dayjs(focusDate).subtract(1, selectedTimeScale).toDate() - ); + setFocusDate(dayjs(focusDate).subtract(1, timeScale).toDate()); } }} onStepForward={() => { // Steps forward to the next day with an event on day view - if (selectedTimeScale === TimeScale.DAY && nextActivityDay) { + if (timeScale === TimeScale.DAY && nextActivityDay) { setFocusDate(nextActivityDay[0]); } else { - setFocusDate(dayjs(focusDate).add(1, selectedTimeScale).toDate()); + setFocusDate(dayjs(focusDate).add(1, timeScale).toDate()); } }} orgId={parseInt(orgId as string)} - timeScale={selectedTimeScale} + timeScale={timeScale} /> { overflow="auto" > - {selectedTimeScale === TimeScale.DAY && ( + {timeScale === TimeScale.DAY && ( setFocusDate(date)} previousActivityDay={prevActivityDay} /> )} - {selectedTimeScale === TimeScale.WEEK && ( + {timeScale === TimeScale.WEEK && ( navigateTo(TimeScale.DAY, date)} /> )} - {selectedTimeScale === TimeScale.MONTH && ( + {timeScale === TimeScale.MONTH && ( navigateTo(TimeScale.DAY, date)} diff --git a/src/features/calendar/hooks/useTimeScale.ts b/src/features/calendar/hooks/useTimeScale.ts new file mode 100644 index 0000000000..6a56f07598 --- /dev/null +++ b/src/features/calendar/hooks/useTimeScale.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; + +import { TimeScale } from '../components'; + +function getTimeScale(timeScaleQueryParam: string | string[] | undefined) { + let timeScale = TimeScale.MONTH; + if ( + timeScaleQueryParam !== undefined && + typeof timeScaleQueryParam === 'string' && + Object.values(TimeScale).includes(timeScaleQueryParam as TimeScale) + ) { + timeScale = timeScaleQueryParam as TimeScale; + } + return timeScale; +} + +export default function useTimeScale( + timeScaleQueryParam: string | string[] | undefined +) { + const [timeScale, setTimeScale] = useState( + getTimeScale(timeScaleQueryParam) + ); + + // When the time scale query param changes, update the time scale + useEffect(() => { + setTimeScale(getTimeScale(timeScaleQueryParam)); + }, [timeScaleQueryParam]); + + const setPersistentTimeScale = (newTimeScale: TimeScale) => { + setTimeScale(newTimeScale); + }; + + return { + setPersistentTimeScale, + timeScale, + }; +} From 130aa86d948f2623d3eb9d5806fb135ebd433875 Mon Sep 17 00:00:00 2001 From: river-bbc Date: Tue, 2 Jul 2024 16:08:26 -0700 Subject: [PATCH 090/369] local scorage for time scale --- src/features/calendar/hooks/useTimeScale.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/features/calendar/hooks/useTimeScale.ts b/src/features/calendar/hooks/useTimeScale.ts index 6a56f07598..143ccea3ec 100644 --- a/src/features/calendar/hooks/useTimeScale.ts +++ b/src/features/calendar/hooks/useTimeScale.ts @@ -1,32 +1,36 @@ import { useEffect, useState } from 'react'; import { TimeScale } from '../components'; +import useLocalStorage from 'zui/hooks/useLocalStorage'; function getTimeScale(timeScaleQueryParam: string | string[] | undefined) { - let timeScale = TimeScale.MONTH; if ( - timeScaleQueryParam !== undefined && typeof timeScaleQueryParam === 'string' && Object.values(TimeScale).includes(timeScaleQueryParam as TimeScale) ) { - timeScale = timeScaleQueryParam as TimeScale; + return timeScaleQueryParam as TimeScale; } - return timeScale; + return TimeScale.MONTH; } export default function useTimeScale( timeScaleQueryParam: string | string[] | undefined ) { + const [localStorageTimeScale, setLocalStorageTimeScale] = useLocalStorage< + TimeScale | undefined + >('calendarTimeScale', undefined); + const [timeScale, setTimeScale] = useState( - getTimeScale(timeScaleQueryParam) + localStorageTimeScale || getTimeScale(timeScaleQueryParam) ); - // When the time scale query param changes, update the time scale + // When the time scale query param changes in the URL useEffect(() => { setTimeScale(getTimeScale(timeScaleQueryParam)); }, [timeScaleQueryParam]); const setPersistentTimeScale = (newTimeScale: TimeScale) => { + setLocalStorageTimeScale(newTimeScale); setTimeScale(newTimeScale); }; From cee389ba54f78e7387f3362a9c21faa74adaad1e Mon Sep 17 00:00:00 2001 From: river-bbc Date: Tue, 2 Jul 2024 16:10:57 -0700 Subject: [PATCH 091/369] remove unneeded useEffect --- src/features/calendar/hooks/useTimeScale.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/features/calendar/hooks/useTimeScale.ts b/src/features/calendar/hooks/useTimeScale.ts index 143ccea3ec..98f6e8c050 100644 --- a/src/features/calendar/hooks/useTimeScale.ts +++ b/src/features/calendar/hooks/useTimeScale.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { TimeScale } from '../components'; import useLocalStorage from 'zui/hooks/useLocalStorage'; @@ -24,11 +24,6 @@ export default function useTimeScale( localStorageTimeScale || getTimeScale(timeScaleQueryParam) ); - // When the time scale query param changes in the URL - useEffect(() => { - setTimeScale(getTimeScale(timeScaleQueryParam)); - }, [timeScaleQueryParam]); - const setPersistentTimeScale = (newTimeScale: TimeScale) => { setLocalStorageTimeScale(newTimeScale); setTimeScale(newTimeScale); From 6944f70dfb91d9e99f0f07073310b1d9fde32a0e Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 3 Jul 2024 13:23:31 +0200 Subject: [PATCH 092/369] Create EmailKPIChart component --- package.json | 1 + .../emails/components/EmailKPIChart.tsx | 82 +++++++++++++++++++ yarn.lock | 63 +++++++------- 3 files changed, 112 insertions(+), 34 deletions(-) create mode 100644 src/features/emails/components/EmailKPIChart.tsx diff --git a/package.json b/package.json index c42d1a9f11..1e1fdc9ad2 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@nivo/core": "^0.80.0", "@nivo/line": "^0.80.0", "@nivo/pie": "^0.80.0", + "@nivo/radial-bar": "^0.80.0", "@reduxjs/toolkit": "^1.8.6", "@types/dompurify": "^2.3.3", "@types/mjml": "^4.7.4", diff --git a/src/features/emails/components/EmailKPIChart.tsx b/src/features/emails/components/EmailKPIChart.tsx new file mode 100644 index 0000000000..863ab88a7e --- /dev/null +++ b/src/features/emails/components/EmailKPIChart.tsx @@ -0,0 +1,82 @@ +import { Box, Typography, useTheme } from '@mui/material'; +import { ResponsiveRadialBar } from '@nivo/radial-bar'; +import { FC } from 'react'; + +import { ZetkinEmail } from 'utils/types/zetkin'; + +type Props = { + email: ZetkinEmail; + title: string; + total: number; + value: number; +}; + +const EmailKPIChart: FC = ({ email, title, total, value }) => { + const theme = useTheme(); + + const data = [ + { + data: [ + { + x: 'Opened', + y: value, + }, + { + x: 'Opened 2', + y: 0, + }, + ], + id: email.title || '', + }, + ]; + + const percentage = Math.round((value / total) * 100); + + return ( + + + {title} + {percentage + '%'} + + + + + + + ); +}; + +export default EmailKPIChart; diff --git a/yarn.lock b/yarn.lock index 4b35244565..a0d65c1fa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2951,6 +2951,30 @@ "@nivo/tooltip" "0.80.0" d3-shape "^1.3.5" +"@nivo/polar-axes@0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/polar-axes/-/polar-axes-0.80.0.tgz#7fad6292c45470cede28f5548f3b71e8181d6da2" + integrity sha512-lhk8ZtpEg7qT6/UFnrXOd0Tg5M6ZipTxjT2JmC6gxAADDiha5BzlAuBZaSudgbwhWbnzTdNGqHBkR5Dp/yqUcA== + dependencies: + "@nivo/arcs" "0.80.0" + "@nivo/scales" "0.80.0" + "@react-spring/web" "9.4.5" + +"@nivo/radial-bar@^0.80.0": + version "0.80.0" + resolved "https://registry.yarnpkg.com/@nivo/radial-bar/-/radial-bar-0.80.0.tgz#1f928f5caefac07d11ab32fc50072804e0b9e6ce" + integrity sha512-IJ+IvGCXcy3y0uwcAsMyjyV5WTPe3vP5aUuV+bglTOUd1PmyD+6XY5FIAX0x3049t8mpHZnVRTewPpfaAZYLPw== + dependencies: + "@nivo/arcs" "0.80.0" + "@nivo/colors" "0.80.0" + "@nivo/legends" "0.80.0" + "@nivo/polar-axes" "0.80.0" + "@nivo/scales" "0.80.0" + "@nivo/tooltip" "0.80.0" + "@react-spring/web" "9.4.5" + d3-scale "^3.2.3" + d3-shape "^1.3.5" + "@nivo/recompose@0.80.0": version "0.80.0" resolved "https://registry.yarnpkg.com/@nivo/recompose/-/recompose-0.80.0.tgz#572048aed793321a0bada1fd176b72df5a25282e" @@ -12867,7 +12891,8 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -"prettier-fallback@npm:prettier@^3": +"prettier-fallback@npm:prettier@^3", prettier@^3.1.1: + name prettier-fallback version "3.3.2" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a" integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA== @@ -12877,11 +12902,6 @@ prettier@^2.5.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a" integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg== -prettier@^3.1.1: - version "3.3.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a" - integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA== - pretty-error@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6" @@ -14312,16 +14332,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14425,14 +14436,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15683,16 +15687,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 2e0052fdf9b781b57cba9e4d1f43c9b2ee87f1b1 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 07:37:55 +0200 Subject: [PATCH 093/369] Create RPC to load email open data --- src/core/rpc/index.ts | 2 + src/features/emails/rpc/getEmailInsights.ts | 64 +++++++++++++++++++++ src/features/emails/types.ts | 27 +++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/features/emails/rpc/getEmailInsights.ts diff --git a/src/core/rpc/index.ts b/src/core/rpc/index.ts index 40003f4c35..3027a4effe 100644 --- a/src/core/rpc/index.ts +++ b/src/core/rpc/index.ts @@ -15,6 +15,7 @@ import { getUserOrgTreeDef } from 'features/organizations/rpc/getUserOrgTree'; import { moveParticipantsDef } from 'features/events/rpc/moveParticipants'; import { setOfficialRoleDef } from 'features/settings/rpc/setOfficialRole'; import { updateEventsDef } from 'features/events/rpc/updateEvents'; +import { getEmailInsightsDef } from 'features/emails/rpc/getEmailInsights'; export function createRPCRouter() { const rpcRouter = new RPCRouter(); @@ -35,6 +36,7 @@ export function createRPCRouter() { rpcRouter.register(deleteEventsDef); rpcRouter.register(getOfficialMembershipsDef); rpcRouter.register(setOfficialRoleDef); + rpcRouter.register(getEmailInsightsDef); return rpcRouter; } diff --git a/src/features/emails/rpc/getEmailInsights.ts b/src/features/emails/rpc/getEmailInsights.ts new file mode 100644 index 0000000000..a1295ef6d3 --- /dev/null +++ b/src/features/emails/rpc/getEmailInsights.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import IApiClient from 'core/api/client/IApiClient'; +import { makeRPCDef } from 'core/rpc/types'; +import { EmailInsights, ZetkinEmailRecipient } from '../types'; + +const paramsSchema = z.object({ + emailId: z.number(), + orgId: z.number(), +}); +type Params = z.input; +type Result = EmailInsights; + +export const getEmailInsightsDef = { + handler: handle, + name: 'getEmailInsights', + schema: paramsSchema, +}; + +export default makeRPCDef(getEmailInsightsDef.name); + +async function handle(params: Params, apiClient: IApiClient) { + const { emailId, orgId } = params; + + const recipients = await apiClient.get( + `/api/orgs/${orgId}/emails/${emailId}/recipients` + ); + + const sortedOpens = recipients + .filter((recipient) => !!recipient.opened) + .sort( + (a, b) => + new Date(a.opened || 0).getTime() - new Date(b.opened || 0).getTime() + ); + + const output: Result = { + id: emailId, + opensByDate: [], + }; + + const firstEvent = sortedOpens[0]; + + if (firstEvent?.opened) { + let dateOfLastPoint = new Date(0); + const minPointDiff = 5 * 60 * 1000; // 5 minutes + + sortedOpens.forEach((recipient, index) => { + const openDate = new Date(recipient.opened || 0); + + const diff = openDate.getTime() - dateOfLastPoint.getTime(); + const isLast = index == sortedOpens.length - 1; + + if (diff > minPointDiff || isLast) { + dateOfLastPoint = new Date(openDate); + output.opensByDate.push({ + accumulatedOpens: index + 1, + date: openDate.toISOString(), + }); + } + }); + } + + return output; +} diff --git a/src/features/emails/types.ts b/src/features/emails/types.ts index e7f85c83c1..456eeea421 100644 --- a/src/features/emails/types.ts +++ b/src/features/emails/types.ts @@ -171,3 +171,30 @@ export type EmailTheme = { frame_mjml: MJMLJsonObject | null; id: number; }; + +export type ZetkinEmailRecipient = { + delivered: string | null; + email: { + id: number; + title: string; + }; + email_address: null; + error: null; + id: number; + opened: string | null; + person: { + first_name: string; + id: number; + last_name: string; + }; + sent: string | null; + status: 'pending' | 'sent' | 'opened'; +}; + +export type EmailInsights = { + id: number; + opensByDate: { + accumulatedOpens: number; + date: string; + }[]; +}; From 3c29e9c81531f96f9cce52237509e07c1650fb31 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 07:39:04 +0200 Subject: [PATCH 094/369] Create hook and store logic to load insights data --- src/features/emails/hooks/useEmailInsights.ts | 23 +++++++++++++++++++ src/features/emails/store.ts | 18 ++++++++++++++- src/utils/testing/mocks/mockState.ts | 1 + 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/features/emails/hooks/useEmailInsights.ts diff --git a/src/features/emails/hooks/useEmailInsights.ts b/src/features/emails/hooks/useEmailInsights.ts new file mode 100644 index 0000000000..dc27d67d18 --- /dev/null +++ b/src/features/emails/hooks/useEmailInsights.ts @@ -0,0 +1,23 @@ +import { IFuture } from 'core/caching/futures'; +import { EmailInsights } from '../types'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { loadItemIfNecessary } from 'core/caching/cacheUtils'; +import { insightsLoad, insightsLoaded } from '../store'; +import getEmailInsights from '../rpc/getEmailInsights'; + +export default function useEmailInsights( + orgId: number, + emailId: number +): IFuture { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const item = useAppSelector( + (state) => state.emails.insightsByEmailId[emailId] + ); + + return loadItemIfNecessary(item, dispatch, { + actionOnLoad: () => insightsLoad(emailId), + actionOnSuccess: (data) => insightsLoaded(data), + loader: () => apiClient.rpc(getEmailInsights, { emailId, orgId }), + }); +} diff --git a/src/features/emails/store.ts b/src/features/emails/store.ts index bcf96bebce..5bcdc924d0 100644 --- a/src/features/emails/store.ts +++ b/src/features/emails/store.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { EmailStats } from './hooks/useEmailStats'; -import { EmailTheme } from './types'; +import { EmailInsights, EmailTheme } from './types'; import { RemoteItem, remoteItem, @@ -15,10 +15,12 @@ export interface EmailStoreSlice { themeList: RemoteList; linksByEmailId: Record>; statsById: Record>; + insightsByEmailId: Record>; } const initialState: EmailStoreSlice = { emailList: remoteList(), + insightsByEmailId: {}, linksByEmailId: {}, statsById: {}, themeList: remoteList(), @@ -120,6 +122,18 @@ const emailsSlice = createSlice({ state.emailList.loaded = timestamp; state.emailList.items.forEach((item) => (item.loaded = timestamp)); }, + insightsLoad: (state, action: PayloadAction) => { + const emailId = action.payload; + state.insightsByEmailId[emailId] ||= remoteItem(emailId); + state.insightsByEmailId[emailId].isLoading = true; + }, + insightsLoaded: (state, action: PayloadAction) => { + const insights = action.payload; + state.insightsByEmailId[insights.id] = remoteItem(insights.id, { + data: insights, + loaded: new Date().toISOString(), + }); + }, statsLoad: (state, action: PayloadAction) => { const id = action.payload; const statsItem = state.statsById[id]; @@ -181,6 +195,8 @@ export const { emailUpdated, emailsLoad, emailsLoaded, + insightsLoad, + insightsLoaded, themesLoad, themesLoaded, statsLoad, diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index 1e7edb1e63..f3154e0f61 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -23,6 +23,7 @@ export default function mockState(overrides?: RootState) { }, emails: { emailList: remoteList(), + insightsByEmailId: {}, linksByEmailId: {}, statsById: {}, themeList: remoteList(), From d9cb17733ac0c379cfbbe6c0e21be52eb015ef59 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 07:39:55 +0200 Subject: [PATCH 095/369] Create "Insights" tab with "Opened" section --- src/features/emails/l10n/messageIds.ts | 12 ++ src/features/emails/layout/EmailLayout.tsx | 4 + .../[campId]/emails/[emailId]/insights.tsx | 153 ++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index 153f350779..008ebb10ea 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -123,6 +123,17 @@ export default makeMessages('feat.emails', { ), goBackButton: m('Go back'), }, + insights: { + opened: { + description: m( + 'Open rate is the percentage of recipients who opened the email. A high rate is an indicator of good targeting and a compelling subject line.' + ), + gauge: { + header: m('Open rate'), + }, + header: m('Opened'), + }, + }, ready: { loading: m('Loading...'), lockButton: m('Lock for delivery'), @@ -154,6 +165,7 @@ export default makeMessages('feat.emails', { }, tabs: { compose: m('Compose'), + insights: m('Insights'), overview: m('Overview'), }, targets: { diff --git a/src/features/emails/layout/EmailLayout.tsx b/src/features/emails/layout/EmailLayout.tsx index b8ae7b0d51..bbd5777a26 100644 --- a/src/features/emails/layout/EmailLayout.tsx +++ b/src/features/emails/layout/EmailLayout.tsx @@ -100,6 +100,10 @@ const EmailLayout: FC = ({ href: '/compose', label: messages.tabs.compose(), }, + { + href: '/insights', + label: messages.tabs.insights(), + }, ]} title={ { + return { + props: {}, + }; + }, + { + authLevelRequired: 2, + localeScope: ['layout.organize.email', 'pages.organizeEmail'], + } +); + +const EmailPage: PageWithLayout = () => { + const messages = useMessages(messageIds); + const { orgId, emailId } = useNumericRouteParams(); + const theme = useTheme(); + const { data: email } = useEmail(orgId, emailId); + const stats = useEmailStats(orgId, emailId); + const insightsFuture = useEmailInsights(orgId, emailId); + + const onServer = useServerSide(); + + if (onServer || !email) { + return null; + } + + return ( + <> + + {email.title} + + + } + > + + + + + {messages.insights.opened.description()} + + + + + {(insights) => ( + ({ + x: new Date(openEvent.date), + y: openEvent.accumulatedOpens, + })), + id: email.title || '', + }, + ]} + defs={[ + linearGradientDef('gradientA', [ + { color: 'inherit', offset: 0 }, + { color: 'inherit', offset: 100, opacity: 0 }, + ]), + ]} + enableArea={true} + enableGridX={false} + enableGridY={false} + enablePoints={false} + enableSlices="x" + fill={[{ id: 'gradientA', match: '*' }]} + isInteractive={true} + lineWidth={3} + margin={{ + bottom: 30, + // Calculate the left margin from the number of digits + // in the y axis labels, to make sure the labels will fit + // inside the clipping rectangle. + left: + 15 + insights.opensByDate.length.toString().length * 8, + top: 20, + }} + sliceTooltip={(props) => { + const dataPoint = props.slice.points[0]; + const date = new Date(dataPoint.data.xFormatted); + + return ( + + + + + + + + ); + }} + xFormat="time:%Y-%m-%d %H:%M:%S.%L" + xScale={{ + format: '%Y-%m-%d %H:%M:%S.%L', + precision: 'minute', + type: 'time', + useUTC: false, + }} + /> + )} + + + + + + ); +}; + +EmailPage.getLayout = function getLayout(page) { + return {page}; +}; + +export default EmailPage; From 92558d7dbebac4daae19dd93d2b2255441a5223a Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 08:27:14 +0200 Subject: [PATCH 096/369] Add sx property to ZUICard --- src/zui/ZUICard/index.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/zui/ZUICard/index.tsx b/src/zui/ZUICard/index.tsx index 58d38ac483..6550e375ee 100644 --- a/src/zui/ZUICard/index.tsx +++ b/src/zui/ZUICard/index.tsx @@ -1,4 +1,4 @@ -import { Box, Card, Typography } from '@mui/material'; +import { Box, Card, CardProps, Typography } from '@mui/material'; import { FC, ReactNode } from 'react'; type ZUICardProps = { @@ -6,11 +6,18 @@ type ZUICardProps = { header: string | JSX.Element; status?: ReactNode; subheader?: string; + sx?: CardProps['sx']; }; -const ZUICard: FC = ({ children, header, status, subheader }) => { +const ZUICard: FC = ({ + children, + header, + status, + subheader, + sx, +}) => { return ( - + {header} From 8f321e91b07b114e0381f99d879010dc75c5d0ea Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 08:30:33 +0200 Subject: [PATCH 097/369] Fix problems with EmailStats type and move it to types.ts --- src/features/emails/hooks/useEmailStats.ts | 25 ++++++---------------- src/features/emails/store.ts | 14 ++++++------ src/features/emails/types.ts | 16 ++++++++++++++ 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/features/emails/hooks/useEmailStats.ts b/src/features/emails/hooks/useEmailStats.ts index a99a5fca63..65f69aa611 100644 --- a/src/features/emails/hooks/useEmailStats.ts +++ b/src/features/emails/hooks/useEmailStats.ts @@ -4,24 +4,10 @@ import { loadItemIfNecessary } from 'core/caching/cacheUtils'; import useEmail from './useEmail'; import { statsLoad, statsLoaded } from '../store'; import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; - -export interface EmailStats { - id: number; - num_target_matches: number; - num_locked_targets: number | null; - num_blocked: { - any: number; - blacklisted: number; - no_email: number; - unsubscribed: number; - }; - num_sent: number; - num_opened: number; - num_clicked: number; -} +import { ZetkinEmailStats } from '../types'; interface UseEmailStatsReturn { - data: EmailStats | null; + data: ZetkinEmailStats | null; error: unknown | null; isLoading: boolean; lockedReadyTargets: number | null; @@ -52,7 +38,7 @@ export default function useEmailStats( actionOnLoad: () => statsLoad(emailId), actionOnSuccess: (data) => statsLoaded(data), loader: async () => { - const data = await apiClient.get( + const data = await apiClient.get( `/api/orgs/${orgId}/emails/${emailId}/stats` ); return { ...data, id: emailId }; @@ -72,7 +58,8 @@ export default function useEmailStats( no_email: 0, unsubscribed: 0, }, - num_clicked: 0, + num_clicks: 0, + num_clicks_by_link: {}, num_locked_targets: 0, num_opened: 0, num_sent: 0, @@ -95,7 +82,7 @@ export default function useEmailStats( noEmail: statsFuture.data?.num_blocked.no_email ?? 0, unsubscribed: statsFuture.data?.num_blocked.unsubscribed ?? 0, }, - numClicked: statsFuture.data?.num_clicked ?? 0, + numClicked: statsFuture.data?.num_clicks ?? 0, numLockedTargets: statsFuture.data?.num_locked_targets ?? 0, numOpened: statsFuture.data?.num_opened ?? 0, numSent: statsFuture.data?.num_sent ?? 0, diff --git a/src/features/emails/store.ts b/src/features/emails/store.ts index 5bcdc924d0..e93a8cfba0 100644 --- a/src/features/emails/store.ts +++ b/src/features/emails/store.ts @@ -1,7 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { EmailStats } from './hooks/useEmailStats'; -import { EmailInsights, EmailTheme } from './types'; +import { EmailInsights, EmailTheme, ZetkinEmailStats } from './types'; import { RemoteItem, remoteItem, @@ -14,7 +13,7 @@ export interface EmailStoreSlice { emailList: RemoteList; themeList: RemoteList; linksByEmailId: Record>; - statsById: Record>; + statsById: Record>; insightsByEmailId: Record>; } @@ -137,7 +136,7 @@ const emailsSlice = createSlice({ statsLoad: (state, action: PayloadAction) => { const id = action.payload; const statsItem = state.statsById[id]; - state.statsById[id] = remoteItem(id, { + state.statsById[id] = remoteItem(id, { data: statsItem?.data || { id, num_blocked: { @@ -146,7 +145,8 @@ const emailsSlice = createSlice({ no_email: 0, unsubscribed: 0, }, - num_clicked: 0, + num_clicks: 0, + num_clicks_by_link: {}, num_locked_targets: 0, num_opened: 0, num_sent: 0, @@ -157,9 +157,9 @@ const emailsSlice = createSlice({ }, statsLoaded: ( state, - action: PayloadAction + action: PayloadAction ) => { - state.statsById[action.payload.id] = remoteItem( + state.statsById[action.payload.id] = remoteItem( action.payload.id, { data: action.payload, diff --git a/src/features/emails/types.ts b/src/features/emails/types.ts index 456eeea421..f8f08eb992 100644 --- a/src/features/emails/types.ts +++ b/src/features/emails/types.ts @@ -198,3 +198,19 @@ export type EmailInsights = { date: string; }[]; }; + +export type ZetkinEmailStats = { + id: number; + num_blocked: { + any: number; + blacklisted: number; + no_email: number; + unsubscribed: number; + }; + num_clicks: number; + num_clicks_by_link: Record; + num_locked_targets: number | null; + num_opened: number; + num_sent: number; + num_target_matches: number; +}; From 404db4835d94c00de1b5e05621ff5f9e905bcda2 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 08:31:55 +0200 Subject: [PATCH 098/369] Add Clicked section on insights page --- src/features/emails/l10n/messageIds.ts | 9 +++ src/features/emails/rpc/getEmailInsights.ts | 34 +++++++++-- src/features/emails/types.ts | 13 ++++ .../[campId]/emails/[emailId]/insights.tsx | 61 ++++++++++++++++++- 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index 008ebb10ea..da550b0ab2 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -124,6 +124,15 @@ export default makeMessages('feat.emails', { goBackButton: m('Go back'), }, insights: { + clicked: { + description: m( + 'Click rate is the percentage of recipients who not only opened the email but also clicked one of the links. A high rate is an indicator of a well-targeted email with convincing copy.' + ), + gauge: { + header: m('Click rate'), + }, + header: m('Clicked'), + }, opened: { description: m( 'Open rate is the percentage of recipients who opened the email. A high rate is an indicator of good targeting and a compelling subject line.' diff --git a/src/features/emails/rpc/getEmailInsights.ts b/src/features/emails/rpc/getEmailInsights.ts index a1295ef6d3..bf22d5a646 100644 --- a/src/features/emails/rpc/getEmailInsights.ts +++ b/src/features/emails/rpc/getEmailInsights.ts @@ -2,7 +2,12 @@ import { z } from 'zod'; import IApiClient from 'core/api/client/IApiClient'; import { makeRPCDef } from 'core/rpc/types'; -import { EmailInsights, ZetkinEmailRecipient } from '../types'; +import { + EmailInsights, + ZetkinEmailLink, + ZetkinEmailRecipient, + ZetkinEmailStats, +} from '../types'; const paramsSchema = z.object({ emailId: z.number(), @@ -22,6 +27,12 @@ export default makeRPCDef(getEmailInsightsDef.name); async function handle(params: Params, apiClient: IApiClient) { const { emailId, orgId } = params; + const output: Result = { + id: emailId, + links: [], + opensByDate: [], + }; + const recipients = await apiClient.get( `/api/orgs/${orgId}/emails/${emailId}/recipients` ); @@ -33,11 +44,6 @@ async function handle(params: Params, apiClient: IApiClient) { new Date(a.opened || 0).getTime() - new Date(b.opened || 0).getTime() ); - const output: Result = { - id: emailId, - opensByDate: [], - }; - const firstEvent = sortedOpens[0]; if (firstEvent?.opened) { @@ -60,5 +66,21 @@ async function handle(params: Params, apiClient: IApiClient) { }); } + const stats = await apiClient.get( + `/api/orgs/${orgId}/emails/${emailId}/stats` + ); + + const links = await apiClient.get( + `/api/orgs/${orgId}/emails/${emailId}/links` + ); + + links.forEach((link) => { + output.links.push({ + ...link, + clicks: stats.num_clicks_by_link[link.id] || 0, + text: '', + }); + }); + return output; } diff --git a/src/features/emails/types.ts b/src/features/emails/types.ts index f8f08eb992..0214d20575 100644 --- a/src/features/emails/types.ts +++ b/src/features/emails/types.ts @@ -191,8 +191,21 @@ export type ZetkinEmailRecipient = { status: 'pending' | 'sent' | 'opened'; }; +export type ZetkinEmailLink = { + email: { + id: number; + title: string; + }; + id: number; + tag: string; + url: string; +}; + +type EmailLinkWithMeta = ZetkinEmailLink & { clicks: number; text: string }; + export type EmailInsights = { id: number; + links: EmailLinkWithMeta[]; opensByDate: { accumulatedOpens: number; date: string; diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 274080cc29..efe844cd5c 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -1,10 +1,18 @@ import { GetServerSideProps } from 'next'; import Head from 'next/head'; -import { Paper, Typography } from '@mui/material'; +import { + Link, + Paper, + Table, + TableCell, + TableRow, + Typography, +} from '@mui/material'; import { Box, useTheme } from '@mui/system'; import { ResponsiveLine } from '@nivo/line'; import { linearGradientDef } from '@nivo/core'; import { FormattedTime } from 'react-intl'; +import { OpenInNew } from '@mui/icons-material'; import EmailLayout from 'features/emails/layout/EmailLayout'; import { PageWithLayout } from 'utils/types'; @@ -60,8 +68,9 @@ const EmailPage: PageWithLayout = () => { value={stats.numOpened} /> } + sx={{ mb: 2 }} > - + { + + } + > + + + + + {messages.insights.clicked.description()} + + + + + {(insights) => ( + + {insights.links.map((link) => ( + + {link.clicks} + {link.text} + + + {link.url} + + + + + ))} +
+ )} +
+
+
+
); }; From 3098e1e2f4cda03f5478e9bfc86a2f069a76e8c9 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 08:52:49 +0200 Subject: [PATCH 099/369] Extract link/button text from email content --- src/features/emails/rpc/getEmailInsights.ts | 30 ++++++++++++++++++- .../[campId]/emails/[emailId]/insights.tsx | 10 ++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/features/emails/rpc/getEmailInsights.ts b/src/features/emails/rpc/getEmailInsights.ts index bf22d5a646..28279ee7d6 100644 --- a/src/features/emails/rpc/getEmailInsights.ts +++ b/src/features/emails/rpc/getEmailInsights.ts @@ -3,11 +3,16 @@ import { z } from 'zod'; import IApiClient from 'core/api/client/IApiClient'; import { makeRPCDef } from 'core/rpc/types'; import { + BlockKind, EmailInsights, + InlineNodeKind, ZetkinEmailLink, ZetkinEmailRecipient, ZetkinEmailStats, } from '../types'; +import { ZetkinEmail } from 'utils/types/zetkin'; +import EmailContentTraverser from '../utils/rendering/EmailContentTraverser'; +import inlineNodesToHtml from '../utils/inlineNodesToHtml'; const paramsSchema = z.object({ emailId: z.number(), @@ -74,11 +79,34 @@ async function handle(params: Params, apiClient: IApiClient) { `/api/orgs/${orgId}/emails/${emailId}/links` ); + const email = await apiClient.get( + `/api/orgs/${orgId}/emails/${emailId}` + ); + + const linkTextByTag: Record = {}; + const traverser = new EmailContentTraverser( + JSON.parse(email.content || '{}') + ); + traverser.traverse({ + handleBlock(block) { + if (block.kind == BlockKind.BUTTON) { + linkTextByTag[block.data.tag] = block.data.text; + } + return block; + }, + handleInline(node) { + if (node.kind == InlineNodeKind.LINK) { + linkTextByTag[node.tag] = inlineNodesToHtml(node.content); + } + return node; + }, + }); + links.forEach((link) => { output.links.push({ ...link, clicks: stats.num_clicks_by_link[link.id] || 0, - text: '', + text: linkTextByTag[link.tag] || '', }); }); diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index efe844cd5c..6d558e1aeb 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -13,6 +13,7 @@ import { ResponsiveLine } from '@nivo/line'; import { linearGradientDef } from '@nivo/core'; import { FormattedTime } from 'react-intl'; import { OpenInNew } from '@mui/icons-material'; +import DOMPurify from 'dompurify'; import EmailLayout from 'features/emails/layout/EmailLayout'; import { PageWithLayout } from 'utils/types'; @@ -55,6 +56,8 @@ const EmailPage: PageWithLayout = () => { return null; } + const sanitizer = DOMPurify(); + return ( <> @@ -179,7 +182,12 @@ const EmailPage: PageWithLayout = () => { {insights.links.map((link) => ( {link.clicks} - {link.text} + + {sanitizer.sanitize(link.text, { + // Remove all inline tags that may exist here + ALLOWED_TAGS: ['#text'], + })} + Date: Thu, 4 Jul 2024 10:03:01 +0200 Subject: [PATCH 100/369] Sort links insights Clicked table --- .../[campId]/emails/[emailId]/insights.tsx | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 6d558e1aeb..3fb911e043 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -179,28 +179,31 @@ const EmailPage: PageWithLayout = () => { {(insights) => ( - {insights.links.map((link) => ( - - {link.clicks} - - {sanitizer.sanitize(link.text, { - // Remove all inline tags that may exist here - ALLOWED_TAGS: ['#text'], - })} - - - - {link.url} - - - - - ))} + {insights.links + .concat() + .sort((a, b) => b.clicks - a.clicks) + .map((link) => ( + + {link.clicks} + + {sanitizer.sanitize(link.text, { + // Remove all inline tags that may exist here + ALLOWED_TAGS: ['#text'], + })} + + + + {link.url} + + + + + ))}
)}
From 13134c649273e870e6b935a9bc40d9b1ce514688 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 10:59:26 +0200 Subject: [PATCH 101/369] Add email miniature to insights Clicked section --- src/core/rpc/index.ts | 2 + .../emails/components/EmailMiniature.tsx | 57 +++++++++++++++++ src/features/emails/rpc/renderEmail/client.ts | 14 ++++ src/features/emails/rpc/renderEmail/server.ts | 30 +++++++++ .../[campId]/emails/[emailId]/insights.tsx | 64 +++++++++++-------- 5 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 src/features/emails/components/EmailMiniature.tsx create mode 100644 src/features/emails/rpc/renderEmail/client.ts create mode 100644 src/features/emails/rpc/renderEmail/server.ts diff --git a/src/core/rpc/index.ts b/src/core/rpc/index.ts index 3027a4effe..c943d45298 100644 --- a/src/core/rpc/index.ts +++ b/src/core/rpc/index.ts @@ -16,6 +16,7 @@ import { moveParticipantsDef } from 'features/events/rpc/moveParticipants'; import { setOfficialRoleDef } from 'features/settings/rpc/setOfficialRole'; import { updateEventsDef } from 'features/events/rpc/updateEvents'; import { getEmailInsightsDef } from 'features/emails/rpc/getEmailInsights'; +import { renderEmailDef } from 'features/emails/rpc/renderEmail/server'; export function createRPCRouter() { const rpcRouter = new RPCRouter(); @@ -37,6 +38,7 @@ export function createRPCRouter() { rpcRouter.register(getOfficialMembershipsDef); rpcRouter.register(setOfficialRoleDef); rpcRouter.register(getEmailInsightsDef); + rpcRouter.register(renderEmailDef); return rpcRouter; } diff --git a/src/features/emails/components/EmailMiniature.tsx b/src/features/emails/components/EmailMiniature.tsx new file mode 100644 index 0000000000..05db127de9 --- /dev/null +++ b/src/features/emails/components/EmailMiniature.tsx @@ -0,0 +1,57 @@ +import { Box } from '@mui/material'; +import { FC, useEffect, useState } from 'react'; + +import { useApiClient } from 'core/hooks'; +import renderEmail from '../rpc/renderEmail/client'; +import ZUICleanHtml from 'zui/ZUICleanHtml'; + +type Props = { + emailId: number; + orgId: number; + width: number; +}; + +const EmailMiniature: FC = ({ emailId, orgId, width }) => { + const [html, setHtml] = useState(''); + const [height, setHeight] = useState(0); + const apiClient = useApiClient(); + + useEffect(() => { + async function load() { + const renderData = await apiClient.rpc(renderEmail, { emailId, orgId }); + setHtml(renderData.html); + } + + load(); + }); + + return ( + +
{ + if (elem) { + setHeight(0.2 * elem.clientHeight); + } + }} + style={{ + left: 0, + position: 'absolute', + top: 0, + transform: 'scale(0.2)', + transformOrigin: 'top left', + width: width / 0.2, + }} + > + ; +
+
+ ); +}; + +export default EmailMiniature; diff --git a/src/features/emails/rpc/renderEmail/client.ts b/src/features/emails/rpc/renderEmail/client.ts new file mode 100644 index 0000000000..cc185d3a8a --- /dev/null +++ b/src/features/emails/rpc/renderEmail/client.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { makeRPCDef } from 'core/rpc/types'; + +export const paramsSchema = z.object({ + emailId: z.number(), + orgId: z.number(), +}); +export type Params = z.input; +export type Result = { + html: string; +}; + +export default makeRPCDef('renderEmail'); diff --git a/src/features/emails/rpc/renderEmail/server.ts b/src/features/emails/rpc/renderEmail/server.ts new file mode 100644 index 0000000000..2d94dffa34 --- /dev/null +++ b/src/features/emails/rpc/renderEmail/server.ts @@ -0,0 +1,30 @@ +import IApiClient from 'core/api/client/IApiClient'; +import { ZetkinEmail, ZetkinUser } from 'utils/types/zetkin'; +import renderEmailHtml from '../../utils/rendering/renderEmailHtml'; +import { Params, paramsSchema } from './client'; + +export const renderEmailDef = { + handler: handle, + name: 'renderEmail', + schema: paramsSchema, +}; + +async function handle(params: Params, apiClient: IApiClient) { + const { emailId, orgId } = params; + + const user = await apiClient.get('/api/users/me'); + + const email = await apiClient.get( + `/api/orgs/${orgId}/emails/${emailId}` + ); + + const html = renderEmailHtml(email, { + 'target.first_name': user.first_name, + 'target.full_name': `${user.first_name} ${user.last_name}`, + 'target.last_name': user.last_name, + }); + + return { + html: html, + }; +} diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 3fb911e043..4e727b08e1 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -29,6 +29,7 @@ import useEmailInsights from 'features/emails/hooks/useEmailInsights'; import ZUIFuture from 'zui/ZUIFuture'; import { useMessages } from 'core/i18n'; import messageIds from 'features/emails/l10n/messageIds'; +import EmailMiniature from 'features/emails/components/EmailMiniature'; export const getServerSideProps: GetServerSideProps = scaffold( async () => { @@ -178,33 +179,42 @@ const EmailPage: PageWithLayout = () => { {(insights) => ( - - {insights.links - .concat() - .sort((a, b) => b.clicks - a.clicks) - .map((link) => ( - - {link.clicks} - - {sanitizer.sanitize(link.text, { - // Remove all inline tags that may exist here - ALLOWED_TAGS: ['#text'], - })} - - - - {link.url} - - - - - ))} -
+ + + {insights.links + .concat() + .sort((a, b) => b.clicks - a.clicks) + .map((link) => ( + + {link.clicks} + + {sanitizer.sanitize(link.text, { + // Remove all inline tags that may exist here + ALLOWED_TAGS: ['#text'], + })} + + + + {link.url} + + + + + ))} +
+ + + +
)}
From 472c7b9c67cf80c9213d0d9dbd25190e6b2cb4e7 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 12:05:57 +0200 Subject: [PATCH 102/369] Include link tag in class name when rendering emails --- src/features/emails/utils/rendering/EmailMJMLConverter.ts | 3 ++- src/features/emails/utils/rendering/renderEmailHtml.spec.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/emails/utils/rendering/EmailMJMLConverter.ts b/src/features/emails/utils/rendering/EmailMJMLConverter.ts index 2bb1e424c5..11d6bf44f8 100644 --- a/src/features/emails/utils/rendering/EmailMJMLConverter.ts +++ b/src/features/emails/utils/rendering/EmailMJMLConverter.ts @@ -38,6 +38,7 @@ export default class EmailMJMLConverter { tagName: 'mj-button', attributes: { ...frame?.block_attributes?.button, + 'css-class': `email-link-${block.data.tag}`, href: block.data.href, }, content: block.data.text, @@ -113,7 +114,7 @@ function inlineNodesToPlainHTML(nodes: EmailContentInlineNode[]): string { output += `${htmlContent}`; } else if (node.kind == 'link') { const htmlContent = inlineNodesToPlainHTML(node.content); - output += `${htmlContent}`; + output += ``; } else if (node.kind == 'lineBreak') { output += '
'; } diff --git a/src/features/emails/utils/rendering/renderEmailHtml.spec.ts b/src/features/emails/utils/rendering/renderEmailHtml.spec.ts index 1e01fa3de7..2b170cb8c7 100644 --- a/src/features/emails/utils/rendering/renderEmailHtml.spec.ts +++ b/src/features/emails/utils/rendering/renderEmailHtml.spec.ts @@ -126,11 +126,12 @@ describe('renderEmailHtml', () => { tagName: 'mj-text', attributes: {}, content: - '

We want to invite you to our PARTY, friend

', + '

We want to invite you to , friend

', }, { tagName: 'mj-button', attributes: { + 'css-class': 'email-link-thebutton', href: 'https://zetkin.org', }, content: 'I want to come', @@ -288,6 +289,7 @@ describe('renderEmailHtml', () => { tagName: 'mj-button', attributes: { 'background-color': 'green', + 'css-class': 'email-link-thebutton', 'font-size': '22px', href: 'https://zetkin.org', padding: '20px', From 387aa24d0bc69affbc0f0f5a09f98dde305766b2 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 12:07:24 +0200 Subject: [PATCH 103/369] Show links in email miniature when hovering them --- .../emails/components/EmailMiniature.tsx | 60 +++++++++++++++++-- .../[campId]/emails/[emailId]/insights.tsx | 21 ++++--- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/src/features/emails/components/EmailMiniature.tsx b/src/features/emails/components/EmailMiniature.tsx index 05db127de9..b2d0e5fde3 100644 --- a/src/features/emails/components/EmailMiniature.tsx +++ b/src/features/emails/components/EmailMiniature.tsx @@ -1,5 +1,5 @@ -import { Box } from '@mui/material'; -import { FC, useEffect, useState } from 'react'; +import { Box, useTheme } from '@mui/material'; +import { FC, useEffect, useRef, useState } from 'react'; import { useApiClient } from 'core/hooks'; import renderEmail from '../rpc/renderEmail/client'; @@ -7,14 +7,33 @@ import ZUICleanHtml from 'zui/ZUICleanHtml'; type Props = { emailId: number; + margin?: number; orgId: number; + selectedTag?: string | null; width: number; }; -const EmailMiniature: FC = ({ emailId, orgId, width }) => { +type Rect = { + height: number; + left: number; + top: number; + width: number; +}; + +const EmailMiniature: FC = ({ + emailId, + margin = 4, + orgId, + selectedTag, + width, +}) => { + const containerRef = useRef(); const [html, setHtml] = useState(''); const [height, setHeight] = useState(0); const apiClient = useApiClient(); + const [rect, setRect] = useState(null); + + const theme = useTheme(); useEffect(() => { async function load() { @@ -23,16 +42,49 @@ const EmailMiniature: FC = ({ emailId, orgId, width }) => { } load(); - }); + }, []); + + useEffect(() => { + const elem = document.querySelector(`.email-link-${selectedTag}`); + if (elem && containerRef.current) { + const containerRect = containerRef.current.getBoundingClientRect(); + const elemRect = elem.getBoundingClientRect(); + setRect({ + height: elemRect.height, + left: elemRect.left - containerRect.left, + top: elemRect.top - containerRect.top, + width: elemRect.width, + }); + } else { + setRect(null); + } + }, [selectedTag]); return ( + {rect && ( + + )}
{ if (elem) { diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 4e727b08e1..255bcdcd6c 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -14,6 +14,7 @@ import { linearGradientDef } from '@nivo/core'; import { FormattedTime } from 'react-intl'; import { OpenInNew } from '@mui/icons-material'; import DOMPurify from 'dompurify'; +import { useState } from 'react'; import EmailLayout from 'features/emails/layout/EmailLayout'; import { PageWithLayout } from 'utils/types'; @@ -50,6 +51,7 @@ const EmailPage: PageWithLayout = () => { const { data: email } = useEmail(orgId, emailId); const stats = useEmailStats(orgId, emailId); const insightsFuture = useEmailInsights(orgId, emailId); + const [selectedLinkTag, setSelectedLinkTag] = useState(null); const onServer = useServerSide(); @@ -185,7 +187,11 @@ const EmailPage: PageWithLayout = () => { .concat() .sort((a, b) => b.clicks - a.clicks) .map((link) => ( - + setSelectedLinkTag(link.tag)} + onMouseLeave={() => setSelectedLinkTag(null)} + > {link.clicks} {sanitizer.sanitize(link.text, { @@ -207,13 +213,12 @@ const EmailPage: PageWithLayout = () => { ))} - - - + )} From 713e0989216778ab0a45f36a1f9f5bf02f25a529 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 12:10:35 +0200 Subject: [PATCH 104/369] Avoid unnecessary re-renders in ZUICleanHtml --- src/zui/ZUICleanHtml.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/zui/ZUICleanHtml.tsx b/src/zui/ZUICleanHtml.tsx index d8117b6a29..826db692f5 100644 --- a/src/zui/ZUICleanHtml.tsx +++ b/src/zui/ZUICleanHtml.tsx @@ -1,6 +1,7 @@ /* eslint-disable react/no-danger */ import DOMPurify from 'isomorphic-dompurify'; import { Box, BoxProps } from '@mui/material'; +import { useMemo } from 'react'; interface ZUICleanHtmlProps { dirtyHtml: string; @@ -11,7 +12,7 @@ const ZUICleanHtml = ({ BoxProps, dirtyHtml, }: ZUICleanHtmlProps): JSX.Element => { - const cleanHtml = DOMPurify.sanitize(dirtyHtml); + const cleanHtml = useMemo(() => DOMPurify.sanitize(dirtyHtml), [dirtyHtml]); return ; }; From 3463d256172ab764865b7bb2d110aa8b9300c2a5 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 14:14:07 +0200 Subject: [PATCH 105/369] Create ZUIDuration component to render a duration of time --- src/zui/ZUIDuration/index.stories.tsx | 29 +++++++++++++ src/zui/ZUIDuration/index.tsx | 60 +++++++++++++++++++++++++++ src/zui/l10n/messageIds.ts | 7 ++++ 3 files changed, 96 insertions(+) create mode 100644 src/zui/ZUIDuration/index.stories.tsx create mode 100644 src/zui/ZUIDuration/index.tsx diff --git a/src/zui/ZUIDuration/index.stories.tsx b/src/zui/ZUIDuration/index.stories.tsx new file mode 100644 index 0000000000..51049028f1 --- /dev/null +++ b/src/zui/ZUIDuration/index.stories.tsx @@ -0,0 +1,29 @@ +import { Meta, StoryFn } from '@storybook/react'; + +import ZUIDuration from '.'; + +export default { + component: ZUIDuration, + title: 'Atoms/ZUIDuration', +} as Meta; + +const Template: StoryFn = (args) => ( + +); + +export const basic = Template.bind({}); +basic.args = { + enableDays: true, + enableHours: true, + enableMilliseconds: false, + enableMinutes: true, + enableSeconds: false, + seconds: 12345, +}; diff --git a/src/zui/ZUIDuration/index.tsx b/src/zui/ZUIDuration/index.tsx new file mode 100644 index 0000000000..b07092315e --- /dev/null +++ b/src/zui/ZUIDuration/index.tsx @@ -0,0 +1,60 @@ +import { FC } from 'react'; + +import messageIds from 'zui/l10n/messageIds'; +import { Msg } from 'core/i18n'; + +type Props = { + enableDays?: boolean; + enableHours?: boolean; + enableMilliseconds?: boolean; + enableMinutes?: boolean; + enableSeconds?: boolean; + seconds: number; +}; + +type DurField = { + msgId: keyof typeof messageIds.duration; + n: number; + visible: boolean; +}; + +const ZUIDuration: FC = ({ + enableDays = true, + enableHours = true, + enableMilliseconds = false, + enableMinutes = true, + enableSeconds = false, + seconds, +}) => { + const ms = (seconds * 1000) % 1000; + const s = Math.floor(seconds) % 60; + const m = Math.floor(seconds / 60) % 60; + const h = Math.floor(seconds / 60 / 60) % 24; + const days = Math.floor(seconds / 60 / 60 / 24); + + const fields: DurField[] = [ + { msgId: 'days', n: days, visible: enableDays }, + { msgId: 'h', n: h, visible: enableHours }, + { msgId: 'm', n: m, visible: enableMinutes }, + { msgId: 's', n: s, visible: enableSeconds }, + { msgId: 'ms', n: ms, visible: enableMilliseconds }, + ]; + + return ( + <> + {fields + .filter((field) => field.visible) + .filter((field) => field.n > 0) + .map((field) => ( + + + + ))} + + ); +}; + +export default ZUIDuration; diff --git a/src/zui/l10n/messageIds.ts b/src/zui/l10n/messageIds.ts index 50567b5f3a..09cbfcae8a 100644 --- a/src/zui/l10n/messageIds.ts +++ b/src/zui/l10n/messageIds.ts @@ -104,6 +104,13 @@ export default makeMessages('zui', { }>('{date}'), singleDayToday: m('Today'), }, + duration: { + days: m<{ n: number }>('{n, plural, =1 {1 day} other {# days}}'), + h: m<{ n: number }>('{n}h'), + m: m<{ n: number }>('{n}m'), + ms: m<{ n: number }>('{n}ms'), + s: m<{ n: number }>('{n}s'), + }, editTextInPlace: { tooltip: { edit: m('Click to edit'), From 52df7890b6f74528766ed5bc57bb9b5de5fcbfaa Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 14:15:04 +0200 Subject: [PATCH 106/369] Improve rendering of chart and overlay --- src/features/emails/l10n/messageIds.ts | 4 ++ .../[campId]/emails/[emailId]/insights.tsx | 56 +++++++++++++++++-- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index da550b0ab2..fb8ae8d909 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -134,6 +134,10 @@ export default makeMessages('feat.emails', { header: m('Clicked'), }, opened: { + chart: { + afterSend: m('After it was sent'), + opened: m<{ count: number }>('{count} opened'), + }, description: m( 'Open rate is the percentage of recipients who opened the email. A high rate is an indicator of good targeting and a compelling subject line.' ), diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 255bcdcd6c..6248afaaec 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -1,6 +1,7 @@ import { GetServerSideProps } from 'next'; import Head from 'next/head'; import { + Divider, Link, Paper, Table, @@ -11,10 +12,10 @@ import { import { Box, useTheme } from '@mui/system'; import { ResponsiveLine } from '@nivo/line'; import { linearGradientDef } from '@nivo/core'; -import { FormattedTime } from 'react-intl'; import { OpenInNew } from '@mui/icons-material'; import DOMPurify from 'dompurify'; import { useState } from 'react'; +import { FormattedDate } from 'react-intl'; import EmailLayout from 'features/emails/layout/EmailLayout'; import { PageWithLayout } from 'utils/types'; @@ -28,9 +29,10 @@ import ZUINumberChip from 'zui/ZUINumberChip'; import EmailKPIChart from 'features/emails/components/EmailKPIChart'; import useEmailInsights from 'features/emails/hooks/useEmailInsights'; import ZUIFuture from 'zui/ZUIFuture'; -import { useMessages } from 'core/i18n'; +import { Msg, useMessages } from 'core/i18n'; import messageIds from 'features/emails/l10n/messageIds'; import EmailMiniature from 'features/emails/components/EmailMiniature'; +import ZUIDuration from 'zui/ZUIDuration'; export const getServerSideProps: GetServerSideProps = scaffold( async () => { @@ -96,13 +98,16 @@ const EmailPage: PageWithLayout = () => { axisBottom={{ format: '%b %d', }} + axisLeft={{ + format: (val) => Math.round(val * 100) + '%', + }} colors={[theme.palette.primary.main]} curve="basis" data={[ { data: insights.opensByDate.map((openEvent) => ({ x: new Date(openEvent.date), - y: openEvent.accumulatedOpens, + y: openEvent.accumulatedOpens / stats.numSent, })), id: email.title || '', }, @@ -133,14 +138,53 @@ const EmailPage: PageWithLayout = () => { sliceTooltip={(props) => { const dataPoint = props.slice.points[0]; const date = new Date(dataPoint.data.xFormatted); + const publishDate = new Date(email.published || 0); + const index = props.slice.points[0].index; + const count = insights.opensByDate[index].accumulatedOpens; return ( - - + + - + + + + + + + + + + + + + + + + + {Math.round((count / stats.numSent) * 100)}% + + + ); }} From 52b359d611c636ce397b1655b60485c1875974c5 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 15:45:46 +0200 Subject: [PATCH 107/369] Add configurable time spans for open chart --- src/features/emails/l10n/messageIds.ts | 7 ++ .../[campId]/emails/[emailId]/insights.tsx | 101 ++++++++++++++---- 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index fb8ae8d909..b25f4f7e1f 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -137,6 +137,13 @@ export default makeMessages('feat.emails', { chart: { afterSend: m('After it was sent'), opened: m<{ count: number }>('{count} opened'), + spans: { + allTime: m('All time'), + first24: m('First 24 hours'), + first48: m('First 48 hours'), + firstMonth: m('First month'), + firstWeek: m('First week'), + }, }, description: m( 'Open rate is the percentage of recipients who opened the email. A high rate is an indicator of good targeting and a compelling subject line.' diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 6248afaaec..9916decd99 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -1,15 +1,19 @@ import { GetServerSideProps } from 'next'; import Head from 'next/head'; import { + Box, Divider, Link, + MenuItem, Paper, Table, TableCell, TableRow, + TextField, Typography, + useTheme, } from '@mui/material'; -import { Box, useTheme } from '@mui/system'; +import { AxisProps } from '@nivo/axes'; import { ResponsiveLine } from '@nivo/line'; import { linearGradientDef } from '@nivo/core'; import { OpenInNew } from '@mui/icons-material'; @@ -46,7 +50,38 @@ export const getServerSideProps: GetServerSideProps = scaffold( } ); +const HOURS_BY_SPAN: Record = { + first24: 24, + first48: 48, + firstMonth: 24 * 30, + firstWeek: 24 * 7, +}; + +function hoursFromSpanValue(value: string): number | undefined { + return HOURS_BY_SPAN[value]; +} + +function axisFromSpanValue(value: string): AxisProps { + if (value == 'first24' || value == 'first48') { + const output: number[] = []; + for (let i = 0; i < 48; i += 4) { + output.push(i * 60 * 60); + } + + return { + format: (val) => Math.round(val / 60 / 60), + tickValues: output, + }; + } else { + return { + format: (val) => Math.round(val / 60 / 60 / 24), + tickValues: value == 'firstWeek' ? 7 : 15, + }; + } +} + const EmailPage: PageWithLayout = () => { + const [timeSpan, setTimeSpan] = useState('first48'); const messages = useMessages(messageIds); const { orgId, emailId } = useNumericRouteParams(); const theme = useTheme(); @@ -63,6 +98,8 @@ const EmailPage: PageWithLayout = () => { const sanitizer = DOMPurify(); + const timeSpanHours = hoursFromSpanValue(timeSpan); + return ( <> @@ -90,14 +127,44 @@ const EmailPage: PageWithLayout = () => { {messages.insights.opened.description()} - + + + setTimeSpan(ev.target.value)} + select + size="small" + value={timeSpan} + > + + + + + + + + + + + + + + + + + {(insights) => ( Math.round(val * 100) + '%', }} @@ -106,7 +173,10 @@ const EmailPage: PageWithLayout = () => { data={[ { data: insights.opensByDate.map((openEvent) => ({ - x: new Date(openEvent.date), + x: + (new Date(openEvent.date).getTime() - + new Date(insights.opensByDate[0].date).getTime()) / + 1000, y: openEvent.accumulatedOpens / stats.numSent, })), id: email.title || '', @@ -133,24 +203,22 @@ const EmailPage: PageWithLayout = () => { // inside the clipping rectangle. left: 15 + insights.opensByDate.length.toString().length * 8, + right: 8, top: 20, }} sliceTooltip={(props) => { - const dataPoint = props.slice.points[0]; - const date = new Date(dataPoint.data.xFormatted); const publishDate = new Date(email.published || 0); const index = props.slice.points[0].index; const count = insights.opensByDate[index].accumulatedOpens; + const date = new Date(insights.opensByDate[index].date); + const secondsAfterPublish = + (date.getTime() - publishDate.getTime()) / 1000; return ( - + { ); }} - xFormat="time:%Y-%m-%d %H:%M:%S.%L" xScale={{ - format: '%Y-%m-%d %H:%M:%S.%L', - precision: 'minute', - type: 'time', - useUTC: false, + max: timeSpanHours ? timeSpanHours * 60 * 60 : undefined, + type: 'linear', }} /> )} From 0b1e4223380b3e0759314b79f41104b6b3d158b0 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 19:53:53 +0200 Subject: [PATCH 108/369] Create hook to (conditionally) load insights for secondary email --- .../emails/hooks/useSecondaryEmailInsights.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/features/emails/hooks/useSecondaryEmailInsights.ts diff --git a/src/features/emails/hooks/useSecondaryEmailInsights.ts b/src/features/emails/hooks/useSecondaryEmailInsights.ts new file mode 100644 index 0000000000..e9e25e2798 --- /dev/null +++ b/src/features/emails/hooks/useSecondaryEmailInsights.ts @@ -0,0 +1,71 @@ +import { IFuture, ResolvedFuture } from 'core/caching/futures'; +import { EmailInsights, ZetkinEmailStats } from '../types'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { loadItemIfNecessary } from 'core/caching/cacheUtils'; +import { + emailLoad, + emailLoaded, + insightsLoad, + insightsLoaded, + statsLoad, + statsLoaded, +} from '../store'; +import getEmailInsights from '../rpc/getEmailInsights'; +import { ZetkinEmail } from 'utils/types/zetkin'; + +type UseSecondaryEmailInsightsReturn = { + emailFuture: IFuture; + insightsFuture: IFuture; + statsFuture: IFuture; +}; + +export default function useSecondaryEmailInsights( + orgId: number, + emailId: number | null +): UseSecondaryEmailInsightsReturn { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const state = useAppSelector((state) => state.emails); + + if (!emailId) { + return { + emailFuture: new ResolvedFuture(null), + insightsFuture: new ResolvedFuture(null), + statsFuture: new ResolvedFuture(null), + }; + } + + const emailItem = state.emailList.items.find((item) => item.id == emailId); + const emailFuture = loadItemIfNecessary(emailItem, dispatch, { + actionOnLoad: () => emailLoad(emailId), + actionOnSuccess: (email) => emailLoaded(email), + loader: () => apiClient.get(`/api/orgs/${orgId}/emails/${emailId}`), + }); + + const insightsFuture = loadItemIfNecessary( + state.insightsByEmailId[emailId], + dispatch, + { + actionOnLoad: () => insightsLoad(emailId), + actionOnSuccess: (data) => insightsLoaded(data), + loader: () => apiClient.rpc(getEmailInsights, { emailId, orgId }), + } + ); + + const statsFuture = loadItemIfNecessary(state.statsById[emailId], dispatch, { + actionOnLoad: () => statsLoad(emailId), + actionOnSuccess: (data) => statsLoaded(data), + loader: async () => { + const data = await apiClient.get( + `/api/orgs/${orgId}/emails/${emailId}/stats` + ); + return { ...data, id: emailId }; + }, + }); + + return { + emailFuture, + insightsFuture, + statsFuture, + }; +} From c6ae6cc6eca4023191c834ec46a069b67871e947 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 19:54:29 +0200 Subject: [PATCH 109/369] Add email comparison to insights opened chart --- .../utils/getRelevantDataPoints.spec.ts | 180 ++++++++++ .../emails/utils/getRelevantDataPoints.ts | 75 ++++ .../[campId]/emails/[emailId]/insights.tsx | 327 +++++++++++++----- 3 files changed, 487 insertions(+), 95 deletions(-) create mode 100644 src/features/emails/utils/getRelevantDataPoints.spec.ts create mode 100644 src/features/emails/utils/getRelevantDataPoints.ts diff --git a/src/features/emails/utils/getRelevantDataPoints.spec.ts b/src/features/emails/utils/getRelevantDataPoints.spec.ts new file mode 100644 index 0000000000..495444fef1 --- /dev/null +++ b/src/features/emails/utils/getRelevantDataPoints.spec.ts @@ -0,0 +1,180 @@ +import getRelevantDataPoints from './getRelevantDataPoints'; + +describe('getRelevantDataPoints()', () => { + it('returns exact point from main if secondary is null', () => { + const input = { + id: 'main.1', + }; + + const mainSeries = { + startDate: new Date('1857-07-05T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1857-07-05T13:37:00.000Z', + }, + { + accumulatedOpens: 125, + date: '1857-07-05T13:38:00.000Z', + }, + ], + }; + + const result = getRelevantDataPoints(input, mainSeries, null); + + expect(result.secondaryPoint).toBeNull(); + expect(result.mainPoint).toEqual(mainSeries.values[1]); + }); + + it('returns nearest preceding from secondary for main input', () => { + const input = { + id: 'main.1', + }; + + const mainSeries = { + startDate: new Date('1857-07-05T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1857-07-05T13:37:00.000Z', + }, + { + accumulatedOpens: 125, + date: '1857-07-05T13:38:00.000Z', + }, + ], + }; + + const secondarySeries = { + startDate: new Date('1933-06-20T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1933-06-20T13:37:00.000Z', + }, + { + accumulatedOpens: 125, + date: '1933-06-20T13:39:00.000Z', + }, + ], + }; + + const result = getRelevantDataPoints(input, mainSeries, secondarySeries); + + expect(result.mainPoint).toEqual(mainSeries.values[1]); + expect(result.secondaryPoint).toEqual(secondarySeries.values[0]); + }); + + it('returns nearest preceding from secondary when main is in the past', () => { + const input = { + id: 'main.0', + }; + + const secondarySeries = { + startDate: new Date('1857-07-05T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1857-07-05T13:37:00.000Z', + }, + { + accumulatedOpens: 125, + date: '1857-07-05T13:38:00.000Z', + }, + ], + }; + + const mainSeries = { + startDate: new Date('1933-06-20T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1933-06-20T13:37:00.000Z', + }, + { + accumulatedOpens: 125, + date: '1933-06-20T13:39:00.000Z', + }, + ], + }; + + const result = getRelevantDataPoints(input, mainSeries, secondarySeries); + + expect(result.mainPoint).toEqual(mainSeries.values[0]); + expect(result.secondaryPoint).toEqual(secondarySeries.values[0]); + }); + + it('returns nearest preceding from main for secondary input', () => { + const input = { + id: 'secondary.1', + }; + + const secondarySeries = { + startDate: new Date('1933-06-20T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1933-06-20T13:37:00.000Z', + }, + { + accumulatedOpens: 125, + date: '1933-06-20T13:38:00.000Z', + }, + ], + }; + + const mainSeries = { + startDate: new Date('1857-07-05T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1857-07-05T13:37:00.000Z', + }, + { + accumulatedOpens: 125, + date: '1857-07-05T13:39:00.000Z', + }, + ], + }; + + const result = getRelevantDataPoints(input, mainSeries, secondarySeries); + + expect(result.secondaryPoint).toEqual(secondarySeries.values[1]); + expect(result.mainPoint).toEqual(mainSeries.values[0]); + }); + + it('returns last from secondary if main exceeds duration', () => { + const input = { + id: 'main.1', + }; + + const mainSeries = { + startDate: new Date('1857-07-05T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1857-07-05T13:37:00.000Z', + }, + { + accumulatedOpens: 125, + date: '1857-07-05T14:37:00.000Z', // 1 hour later + }, + ], + }; + + const secondarySeries = { + startDate: new Date('1933-06-20T13:37:00.000Z'), + values: [ + { + accumulatedOpens: 123, + date: '1933-06-20T13:37:00.000Z', + }, + ], + }; + + const result = getRelevantDataPoints(input, mainSeries, secondarySeries); + + expect(result.mainPoint).toEqual(mainSeries.values[1]); + expect(result.secondaryPoint).toEqual(secondarySeries.values[0]); + }); +}); diff --git a/src/features/emails/utils/getRelevantDataPoints.ts b/src/features/emails/utils/getRelevantDataPoints.ts new file mode 100644 index 0000000000..a8f8532936 --- /dev/null +++ b/src/features/emails/utils/getRelevantDataPoints.ts @@ -0,0 +1,75 @@ +import { EmailInsights } from '../types'; + +type InputPoint = { + id: string; +}; + +type OutputPoint = EmailInsights['opensByDate'][0]; + +type GetRelevantDataPoints = { + mainPoint: OutputPoint; + secondaryPoint: OutputPoint | null; +}; + +type Series = { + startDate: Date; + values: OutputPoint[]; +}; + +export default function getRelevantDataPoints( + inputPoint: InputPoint, + main: Series, + secondary: Series | null +): GetRelevantDataPoints { + const [serieId, indexStr] = inputPoint.id.split('.'); + const index = parseInt(indexStr); + if (serieId == 'main') { + const mainPoint = main.values[index]; + const mainDate = new Date(mainPoint.date); + const mainOffset = mainDate.getTime() - main.startDate.getTime(); + + const secondaryPoint = + secondary?.values.find((_, index) => { + const nextPoint = secondary.values[index + 1]; + if (!nextPoint) { + return true; + } + + const nextOffset = + new Date(nextPoint.date).getTime() - secondary.startDate.getTime(); + + return nextOffset > mainOffset; + }) ?? null; + + return { + mainPoint: mainPoint, + secondaryPoint: secondaryPoint, + }; + } else if (secondary) { + const secondaryPoint = secondary.values[index]; + const secondaryDate = new Date(secondaryPoint.date); + const secondaryOffset = + secondaryDate.getTime() - secondary.startDate.getTime(); + + const mainPoint = main.values.find((item, index) => { + const nextPoint = main.values[index + 1]; + if (!nextPoint) { + return true; + } + + const nextOffset = + new Date(nextPoint.date).getTime() - main.startDate.getTime(); + + return nextOffset > secondaryOffset; + }); + + return { + mainPoint: mainPoint!, + secondaryPoint, + }; + } else { + throw new Error( + 'Secondary series must be supplied when input point is from secondary' + ); + } +} diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 9916decd99..32ca645df8 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -37,6 +37,10 @@ import { Msg, useMessages } from 'core/i18n'; import messageIds from 'features/emails/l10n/messageIds'; import EmailMiniature from 'features/emails/components/EmailMiniature'; import ZUIDuration from 'zui/ZUIDuration'; +import ZUIFutures from 'zui/ZUIFutures'; +import useSecondaryEmailInsights from 'features/emails/hooks/useSecondaryEmailInsights'; +import getRelevantDataPoints from 'features/emails/utils/getRelevantDataPoints'; +import useEmails from 'features/emails/hooks/useEmails'; export const getServerSideProps: GetServerSideProps = scaffold( async () => { @@ -81,6 +85,7 @@ function axisFromSpanValue(value: string): AxisProps { } const EmailPage: PageWithLayout = () => { + const [secondaryEmailId, setSecondaryEmailId] = useState(0); const [timeSpan, setTimeSpan] = useState('first48'); const messages = useMessages(messageIds); const { orgId, emailId } = useNumericRouteParams(); @@ -89,6 +94,12 @@ const EmailPage: PageWithLayout = () => { const stats = useEmailStats(orgId, emailId); const insightsFuture = useEmailInsights(orgId, emailId); const [selectedLinkTag, setSelectedLinkTag] = useState(null); + const emailsFuture = useEmails(orgId); + const { + emailFuture: secondaryEmailFuture, + insightsFuture: secondaryInsightsFuture, + statsFuture: secondaryStatsFuture, + } = useSecondaryEmailInsights(orgId, secondaryEmailId); const onServer = useServerSide(); @@ -105,6 +116,30 @@ const EmailPage: PageWithLayout = () => { {email.title} + + + {(emails) => ( + + setSecondaryEmailId(parseInt(ev.target.value) || 0) + } + select + size="small" + value={secondaryEmailId} + > + Nothing + {emails + .filter((email) => email.id != emailId) + .map((email) => ( + + {email.title} + + ))} + + )} + + { - - {(insights) => ( - Math.round(val * 100) + '%', - }} - colors={[theme.palette.primary.main]} - curve="basis" - data={[ - { - data: insights.opensByDate.map((openEvent) => ({ - x: - (new Date(openEvent.date).getTime() - - new Date(insights.opensByDate[0].date).getTime()) / - 1000, - y: openEvent.accumulatedOpens / stats.numSent, - })), - id: email.title || '', - }, - ]} - defs={[ - linearGradientDef('gradientA', [ - { color: 'inherit', offset: 0 }, - { color: 'inherit', offset: 100, opacity: 0 }, - ]), - ]} - enableArea={true} - enableGridX={false} - enableGridY={false} - enablePoints={false} - enableSlices="x" - fill={[{ id: 'gradientA', match: '*' }]} - isInteractive={true} - lineWidth={3} - margin={{ - bottom: 30, - // Calculate the left margin from the number of digits - // in the y axis labels, to make sure the labels will fit - // inside the clipping rectangle. - left: - 15 + insights.opensByDate.length.toString().length * 8, - right: 8, - top: 20, - }} - sliceTooltip={(props) => { - const publishDate = new Date(email.published || 0); - const index = props.slice.points[0].index; - const count = insights.opensByDate[index].accumulatedOpens; - const date = new Date(insights.opensByDate[index].date); - const secondsAfterPublish = - (date.getTime() - publishDate.getTime()) / 1000; + + {({ + data: { + mainInsights, + secondaryEmail, + secondaryInsights, + secondaryStats, + }, + }) => { + const lineData = [ + { + data: mainInsights.opensByDate.map((openEvent) => ({ + x: + (new Date(openEvent.date).getTime() - + new Date( + mainInsights.opensByDate[0].date + ).getTime()) / + 1000, + y: openEvent.accumulatedOpens / stats.numSent, + })), + id: 'main', + }, + ]; - return ( - - - - - - - - - - - - - - + if (secondaryInsights && secondaryStats) { + lineData.push({ + data: secondaryInsights.opensByDate.map((openEvent) => ({ + x: + (new Date(openEvent.date).getTime() - + new Date( + secondaryInsights.opensByDate[0].date + ).getTime()) / + 1000, + y: openEvent.accumulatedOpens / secondaryStats.num_sent, + })), + id: 'secondary', + }); + } + + return ( + Math.round(val * 100) + '%', + }} + colors={[ + theme.palette.primary.main, + theme.palette.secondary.dark, + ]} + curve="basis" + data={lineData} + defs={[ + linearGradientDef('gradientA', [ + { color: 'inherit', offset: 0 }, + { color: 'inherit', offset: 100, opacity: 0 }, + ]), + linearGradientDef('transparent', [ + { color: 'white', offset: 0, opacity: 0 }, + { color: 'white', offset: 100, opacity: 0 }, + ]), + ]} + enableArea={true} + enableGridX={false} + enableGridY={false} + enablePoints={false} + enableSlices="x" + fill={[ + { id: 'gradientA', match: { id: 'main' } }, + { id: 'transparent', match: { id: 'secondary' } }, + ]} + isInteractive={true} + lineWidth={3} + margin={{ + bottom: 30, + // Calculate the left margin from the number of digits + // in the y axis labels, to make sure the labels will fit + // inside the clipping rectangle. + left: + 15 + + mainInsights.opensByDate.length.toString().length * 8, + right: 8, + top: 20, + }} + sliceTooltip={(props) => { + const publishDate = new Date(email?.published || 0); + const { mainPoint, secondaryPoint } = + getRelevantDataPoints( + props.slice.points[0], + { + startDate: new Date(email.published!), + values: mainInsights.opensByDate, + }, + secondaryInsights && secondaryEmail?.published + ? { + startDate: new Date(secondaryEmail.published), + values: secondaryInsights.opensByDate, + } + : null + ); + const count = mainPoint.accumulatedOpens; + const date = new Date(mainPoint.date); + + const secondsAfterPublish = + (date.getTime() - publishDate.getTime()) / 1000; + + return ( + + + + - - - {Math.round((count / stats.numSent) * 100)}% - + + + + + + + + + + + + + + {Math.round((count / stats.numSent) * 100)}% + + + + {secondaryPoint && secondaryStats && ( + <> + + + + + + + + + + + + + {Math.round( + (secondaryPoint.accumulatedOpens / + secondaryStats.num_sent) * + 100 + )} + % + + + + + )} - - - ); - }} - xScale={{ - max: timeSpanHours ? timeSpanHours * 60 * 60 : undefined, - type: 'linear', - }} - /> - )} - + + ); + }} + xScale={{ + max: timeSpanHours ? timeSpanHours * 60 * 60 : undefined, + type: 'linear', + }} + /> + ); + }} + From 2fdd0dd48d635e05cd21bda72aa9bb62dcf146e9 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 4 Jul 2024 20:55:45 +0200 Subject: [PATCH 110/369] Implement comparison in KPI charts --- .../emails/components/EmailKPIChart.tsx | 56 +++++++++++++-- src/features/emails/l10n/messageIds.ts | 4 ++ .../[campId]/emails/[emailId]/insights.tsx | 70 +++++++++++++------ 3 files changed, 104 insertions(+), 26 deletions(-) diff --git a/src/features/emails/components/EmailKPIChart.tsx b/src/features/emails/components/EmailKPIChart.tsx index 863ab88a7e..06935ac9f3 100644 --- a/src/features/emails/components/EmailKPIChart.tsx +++ b/src/features/emails/components/EmailKPIChart.tsx @@ -6,12 +6,23 @@ import { ZetkinEmail } from 'utils/types/zetkin'; type Props = { email: ZetkinEmail; + secondaryEmail?: ZetkinEmail | null; + secondaryTotal?: number | null; + secondaryValue?: number | null; title: string; total: number; value: number; }; -const EmailKPIChart: FC = ({ email, title, total, value }) => { +const EmailKPIChart: FC = ({ + email, + secondaryEmail, + secondaryTotal, + secondaryValue, + title, + total, + value, +}) => { const theme = useTheme(); const data = [ @@ -19,18 +30,46 @@ const EmailKPIChart: FC = ({ email, title, total, value }) => { data: [ { x: 'Opened', - y: value, + y: value / total, }, { x: 'Opened 2', y: 0, }, + { + x: 'Other', + y: 1 - value / total, + }, ], id: email.title || '', }, ]; + if (secondaryEmail && secondaryTotal && secondaryValue) { + data.push({ + data: [ + { + x: 'Opened', + y: 0, + }, + { + x: 'Opened 2', + y: secondaryValue / secondaryTotal, + }, + { + x: 'Other', + y: 1 - secondaryValue / secondaryTotal, + }, + ], + id: secondaryEmail.title || '', + }); + } + const percentage = Math.round((value / total) * 100); + const secondaryPercentage = + secondaryValue && secondaryTotal + ? Math.round((secondaryValue / secondaryTotal) * 100) + : -1; return ( = ({ email, title, total, value }) => { > {title} {percentage + '%'} - + + {secondaryPercentage >= 0 && + secondaryPercentage.toString().concat('%')} + diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index b25f4f7e1f..4a6626f01b 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -133,6 +133,10 @@ export default makeMessages('feat.emails', { }, header: m('Clicked'), }, + comparison: { + label: m('Compare with'), + noneOption: m('(No other email)'), + }, opened: { chart: { afterSend: m('After it was sent'), diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 32ca645df8..116bde9f41 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -120,7 +120,7 @@ const EmailPage: PageWithLayout = () => { {(emails) => ( setSecondaryEmailId(parseInt(ev.target.value) || 0) } @@ -128,7 +128,9 @@ const EmailPage: PageWithLayout = () => { size="small" value={secondaryEmailId} > - Nothing + + {messages.insights.comparison.noneOption()} + {emails .filter((email) => email.id != emailId) .map((email) => ( @@ -152,15 +154,29 @@ const EmailPage: PageWithLayout = () => { > - - - {messages.insights.opened.description()} - + + {({ data: { secondaryEmail, secondaryStats } }) => ( + <> + + + {messages.insights.opened.description()} + + + )} + { > - - - {messages.insights.clicked.description()} - + + {({ data: { secondaryEmail, secondaryStats } }) => ( + <> + + + {messages.insights.clicked.description()} + + + )} + From 8e9202bf17f6d1220b0fd8b455873c47cea7273d Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 5 Jul 2024 10:10:28 +0200 Subject: [PATCH 111/369] Use Autocomplete to pick comparison email --- src/features/emails/l10n/messageIds.ts | 1 - .../[campId]/emails/[emailId]/insights.tsx | 70 ++++++++++++++----- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index 4a6626f01b..b9402d73e0 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -135,7 +135,6 @@ export default makeMessages('feat.emails', { }, comparison: { label: m('Compare with'), - noneOption: m('(No other email)'), }, opened: { chart: { diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 116bde9f41..5939cb9e00 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -1,9 +1,11 @@ import { GetServerSideProps } from 'next'; import Head from 'next/head'; import { + Autocomplete, Box, Divider, Link, + ListItem, MenuItem, Paper, Table, @@ -119,26 +121,56 @@ const EmailPage: PageWithLayout = () => { {(emails) => ( - - setSecondaryEmailId(parseInt(ev.target.value) || 0) + + options.filter( + (email) => + email.title + ?.toLowerCase() + .includes(state.inputValue.toLowerCase()) || + email.campaign?.title + .toLowerCase() + .includes(state.inputValue.toLowerCase()) + ) } - select - size="small" - value={secondaryEmailId} - > - - {messages.insights.comparison.noneOption()} - - {emails - .filter((email) => email.id != emailId) - .map((email) => ( - - {email.title} - - ))} - + getOptionLabel={(option) => option.title || ''} + onChange={(_, value) => setSecondaryEmailId(value?.id ?? 0)} + onReset={() => setSecondaryEmailId(0)} + options={emails.filter( + // Can only compare with published emails, and not itself + (email) => email.id != emailId && email.published + )} + renderInput={(params) => ( + + )} + renderOption={(props, option) => ( + + + {option.title} + + {option.campaign?.title} + + + + )} + sx={{ + minWidth: 300, + }} + value={ + emails.find((email) => email.id == secondaryEmailId) || null + } + /> )} From 80be136680c875ea9a003352b088acdcc9391a8d Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 5 Jul 2024 11:44:28 +0200 Subject: [PATCH 112/369] Include time in hover card over opened chart --- .../[campId]/emails/[emailId]/insights.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 5939cb9e00..0539fbdd02 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -21,7 +21,7 @@ import { linearGradientDef } from '@nivo/core'; import { OpenInNew } from '@mui/icons-material'; import DOMPurify from 'dompurify'; import { useState } from 'react'; -import { FormattedDate } from 'react-intl'; +import { FormattedTime } from 'react-intl'; import EmailLayout from 'features/emails/layout/EmailLayout'; import { PageWithLayout } from 'utils/types'; @@ -357,7 +357,7 @@ const EmailPage: PageWithLayout = () => { (date.getTime() - publishDate.getTime()) / 1000; return ( - + @@ -378,7 +378,11 @@ const EmailPage: PageWithLayout = () => { > - + { > - From 1822b336f1ea09e0771bab30c526518acbf89431 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 5 Jul 2024 14:52:51 +0200 Subject: [PATCH 113/369] Add CTOR metric to Clicked section --- src/features/emails/l10n/messageIds.ts | 20 ++++++++--- .../[campId]/emails/[emailId]/insights.tsx | 36 ++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index b9402d73e0..e2aee56637 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -125,13 +125,25 @@ export default makeMessages('feat.emails', { }, insights: { clicked: { - description: m( - 'Click rate is the percentage of recipients who not only opened the email but also clicked one of the links. A high rate is an indicator of a well-targeted email with convincing copy.' - ), + descriptions: { + ctor: m( + 'Click-to-open rate (CTOR) is the share of those who have opened the email that also clicked one of the links. A high rate is an indicator of a well-targeted email with convincing copy.' + ), + ctr: m( + 'Clickthrough rate (CTR) is the percentage of recipients who not only opened the email but also clicked one of the links. A high rate is an indicator of a well-targeted email with convincing copy.' + ), + }, gauge: { - header: m('Click rate'), + headers: { + ctor: m('CTOR'), + ctr: m('CTR'), + }, }, header: m('Clicked'), + metrics: { + ctor: m('CTOR'), + ctr: m('CTR'), + }, }, comparison: { label: m('Compare with'), diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 0539fbdd02..30186f6909 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -12,6 +12,8 @@ import { TableCell, TableRow, TextField, + ToggleButton, + ToggleButtonGroup, Typography, useTheme, } from '@mui/material'; @@ -87,6 +89,7 @@ function axisFromSpanValue(value: string): AxisProps { } const EmailPage: PageWithLayout = () => { + const [clickMetric, setClickMetric] = useState<'ctr' | 'ctor'>('ctr'); const [secondaryEmailId, setSecondaryEmailId] = useState(0); const [timeSpan, setTimeSpan] = useState('first48'); const messages = useMessages(messageIds); @@ -479,14 +482,39 @@ const EmailPage: PageWithLayout = () => { + + + setClickMetric(value || clickMetric) + } + size="small" + value={clickMetric} + > + + {messages.insights.clicked.metrics.ctr()} + + + {messages.insights.clicked.metrics.ctor()} + + + - {messages.insights.clicked.description()} + {messages.insights.clicked.descriptions[clickMetric]()} )} From a3d56f9f46fdc51764b514b65cdd806372286085 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 5 Jul 2024 15:00:13 +0200 Subject: [PATCH 114/369] Add date to comparison dropdown --- .../[campId]/emails/[emailId]/insights.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 30186f6909..b29016a980 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -23,7 +23,7 @@ import { linearGradientDef } from '@nivo/core'; import { OpenInNew } from '@mui/icons-material'; import DOMPurify from 'dompurify'; import { useState } from 'react'; -import { FormattedTime } from 'react-intl'; +import { FormattedDate, FormattedTime } from 'react-intl'; import EmailLayout from 'features/emails/layout/EmailLayout'; import { PageWithLayout } from 'utils/types'; @@ -160,10 +160,19 @@ const EmailPage: PageWithLayout = () => { flexDirection: 'column', }} > - {option.title} - - {option.campaign?.title} - + + {option.title} + + + + {option.published && ( + + )} + + + {option.campaign?.title} + + )} From 9b546e585db8daf48651ea196c3b4ec085f33f27 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 5 Jul 2024 15:21:46 +0200 Subject: [PATCH 115/369] Tweak colors and hover card for charts --- .../emails/components/EmailKPIChart.tsx | 48 +++++++++++++++---- .../[campId]/emails/[emailId]/insights.tsx | 2 +- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/features/emails/components/EmailKPIChart.tsx b/src/features/emails/components/EmailKPIChart.tsx index 06935ac9f3..e577455e03 100644 --- a/src/features/emails/components/EmailKPIChart.tsx +++ b/src/features/emails/components/EmailKPIChart.tsx @@ -1,7 +1,8 @@ -import { Box, Typography, useTheme } from '@mui/material'; +import { Box, Paper, Typography, useTheme } from '@mui/material'; import { ResponsiveRadialBar } from '@nivo/radial-bar'; import { FC } from 'react'; +import { truncateOnMiddle } from 'utils/stringUtils'; import { ZetkinEmail } from 'utils/types/zetkin'; type Props = { @@ -29,15 +30,15 @@ const EmailKPIChart: FC = ({ { data: [ { - x: 'Opened', + x: 'main', y: value / total, }, { - x: 'Opened 2', + x: 'secondary', y: 0, }, { - x: 'Other', + x: 'void', y: 1 - value / total, }, ], @@ -49,15 +50,15 @@ const EmailKPIChart: FC = ({ data.push({ data: [ { - x: 'Opened', + x: 'main', y: 0, }, { - x: 'Opened 2', + x: 'secondary', y: secondaryValue / secondaryTotal, }, { - x: 'Other', + x: 'void', y: 1 - secondaryValue / secondaryTotal, }, ], @@ -94,7 +95,9 @@ const EmailKPIChart: FC = ({ }} > {title} - {percentage + '%'} + + {percentage + '%'} + {secondaryPercentage >= 0 && secondaryPercentage.toString().concat('%')} @@ -105,7 +108,7 @@ const EmailKPIChart: FC = ({ circularAxisOuter={null} colors={[ theme.palette.primary.main, - theme.palette.grey[500], + theme.palette.grey[400], theme.palette.grey[100], ]} cornerRadius={2} @@ -116,6 +119,33 @@ const EmailKPIChart: FC = ({ innerRadius={0.6} padding={0.4} radialAxisStart={null} + tooltip={(props) => { + if (props.bar.category == 'void') { + return null; + } + + const percentage = Math.round(props.bar.value * 100); + + return ( + + + + {truncateOnMiddle(props.bar.groupId, 40)} + + + {percentage}% + + + + ); + }} valueFormat=">-.2f" /> diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index b29016a980..27ce1a519d 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -310,7 +310,7 @@ const EmailPage: PageWithLayout = () => { }} colors={[ theme.palette.primary.main, - theme.palette.secondary.dark, + theme.palette.grey[600], ]} curve="basis" data={lineData} From e171082f9c68940bea5602c3c0ffe1e109c50bb0 Mon Sep 17 00:00:00 2001 From: river-bbc Date: Fri, 12 Jul 2024 11:03:36 +0100 Subject: [PATCH 116/369] update local storage if url changes --- src/features/calendar/hooks/useTimeScale.ts | 31 +++++++++++---------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/features/calendar/hooks/useTimeScale.ts b/src/features/calendar/hooks/useTimeScale.ts index 98f6e8c050..56cb64f56c 100644 --- a/src/features/calendar/hooks/useTimeScale.ts +++ b/src/features/calendar/hooks/useTimeScale.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect } from 'react'; import { TimeScale } from '../components'; import useLocalStorage from 'zui/hooks/useLocalStorage'; @@ -16,21 +16,24 @@ function getTimeScale(timeScaleQueryParam: string | string[] | undefined) { export default function useTimeScale( timeScaleQueryParam: string | string[] | undefined ) { - const [localStorageTimeScale, setLocalStorageTimeScale] = useLocalStorage< - TimeScale | undefined - >('calendarTimeScale', undefined); + const [localStorageTimeScale, setLocalStorageTimeScale] = + useLocalStorage( + 'calendarTimeScale', + getTimeScale(timeScaleQueryParam) + ); - const [timeScale, setTimeScale] = useState( - localStorageTimeScale || getTimeScale(timeScaleQueryParam) - ); - - const setPersistentTimeScale = (newTimeScale: TimeScale) => { - setLocalStorageTimeScale(newTimeScale); - setTimeScale(newTimeScale); - }; + useEffect(() => { + // If the time scale changes in the URL, update it in local storage + if (timeScaleQueryParam) { + const newTimeScale = getTimeScale(timeScaleQueryParam); + if (newTimeScale !== localStorageTimeScale) { + setLocalStorageTimeScale(newTimeScale); + } + } + }, [timeScaleQueryParam]); return { - setPersistentTimeScale, - timeScale, + setPersistentTimeScale: setLocalStorageTimeScale, + timeScale: localStorageTimeScale, }; } From 982fbd36781f5f937f9ee0ba1fbbd8b851c23501 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Wed, 17 Jul 2024 22:08:23 +0200 Subject: [PATCH 117/369] Replace 000Z suffix with dayjs.utc call --- src/features/tasks/utils/getTaskStatus.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/tasks/utils/getTaskStatus.ts b/src/features/tasks/utils/getTaskStatus.ts index 6ae6591199..8bfee82655 100644 --- a/src/features/tasks/utils/getTaskStatus.ts +++ b/src/features/tasks/utils/getTaskStatus.ts @@ -20,8 +20,7 @@ const getTaskStatus = (task: ZetkinTask): TASK_STATUS => { return TASK_STATUS.EXPIRED; } - const isPublishedPassed = - published && dayjs(published + '.000Z').isBefore(now); + const isPublishedPassed = published && dayjs.utc(published).isBefore(now); if (isPublishedPassed) { return TASK_STATUS.ACTIVE; From bb6edb37184e303b6172d5f0916cfe0d85673b7c Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sun, 21 Jul 2024 09:47:05 +0200 Subject: [PATCH 118/369] Add loading state for join form submissions --- .../joinForms/components/JoinFormSelect.tsx | 57 +++++++ .../[orgId]/people/incoming/index.tsx | 145 +++++++++--------- 2 files changed, 128 insertions(+), 74 deletions(-) create mode 100644 src/features/joinForms/components/JoinFormSelect.tsx diff --git a/src/features/joinForms/components/JoinFormSelect.tsx b/src/features/joinForms/components/JoinFormSelect.tsx new file mode 100644 index 0000000000..1d91d901a1 --- /dev/null +++ b/src/features/joinForms/components/JoinFormSelect.tsx @@ -0,0 +1,57 @@ +import { FC } from 'react'; +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, +} from '@mui/material'; + +import { useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; + +type Form = { id: number; title: string }; + +type Props = { + formId?: number; + forms?: Form[]; + onFormSelect?: (form?: Form) => void; +}; + +const JoinFormSelect: FC = ({ + formId, + forms = [], + onFormSelect = () => {}, +}) => { + const messages = useMessages(messageIds); + + const onChange = (event: SelectChangeEvent) => { + if (event.target.value === 'all') { + onFormSelect(undefined); + } else { + onFormSelect(forms.find((f) => f.id === event.target.value)!); + } + }; + + return ( + + {messages.forms()} + + + ); +}; +export default JoinFormSelect; diff --git a/src/pages/organize/[orgId]/people/incoming/index.tsx b/src/pages/organize/[orgId]/people/incoming/index.tsx index 9726bf2350..bd344f88c1 100644 --- a/src/pages/organize/[orgId]/people/incoming/index.tsx +++ b/src/pages/organize/[orgId]/people/incoming/index.tsx @@ -2,7 +2,9 @@ import { GetServerSideProps } from 'next'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useState } from 'react'; import { Box, FormControl, InputLabel, MenuItem, Select } from '@mui/material'; +import uniqBy from 'lodash/uniqBy'; +import JoinFormSelect from 'features/joinForms/components/JoinFormSelect'; import JoinSubmissionPane from 'features/joinForms/panes/JoinSubmissionPane'; import JoinSubmissionTable from 'features/joinForms/components/JoinSubmissionTable'; import messageIds from '../../../../../features/joinForms/l10n/messageIds'; @@ -13,6 +15,7 @@ import useJoinSubmissions from 'features/joinForms/hooks/useJoinSubmissions'; import { useMessages } from 'core/i18n'; import { usePanes } from 'utils/panes'; import ZUIEmptyState from 'zui/ZUIEmptyState'; +import ZUIFuture from 'zui/ZUIFuture'; export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { const { orgId } = ctx.params!; @@ -26,68 +29,16 @@ type Props = { }; const IncomingPage: PageWithLayout = ({ orgId }) => { - const { data: submissions } = useJoinSubmissions(parseInt(orgId)); + const joinSubmissions = useJoinSubmissions(parseInt(orgId)); type FilterByStatusType = 'all' | 'pending' | 'accepted'; const [filterByStatus, setFilterByStatus] = useState('all'); - const [filterByForm, setFilterByForm] = useState('all'); + const [filterByForm, setFilterByForm] = useState(); const messages = useMessages(messageIds); const { openPane } = usePanes(); - if (!submissions) { - return null; - } - - const formTitles = submissions.map((submission) => submission.form.title); - const uniqueFormTitles = [...new Set(formTitles)]; - - const filteredSubmissions = submissions.filter((submission) => { - const hasFormMatches = - filterByForm === 'all' || submission.form.title === filterByForm; - const hasStatusMatches = - filterByStatus === 'all' || submission.state === filterByStatus; - return hasFormMatches && hasStatusMatches; - }); - - const RenderSubmissions = () => { - if (filteredSubmissions.length > 0) { - return ( - { - openPane({ - render: () => ( - - ), - width: 500, - }); - }} - orgId={parseInt(orgId)} - submissions={filteredSubmissions} - /> - ); - } else { - return ( - - } - /> - - ); - } - }; - return ( <> = ({ orgId }) => { justifyContent="flex-end" sx={{ mr: 2, my: 2 }} > - {/* filter by form name */} - - {messages.forms()} - - + } + > + {(submissions) => ( + s.form), + 'id' + )} + onFormSelect={(form) => setFilterByForm(form?.id)} + /> + )} + {/* filter by form submission status */} @@ -136,7 +84,56 @@ const IncomingPage: PageWithLayout = ({ orgId }) => { - + } + > + {(submissions) => { + const filteredSubmissions = submissions.filter((submission) => { + const hasFormMatches = + filterByForm === undefined || submission.form.id === filterByForm; + const hasStatusMatches = + filterByStatus === 'all' || submission.state === filterByStatus; + return hasFormMatches && hasStatusMatches; + }); + + if (filteredSubmissions.length > 0) { + return ( + { + openPane({ + render: () => ( + + ), + width: 500, + }); + }} + orgId={parseInt(orgId)} + submissions={filteredSubmissions} + /> + ); + } else { + return ( + + } + /> + + ); + } + }} + ); }; From 0f74c12f4d319505123b3dd835f4a7fd77590f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sat, 17 Aug 2024 15:05:13 +0200 Subject: [PATCH 119/369] Provides additional info for unavailable links Adds selectedEmail variable which is used to check if the email has been sent Adds additional error messages for user feedback on why links are not available, such as no email selected or email not sent Processed string is now included in ZetkinEmail to know if the email has been sent Fixes a found spelling error --- .../components/filters/EmailClick/index.tsx | 16 ++++++++++++++-- src/features/smartSearch/l10n/messageIds.ts | 4 ++++ src/utils/types/zetkin.ts | 1 + 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/features/smartSearch/components/filters/EmailClick/index.tsx b/src/features/smartSearch/components/filters/EmailClick/index.tsx index 1823b5da24..09d7d67ec9 100644 --- a/src/features/smartSearch/components/filters/EmailClick/index.tsx +++ b/src/features/smartSearch/components/filters/EmailClick/index.tsx @@ -103,6 +103,8 @@ const EmailClick = ({ }); }; + const selectedEmail = emails.find((email) => email.id == filter.config.email); + return ( filter.config.links?.includes(link.id) || false } - noOptionsText={} + noOptionsText={ + selectedEmail ? ( + selectedEmail.processed ? ( + + ) : ( + + ) + ) : ( + + ) + } onChange={(_, value) => setValueToKey( 'links', @@ -265,7 +277,7 @@ const EmailClick = ({ value={filter.config.campaign || ''} > {projectsFuture?.map((project) => ( - + {`"${project.title}"`} ))} diff --git a/src/features/smartSearch/l10n/messageIds.ts b/src/features/smartSearch/l10n/messageIds.ts index 824bc0461c..db9b3030ba 100644 --- a/src/features/smartSearch/l10n/messageIds.ts +++ b/src/features/smartSearch/l10n/messageIds.ts @@ -775,6 +775,10 @@ export default makeMessages('feat.smartSearch', { }, misc: { noOptions: m('No matching tags'), + noOptionsEmailNotSent: m( + 'Email not sent. Links included in the email are added after it has been sent.' + ), + noOptionsInvalidEmail: m('Invalid email. Select an email first.'), noOptionsLinks: m('No matching links'), }, operators: { diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index 98f916fe61..7ea84b0c97 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -509,6 +509,7 @@ export interface ZetkinEmail { theme: EmailTheme | null; id: number; locked: string | null; + processed: string | null; published: string | null; subject: string | null; organization: { id: number; title: string }; From e7f837cf173608fc645da7a5154b9b8b2572420a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sat, 17 Aug 2024 15:24:15 +0200 Subject: [PATCH 120/369] Fixes type error for processed --- src/utils/types/zetkin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index 7ea84b0c97..7ccb80546e 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -509,7 +509,7 @@ export interface ZetkinEmail { theme: EmailTheme | null; id: number; locked: string | null; - processed: string | null; + processed: string | null | undefined; published: string | null; subject: string | null; organization: { id: number; title: string }; From 2a8115b8f4e54abecf71ccd21152deef14dc5e86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sat, 17 Aug 2024 15:28:40 +0200 Subject: [PATCH 121/369] Processed in ZetkinEmail is now a optional --- src/utils/types/zetkin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index 7ccb80546e..02c47b9dcb 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -509,7 +509,7 @@ export interface ZetkinEmail { theme: EmailTheme | null; id: number; locked: string | null; - processed: string | null | undefined; + processed?: string | null | undefined; published: string | null; subject: string | null; organization: { id: number; title: string }; From 477f47e6b3961fd173b580d88bfefb4d00d380c6 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 10:24:01 +0200 Subject: [PATCH 122/369] Use more precise labels in time span selector Co-authored-by: WULCAN <7813515+WULCAN@users.noreply.github.com> --- src/features/emails/l10n/messageIds.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/emails/l10n/messageIds.ts b/src/features/emails/l10n/messageIds.ts index e2aee56637..ffc6002bcc 100644 --- a/src/features/emails/l10n/messageIds.ts +++ b/src/features/emails/l10n/messageIds.ts @@ -156,8 +156,8 @@ export default makeMessages('feat.emails', { allTime: m('All time'), first24: m('First 24 hours'), first48: m('First 48 hours'), - firstMonth: m('First month'), - firstWeek: m('First week'), + firstMonth: m('First 30 days'), + firstWeek: m('First 7 days'), }, }, description: m( From 3d73bb65d509fe84274ee6b31bc7cb03e8ddb7eb Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 10:39:23 +0200 Subject: [PATCH 123/369] Remove non-null assertion and handle empty arrays in getRelevantDataPoints() --- src/features/emails/utils/getRelevantDataPoints.ts | 4 ++-- .../[orgId]/projects/[campId]/emails/[emailId]/insights.tsx | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/features/emails/utils/getRelevantDataPoints.ts b/src/features/emails/utils/getRelevantDataPoints.ts index a8f8532936..731dfea53f 100644 --- a/src/features/emails/utils/getRelevantDataPoints.ts +++ b/src/features/emails/utils/getRelevantDataPoints.ts @@ -7,7 +7,7 @@ type InputPoint = { type OutputPoint = EmailInsights['opensByDate'][0]; type GetRelevantDataPoints = { - mainPoint: OutputPoint; + mainPoint: OutputPoint | null; secondaryPoint: OutputPoint | null; }; @@ -64,7 +64,7 @@ export default function getRelevantDataPoints( }); return { - mainPoint: mainPoint!, + mainPoint: mainPoint || null, secondaryPoint, }; } else { diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 27ce1a519d..9f4b699d16 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -362,6 +362,11 @@ const EmailPage: PageWithLayout = () => { } : null ); + + if (!mainPoint) { + return null; + } + const count = mainPoint.accumulatedOpens; const date = new Date(mainPoint.date); From aeb026d2a4967de0b0e2b5e3cf7ec8e427cb9d10 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 10:44:09 +0200 Subject: [PATCH 124/369] Use Component Story Format 3 for ZUIDuration --- src/zui/ZUIDuration/index.stories.tsx | 30 +++++++++------------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/zui/ZUIDuration/index.stories.tsx b/src/zui/ZUIDuration/index.stories.tsx index 51049028f1..a9d520d12e 100644 --- a/src/zui/ZUIDuration/index.stories.tsx +++ b/src/zui/ZUIDuration/index.stories.tsx @@ -1,4 +1,4 @@ -import { Meta, StoryFn } from '@storybook/react'; +import { Meta, StoryObj } from '@storybook/react'; import ZUIDuration from '.'; @@ -7,23 +7,13 @@ export default { title: 'Atoms/ZUIDuration', } as Meta; -const Template: StoryFn = (args) => ( - -); - -export const basic = Template.bind({}); -basic.args = { - enableDays: true, - enableHours: true, - enableMilliseconds: false, - enableMinutes: true, - enableSeconds: false, - seconds: 12345, +export const Basic: StoryObj = { + args: { + enableDays: true, + enableHours: true, + enableMilliseconds: false, + enableMinutes: true, + enableSeconds: false, + seconds: 12345, + }, }; From 63c5b5b20dc7315dfd5b710ed852977bfd85c9b1 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 10:49:48 +0200 Subject: [PATCH 125/369] Change ZUIDuration property names to sort more beautifully --- src/zui/ZUIDuration/index.stories.tsx | 10 ++++----- src/zui/ZUIDuration/index.tsx | 30 +++++++++++++-------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/zui/ZUIDuration/index.stories.tsx b/src/zui/ZUIDuration/index.stories.tsx index a9d520d12e..2a07ed5103 100644 --- a/src/zui/ZUIDuration/index.stories.tsx +++ b/src/zui/ZUIDuration/index.stories.tsx @@ -9,11 +9,11 @@ export default { export const Basic: StoryObj = { args: { - enableDays: true, - enableHours: true, - enableMilliseconds: false, - enableMinutes: true, - enableSeconds: false, seconds: 12345, + withDays: true, + withHours: true, + withMinutes: true, + withSeconds: false, + withThousands: false, }, }; diff --git a/src/zui/ZUIDuration/index.tsx b/src/zui/ZUIDuration/index.tsx index b07092315e..fcff2f83b3 100644 --- a/src/zui/ZUIDuration/index.tsx +++ b/src/zui/ZUIDuration/index.tsx @@ -4,12 +4,12 @@ import messageIds from 'zui/l10n/messageIds'; import { Msg } from 'core/i18n'; type Props = { - enableDays?: boolean; - enableHours?: boolean; - enableMilliseconds?: boolean; - enableMinutes?: boolean; - enableSeconds?: boolean; seconds: number; + withDays?: boolean; + withHours?: boolean; + withMinutes?: boolean; + withSeconds?: boolean; + withThousands?: boolean; }; type DurField = { @@ -19,11 +19,11 @@ type DurField = { }; const ZUIDuration: FC = ({ - enableDays = true, - enableHours = true, - enableMilliseconds = false, - enableMinutes = true, - enableSeconds = false, + withDays = true, + withHours = true, + withThousands = false, + withMinutes = true, + withSeconds = false, seconds, }) => { const ms = (seconds * 1000) % 1000; @@ -33,11 +33,11 @@ const ZUIDuration: FC = ({ const days = Math.floor(seconds / 60 / 60 / 24); const fields: DurField[] = [ - { msgId: 'days', n: days, visible: enableDays }, - { msgId: 'h', n: h, visible: enableHours }, - { msgId: 'm', n: m, visible: enableMinutes }, - { msgId: 's', n: s, visible: enableSeconds }, - { msgId: 'ms', n: ms, visible: enableMilliseconds }, + { msgId: 'days', n: days, visible: withDays }, + { msgId: 'h', n: h, visible: withHours }, + { msgId: 'm', n: m, visible: withMinutes }, + { msgId: 's', n: s, visible: withSeconds }, + { msgId: 'ms', n: ms, visible: withThousands }, ]; return ( From 1b1560b5fd6625f0785bab14d0f9524751bdb9d1 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 10:56:12 +0200 Subject: [PATCH 126/369] Add comment explaining that ZUIDuration only supports positive durations --- src/zui/ZUIDuration/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/zui/ZUIDuration/index.tsx b/src/zui/ZUIDuration/index.tsx index fcff2f83b3..1dfba4d956 100644 --- a/src/zui/ZUIDuration/index.tsx +++ b/src/zui/ZUIDuration/index.tsx @@ -4,6 +4,10 @@ import messageIds from 'zui/l10n/messageIds'; import { Msg } from 'core/i18n'; type Props = { + /** + * The duration in seconds that should be visualized. Only positive durations + * are supported and negative values will result in no rendered output. + */ seconds: number; withDays?: boolean; withHours?: boolean; From 44a8fbcbe441905b0c8bf1457cc73dca84dca9b7 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 11:03:42 +0200 Subject: [PATCH 127/369] Remove renamed destructuring that makes code more verbose --- .../[campId]/emails/[emailId]/insights.tsx | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 9f4b699d16..9cccfe4256 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -100,11 +100,7 @@ const EmailPage: PageWithLayout = () => { const insightsFuture = useEmailInsights(orgId, emailId); const [selectedLinkTag, setSelectedLinkTag] = useState(null); const emailsFuture = useEmails(orgId); - const { - emailFuture: secondaryEmailFuture, - insightsFuture: secondaryInsightsFuture, - statsFuture: secondaryStatsFuture, - } = useSecondaryEmailInsights(orgId, secondaryEmailId); + const secondary = useSecondaryEmailInsights(orgId, secondaryEmailId); const onServer = useServerSide(); @@ -200,8 +196,8 @@ const EmailPage: PageWithLayout = () => { {({ data: { secondaryEmail, secondaryStats } }) => ( @@ -258,9 +254,9 @@ const EmailPage: PageWithLayout = () => { {({ @@ -487,8 +483,8 @@ const EmailPage: PageWithLayout = () => { {({ data: { secondaryEmail, secondaryStats } }) => ( From b7516e4e1117ce59e13e7fa247d343958cd9bd6a Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 11:08:07 +0200 Subject: [PATCH 128/369] Remove redundant onReset property --- .../[orgId]/projects/[campId]/emails/[emailId]/insights.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 9cccfe4256..7ecb06120d 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -134,7 +134,6 @@ const EmailPage: PageWithLayout = () => { } getOptionLabel={(option) => option.title || ''} onChange={(_, value) => setSecondaryEmailId(value?.id ?? 0)} - onReset={() => setSecondaryEmailId(0)} options={emails.filter( // Can only compare with published emails, and not itself (email) => email.id != emailId && email.published From 89d2c693d37ef278ac0b79f62b45b958b6b21bfd Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 11:21:52 +0200 Subject: [PATCH 129/369] Add missing processed attribute on ZetkinEmail type --- src/features/emails/utils/deliveryProblems.spec.ts | 1 + src/features/emails/utils/rendering/renderEmailHtml.spec.ts | 1 + src/utils/types/zetkin.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/src/features/emails/utils/deliveryProblems.spec.ts b/src/features/emails/utils/deliveryProblems.spec.ts index eb4822dcd2..17fc7a8a9d 100644 --- a/src/features/emails/utils/deliveryProblems.spec.ts +++ b/src/features/emails/utils/deliveryProblems.spec.ts @@ -23,6 +23,7 @@ const mockEmail = (emailOverrides?: Partial): ZetkinEmail => { id: 1, locked: '2024-02-26T12:27:32.237413', organization: { id: 1, title: 'My Organization' }, + processed: null, published: null, subject: 'Hello new member!', target: { diff --git a/src/features/emails/utils/rendering/renderEmailHtml.spec.ts b/src/features/emails/utils/rendering/renderEmailHtml.spec.ts index 2b170cb8c7..c5c09fcd43 100644 --- a/src/features/emails/utils/rendering/renderEmailHtml.spec.ts +++ b/src/features/emails/utils/rendering/renderEmailHtml.spec.ts @@ -325,6 +325,7 @@ export default function mockEmail( title: 'Organization', }, published: null, + processed: null, subject: 'This is the subject', target: { id: 1, diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index 98f916fe61..2c0c93ef96 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -510,6 +510,7 @@ export interface ZetkinEmail { id: number; locked: string | null; published: string | null; + processed: string | null; subject: string | null; organization: { id: number; title: string }; content: string | null; From 0e03264d29ddffdf13adc5202dde184dd14468e2 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 11:22:31 +0200 Subject: [PATCH 130/369] Show Insights tab only for processed emails --- src/features/emails/layout/EmailLayout.tsx | 33 +++++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/features/emails/layout/EmailLayout.tsx b/src/features/emails/layout/EmailLayout.tsx index bbd5777a26..ed23486ee8 100644 --- a/src/features/emails/layout/EmailLayout.tsx +++ b/src/features/emails/layout/EmailLayout.tsx @@ -40,6 +40,24 @@ const EmailLayout: FC = ({ return null; } + const tabs = [ + { + href: '/', + label: messages.tabs.overview(), + }, + { + href: '/compose', + label: messages.tabs.compose(), + }, + ]; + + if (email.processed) { + tabs.push({ + href: '/insights', + label: messages.tabs.insights(), + }); + } + return ( <> = ({ } - tabs={[ - { - href: '/', - label: messages.tabs.overview(), - }, - { - href: '/compose', - label: messages.tabs.compose(), - }, - { - href: '/insights', - label: messages.tabs.insights(), - }, - ]} + tabs={tabs} title={ { From 917117537fd424f78d9eb99f1c10698df4ac1801 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 11:22:56 +0200 Subject: [PATCH 131/369] Remove non-null assertion of email.published on insights page --- .../projects/[campId]/emails/[emailId]/insights.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 7ecb06120d..6e0d81b412 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -108,6 +108,11 @@ const EmailPage: PageWithLayout = () => { return null; } + const emailPublished = email.published; + if (!emailPublished || !email.processed) { + return null; + } + const sanitizer = DOMPurify(); const timeSpanHours = hoursFromSpanValue(timeSpan); @@ -342,12 +347,12 @@ const EmailPage: PageWithLayout = () => { top: 20, }} sliceTooltip={(props) => { - const publishDate = new Date(email?.published || 0); + const publishDate = new Date(emailPublished); const { mainPoint, secondaryPoint } = getRelevantDataPoints( props.slice.points[0], { - startDate: new Date(email.published!), + startDate: new Date(emailPublished), values: mainInsights.opensByDate, }, secondaryInsights && secondaryEmail?.published From c7a9014f5949cf9d6c53c48e3da57e14eb410062 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 11:27:26 +0200 Subject: [PATCH 132/369] Simplify padding to left of chart --- .../projects/[campId]/emails/[emailId]/insights.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 6e0d81b412..639bb8b07b 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -337,12 +337,7 @@ const EmailPage: PageWithLayout = () => { lineWidth={3} margin={{ bottom: 30, - // Calculate the left margin from the number of digits - // in the y axis labels, to make sure the labels will fit - // inside the clipping rectangle. - left: - 15 + - mainInsights.opensByDate.length.toString().length * 8, + left: 40, right: 8, top: 20, }} From 043b2bf469695d5fb8e482db1b3ab2ee2c44d98b Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 11:52:57 +0200 Subject: [PATCH 133/369] Break most of insights page into two separate components --- .../components/ClickedInsightsSection.tsx | 155 ++++++ .../components/OpenedInsightsSection.tsx | 361 ++++++++++++++ .../[campId]/emails/[emailId]/insights.tsx | 467 +----------------- 3 files changed, 525 insertions(+), 458 deletions(-) create mode 100644 src/features/emails/components/ClickedInsightsSection.tsx create mode 100644 src/features/emails/components/OpenedInsightsSection.tsx diff --git a/src/features/emails/components/ClickedInsightsSection.tsx b/src/features/emails/components/ClickedInsightsSection.tsx new file mode 100644 index 0000000000..b9512df357 --- /dev/null +++ b/src/features/emails/components/ClickedInsightsSection.tsx @@ -0,0 +1,155 @@ +import { Box } from '@mui/system'; +import DOMPurify from 'dompurify'; +import { OpenInNew } from '@mui/icons-material'; +import { FC, useState } from 'react'; +import { + Link, + Table, + TableCell, + TableRow, + ToggleButton, + ToggleButtonGroup, + Typography, + useTheme, +} from '@mui/material'; + +import ZUICard from 'zui/ZUICard'; +import ZUIFutures from 'zui/ZUIFutures'; +import ZUINumberChip from 'zui/ZUINumberChip'; +import EmailKPIChart from './EmailKPIChart'; +import ZUIFuture from 'zui/ZUIFuture'; +import EmailMiniature from './EmailMiniature'; +import { useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; +import useEmailStats from '../hooks/useEmailStats'; +import useSecondaryEmailInsights from '../hooks/useSecondaryEmailInsights'; +import { ZetkinEmail } from 'utils/types/zetkin'; +import useEmailInsights from '../hooks/useEmailInsights'; + +type Props = { + email: ZetkinEmail; + secondaryEmailId: number; +}; + +const ClickedInsightsSection: FC = ({ email, secondaryEmailId }) => { + const theme = useTheme(); + const messages = useMessages(messageIds); + const stats = useEmailStats(email.organization.id, email.id); + const [clickMetric, setClickMetric] = useState<'ctr' | 'ctor'>('ctr'); + const [selectedLinkTag, setSelectedLinkTag] = useState(null); + const insightsFuture = useEmailInsights(email.organization.id, email.id); + const secondary = useSecondaryEmailInsights( + email.organization.id, + secondaryEmailId + ); + + const sanitizer = DOMPurify(); + + return ( + + } + > + + + + {({ data: { secondaryEmail, secondaryStats } }) => ( + <> + + + + setClickMetric(value || clickMetric) + } + size="small" + value={clickMetric} + > + + {messages.insights.clicked.metrics.ctr()} + + + {messages.insights.clicked.metrics.ctor()} + + + + + {messages.insights.clicked.descriptions[clickMetric]()} + + + )} + + + + + {(insights) => ( + + + {insights.links + .concat() + .sort((a, b) => b.clicks - a.clicks) + .map((link) => ( + setSelectedLinkTag(link.tag)} + onMouseLeave={() => setSelectedLinkTag(null)} + > + {link.clicks} + + {sanitizer.sanitize(link.text, { + // Remove all inline tags that may exist here + ALLOWED_TAGS: ['#text'], + })} + + + + {link.url} + + + + + ))} +
+ +
+ )} +
+
+
+
+ ); +}; + +export default ClickedInsightsSection; diff --git a/src/features/emails/components/OpenedInsightsSection.tsx b/src/features/emails/components/OpenedInsightsSection.tsx new file mode 100644 index 0000000000..b1c7b31e45 --- /dev/null +++ b/src/features/emails/components/OpenedInsightsSection.tsx @@ -0,0 +1,361 @@ +import { AxisProps } from '@nivo/axes'; +import { FormattedTime } from 'react-intl'; +import { ResponsiveLine } from '@nivo/line'; +import { linearGradientDef } from '@nivo/core'; +import { FC, useState } from 'react'; +import { + Box, + Divider, + MenuItem, + Paper, + TextField, + Typography, + useTheme, +} from '@mui/material'; + +import { Msg, useMessages } from 'core/i18n'; +import ZUICard from 'zui/ZUICard'; +import ZUINumberChip from 'zui/ZUINumberChip'; +import messageIds from '../l10n/messageIds'; +import ZUIFutures from 'zui/ZUIFutures'; +import EmailKPIChart from './EmailKPIChart'; +import getRelevantDataPoints from '../utils/getRelevantDataPoints'; +import ZUIDuration from 'zui/ZUIDuration'; +import { ZetkinEmail } from 'utils/types/zetkin'; +import useEmailInsights from '../hooks/useEmailInsights'; +import useEmailStats from '../hooks/useEmailStats'; +import useSecondaryEmailInsights from '../hooks/useSecondaryEmailInsights'; + +type Props = { + email: ZetkinEmail; + secondaryEmailId: number; +}; + +const HOURS_BY_SPAN: Record = { + first24: 24, + first48: 48, + firstMonth: 24 * 30, + firstWeek: 24 * 7, +}; + +function hoursFromSpanValue(value: string): number | undefined { + return HOURS_BY_SPAN[value]; +} + +function axisFromSpanValue(value: string): AxisProps { + if (value == 'first24' || value == 'first48') { + const output: number[] = []; + for (let i = 0; i < 48; i += 4) { + output.push(i * 60 * 60); + } + + return { + format: (val) => Math.round(val / 60 / 60), + tickValues: output, + }; + } else { + return { + format: (val) => Math.round(val / 60 / 60 / 24), + tickValues: value == 'firstWeek' ? 7 : 15, + }; + } +} + +const OpenedInsightsSection: FC = ({ email, secondaryEmailId }) => { + const theme = useTheme(); + const messages = useMessages(messageIds); + const [timeSpan, setTimeSpan] = useState('first48'); + const stats = useEmailStats(email.organization.id, email.id); + const insightsFuture = useEmailInsights(email.organization.id, email.id); + const secondary = useSecondaryEmailInsights( + email.organization.id, + secondaryEmailId + ); + + const timeSpanHours = hoursFromSpanValue(timeSpan); + + const emailPublished = email.published; + if (!emailPublished || !email.processed) { + return null; + } + + return ( + + } + sx={{ mb: 2 }} + > + + + + {({ data: { secondaryEmail, secondaryStats } }) => ( + <> + + + {messages.insights.opened.description()} + + + )} + + + + + setTimeSpan(ev.target.value)} + select + size="small" + value={timeSpan} + > + + + + + + + + + + + + + + + + + + + {({ + data: { + mainInsights, + secondaryEmail, + secondaryInsights, + secondaryStats, + }, + }) => { + const lineData = [ + { + data: mainInsights.opensByDate.map((openEvent) => ({ + x: + (new Date(openEvent.date).getTime() - + new Date(mainInsights.opensByDate[0].date).getTime()) / + 1000, + y: openEvent.accumulatedOpens / stats.numSent, + })), + id: 'main', + }, + ]; + + if (secondaryInsights && secondaryStats) { + lineData.push({ + data: secondaryInsights.opensByDate.map((openEvent) => ({ + x: + (new Date(openEvent.date).getTime() - + new Date( + secondaryInsights.opensByDate[0].date + ).getTime()) / + 1000, + y: openEvent.accumulatedOpens / secondaryStats.num_sent, + })), + id: 'secondary', + }); + } + + return ( + Math.round(val * 100) + '%', + }} + colors={[theme.palette.primary.main, theme.palette.grey[600]]} + curve="basis" + data={lineData} + defs={[ + linearGradientDef('gradientA', [ + { color: 'inherit', offset: 0 }, + { color: 'inherit', offset: 100, opacity: 0 }, + ]), + linearGradientDef('transparent', [ + { color: 'white', offset: 0, opacity: 0 }, + { color: 'white', offset: 100, opacity: 0 }, + ]), + ]} + enableArea={true} + enableGridX={false} + enableGridY={false} + enablePoints={false} + enableSlices="x" + fill={[ + { id: 'gradientA', match: { id: 'main' } }, + { id: 'transparent', match: { id: 'secondary' } }, + ]} + isInteractive={true} + lineWidth={3} + margin={{ + bottom: 30, + left: 40, + right: 8, + top: 20, + }} + sliceTooltip={(props) => { + const publishDate = new Date(emailPublished); + const { mainPoint, secondaryPoint } = getRelevantDataPoints( + props.slice.points[0], + { + startDate: new Date(emailPublished), + values: mainInsights.opensByDate, + }, + secondaryInsights && secondaryEmail?.published + ? { + startDate: new Date(secondaryEmail.published), + values: secondaryInsights.opensByDate, + } + : null + ); + + if (!mainPoint) { + return null; + } + + const count = mainPoint.accumulatedOpens; + const date = new Date(mainPoint.date); + + const secondsAfterPublish = + (date.getTime() - publishDate.getTime()) / 1000; + + return ( + + + + + + + + + + + + + + + + + + + + + + + {Math.round((count / stats.numSent) * 100)}% + + + + {secondaryPoint && secondaryStats && ( + <> + + + + + + + + + + + + + {Math.round( + (secondaryPoint.accumulatedOpens / + secondaryStats.num_sent) * + 100 + )} + % + + + + + )} + + + ); + }} + xScale={{ + max: timeSpanHours ? timeSpanHours * 60 * 60 : undefined, + type: 'linear', + }} + /> + ); + }} + + + + + ); +}; + +export default OpenedInsightsSection; diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx index 639bb8b07b..940c7422bf 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/insights.tsx @@ -3,48 +3,25 @@ import Head from 'next/head'; import { Autocomplete, Box, - Divider, - Link, ListItem, - MenuItem, - Paper, - Table, - TableCell, - TableRow, TextField, - ToggleButton, - ToggleButtonGroup, Typography, - useTheme, } from '@mui/material'; -import { AxisProps } from '@nivo/axes'; -import { ResponsiveLine } from '@nivo/line'; -import { linearGradientDef } from '@nivo/core'; -import { OpenInNew } from '@mui/icons-material'; -import DOMPurify from 'dompurify'; import { useState } from 'react'; -import { FormattedDate, FormattedTime } from 'react-intl'; +import { FormattedDate } from 'react-intl'; import EmailLayout from 'features/emails/layout/EmailLayout'; import { PageWithLayout } from 'utils/types'; import { scaffold } from 'utils/next'; import useEmail from 'features/emails/hooks/useEmail'; -import useEmailStats from 'features/emails/hooks/useEmailStats'; import { useNumericRouteParams } from 'core/hooks'; import useServerSide from 'core/useServerSide'; -import ZUICard from 'zui/ZUICard'; -import ZUINumberChip from 'zui/ZUINumberChip'; -import EmailKPIChart from 'features/emails/components/EmailKPIChart'; -import useEmailInsights from 'features/emails/hooks/useEmailInsights'; import ZUIFuture from 'zui/ZUIFuture'; -import { Msg, useMessages } from 'core/i18n'; +import { useMessages } from 'core/i18n'; import messageIds from 'features/emails/l10n/messageIds'; -import EmailMiniature from 'features/emails/components/EmailMiniature'; -import ZUIDuration from 'zui/ZUIDuration'; -import ZUIFutures from 'zui/ZUIFutures'; -import useSecondaryEmailInsights from 'features/emails/hooks/useSecondaryEmailInsights'; -import getRelevantDataPoints from 'features/emails/utils/getRelevantDataPoints'; import useEmails from 'features/emails/hooks/useEmails'; +import OpenedInsightsSection from 'features/emails/components/OpenedInsightsSection'; +import ClickInsightsSection from 'features/emails/components/ClickedInsightsSection'; export const getServerSideProps: GetServerSideProps = scaffold( async () => { @@ -58,49 +35,12 @@ export const getServerSideProps: GetServerSideProps = scaffold( } ); -const HOURS_BY_SPAN: Record = { - first24: 24, - first48: 48, - firstMonth: 24 * 30, - firstWeek: 24 * 7, -}; - -function hoursFromSpanValue(value: string): number | undefined { - return HOURS_BY_SPAN[value]; -} - -function axisFromSpanValue(value: string): AxisProps { - if (value == 'first24' || value == 'first48') { - const output: number[] = []; - for (let i = 0; i < 48; i += 4) { - output.push(i * 60 * 60); - } - - return { - format: (val) => Math.round(val / 60 / 60), - tickValues: output, - }; - } else { - return { - format: (val) => Math.round(val / 60 / 60 / 24), - tickValues: value == 'firstWeek' ? 7 : 15, - }; - } -} - const EmailPage: PageWithLayout = () => { - const [clickMetric, setClickMetric] = useState<'ctr' | 'ctor'>('ctr'); const [secondaryEmailId, setSecondaryEmailId] = useState(0); - const [timeSpan, setTimeSpan] = useState('first48'); const messages = useMessages(messageIds); const { orgId, emailId } = useNumericRouteParams(); - const theme = useTheme(); const { data: email } = useEmail(orgId, emailId); - const stats = useEmailStats(orgId, emailId); - const insightsFuture = useEmailInsights(orgId, emailId); - const [selectedLinkTag, setSelectedLinkTag] = useState(null); const emailsFuture = useEmails(orgId); - const secondary = useSecondaryEmailInsights(orgId, secondaryEmailId); const onServer = useServerSide(); @@ -113,10 +53,6 @@ const EmailPage: PageWithLayout = () => { return null; } - const sanitizer = DOMPurify(); - - const timeSpanHours = hoursFromSpanValue(timeSpan); - return ( <> @@ -186,396 +122,11 @@ const EmailPage: PageWithLayout = () => { )}
- - } - sx={{ mb: 2 }} - > - - - - {({ data: { secondaryEmail, secondaryStats } }) => ( - <> - - - {messages.insights.opened.description()} - - - )} - - - - - setTimeSpan(ev.target.value)} - select - size="small" - value={timeSpan} - > - - - - - - - - - - - - - - - - - - - {({ - data: { - mainInsights, - secondaryEmail, - secondaryInsights, - secondaryStats, - }, - }) => { - const lineData = [ - { - data: mainInsights.opensByDate.map((openEvent) => ({ - x: - (new Date(openEvent.date).getTime() - - new Date( - mainInsights.opensByDate[0].date - ).getTime()) / - 1000, - y: openEvent.accumulatedOpens / stats.numSent, - })), - id: 'main', - }, - ]; - - if (secondaryInsights && secondaryStats) { - lineData.push({ - data: secondaryInsights.opensByDate.map((openEvent) => ({ - x: - (new Date(openEvent.date).getTime() - - new Date( - secondaryInsights.opensByDate[0].date - ).getTime()) / - 1000, - y: openEvent.accumulatedOpens / secondaryStats.num_sent, - })), - id: 'secondary', - }); - } - - return ( - Math.round(val * 100) + '%', - }} - colors={[ - theme.palette.primary.main, - theme.palette.grey[600], - ]} - curve="basis" - data={lineData} - defs={[ - linearGradientDef('gradientA', [ - { color: 'inherit', offset: 0 }, - { color: 'inherit', offset: 100, opacity: 0 }, - ]), - linearGradientDef('transparent', [ - { color: 'white', offset: 0, opacity: 0 }, - { color: 'white', offset: 100, opacity: 0 }, - ]), - ]} - enableArea={true} - enableGridX={false} - enableGridY={false} - enablePoints={false} - enableSlices="x" - fill={[ - { id: 'gradientA', match: { id: 'main' } }, - { id: 'transparent', match: { id: 'secondary' } }, - ]} - isInteractive={true} - lineWidth={3} - margin={{ - bottom: 30, - left: 40, - right: 8, - top: 20, - }} - sliceTooltip={(props) => { - const publishDate = new Date(emailPublished); - const { mainPoint, secondaryPoint } = - getRelevantDataPoints( - props.slice.points[0], - { - startDate: new Date(emailPublished), - values: mainInsights.opensByDate, - }, - secondaryInsights && secondaryEmail?.published - ? { - startDate: new Date(secondaryEmail.published), - values: secondaryInsights.opensByDate, - } - : null - ); - - if (!mainPoint) { - return null; - } - - const count = mainPoint.accumulatedOpens; - const date = new Date(mainPoint.date); - - const secondsAfterPublish = - (date.getTime() - publishDate.getTime()) / 1000; - - return ( - - - - - - - - - - - - - - - - - - - - - - - {Math.round((count / stats.numSent) * 100)}% - - - - {secondaryPoint && secondaryStats && ( - <> - - - - - - - - - - - - - {Math.round( - (secondaryPoint.accumulatedOpens / - secondaryStats.num_sent) * - 100 - )} - % - - - - - )} - - - ); - }} - xScale={{ - max: timeSpanHours ? timeSpanHours * 60 * 60 : undefined, - type: 'linear', - }} - /> - ); - }} - - - - - - } - > - - - - {({ data: { secondaryEmail, secondaryStats } }) => ( - <> - - - - setClickMetric(value || clickMetric) - } - size="small" - value={clickMetric} - > - - {messages.insights.clicked.metrics.ctr()} - - - {messages.insights.clicked.metrics.ctor()} - - - - - {messages.insights.clicked.descriptions[clickMetric]()} - - - )} - - - - - {(insights) => ( - - - {insights.links - .concat() - .sort((a, b) => b.clicks - a.clicks) - .map((link) => ( - setSelectedLinkTag(link.tag)} - onMouseLeave={() => setSelectedLinkTag(null)} - > - {link.clicks} - - {sanitizer.sanitize(link.text, { - // Remove all inline tags that may exist here - ALLOWED_TAGS: ['#text'], - })} - - - - {link.url} - - - - - ))} -
- -
- )} -
-
-
-
+ + ); }; From 5cfa7fa663def7bac81b2f47bdc0193cd9aff68f Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 19 Aug 2024 12:01:16 +0200 Subject: [PATCH 134/369] Break out line data generation to separate function --- .../components/OpenedInsightsSection.tsx | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/features/emails/components/OpenedInsightsSection.tsx b/src/features/emails/components/OpenedInsightsSection.tsx index b1c7b31e45..388cbf8f67 100644 --- a/src/features/emails/components/OpenedInsightsSection.tsx +++ b/src/features/emails/components/OpenedInsightsSection.tsx @@ -25,6 +25,7 @@ import { ZetkinEmail } from 'utils/types/zetkin'; import useEmailInsights from '../hooks/useEmailInsights'; import useEmailStats from '../hooks/useEmailStats'; import useSecondaryEmailInsights from '../hooks/useSecondaryEmailInsights'; +import { EmailInsights } from '../types'; type Props = { email: ZetkinEmail; @@ -61,6 +62,19 @@ function axisFromSpanValue(value: string): AxisProps { } } +function lineDataFromInsights( + insights: EmailInsights, + numSent: number +): { x: number; y: number }[] { + return insights.opensByDate.map((openEvent) => ({ + x: + (new Date(openEvent.date).getTime() - + new Date(insights.opensByDate[0].date).getTime()) / + 1000, + y: openEvent.accumulatedOpens / numSent, + })); +} + const OpenedInsightsSection: FC = ({ email, secondaryEmailId }) => { const theme = useTheme(); const messages = useMessages(messageIds); @@ -167,28 +181,17 @@ const OpenedInsightsSection: FC = ({ email, secondaryEmailId }) => { }) => { const lineData = [ { - data: mainInsights.opensByDate.map((openEvent) => ({ - x: - (new Date(openEvent.date).getTime() - - new Date(mainInsights.opensByDate[0].date).getTime()) / - 1000, - y: openEvent.accumulatedOpens / stats.numSent, - })), + data: lineDataFromInsights(mainInsights, stats.numSent), id: 'main', }, ]; if (secondaryInsights && secondaryStats) { lineData.push({ - data: secondaryInsights.opensByDate.map((openEvent) => ({ - x: - (new Date(openEvent.date).getTime() - - new Date( - secondaryInsights.opensByDate[0].date - ).getTime()) / - 1000, - y: openEvent.accumulatedOpens / secondaryStats.num_sent, - })), + data: lineDataFromInsights( + secondaryInsights, + secondaryStats.num_sent + ), id: 'secondary', }); } From 14688365f7b620586fa23309aa654a589317f766 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 23 Aug 2024 10:33:28 +0200 Subject: [PATCH 135/369] Remove areas redirect --- next.config.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/next.config.js b/next.config.js index c7cdd6b11f..8458f35908 100644 --- a/next.config.js +++ b/next.config.js @@ -38,11 +38,6 @@ module.exports = { destination: '/legacy?orgId=:orgId', permanent: false, }, - { - source: '/organize/:orgId(\\d{1,})/areas', - destination: '/legacy?path=/maps&orgId=:orgId', - permanent: false, - }, { source: '/organize/:orgId(\\d{1,})/projects/calendar/events/:eventId(\\d{1,})', From cdfc5b286292f5cc76718b84ab191176e61159de Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 23 Aug 2024 10:36:07 +0200 Subject: [PATCH 136/369] Add empty map to areas page --- src/features/areas/components/AreasMap.tsx | 37 +++++++++++++++++++++ src/pages/organize/[orgId]/areas/index.tsx | 38 ++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/features/areas/components/AreasMap.tsx create mode 100644 src/pages/organize/[orgId]/areas/index.tsx diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx new file mode 100644 index 0000000000..f870399904 --- /dev/null +++ b/src/features/areas/components/AreasMap.tsx @@ -0,0 +1,37 @@ +import 'leaflet/dist/leaflet.css'; +import { FC } from 'react'; +import { MapContainer, TileLayer, useMap } from 'react-leaflet'; +import { latLngBounds, Map as MapType } from 'leaflet'; + +interface MapProps {} + +const MapWrapper = ({ + children, +}: { + children: (map: MapType) => JSX.Element; +}) => { + const map = useMap(); + return children(map); +}; + +const Map: FC = () => { + return ( + + + {() => { + return ( + + ); + }} + + + ); +}; + +export default Map; diff --git a/src/pages/organize/[orgId]/areas/index.tsx b/src/pages/organize/[orgId]/areas/index.tsx new file mode 100644 index 0000000000..a875555e28 --- /dev/null +++ b/src/pages/organize/[orgId]/areas/index.tsx @@ -0,0 +1,38 @@ +import { GetServerSideProps } from 'next'; +import Head from 'next/head'; + +import AreasMap from 'features/areas/components/AreasMap'; +import SimpleLayout from 'utils/layout/SimpleLayout'; +import { scaffold } from 'utils/next'; +import { PageWithLayout } from 'utils/types'; + +const scaffoldOptions = { + authLevelRequired: 2, +}; + +export const getServerSideProps: GetServerSideProps = scaffold(async () => { + return { + props: {}, + }; +}, scaffoldOptions); + +const AreasPage: PageWithLayout = () => { + return ( + <> + + Areas + + + + ); +}; + +AreasPage.getLayout = function getLayout(page) { + return ( + + {page} + + ); +}; + +export default AreasPage; From 0e429413d6bebf3cf9b41a5c19cd184c87717dc3 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 23 Aug 2024 10:36:52 +0200 Subject: [PATCH 137/369] Install react-leaflet-draw --- package.json | 2 ++ yarn.lock | 54 ++++++++++++++++++---------------------------------- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index c42d1a9f11..8346de55bc 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "is-url": "^1.2.4", "isomorphic-dompurify": "^0.19.0", "leaflet": "^1.9.3", + "leaflet-draw": "^1.0.4", "letterparser": "^0.0.8", "libphonenumber-js": "^1.10.51", "lodash": "^4.17.21", @@ -81,6 +82,7 @@ "react-final-form": "^6.5.9", "react-intl": "^6.1.0", "react-leaflet": "^4.2.1", + "react-leaflet-draw": "^0.20.4", "react-redux": "^8.0.4", "remark-gfm": "^3.0.1", "remark-parse": "^10.0.1", diff --git a/yarn.lock b/yarn.lock index 4b35244565..18f6bd3582 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10632,6 +10632,11 @@ lazy-universal-dotenv@^4.0.0: dotenv "^16.0.0" dotenv-expand "^10.0.0" +leaflet-draw@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/leaflet-draw/-/leaflet-draw-1.0.4.tgz#45be92f378ed253e7202fdeda1fcc71885198d46" + integrity sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ== + leaflet@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.3.tgz#52ec436954964e2d3d39e0d433da4b2500d74414" @@ -10749,7 +10754,7 @@ locate-path@^7.1.0: dependencies: p-locate "^6.0.0" -lodash-es@^4.17.21: +lodash-es@^4.17.15, lodash-es@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== @@ -12867,7 +12872,8 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -"prettier-fallback@npm:prettier@^3": +"prettier-fallback@npm:prettier@^3", prettier@^3.1.1: + name prettier-fallback version "3.3.2" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a" integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA== @@ -12877,11 +12883,6 @@ prettier@^2.5.1: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a" integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg== -prettier@^3.1.1: - version "3.3.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a" - integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA== - pretty-error@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6" @@ -13259,6 +13260,14 @@ react-is@^18.0.0, react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-leaflet-draw@^0.20.4: + version "0.20.4" + resolved "https://registry.yarnpkg.com/react-leaflet-draw/-/react-leaflet-draw-0.20.4.tgz#e3f68dc783cbe00d24ff3f2e02d5208c49f7c971" + integrity sha512-u5JHdow2Z9G2AveyUEOTWHXhdhzXdEVQifkNfSaVbEn0gvD+2xW03TQN444zVqovDBvIrBcVWo1VajL4zgl6yg== + dependencies: + fast-deep-equal "^3.1.3" + lodash-es "^4.17.15" + react-leaflet@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780" @@ -14312,16 +14321,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -14425,14 +14425,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15683,16 +15676,7 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 0f4b54f2ae8004c2becbbec8d2e6155a2ace9a61 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 23 Aug 2024 11:17:38 +0200 Subject: [PATCH 138/369] Implement simple polygon drawing using react-leaflet-draw --- src/features/areas/components/AreasMap.tsx | 45 ++++++++++++++++++---- src/pages/organize/[orgId]/areas/index.tsx | 7 +++- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index f870399904..5ca7b7ba9f 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -1,7 +1,15 @@ import 'leaflet/dist/leaflet.css'; -import { FC } from 'react'; -import { MapContainer, TileLayer, useMap } from 'react-leaflet'; -import { latLngBounds, Map as MapType } from 'leaflet'; +import 'leaflet-draw/dist/images/spritesheet.png'; +import 'leaflet-draw/dist/images/spritesheet-2x.png'; +import { FC, useRef } from 'react'; +import { + FeatureGroup as FGComponent, + MapContainer, + TileLayer, + useMap, +} from 'react-leaflet'; +import { FeatureGroup, latLngBounds, Map as MapType } from 'leaflet'; +import { EditControl } from 'react-leaflet-draw'; interface MapProps {} @@ -13,8 +21,9 @@ const MapWrapper = ({ const map = useMap(); return children(map); }; - const Map: FC = () => { + const reactFGref = useRef(null); + return ( = () => { {() => { return ( - + <> + + { + reactFGref.current = fgRef; + }} + > + + + ); }} diff --git a/src/pages/organize/[orgId]/areas/index.tsx b/src/pages/organize/[orgId]/areas/index.tsx index a875555e28..0a3505a01c 100644 --- a/src/pages/organize/[orgId]/areas/index.tsx +++ b/src/pages/organize/[orgId]/areas/index.tsx @@ -1,7 +1,7 @@ import { GetServerSideProps } from 'next'; import Head from 'next/head'; +import dynamic from 'next/dynamic'; -import AreasMap from 'features/areas/components/AreasMap'; import SimpleLayout from 'utils/layout/SimpleLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; @@ -16,6 +16,11 @@ export const getServerSideProps: GetServerSideProps = scaffold(async () => { }; }, scaffoldOptions); +const AreasMap = dynamic( + () => import('../../../../features/areas/components/AreasMap'), + { ssr: false } +); + const AreasPage: PageWithLayout = () => { return ( <> From 654fb8c8d3c8b35afa6024afab0bad3efa7c188a Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 23 Aug 2024 11:32:15 +0200 Subject: [PATCH 139/369] Implement simple bespoke polygon drawing UX --- src/features/areas/components/AreasMap.tsx | 127 +++++++++++++++------ 1 file changed, 89 insertions(+), 38 deletions(-) diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index 5ca7b7ba9f..44fa9a8a09 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -1,15 +1,16 @@ import 'leaflet/dist/leaflet.css'; -import 'leaflet-draw/dist/images/spritesheet.png'; -import 'leaflet-draw/dist/images/spritesheet-2x.png'; -import { FC, useRef } from 'react'; +import { FC, useRef, useState } from 'react'; import { FeatureGroup as FGComponent, MapContainer, + Polygon, + Polyline, TileLayer, useMap, } from 'react-leaflet'; import { FeatureGroup, latLngBounds, Map as MapType } from 'leaflet'; -import { EditControl } from 'react-leaflet-draw'; +import { Box, ButtonGroup, IconButton } from '@mui/material'; +import { Create } from '@mui/icons-material'; interface MapProps {} @@ -21,45 +22,95 @@ const MapWrapper = ({ const map = useMap(); return children(map); }; + +type PointData = [number, number]; +type PolygonData = { + id: number; + points: PointData[]; +}; + const Map: FC = () => { const reactFGref = useRef(null); + const [drawingPoints, setDrawingPoints] = useState(null); + const [polygons, setPolygons] = useState([]); return ( - - - {() => { - return ( - <> - - { - reactFGref.current = fgRef; - }} - > - - - - ); - }} - - + + + { + if (drawingPoints) { + if (drawingPoints.length > 2) { + setPolygons((current) => [ + ...current, + { id: current.length + 1, points: drawingPoints }, + ]); + } + setDrawingPoints(null); + } else { + setDrawingPoints([]); + } + }} + > + + + + + + + + + {(map) => { + map.on('click', (evt) => { + if (drawingPoints) { + const lat = evt.latlng.lat; + const lng = evt.latlng.lng; + setDrawingPoints((current) => [ + ...(current || []), + [lat, lng], + ]); + } + }); + + return ( + <> + + { + reactFGref.current = fgRef; + }} + > + {drawingPoints && ( + + )} + {polygons.map((polygon) => ( + + ))} + + + ); + }} + + + +
); }; From 11f13a01c3196bde4ef63355ea6f26bc14c49d9b Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 23 Aug 2024 12:02:11 +0200 Subject: [PATCH 140/369] Implement crude click handling for polygons --- src/features/areas/components/AreasMap.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index 44fa9a8a09..c198d635fb 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -33,6 +33,7 @@ const Map: FC = () => { const reactFGref = useRef(null); const [drawingPoints, setDrawingPoints] = useState(null); const [polygons, setPolygons] = useState([]); + const drawingRef = useRef(false); return ( = () => { ]); } setDrawingPoints(null); + drawingRef.current = false; } else { setDrawingPoints([]); + drawingRef.current = true; } }} > @@ -73,7 +76,7 @@ const Map: FC = () => { {(map) => { map.on('click', (evt) => { - if (drawingPoints) { + if (drawingRef.current) { const lat = evt.latlng.lat; const lng = evt.latlng.lng; setDrawingPoints((current) => [ @@ -101,7 +104,15 @@ const Map: FC = () => { /> )} {polygons.map((polygon) => ( - + { + alert('clicked polygon ' + polygon.id); + }, + }} + positions={polygon.points} + /> ))} From daf225bec8d71c1b7f344700240e45b67483a3ab Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 28 Aug 2024 08:50:14 +0200 Subject: [PATCH 141/369] Fix incorrect import --- src/features/emails/components/ClickedInsightsSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/emails/components/ClickedInsightsSection.tsx b/src/features/emails/components/ClickedInsightsSection.tsx index b9512df357..10ae0f1d17 100644 --- a/src/features/emails/components/ClickedInsightsSection.tsx +++ b/src/features/emails/components/ClickedInsightsSection.tsx @@ -1,8 +1,8 @@ -import { Box } from '@mui/system'; import DOMPurify from 'dompurify'; import { OpenInNew } from '@mui/icons-material'; import { FC, useState } from 'react'; import { + Box, Link, Table, TableCell, From 2edc4c521cbd68fea28bd76d76afdb217cbdeb5a Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 28 Aug 2024 08:58:46 +0200 Subject: [PATCH 142/369] Use email publish time instead of time of first open --- .../components/OpenedInsightsSection.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/features/emails/components/OpenedInsightsSection.tsx b/src/features/emails/components/OpenedInsightsSection.tsx index 388cbf8f67..1214146905 100644 --- a/src/features/emails/components/OpenedInsightsSection.tsx +++ b/src/features/emails/components/OpenedInsightsSection.tsx @@ -63,13 +63,18 @@ function axisFromSpanValue(value: string): AxisProps { } function lineDataFromInsights( + email: ZetkinEmail, insights: EmailInsights, numSent: number ): { x: number; y: number }[] { + const startTime = email.published; + if (!startTime) { + return []; + } + return insights.opensByDate.map((openEvent) => ({ x: - (new Date(openEvent.date).getTime() - - new Date(insights.opensByDate[0].date).getTime()) / + (new Date(openEvent.date).getTime() - new Date(startTime).getTime()) / 1000, y: openEvent.accumulatedOpens / numSent, })); @@ -181,14 +186,19 @@ const OpenedInsightsSection: FC = ({ email, secondaryEmailId }) => { }) => { const lineData = [ { - data: lineDataFromInsights(mainInsights, stats.numSent), + data: lineDataFromInsights( + email, + mainInsights, + stats.numSent + ), id: 'main', }, ]; - if (secondaryInsights && secondaryStats) { + if (secondaryEmail && secondaryInsights && secondaryStats) { lineData.push({ data: lineDataFromInsights( + secondaryEmail, secondaryInsights, secondaryStats.num_sent ), From 62d3ffbd80b33ac168c902458436d5efb8e3ad98 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 28 Aug 2024 09:00:05 +0200 Subject: [PATCH 143/369] Extract hover card on email opened diagram to separate component --- .../components/EmailDiagramHoverCard.tsx | 133 ++++++++++++++++++ .../components/OpenedInsightsSection.tsx | 133 ++---------------- 2 files changed, 144 insertions(+), 122 deletions(-) create mode 100644 src/features/emails/components/EmailDiagramHoverCard.tsx diff --git a/src/features/emails/components/EmailDiagramHoverCard.tsx b/src/features/emails/components/EmailDiagramHoverCard.tsx new file mode 100644 index 0000000000..70f624aa92 --- /dev/null +++ b/src/features/emails/components/EmailDiagramHoverCard.tsx @@ -0,0 +1,133 @@ +import { FC } from 'react'; +import { FormattedTime } from 'react-intl'; +import { Box, Divider, Paper, Typography } from '@mui/material'; + +import { Msg } from 'core/i18n'; +import ZUIDuration from 'zui/ZUIDuration'; +import messageIds from '../l10n/messageIds'; +import { EmailInsights, ZetkinEmailStats } from '../types'; +import getRelevantDataPoints from '../utils/getRelevantDataPoints'; +import { ZetkinEmail } from 'utils/types/zetkin'; + +type Props = { + mainInsights: EmailInsights; + mainStats: ZetkinEmailStats | null; + pointId: string; + publishDate: Date; + secondaryEmail: ZetkinEmail | null; + secondaryInsights: EmailInsights | null; + secondaryStats: ZetkinEmailStats | null; +}; + +const EmailDiagramHoverCard: FC = ({ + mainInsights, + mainStats, + pointId, + publishDate, + secondaryEmail, + secondaryInsights, + secondaryStats, +}) => { + const { mainPoint, secondaryPoint } = getRelevantDataPoints( + { id: pointId }, + { + startDate: publishDate, + values: mainInsights.opensByDate, + }, + secondaryInsights && secondaryEmail?.published + ? { + startDate: new Date(secondaryEmail.published), + values: secondaryInsights.opensByDate, + } + : null + ); + if (!mainPoint) { + return null; + } + + const count = mainPoint.accumulatedOpens; + const date = new Date(mainPoint.date); + + const secondsAfterPublish = (date.getTime() - publishDate.getTime()) / 1000; + return ( + + + + + + + + + + + + + + + + + + + + + + + {Math.round((count / (mainStats?.num_sent || 1)) * 100)}% + + + + {secondaryPoint && secondaryStats && ( + <> + + + + + + + + + + + + + {Math.round( + (secondaryPoint.accumulatedOpens / + secondaryStats.num_sent) * + 100 + )} + % + + + + + )} + + + ); +}; + +export default EmailDiagramHoverCard; diff --git a/src/features/emails/components/OpenedInsightsSection.tsx b/src/features/emails/components/OpenedInsightsSection.tsx index 1214146905..1b7477ce22 100644 --- a/src/features/emails/components/OpenedInsightsSection.tsx +++ b/src/features/emails/components/OpenedInsightsSection.tsx @@ -1,17 +1,8 @@ import { AxisProps } from '@nivo/axes'; -import { FormattedTime } from 'react-intl'; import { ResponsiveLine } from '@nivo/line'; import { linearGradientDef } from '@nivo/core'; import { FC, useState } from 'react'; -import { - Box, - Divider, - MenuItem, - Paper, - TextField, - Typography, - useTheme, -} from '@mui/material'; +import { Box, MenuItem, TextField, Typography, useTheme } from '@mui/material'; import { Msg, useMessages } from 'core/i18n'; import ZUICard from 'zui/ZUICard'; @@ -19,13 +10,12 @@ import ZUINumberChip from 'zui/ZUINumberChip'; import messageIds from '../l10n/messageIds'; import ZUIFutures from 'zui/ZUIFutures'; import EmailKPIChart from './EmailKPIChart'; -import getRelevantDataPoints from '../utils/getRelevantDataPoints'; -import ZUIDuration from 'zui/ZUIDuration'; import { ZetkinEmail } from 'utils/types/zetkin'; import useEmailInsights from '../hooks/useEmailInsights'; import useEmailStats from '../hooks/useEmailStats'; import useSecondaryEmailInsights from '../hooks/useSecondaryEmailInsights'; import { EmailInsights } from '../types'; +import EmailDiagramHoverCard from './EmailDiagramHoverCard'; type Props = { email: ZetkinEmail; @@ -244,117 +234,16 @@ const OpenedInsightsSection: FC = ({ email, secondaryEmailId }) => { top: 20, }} sliceTooltip={(props) => { - const publishDate = new Date(emailPublished); - const { mainPoint, secondaryPoint } = getRelevantDataPoints( - props.slice.points[0], - { - startDate: new Date(emailPublished), - values: mainInsights.opensByDate, - }, - secondaryInsights && secondaryEmail?.published - ? { - startDate: new Date(secondaryEmail.published), - values: secondaryInsights.opensByDate, - } - : null - ); - - if (!mainPoint) { - return null; - } - - const count = mainPoint.accumulatedOpens; - const date = new Date(mainPoint.date); - - const secondsAfterPublish = - (date.getTime() - publishDate.getTime()) / 1000; - return ( - - - - - - - - - - - - - - - - - - - - - - - {Math.round((count / stats.numSent) * 100)}% - - - - {secondaryPoint && secondaryStats && ( - <> - - - - - - - - - - - - - {Math.round( - (secondaryPoint.accumulatedOpens / - secondaryStats.num_sent) * - 100 - )} - % - - - - - )} - - + ); }} xScale={{ From 9c322a85347d1758d4ecd8340f7c9b924cb8b77b Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 28 Aug 2024 09:40:12 +0200 Subject: [PATCH 144/369] Rename EmailKPIChart properties to group nicer --- .../components/ClickedInsightsSection.tsx | 8 +++++--- .../emails/components/EmailKPIChart.tsx | 20 +++++++++---------- .../components/OpenedInsightsSection.tsx | 6 +++--- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/features/emails/components/ClickedInsightsSection.tsx b/src/features/emails/components/ClickedInsightsSection.tsx index 10ae0f1d17..07116af013 100644 --- a/src/features/emails/components/ClickedInsightsSection.tsx +++ b/src/features/emails/components/ClickedInsightsSection.tsx @@ -66,7 +66,11 @@ const ClickedInsightsSection: FC = ({ email, secondaryEmailId }) => { {({ data: { secondaryEmail, secondaryStats } }) => ( <> = ({ email, secondaryEmailId }) => { } secondaryValue={secondaryStats?.num_clicks ?? null} title={messages.insights.clicked.gauge.headers[clickMetric]()} - total={clickMetric == 'ctr' ? stats.numSent : stats.numOpened} - value={stats.numClicked} /> = ({ - email, + mainEmail, + mainTotal, + mainValue, secondaryEmail, secondaryTotal, secondaryValue, title, - total, - value, }) => { const theme = useTheme(); @@ -31,7 +31,7 @@ const EmailKPIChart: FC = ({ data: [ { x: 'main', - y: value / total, + y: mainValue / mainTotal, }, { x: 'secondary', @@ -39,10 +39,10 @@ const EmailKPIChart: FC = ({ }, { x: 'void', - y: 1 - value / total, + y: 1 - mainValue / mainTotal, }, ], - id: email.title || '', + id: mainEmail.title || '', }, ]; @@ -66,7 +66,7 @@ const EmailKPIChart: FC = ({ }); } - const percentage = Math.round((value / total) * 100); + const percentage = Math.round((mainValue / mainTotal) * 100); const secondaryPercentage = secondaryValue && secondaryTotal ? Math.round((secondaryValue / secondaryTotal) * 100) diff --git a/src/features/emails/components/OpenedInsightsSection.tsx b/src/features/emails/components/OpenedInsightsSection.tsx index 1b7477ce22..6692106575 100644 --- a/src/features/emails/components/OpenedInsightsSection.tsx +++ b/src/features/emails/components/OpenedInsightsSection.tsx @@ -110,13 +110,13 @@ const OpenedInsightsSection: FC = ({ email, secondaryEmailId }) => { {({ data: { secondaryEmail, secondaryStats } }) => ( <> {messages.insights.opened.description()} From 3ca74b32088941aa5ed03e95e601733ccccb63d6 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 28 Aug 2024 09:43:21 +0200 Subject: [PATCH 145/369] Require all props on EmailKPIChart --- src/features/emails/components/EmailKPIChart.tsx | 6 +++--- src/features/emails/components/OpenedInsightsSection.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/emails/components/EmailKPIChart.tsx b/src/features/emails/components/EmailKPIChart.tsx index bfca8cabbf..e8a3001610 100644 --- a/src/features/emails/components/EmailKPIChart.tsx +++ b/src/features/emails/components/EmailKPIChart.tsx @@ -9,9 +9,9 @@ type Props = { mainEmail: ZetkinEmail; mainTotal: number; mainValue: number; - secondaryEmail?: ZetkinEmail | null; - secondaryTotal?: number | null; - secondaryValue?: number | null; + secondaryEmail: ZetkinEmail | null; + secondaryTotal: number | null; + secondaryValue: number | null; title: string; }; diff --git a/src/features/emails/components/OpenedInsightsSection.tsx b/src/features/emails/components/OpenedInsightsSection.tsx index 6692106575..2ac80564d7 100644 --- a/src/features/emails/components/OpenedInsightsSection.tsx +++ b/src/features/emails/components/OpenedInsightsSection.tsx @@ -114,8 +114,8 @@ const OpenedInsightsSection: FC = ({ email, secondaryEmailId }) => { mainTotal={stats.numSent} mainValue={stats.numOpened} secondaryEmail={secondaryEmail} - secondaryTotal={secondaryStats?.num_sent} - secondaryValue={secondaryStats?.num_opened} + secondaryTotal={secondaryStats?.num_sent ?? null} + secondaryValue={secondaryStats?.num_opened ?? null} title={messages.insights.opened.gauge.header()} /> From 2bad36204c7ae29945e90f19c8a4b9ef8748a08c Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 29 Aug 2024 13:00:48 +0200 Subject: [PATCH 146/369] Add check before type cast. --- src/features/import/hooks/useDateConfig.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/import/hooks/useDateConfig.ts b/src/features/import/hooks/useDateConfig.ts index ad710221bf..73121f3fde 100644 --- a/src/features/import/hooks/useDateConfig.ts +++ b/src/features/import/hooks/useDateConfig.ts @@ -54,7 +54,12 @@ export default function useDateConfig(column: DateColumn, columnIndex: number) { const personNumberFormats: PersonNumberFormat[] = []; - if (organization) { + if ( + organization && + (organization.country === 'se' || + organization.country === 'dk' || + organization.country === 'no') + ) { personNumberFormats.push( organization.country.toLowerCase() as PersonNumberFormat ); From 07e1b4917354cc78dbd9df02c3afa7f56d1d6ce6 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Fri, 30 Aug 2024 10:50:17 +0200 Subject: [PATCH 147/369] Remove behaviour to only show person number format of the country of the org. --- src/features/import/hooks/useDateConfig.ts | 24 ++-------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/src/features/import/hooks/useDateConfig.ts b/src/features/import/hooks/useDateConfig.ts index 73121f3fde..bfc3ae3159 100644 --- a/src/features/import/hooks/useDateConfig.ts +++ b/src/features/import/hooks/useDateConfig.ts @@ -3,17 +3,10 @@ import { useState } from 'react'; import { columnUpdate } from '../store'; import { DateColumn } from '../utils/types'; import parseDate from '../utils/parseDate'; -import useOrganization from 'features/organizations/hooks/useOrganization'; -import { - useAppDispatch, - useAppSelector, - useNumericRouteParams, -} from 'core/hooks'; +import { useAppDispatch, useAppSelector } from 'core/hooks'; export default function useDateConfig(column: DateColumn, columnIndex: number) { const dispatch = useAppDispatch(); - const { orgId } = useNumericRouteParams(); - const organization = useOrganization(orgId).data; const selectedSheetIndex = useAppSelector( (state) => state.import.pendingFile.selectedSheetIndex @@ -52,20 +45,7 @@ export default function useDateConfig(column: DateColumn, columnIndex: number) { type PersonNumberFormat = 'se' | 'dk' | 'no'; - const personNumberFormats: PersonNumberFormat[] = []; - - if ( - organization && - (organization.country === 'se' || - organization.country === 'dk' || - organization.country === 'no') - ) { - personNumberFormats.push( - organization.country.toLowerCase() as PersonNumberFormat - ); - } else { - personNumberFormats.push('se', 'dk', 'no'); - } + const personNumberFormats: PersonNumberFormat[] = ['se', 'dk', 'no']; const dateFormats = { ['MM-DD-YYYY']: '10-06-2024', From 472a4cccde912cf0e89ceb34f8a93afd5a573229 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 30 Aug 2024 11:14:07 +0200 Subject: [PATCH 148/369] Move types to separate module and rename --- src/features/areas/components/AreasMap.tsx | 16 +++++++--------- src/features/areas/types.ts | 6 ++++++ 2 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 src/features/areas/types.ts diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index c198d635fb..24bffc8cca 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -12,7 +12,11 @@ import { FeatureGroup, latLngBounds, Map as MapType } from 'leaflet'; import { Box, ButtonGroup, IconButton } from '@mui/material'; import { Create } from '@mui/icons-material'; -interface MapProps {} +import { PointData, ZetkinArea } from '../types'; + +interface MapProps { + areas: ZetkinArea[]; +} const MapWrapper = ({ children, @@ -23,16 +27,10 @@ const MapWrapper = ({ return children(map); }; -type PointData = [number, number]; -type PolygonData = { - id: number; - points: PointData[]; -}; - -const Map: FC = () => { +const Map: FC = ({ areas }) => { const reactFGref = useRef(null); const [drawingPoints, setDrawingPoints] = useState(null); - const [polygons, setPolygons] = useState([]); + const [polygons, setPolygons] = useState(areas); const drawingRef = useRef(false); return ( diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts new file mode 100644 index 0000000000..ffc6d7b7dc --- /dev/null +++ b/src/features/areas/types.ts @@ -0,0 +1,6 @@ +export type PointData = [number, number]; + +export type ZetkinArea = { + id: number; + points: PointData[]; +}; From 56da311e2eeafe35a1a4cddd28afdcb5a61d5b80 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 30 Aug 2024 11:15:06 +0200 Subject: [PATCH 149/369] Add areas slice to store --- src/core/store.ts | 3 +++ src/features/areas/store.ts | 32 ++++++++++++++++++++++++++++ src/utils/testing/mocks/mockState.ts | 3 +++ 3 files changed, 38 insertions(+) create mode 100644 src/features/areas/store.ts diff --git a/src/core/store.ts b/src/core/store.ts index 6f3fe1ed88..3ab45e1406 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -49,8 +49,10 @@ import tagsSlice, { TagsStoreSlice } from 'features/tags/store'; import tasksSlice, { TasksStoreSlice } from 'features/tasks/store'; import userSlice, { UserStoreSlice } from 'features/user/store'; import viewsSlice, { ViewsStoreSlice } from 'features/views/store'; +import areasSlice, { AreasStoreSlice } from 'features/areas/store'; export interface RootState { + areas: AreasStoreSlice; breadcrumbs: BreadcrumbsStoreSlice; callAssignments: CallAssignmentSlice; campaigns: CampaignsStoreSlice; @@ -74,6 +76,7 @@ export interface RootState { } const reducer = { + areas: areasSlice.reducer, breadcrumbs: breadcrumbsSlice.reducer, callAssignments: callAssignmentsSlice.reducer, campaigns: campaignsSlice.reducer, diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts new file mode 100644 index 0000000000..60861f243f --- /dev/null +++ b/src/features/areas/store.ts @@ -0,0 +1,32 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { remoteList, RemoteList } from 'utils/storeUtils'; +import { ZetkinArea } from './types'; + +export interface AreasStoreSlice { + areaList: RemoteList; +} + +const initialState: AreasStoreSlice = { + areaList: remoteList(), +}; + +const areasSlice = createSlice({ + initialState: initialState, + name: 'areas', + reducers: { + areasLoad: (state) => { + state.areaList.isLoading = true; + }, + areasLoaded: (state, action: PayloadAction) => { + const timestamp = new Date().toISOString(); + const areas = action.payload; + state.areaList = remoteList(areas); + state.areaList.loaded = timestamp; + state.areaList.items.forEach((item) => (item.loaded = timestamp)); + }, + }, +}); + +export default areasSlice; +export const { areasLoad, areasLoaded } = areasSlice.actions; diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index 1e7edb1e63..a27ae26308 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -3,6 +3,9 @@ import { remoteItem, remoteList } from 'utils/storeUtils'; export default function mockState(overrides?: RootState) { const emptyState: RootState = { + areas: { + areaList: remoteList(), + }, breadcrumbs: { crumbsByPath: {}, }, From 9e1fd7f61616e92f27eee72f783092f8450af40c Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 30 Aug 2024 11:15:17 +0200 Subject: [PATCH 150/369] Load dummy areas from new API route --- src/app/beta/orgs/[orgId]/areas/route.ts | 40 ++++++++++++++++++++++ src/features/areas/hooks/useAreas.ts | 16 +++++++++ src/pages/organize/[orgId]/areas/index.tsx | 10 +++++- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/app/beta/orgs/[orgId]/areas/route.ts create mode 100644 src/features/areas/hooks/useAreas.ts diff --git a/src/app/beta/orgs/[orgId]/areas/route.ts b/src/app/beta/orgs/[orgId]/areas/route.ts new file mode 100644 index 0000000000..0944494028 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/areas/route.ts @@ -0,0 +1,40 @@ +import { ZetkinArea } from 'features/areas/types'; + +export function GET() { + const areas: ZetkinArea[] = [ + { + id: 2, + points: [ + [55.59361532349994, 12.977986335754396], + [55.5914203134015, 12.97790050506592], + [55.59045010406615, 12.977342605590822], + [55.59007414150065, 12.979617118835451], + [55.58915241158536, 12.983243465423586], + [55.589698175333524, 12.983586788177492], + [55.59359106991554, 12.983479499816896], + ], + }, + { + id: 3, + points: [ + [55.59353043588896, 12.97290086746216], + [55.59151733301583, 12.971913814544678], + [55.59047435959185, 12.977235317230225], + [55.591396058460454, 12.977771759033205], + [55.59357894311772, 12.977921962738039], + ], + }, + { + id: 4, + points: [ + [55.59355468951083, 12.983586788177492], + [55.59353043588896, 12.987470626831056], + [55.59174775363858, 12.98362970352173], + ], + }, + ]; + + return Response.json({ + data: areas, + }); +} diff --git a/src/features/areas/hooks/useAreas.ts b/src/features/areas/hooks/useAreas.ts new file mode 100644 index 0000000000..2468eefe21 --- /dev/null +++ b/src/features/areas/hooks/useAreas.ts @@ -0,0 +1,16 @@ +import { loadListIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { areasLoad, areasLoaded } from '../store'; +import { ZetkinArea } from '../types'; + +export default function useAreas(orgId: number) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const list = useAppSelector((state) => state.areas.areaList); + + return loadListIfNecessary(list, dispatch, { + actionOnLoad: () => areasLoad(), + actionOnSuccess: (data) => areasLoaded(data), + loader: () => apiClient.get(`/beta/orgs/${orgId}/areas`), + }); +} diff --git a/src/pages/organize/[orgId]/areas/index.tsx b/src/pages/organize/[orgId]/areas/index.tsx index 0a3505a01c..80d67f53ab 100644 --- a/src/pages/organize/[orgId]/areas/index.tsx +++ b/src/pages/organize/[orgId]/areas/index.tsx @@ -5,6 +5,9 @@ import dynamic from 'next/dynamic'; import SimpleLayout from 'utils/layout/SimpleLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; +import useAreas from 'features/areas/hooks/useAreas'; +import { useNumericRouteParams } from 'core/hooks'; +import ZUIFuture from 'zui/ZUIFuture'; const scaffoldOptions = { authLevelRequired: 2, @@ -22,12 +25,17 @@ const AreasMap = dynamic( ); const AreasPage: PageWithLayout = () => { + const { orgId } = useNumericRouteParams(); + const areasFuture = useAreas(orgId); + return ( <> Areas - + + {(areas) => } + ); }; From c5c1b4e8fa83efbf2e0ed6c4cfa68cc2886d3113 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Fri, 30 Aug 2024 12:33:19 +0200 Subject: [PATCH 151/369] Create hook + store functions to get single area. --- src/features/areas/hooks/useArea.ts | 18 ++++++++++++++++++ src/features/areas/store.ts | 23 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/features/areas/hooks/useArea.ts diff --git a/src/features/areas/hooks/useArea.ts b/src/features/areas/hooks/useArea.ts new file mode 100644 index 0000000000..b9bac1f707 --- /dev/null +++ b/src/features/areas/hooks/useArea.ts @@ -0,0 +1,18 @@ +import { loadItemIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { areaLoad, areaLoaded } from '../store'; +import { ZetkinArea } from '../types'; + +export default function useArea(orgId: number, areaId: number) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const areaItems = useAppSelector((state) => state.areas.areaList.items); + const areaItem = areaItems.find((item) => item.id === areaId); + + return loadItemIfNecessary(areaItem, dispatch, { + actionOnLoad: () => areaLoad(areaId), + actionOnSuccess: (data) => areaLoaded(data), + loader: () => + apiClient.get(`/beta/orgs/${orgId}/areas/${areaId}`), + }); +} diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 60861f243f..91c0f0fcaa 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -15,6 +15,26 @@ const areasSlice = createSlice({ initialState: initialState, name: 'areas', reducers: { + areaLoad: (state, action: PayloadAction) => { + const areaId = action.payload; + const item = state.areaList.items.find((item) => item.id == areaId); + + if (item) { + item.isLoading = true; + } + }, + areaLoaded: (state, action: PayloadAction) => { + const area = action.payload; + const item = state.areaList.items.find((item) => item.id == area.id); + + if (!item) { + throw new Error('Finished loading item that never started loading'); + } + + item.data = area; + item.isLoading = false; + item.loaded = new Date().toISOString(); + }, areasLoad: (state) => { state.areaList.isLoading = true; }, @@ -29,4 +49,5 @@ const areasSlice = createSlice({ }); export default areasSlice; -export const { areasLoad, areasLoaded } = areasSlice.actions; +export const { areaLoad, areaLoaded, areasLoad, areasLoaded } = + areasSlice.actions; From 2d3fef56ae3fc80f2b3b4ad069e9e803b636a597 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Fri, 30 Aug 2024 12:34:14 +0200 Subject: [PATCH 152/369] Create route to get one dummy area. --- .../beta/orgs/[orgId]/areas/[areaId]/route.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts diff --git a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts new file mode 100644 index 0000000000..0b0c8335f9 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts @@ -0,0 +1,20 @@ +import { ZetkinArea } from 'features/areas/types'; + +export function GET() { + const area: ZetkinArea = { + id: 2, + points: [ + [55.59361532349994, 12.977986335754396], + [55.5914203134015, 12.97790050506592], + [55.59045010406615, 12.977342605590822], + [55.59007414150065, 12.979617118835451], + [55.58915241158536, 12.983243465423586], + [55.589698175333524, 12.983586788177492], + [55.59359106991554, 12.983479499816896], + ], + }; + + return Response.json({ + data: area, + }); +} From 72361650c70e7cb3998b11c3a1b571c2cf2bef90 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Fri, 30 Aug 2024 12:35:34 +0200 Subject: [PATCH 153/369] Create basic page for single area. --- src/app/o/[orgId]/areas/[areaId]/page.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/app/o/[orgId]/areas/[areaId]/page.tsx diff --git a/src/app/o/[orgId]/areas/[areaId]/page.tsx b/src/app/o/[orgId]/areas/[areaId]/page.tsx new file mode 100644 index 0000000000..cc5ff394d7 --- /dev/null +++ b/src/app/o/[orgId]/areas/[areaId]/page.tsx @@ -0,0 +1,11 @@ +interface AreaPageParams { + params: { + areaId: string; + orgId: string; + }; +} + +export default function Page({ params }: AreaPageParams) { + const { orgId, areaId } = params; + return
{`this is the page for org ${orgId} area ${areaId}`}
; +} From e8e11cf50b227b487d478f90e12c5b77bac10df9 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Fri, 30 Aug 2024 14:16:39 +0200 Subject: [PATCH 154/369] Correct mistake in store logic. --- src/features/areas/store.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 91c0f0fcaa..b49cb80890 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { remoteList, RemoteList } from 'utils/storeUtils'; +import { remoteItem, remoteList, RemoteList } from 'utils/storeUtils'; import { ZetkinArea } from './types'; export interface AreasStoreSlice { @@ -21,6 +21,10 @@ const areasSlice = createSlice({ if (item) { item.isLoading = true; + } else { + state.areaList.items = state.areaList.items.concat([ + remoteItem(areaId, { isLoading: true }), + ]); } }, areaLoaded: (state, action: PayloadAction) => { From fb9501bcd833b9930697237bb0125202279f7398 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Fri, 30 Aug 2024 14:19:16 +0200 Subject: [PATCH 155/369] Add org title + avatar to page, get area data from hook. --- src/app/o/[orgId]/areas/[areaId]/page.tsx | 8 +++-- src/features/areas/components/AreaPage.tsx | 34 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) create mode 100644 src/features/areas/components/AreaPage.tsx diff --git a/src/app/o/[orgId]/areas/[areaId]/page.tsx b/src/app/o/[orgId]/areas/[areaId]/page.tsx index cc5ff394d7..78b6a45125 100644 --- a/src/app/o/[orgId]/areas/[areaId]/page.tsx +++ b/src/app/o/[orgId]/areas/[areaId]/page.tsx @@ -1,11 +1,13 @@ -interface AreaPageParams { +import AreaPage from 'features/areas/components/AreaPage'; + +interface AreaPageProps { params: { areaId: string; orgId: string; }; } -export default function Page({ params }: AreaPageParams) { +export default function Page({ params }: AreaPageProps) { const { orgId, areaId } = params; - return
{`this is the page for org ${orgId} area ${areaId}`}
; + return ; } diff --git a/src/features/areas/components/AreaPage.tsx b/src/features/areas/components/AreaPage.tsx new file mode 100644 index 0000000000..0630e0eb1e --- /dev/null +++ b/src/features/areas/components/AreaPage.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { Avatar, Box } from '@mui/material'; +import { FC } from 'react'; + +import useArea from '../hooks/useArea'; +import useOrganization from 'features/organizations/hooks/useOrganization'; +import ZUIFutures from 'zui/ZUIFutures'; + +type AreaPageProps = { + areaId: number; + orgId: number; +}; + +const AreaPage: FC = ({ areaId, orgId }) => { + const orgFuture = useOrganization(orgId); + const areaFuture = useArea(orgId, areaId); + + return ( + + {({ data: { area, org } }) => ( + + + + {org.title} + + {area.id} + + )} + + ); +}; + +export default AreaPage; From ad10fe4eca23357302c59a0e0f15799b361cee8d Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 30 Aug 2024 15:52:43 +0200 Subject: [PATCH 156/369] Install mongoose and configure nextjs for compatibility --- .env.development | 3 +- next.config.js | 3 +- package.json | 1 + yarn.lock | 115 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/.env.development b/.env.development index 5b9b6db6b5..2c8219c4a8 100644 --- a/.env.development +++ b/.env.development @@ -8,4 +8,5 @@ ZETKIN_CLIENT_SECRET=MWQyZmE2M2UtMzM3Yi00ODUyLWI2NGMtOWY5YTY5NTY3YjU5 ZETKIN_APP_HOST=localhost:3000 ZETKIN_USE_TLS=0 -SESSION_PASSWORD=thisispasswordandshouldbelongerthan32characters \ No newline at end of file +SESSION_PASSWORD=thisispasswordandshouldbelongerthan32characters +MONGODB_URL= \ No newline at end of file diff --git a/next.config.js b/next.config.js index 8458f35908..9946118a2d 100644 --- a/next.config.js +++ b/next.config.js @@ -1,6 +1,7 @@ module.exports = { experimental: { - serverComponentsExternalPackages: ["mjml"], + esmExternals: "loose", + serverComponentsExternalPackages: ["mjml", "mongoose"], }, images: { domains: [ diff --git a/package.json b/package.json index 8346de55bc..2685da9831 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "lodash": "^4.17.21", "marked": "^4.0.16", "mjml": "^4.15.3", + "mongoose": "^8.6.0", "mui-rff": "^6.1.2", "negotiator": "^0.6.2", "next": "^14.1.0", diff --git a/yarn.lock b/yarn.lock index 18f6bd3582..bcce40d450 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2511,6 +2511,13 @@ dependencies: moo "^0.5.1" +"@mongodb-js/saslprep@^1.1.5": + version "1.1.8" + resolved "https://registry.yarnpkg.com/@mongodb-js/saslprep/-/saslprep-1.1.8.tgz#d39744540be8800d17749990b0da95b4271840d1" + integrity sha512-qKwC/M/nNNaKUBMQ0nuzm47b7ZYWQHN3pcXq4IIcoSBc2hOIrflAxJduIvvqmhoz3gR2TacTAs8vlsCVPkiEdQ== + dependencies: + sparse-bitfield "^3.0.3" + "@mui/base@5.0.0-alpha.98": version "5.0.0-alpha.98" resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-alpha.98.tgz#d1c89477d74cbfc64a7d1b336a6dbe83d4057ee8" @@ -4678,6 +4685,18 @@ resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.7.0.tgz#fa25263656d234473025c2d48249a900053c355a" integrity sha512-+jBxVvXVuggZOrm04NR8z+5+bgoW4VZyLzUO+hmPPW1mVFL/HaitLAkizfv4yg9TbG8lkfHWVMQ11yDqrVVCzA== +"@types/webidl-conversions@*": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" + integrity sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== + +"@types/whatwg-url@^11.0.2": + version "11.0.5" + resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-11.0.5.tgz#aaa2546e60f0c99209ca13360c32c78caf2c409f" + integrity sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ== + dependencies: + "@types/webidl-conversions" "*" + "@types/yargs-parser@*": version "20.2.1" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129" @@ -5879,6 +5898,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +bson@^6.7.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/bson/-/bson-6.8.0.tgz#5063c41ba2437c2b8ff851b50d9e36cb7aaa7525" + integrity sha512-iOJg8pr7wq2tg/zSlCCHMi3hMm5JTOxLTagf3zxhcenHsFp+c6uOs6K7W5UE7A4QIJGtqh/ZovFNMP4mOPJynQ== + btoa@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/btoa/-/btoa-1.2.1.tgz#01a9909f8b2c93f6bf680ba26131eb30f7fa3d73" @@ -6889,6 +6913,13 @@ debug@4, debug@^4.1.0, debug@^4.1.1: dependencies: ms "2.1.2" +debug@4.x: + version "4.3.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -10579,6 +10610,11 @@ juice@^10.0.0: slick "^1.12.2" web-resource-inliner "^6.0.1" +kareem@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/kareem/-/kareem-2.6.3.tgz#23168ec8ffb6c1abfd31b7169a6fb1dd285992ac" + integrity sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q== + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -11059,6 +11095,11 @@ memoizerific@^1.11.3: dependencies: map-or-similar "^1.5.0" +memory-pager@^1.0.2: + version "1.5.0" + resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5" + integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg== + memorystream@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" @@ -11864,6 +11905,36 @@ mkdirp@^1.0.3: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mongodb-connection-string-url@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz#c13e6ac284ae401752ebafdb8cd7f16c6723b141" + integrity sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg== + dependencies: + "@types/whatwg-url" "^11.0.2" + whatwg-url "^13.0.0" + +mongodb@6.8.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.8.0.tgz#680450f113cdea6d2d9f7121fe57cd29111fd2ce" + integrity sha512-HGQ9NWDle5WvwMnrvUxsFYPd3JEbqD3RgABHBQRuoCEND0qzhsd0iH5ypHsf1eJ+sXmvmyKpP+FLOKY8Il7jMw== + dependencies: + "@mongodb-js/saslprep" "^1.1.5" + bson "^6.7.0" + mongodb-connection-string-url "^3.0.0" + +mongoose@^8.6.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-8.6.0.tgz#52a7cc6026c4d49cd14e4a40e6601c44b1186e49" + integrity sha512-p6VSbYKvD4ZIabqo8C0kS5eKX1Xpji+opTAIJ9wyuPJ8Y/FblgXSMnFRXnB40bYZLKPQT089K5KU8+bqIXtFdw== + dependencies: + bson "^6.7.0" + kareem "2.6.3" + mongodb "6.8.0" + mpath "0.9.0" + mquery "5.0.0" + ms "2.1.3" + sift "17.1.3" + moo@^0.5.1: version "0.5.2" resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c" @@ -11880,6 +11951,18 @@ moxy@^0.1.2: express "^4.17.1" http-proxy-middleware "^2.0.0" +mpath@0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/mpath/-/mpath-0.9.0.tgz#0c122fe107846e31fc58c75b09c35514b3871904" + integrity sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew== + +mquery@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/mquery/-/mquery-5.0.0.tgz#a95be5dfc610b23862df34a47d3e5d60e110695d" + integrity sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg== + dependencies: + debug "4.x" + mri@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" @@ -13026,6 +13109,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +punycode@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + qs@6.11.0: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" @@ -14095,6 +14183,11 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +sift@17.1.3: + version "17.1.3" + resolved "https://registry.yarnpkg.com/sift/-/sift-17.1.3.tgz#9d2000d4d41586880b0079b5183d839c7a142bf7" + integrity sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ== + signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.6" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.6.tgz#24e630c4b0f03fea446a2bd299e62b4a6ca8d0af" @@ -14206,6 +14299,13 @@ space-separated-tokens@^2.0.0: resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== +sparse-bitfield@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz#ff4ae6e68656056ba4b3e792ab3334d38273ca11" + integrity sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ== + dependencies: + memory-pager "^1.0.2" + spdx-correct@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" @@ -14783,6 +14883,13 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" +tr46@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469" + integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw== + dependencies: + punycode "^2.3.0" + tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" @@ -15571,6 +15678,14 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" +whatwg-url@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-13.0.0.tgz#b7b536aca48306394a34e44bda8e99f332410f8f" + integrity sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig== + dependencies: + tr46 "^4.1.1" + webidl-conversions "^7.0.0" + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" From 5ddc35d4694e88a3d744382f9e74beaa0f6c7bb2 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 30 Aug 2024 15:53:42 +0200 Subject: [PATCH 157/369] Use MongoDB model for areas API --- src/app/beta/orgs/[orgId]/areas/route.ts | 52 ++++++++---------------- src/features/areas/models.ts | 16 ++++++++ 2 files changed, 32 insertions(+), 36 deletions(-) create mode 100644 src/features/areas/models.ts diff --git a/src/app/beta/orgs/[orgId]/areas/route.ts b/src/app/beta/orgs/[orgId]/areas/route.ts index 0944494028..8dc0b8f8fe 100644 --- a/src/app/beta/orgs/[orgId]/areas/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/route.ts @@ -1,40 +1,20 @@ +import mongoose from 'mongoose'; +import { NextRequest } from 'next/server'; + +import { AreaModel } from 'features/areas/models'; import { ZetkinArea } from 'features/areas/types'; -export function GET() { - const areas: ZetkinArea[] = [ - { - id: 2, - points: [ - [55.59361532349994, 12.977986335754396], - [55.5914203134015, 12.97790050506592], - [55.59045010406615, 12.977342605590822], - [55.59007414150065, 12.979617118835451], - [55.58915241158536, 12.983243465423586], - [55.589698175333524, 12.983586788177492], - [55.59359106991554, 12.983479499816896], - ], - }, - { - id: 3, - points: [ - [55.59353043588896, 12.97290086746216], - [55.59151733301583, 12.971913814544678], - [55.59047435959185, 12.977235317230225], - [55.591396058460454, 12.977771759033205], - [55.59357894311772, 12.977921962738039], - ], - }, - { - id: 4, - points: [ - [55.59355468951083, 12.983586788177492], - [55.59353043588896, 12.987470626831056], - [55.59174775363858, 12.98362970352173], - ], - }, - ]; +export async function GET( + request: NextRequest, + { params }: { params: { orgId: string } } +) { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const areaModels = await AreaModel.find({ orgId: parseInt(params.orgId) }); + const areas: ZetkinArea[] = areaModels.map((model) => ({ + id: model.id, + points: model.points, + })); - return Response.json({ - data: areas, - }); + return Response.json({ data: areas }); } diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts new file mode 100644 index 0000000000..d377d92e3c --- /dev/null +++ b/src/features/areas/models.ts @@ -0,0 +1,16 @@ +import mongoose from 'mongoose'; + +import { ZetkinArea } from './types'; + +type IZetkinModel = ZetkinArea & { + orgId: number; +}; + +const areaSchema = new mongoose.Schema({ + id: Number, + orgId: Number, + points: Array, +}); + +export const AreaModel = + mongoose.models.Area || mongoose.model('Area', areaSchema); From 0e4c1f615d65bc54bc330416cb302730ea0aa822 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 30 Aug 2024 16:19:15 +0200 Subject: [PATCH 158/369] Fix bug causing click events to be triggered multiple times --- src/features/areas/components/AreasMap.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index 24bffc8cca..d55f528a92 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -73,16 +73,18 @@ const Map: FC = ({ areas }) => { > {(map) => { - map.on('click', (evt) => { - if (drawingRef.current) { - const lat = evt.latlng.lat; - const lng = evt.latlng.lng; - setDrawingPoints((current) => [ - ...(current || []), - [lat, lng], - ]); - } - }); + if (!map.hasEventListeners('click')) { + map.on('click', (evt) => { + if (drawingRef.current) { + const lat = evt.latlng.lat; + const lng = evt.latlng.lng; + setDrawingPoints((current) => [ + ...(current || []), + [lat, lng], + ]); + } + }); + } return ( <> From b3f51ec4152929d13fd8e9ec447cefc222f2afc6 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 30 Aug 2024 17:01:36 +0200 Subject: [PATCH 159/369] Save area to API after drawing --- src/app/beta/orgs/[orgId]/areas/route.ts | 35 ++++++++++++++++++---- src/features/areas/components/AreasMap.tsx | 13 ++++---- src/features/areas/hooks/useCreateArea.ts | 16 ++++++++++ src/features/areas/models.ts | 8 ++--- src/features/areas/store.ts | 13 ++++++-- src/features/areas/types.ts | 4 ++- 6 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 src/features/areas/hooks/useCreateArea.ts diff --git a/src/app/beta/orgs/[orgId]/areas/route.ts b/src/app/beta/orgs/[orgId]/areas/route.ts index 8dc0b8f8fe..e40b190a60 100644 --- a/src/app/beta/orgs/[orgId]/areas/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/route.ts @@ -1,20 +1,43 @@ import mongoose from 'mongoose'; -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { AreaModel } from 'features/areas/models'; import { ZetkinArea } from 'features/areas/types'; -export async function GET( - request: NextRequest, - { params }: { params: { orgId: string } } -) { +type RouteMeta = { + params: { + orgId: string; + }; +}; + +export async function GET(request: NextRequest, { params }: RouteMeta) { await mongoose.connect(process.env.MONGODB_URL || ''); const areaModels = await AreaModel.find({ orgId: parseInt(params.orgId) }); const areas: ZetkinArea[] = areaModels.map((model) => ({ - id: model.id, + id: model._id.toString(), points: model.points, })); return Response.json({ data: areas }); } + +export async function POST(request: NextRequest, { params }: RouteMeta) { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const payload = await request.json(); + + const model = new AreaModel({ + orgId: parseInt(params.orgId), + points: payload.points, + }); + + await model.save(); + + return NextResponse.json({ + data: { + id: model._id.toString(), + points: model.points, + }, + }); +} diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index d55f528a92..79d14be49c 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -13,6 +13,8 @@ import { Box, ButtonGroup, IconButton } from '@mui/material'; import { Create } from '@mui/icons-material'; import { PointData, ZetkinArea } from '../types'; +import useCreateArea from '../hooks/useCreateArea'; +import { useNumericRouteParams } from 'core/hooks'; interface MapProps { areas: ZetkinArea[]; @@ -30,9 +32,11 @@ const MapWrapper = ({ const Map: FC = ({ areas }) => { const reactFGref = useRef(null); const [drawingPoints, setDrawingPoints] = useState(null); - const [polygons, setPolygons] = useState(areas); const drawingRef = useRef(false); + const { orgId } = useNumericRouteParams(); + const createArea = useCreateArea(orgId); + return ( = ({ areas }) => { onClick={() => { if (drawingPoints) { if (drawingPoints.length > 2) { - setPolygons((current) => [ - ...current, - { id: current.length + 1, points: drawingPoints }, - ]); + createArea({ points: drawingPoints }); } setDrawingPoints(null); drawingRef.current = false; @@ -103,7 +104,7 @@ const Map: FC = ({ areas }) => { positions={drawingPoints} /> )} - {polygons.map((polygon) => ( + {areas.map((polygon) => ( ( + `/beta/orgs/${orgId}/areas`, + data + ); + dispatch(areaCreated(created)); + }; +} diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index d377d92e3c..41721b722b 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -2,15 +2,15 @@ import mongoose from 'mongoose'; import { ZetkinArea } from './types'; -type IZetkinModel = ZetkinArea & { +type IZetkinModel = { orgId: number; + points: ZetkinArea['points']; }; const areaSchema = new mongoose.Schema({ - id: Number, - orgId: Number, + orgId: { required: true, type: Number }, points: Array, }); -export const AreaModel = +export const AreaModel: mongoose.Model = mongoose.models.Area || mongoose.model('Area', areaSchema); diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 60861f243f..c3a559d8a8 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { remoteList, RemoteList } from 'utils/storeUtils'; +import { remoteItem, remoteList, RemoteList } from 'utils/storeUtils'; import { ZetkinArea } from './types'; export interface AreasStoreSlice { @@ -15,6 +15,15 @@ const areasSlice = createSlice({ initialState: initialState, name: 'areas', reducers: { + areaCreated: (state, action: PayloadAction) => { + const area = action.payload; + const item = remoteItem(area.id, { + data: area, + loaded: new Date().toISOString(), + }); + + state.areaList.items.push(item); + }, areasLoad: (state) => { state.areaList.isLoading = true; }, @@ -29,4 +38,4 @@ const areasSlice = createSlice({ }); export default areasSlice; -export const { areasLoad, areasLoaded } = areasSlice.actions; +export const { areaCreated, areasLoad, areasLoaded } = areasSlice.actions; diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index ffc6d7b7dc..1445745f3c 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -1,6 +1,8 @@ export type PointData = [number, number]; export type ZetkinArea = { - id: number; + id: string; points: PointData[]; }; + +export type ZetkinAreaPostBody = Partial>; From 1884c469d73367c6dd2d317a8d84f2cf4ab191f2 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 06:25:03 +0200 Subject: [PATCH 160/369] Create bespoke error class for API client errors --- src/core/api/client/FetchApiClient.ts | 3 ++- src/core/api/errors.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/core/api/errors.ts diff --git a/src/core/api/client/FetchApiClient.ts b/src/core/api/client/FetchApiClient.ts index ad48ed41cb..d509acf39e 100644 --- a/src/core/api/client/FetchApiClient.ts +++ b/src/core/api/client/FetchApiClient.ts @@ -1,10 +1,11 @@ import { ApiFetch } from 'utils/apiFetch'; import IApiClient from './IApiClient'; import { RPCDef, RPCRequestBody, RPCResponseBody } from 'core/rpc/types'; +import { ApiClientError } from '../errors'; function assertOk(res: Response) { if (!res.ok) { - throw new Error(`Error during request: ${res.status}, ${res.url}`); + throw ApiClientError.fromResponse(res); } } diff --git a/src/core/api/errors.ts b/src/core/api/errors.ts new file mode 100644 index 0000000000..3da711371b --- /dev/null +++ b/src/core/api/errors.ts @@ -0,0 +1,22 @@ +export class ApiClientError extends Error { + private _status: number; + private _url: string; + + constructor(status: number, url: string) { + super(`Error during request: ${status}, ${url}`); + this._status = status; + this._url = url; + } + + static fromResponse(res: Response) { + return new ApiClientError(res.status, res.url); + } + + public get status(): number { + return this._status; + } + + public get url(): string { + return this._url; + } +} From e43df6768539830c520696aaff99577ceb766a08 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 06:26:22 +0200 Subject: [PATCH 161/369] Protect areas API routes --- src/app/beta/orgs/[orgId]/areas/route.ts | 69 +++++++++++++++--------- src/utils/api/asOrgAuthorized.ts | 53 ++++++++++++++++++ 2 files changed, 97 insertions(+), 25 deletions(-) create mode 100644 src/utils/api/asOrgAuthorized.ts diff --git a/src/app/beta/orgs/[orgId]/areas/route.ts b/src/app/beta/orgs/[orgId]/areas/route.ts index e40b190a60..4d3efa8e0e 100644 --- a/src/app/beta/orgs/[orgId]/areas/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { AreaModel } from 'features/areas/models'; import { ZetkinArea } from 'features/areas/types'; +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; type RouteMeta = { params: { @@ -11,33 +12,51 @@ type RouteMeta = { }; export async function GET(request: NextRequest, { params }: RouteMeta) { - await mongoose.connect(process.env.MONGODB_URL || ''); - - const areaModels = await AreaModel.find({ orgId: parseInt(params.orgId) }); - const areas: ZetkinArea[] = areaModels.map((model) => ({ - id: model._id.toString(), - points: model.points, - })); - - return Response.json({ data: areas }); + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin', 'organizer'], + }, + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const areaModels = await AreaModel.find({ orgId }); + const areas: ZetkinArea[] = areaModels.map((model) => ({ + id: model._id.toString(), + points: model.points, + })); + + return Response.json({ data: areas }); + } + ); } export async function POST(request: NextRequest, { params }: RouteMeta) { - await mongoose.connect(process.env.MONGODB_URL || ''); - - const payload = await request.json(); - - const model = new AreaModel({ - orgId: parseInt(params.orgId), - points: payload.points, - }); - - await model.save(); - - return NextResponse.json({ - data: { - id: model._id.toString(), - points: model.points, + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], }, - }); + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const payload = await request.json(); + + const model = new AreaModel({ + orgId: orgId, + points: payload.points, + }); + + await model.save(); + + return NextResponse.json({ + data: { + id: model._id.toString(), + points: model.points, + }, + }); + } + ); } diff --git a/src/utils/api/asOrgAuthorized.ts b/src/utils/api/asOrgAuthorized.ts new file mode 100644 index 0000000000..5b9f8769cf --- /dev/null +++ b/src/utils/api/asOrgAuthorized.ts @@ -0,0 +1,53 @@ +import { IncomingHttpHeaders } from 'http'; +import { NextResponse } from 'next/server'; + +import BackendApiClient from 'core/api/client/BackendApiClient'; +import { ZetkinMembership } from 'utils/types/zetkin'; +import { ApiClientError } from 'core/api/errors'; + +type GuardedFnProps = { + orgId: number; + role: string | null; +}; + +type GuardedFn = (props: GuardedFnProps) => Promise; + +type AuthParams = { + orgId: number | string; + request: Request; + roles?: string[]; +}; + +export default async function asOrgAuthorized( + params: AuthParams, + fn: GuardedFn +): Promise { + const { request, orgId, roles } = params; + const headers: IncomingHttpHeaders = {}; + request.headers.forEach((value, key) => (headers[key] = value)); + const apiClient = new BackendApiClient(headers); + + try { + const membership = await apiClient.get( + `/api/users/me/memberships/${orgId}` + ); + + if (roles) { + const role = membership.role; + if (!role || !roles.includes(role)) { + return new NextResponse(null, { status: 403 }); + } + } + + return fn({ + orgId: membership.organization.id, + role: membership.role, + }); + } catch (err) { + if (err instanceof ApiClientError) { + return new NextResponse(null, { status: err.status }); + } else { + return new NextResponse(null, { status: 500 }); + } + } +} From 4c6bae2d9c2ecf761616111e07c853547a77dc47 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 11:58:10 +0200 Subject: [PATCH 162/369] Add organization ID to ZetinArea type --- src/app/beta/orgs/[orgId]/areas/route.ts | 6 ++++++ src/features/areas/types.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/src/app/beta/orgs/[orgId]/areas/route.ts b/src/app/beta/orgs/[orgId]/areas/route.ts index 4d3efa8e0e..51568e56ce 100644 --- a/src/app/beta/orgs/[orgId]/areas/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/route.ts @@ -24,6 +24,9 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const areaModels = await AreaModel.find({ orgId }); const areas: ZetkinArea[] = areaModels.map((model) => ({ id: model._id.toString(), + organization: { + id: orgId, + }, points: model.points, })); @@ -54,6 +57,9 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { return NextResponse.json({ data: { id: model._id.toString(), + organization: { + id: orgId, + }, points: model.points, }, }); diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 1445745f3c..71c4f92f06 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -2,6 +2,9 @@ export type PointData = [number, number]; export type ZetkinArea = { id: string; + organization: { + id: number; + }; points: PointData[]; }; From 3e973ac45bb3d3eb6f92565d3459a243adbe3959 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 12:00:02 +0200 Subject: [PATCH 163/369] Add overlay with link URL when selecting an area --- src/features/areas/components/AreaOverlay.tsx | 36 +++++++++++++++++++ src/features/areas/components/AreasMap.tsx | 9 +++-- 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/features/areas/components/AreaOverlay.tsx diff --git a/src/features/areas/components/AreaOverlay.tsx b/src/features/areas/components/AreaOverlay.tsx new file mode 100644 index 0000000000..7c3b2098a9 --- /dev/null +++ b/src/features/areas/components/AreaOverlay.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { Link, Paper, Typography } from '@mui/material'; + +import { ZetkinArea } from '../types'; + +type Props = { + area: ZetkinArea; +}; + +const AreaOverlay: FC = ({ area }) => { + const hostAndPath = `${window?.location.host}/o/${area.organization.id}/areas/${area.id}`; + const href = `${window?.location.protocol}//${hostAndPath}`; + + return ( + + {area.id} + + + {hostAndPath} + + + + ); +}; + +export default AreaOverlay; diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index 79d14be49c..b9f1312bec 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -15,6 +15,7 @@ import { Create } from '@mui/icons-material'; import { PointData, ZetkinArea } from '../types'; import useCreateArea from '../hooks/useCreateArea'; import { useNumericRouteParams } from 'core/hooks'; +import AreaOverlay from './AreaOverlay'; interface MapProps { areas: ZetkinArea[]; @@ -33,6 +34,9 @@ const Map: FC = ({ areas }) => { const reactFGref = useRef(null); const [drawingPoints, setDrawingPoints] = useState(null); const drawingRef = useRef(false); + const [selectedId, setSelectedId] = useState(''); + + const selectedArea = areas.find((area) => area.id == selectedId); const { orgId } = useNumericRouteParams(); const createArea = useCreateArea(orgId); @@ -67,7 +71,8 @@ const Map: FC = ({ areas }) => { - + + {selectedArea && } = ({ areas }) => { key={polygon.id} eventHandlers={{ click: () => { - alert('clicked polygon ' + polygon.id); + setSelectedId(polygon.id); }, }} positions={polygon.points} From 476ad645b2648b564d8bb7a69b6a09935cf16808 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 13:34:00 +0200 Subject: [PATCH 164/369] Add title and description to ZetkinArea type and model --- src/app/beta/orgs/[orgId]/areas/route.ts | 6 ++++++ src/features/areas/models.ts | 4 ++++ src/features/areas/types.ts | 2 ++ 3 files changed, 12 insertions(+) diff --git a/src/app/beta/orgs/[orgId]/areas/route.ts b/src/app/beta/orgs/[orgId]/areas/route.ts index 51568e56ce..35509d77ed 100644 --- a/src/app/beta/orgs/[orgId]/areas/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/route.ts @@ -23,11 +23,13 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const areaModels = await AreaModel.find({ orgId }); const areas: ZetkinArea[] = areaModels.map((model) => ({ + description: model.description, id: model._id.toString(), organization: { id: orgId, }, points: model.points, + title: model.title, })); return Response.json({ data: areas }); @@ -48,19 +50,23 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); const model = new AreaModel({ + description: payload.description, orgId: orgId, points: payload.points, + title: payload.title, }); await model.save(); return NextResponse.json({ data: { + description: model.description, id: model._id.toString(), organization: { id: orgId, }, points: model.points, + title: model.title, }, }); } diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 41721b722b..626b53d3cb 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -3,13 +3,17 @@ import mongoose from 'mongoose'; import { ZetkinArea } from './types'; type IZetkinModel = { + description: string | null; orgId: number; points: ZetkinArea['points']; + title: string | null; }; const areaSchema = new mongoose.Schema({ + description: String, orgId: { required: true, type: Number }, points: Array, + title: String, }); export const AreaModel: mongoose.Model = diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 71c4f92f06..4be1918dee 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -1,11 +1,13 @@ export type PointData = [number, number]; export type ZetkinArea = { + description: string | null; id: string; organization: { id: number; }; points: PointData[]; + title: string | null; }; export type ZetkinAreaPostBody = Partial>; From d757cfcde3bc51a764d55f5fea7ef617fcf25924 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 13:35:28 +0200 Subject: [PATCH 165/369] Add API, hooks and store logic to update areas --- .../beta/orgs/[orgId]/areas/[areaId]/route.ts | 53 +++++++++++++++++++ src/features/areas/hooks/useAreaMutations.ts | 18 +++++++ src/features/areas/store.ts | 17 +++++- src/utils/storeUtils.ts | 2 +- 4 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts create mode 100644 src/features/areas/hooks/useAreaMutations.ts diff --git a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts new file mode 100644 index 0000000000..7aa3edaec1 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts @@ -0,0 +1,53 @@ +import mongoose from 'mongoose'; +import { NextRequest, NextResponse } from 'next/server'; + +import { AreaModel } from 'features/areas/models'; +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; + +type RouteMeta = { + params: { + areaId: string; + orgId: string; + }; +}; + +export async function PATCH(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const payload = await request.json(); + + const model = await AreaModel.findOneAndUpdate( + { _id: params.areaId }, + { + description: payload.description, + points: payload.points, + title: payload.title, + }, + { new: true } + ); + + if (!model) { + return new NextResponse(null, { status: 404 }); + } + + return NextResponse.json({ + data: { + description: model.description, + id: model._id.toString(), + organization: { + id: orgId, + }, + points: model.points, + title: model.title, + }, + }); + } + ); +} diff --git a/src/features/areas/hooks/useAreaMutations.ts b/src/features/areas/hooks/useAreaMutations.ts new file mode 100644 index 0000000000..c325727dd1 --- /dev/null +++ b/src/features/areas/hooks/useAreaMutations.ts @@ -0,0 +1,18 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinArea, ZetkinAreaPostBody } from '../types'; +import { areaUpdated } from '../store'; + +export default function useAreaMutations(orgId: number, areaId: string) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return { + async updateArea(data: ZetkinAreaPostBody) { + const area = await apiClient.patch( + `/beta/orgs/${orgId}/areas/${areaId}`, + data + ); + dispatch(areaUpdated(area)); + }, + }; +} diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index c3a559d8a8..50600bc3a3 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -1,6 +1,11 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { remoteItem, remoteList, RemoteList } from 'utils/storeUtils'; +import { + findOrAddItem, + remoteItem, + remoteList, + RemoteList, +} from 'utils/storeUtils'; import { ZetkinArea } from './types'; export interface AreasStoreSlice { @@ -24,6 +29,13 @@ const areasSlice = createSlice({ state.areaList.items.push(item); }, + areaUpdated: (state, action: PayloadAction) => { + const area = action.payload; + const item = findOrAddItem(state.areaList, area.id); + + item.data = area; + item.loaded = new Date().toISOString(); + }, areasLoad: (state) => { state.areaList.isLoading = true; }, @@ -38,4 +50,5 @@ const areasSlice = createSlice({ }); export default areasSlice; -export const { areaCreated, areasLoad, areasLoaded } = areasSlice.actions; +export const { areaCreated, areaUpdated, areasLoad, areasLoaded } = + areasSlice.actions; diff --git a/src/utils/storeUtils.ts b/src/utils/storeUtils.ts index 5babd577a4..a7da6fb6a2 100644 --- a/src/utils/storeUtils.ts +++ b/src/utils/storeUtils.ts @@ -51,7 +51,7 @@ export function remoteList( export function findOrAddItem( list: RemoteList, - id: number + id: number | string ): RemoteItem { const existingItem = list.items.find((item) => item.id == id); if (existingItem) { From 7b81219835f350f0d38544c4998fc274dc717052 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 13:36:45 +0200 Subject: [PATCH 166/369] Duplicate overlay from LocationModal for areas --- src/features/areas/components/AreaOverlay.tsx | 143 +++++++++++++++++- src/features/areas/components/AreasMap.tsx | 4 +- src/features/areas/l10n/messageIds.ts | 8 + 3 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 src/features/areas/l10n/messageIds.ts diff --git a/src/features/areas/components/AreaOverlay.tsx b/src/features/areas/components/AreaOverlay.tsx index 7c3b2098a9..64473fa0de 100644 --- a/src/features/areas/components/AreaOverlay.tsx +++ b/src/features/areas/components/AreaOverlay.tsx @@ -1,13 +1,55 @@ -import { FC } from 'react'; -import { Link, Paper, Typography } from '@mui/material'; +import { FC, useCallback, useEffect, useState } from 'react'; +import { Close } from '@mui/icons-material'; +import { + Box, + ClickAwayListener, + Divider, + Link, + Paper, + TextField, + Typography, +} from '@mui/material'; import { ZetkinArea } from '../types'; +import useAreaMutations from '../hooks/useAreaMutations'; +import ZUIPreviewableInput, { + ZUIPreviewableMode, +} from 'zui/ZUIPreviewableInput'; +import { useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; type Props = { area: ZetkinArea; + onClose: () => void; }; -const AreaOverlay: FC = ({ area }) => { +const AreaOverlay: FC = ({ area, onClose }) => { + const [title, setTitle] = useState(area.title); + const [description, setDescription] = useState(area.description); + const [fieldEditing, setFieldEditing] = useState< + 'title' | 'description' | null + >(null); + const { updateArea } = useAreaMutations(area.organization.id, area.id); + const messages = useMessages(messageIds); + + const handleDescriptionTextAreaRef = useCallback( + (el: HTMLTextAreaElement | null) => { + if (el) { + // When entering edit mode for desciption, focus the text area and put + // caret at the end of the text + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + el.scrollTop = el.scrollHeight; + } + }, + [] + ); + + useEffect(() => { + setTitle(area.title); + setDescription(area.description); + }, [area]); + const hostAndPath = `${window?.location.host}/o/${area.organization.id}/areas/${area.id}`; const href = `${window?.location.protocol}//${hostAndPath}`; @@ -23,7 +65,100 @@ const AreaOverlay: FC = ({ area }) => { zIndex: 9999, }} > - {area.id} + { + if (fieldEditing === 'title') { + setFieldEditing(null); + updateArea({ title }); + } else if (fieldEditing === 'description') { + setFieldEditing(null); + updateArea({ description }); + } + }} + > + + + { + setFieldEditing( + mode === ZUIPreviewableMode.EDITABLE ? 'title' : null + ); + }} + renderInput={(props) => ( + setTitle(ev.target.value)} + sx={{ marginBottom: 2 }} + value={title} + /> + )} + renderPreview={() => ( + + {area.title || messages.empty.title()} + + )} + value={area.title || ''} + /> + { + onClose(); + }} + sx={{ + cursor: 'pointer', + }} + /> + + { + setFieldEditing( + mode === ZUIPreviewableMode.EDITABLE ? 'description' : null + ); + }} + renderInput={(props) => ( + setDescription(ev.target.value)} + sx={{ marginTop: 2 }} + value={description} + /> + )} + renderPreview={() => ( + + + {area.description?.trim().length + ? area.description + : messages.empty.description()} + + + )} + value={area.description || ''} + /> + + + {hostAndPath} diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index b9f1312bec..9dc475b94b 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -72,7 +72,9 @@ const Map: FC = ({ areas }) => { - {selectedArea && } + {selectedArea && ( + setSelectedId('')} /> + )} Date: Sat, 31 Aug 2024 13:46:55 +0200 Subject: [PATCH 167/369] Add custom zoom in/out buttons --- src/features/areas/components/AreasMap.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index 9dc475b94b..5d71d6184b 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -9,8 +9,8 @@ import { useMap, } from 'react-leaflet'; import { FeatureGroup, latLngBounds, Map as MapType } from 'leaflet'; -import { Box, ButtonGroup, IconButton } from '@mui/material'; -import { Create } from '@mui/icons-material'; +import { Box, Button, ButtonGroup, IconButton } from '@mui/material'; +import { Add, Create, Remove } from '@mui/icons-material'; import { PointData, ZetkinArea } from '../types'; import useCreateArea from '../hooks/useCreateArea'; @@ -31,6 +31,7 @@ const MapWrapper = ({ }; const Map: FC = ({ areas }) => { + const mapRef = useRef(null); const reactFGref = useRef(null); const [drawingPoints, setDrawingPoints] = useState(null); const drawingRef = useRef(false); @@ -51,6 +52,14 @@ const Map: FC = ({ areas }) => { }} > + + + + { @@ -78,9 +87,11 @@ const Map: FC = ({ areas }) => { {(map) => { + mapRef.current = map; if (!map.hasEventListeners('click')) { map.on('click', (evt) => { if (drawingRef.current) { From f2cbd75a16c4da4e2ae694a5c65b2644895f9761 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 14:07:35 +0200 Subject: [PATCH 168/369] Implement separate draw/save/cancel buttons --- src/features/areas/components/AreasMap.tsx | 70 +++++++++++++++------- src/features/areas/l10n/messageIds.ts | 5 ++ 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index 5d71d6184b..7e6fff5869 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -1,5 +1,5 @@ import 'leaflet/dist/leaflet.css'; -import { FC, useRef, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import { FeatureGroup as FGComponent, MapContainer, @@ -9,13 +9,15 @@ import { useMap, } from 'react-leaflet'; import { FeatureGroup, latLngBounds, Map as MapType } from 'leaflet'; -import { Box, Button, ButtonGroup, IconButton } from '@mui/material'; -import { Add, Create, Remove } from '@mui/icons-material'; +import { Box, Button, ButtonGroup } from '@mui/material'; +import { Add, Close, Create, Remove, Save } from '@mui/icons-material'; import { PointData, ZetkinArea } from '../types'; import useCreateArea from '../hooks/useCreateArea'; import { useNumericRouteParams } from 'core/hooks'; import AreaOverlay from './AreaOverlay'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; interface MapProps { areas: ZetkinArea[]; @@ -51,8 +53,17 @@ const Map: FC = ({ areas }) => { width: '100%', }} > - - + + @@ -60,23 +71,42 @@ const Map: FC = ({ areas }) => { - - { - if (drawingPoints) { - if (drawingPoints.length > 2) { - createArea({ points: drawingPoints }); - } - setDrawingPoints(null); - drawingRef.current = false; - } else { + + + {!drawingPoints && ( + + )} + {drawingPoints && ( + + )} + {drawingPoints && drawingPoints.length > 2 && ( + + )} diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 17bf893397..271b6798b0 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -5,4 +5,9 @@ export default makeMessages('feat.areas', { description: m('Empty description'), title: m('Untitled area'), }, + tools: { + cancel: m('Cancel'), + draw: m('Draw'), + save: m('Save'), + }, }); From 3329fe108ba75893d035af0b5556df7e4e9d86f8 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 31 Aug 2024 14:08:06 +0200 Subject: [PATCH 169/369] Use separate cursor when drawing on map --- src/features/areas/components/AreasMap.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index 7e6fff5869..d485fa95e6 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -44,6 +44,13 @@ const Map: FC = ({ areas }) => { const { orgId } = useNumericRouteParams(); const createArea = useCreateArea(orgId); + useEffect(() => { + const ctr = mapRef.current?.getContainer(); + if (ctr) { + ctr.style.cursor = drawingPoints ? 'crosshair' : ''; + } + }, [drawingPoints]); + return ( Date: Sat, 31 Aug 2024 14:29:21 +0200 Subject: [PATCH 170/369] Implement finishing an area by clicking the opening point to close --- src/features/areas/components/AreasMap.tsx | 30 +++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap.tsx index d485fa95e6..e28313194d 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap.tsx @@ -18,6 +18,7 @@ import { useNumericRouteParams } from 'core/hooks'; import AreaOverlay from './AreaOverlay'; import { Msg } from 'core/i18n'; import messageIds from '../l10n/messageIds'; +import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; interface MapProps { areas: ZetkinArea[]; @@ -51,6 +52,14 @@ const Map: FC = ({ areas }) => { } }, [drawingPoints]); + function finishDrawing() { + if (drawingPoints && drawingPoints.length > 2) { + createArea({ points: drawingPoints }); + } + setDrawingPoints(null); + drawingRef.current = false; + } + return ( = ({ areas }) => { {drawingPoints && drawingPoints.length > 2 && ( - - - - - {!drawingPoints && ( - - )} - {drawingPoints && ( - - )} - {drawingPoints && drawingPoints.length > 2 && ( - - )} - + + + + {!drawingPoints && ( + + )} + {drawingPoints && ( + + )} + {drawingPoints && drawingPoints.length > 2 && ( + + )} + + + + + + filterAreas(options, state.inputValue) + } + getOptionLabel={(option) => option.id} + inputValue={filterText} + onChange={(ev, area) => { + if (area) { + setSelectedId(area.id); + setFilterText(''); + } + }} + onInputChange={(ev, value, reason) => { + if (reason == 'input') { + setFilterText(value); + } + }} + options={areas} + renderInput={(params) => ( + + )} + renderOption={(props, area) => ( + + {area.title || messages.empty.title()} + + )} + value={null} + /> + @@ -198,7 +265,7 @@ const Map: FC = ({ areas }) => { /> )} - {areas.map((polygon) => ( + {filteredAreas.map((polygon) => ( Date: Sun, 1 Sep 2024 12:03:39 +0200 Subject: [PATCH 173/369] Refactor AreaMap to separate the map renderer from UI --- .../areas/components/AreasMap/MapRenderer.tsx | 113 +++++++++++++++ .../{AreasMap.tsx => AreasMap/index.tsx} | 135 +++--------------- 2 files changed, 130 insertions(+), 118 deletions(-) create mode 100644 src/features/areas/components/AreasMap/MapRenderer.tsx rename src/features/areas/components/{AreasMap.tsx => AreasMap/index.tsx} (54%) diff --git a/src/features/areas/components/AreasMap/MapRenderer.tsx b/src/features/areas/components/AreasMap/MapRenderer.tsx new file mode 100644 index 0000000000..f75c5efbf5 --- /dev/null +++ b/src/features/areas/components/AreasMap/MapRenderer.tsx @@ -0,0 +1,113 @@ +import { Box } from '@mui/material'; +import { FeatureGroup } from 'leaflet'; +import { FC, useEffect, useRef, useState } from 'react'; +import { + FeatureGroup as FeatureGroupComponent, + Polygon, + Polyline, + TileLayer, + useMapEvents, +} from 'react-leaflet'; + +import { PointData, ZetkinArea } from 'features/areas/types'; +import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; + +type Props = { + areas: ZetkinArea[]; + drawingPoints: PointData[] | null; + onChangeDrawingPoints: (points: PointData[]) => void; + onFinishDrawing: () => void; + onSelectArea: (area: ZetkinArea) => void; +}; + +const MapRenderer: FC = ({ + areas, + drawingPoints, + onChangeDrawingPoints, + onFinishDrawing, + onSelectArea, +}) => { + const [zoomed, setZoomed] = useState(false); + const reactFGref = useRef(null); + + const map = useMapEvents({ + click: (evt) => { + if (isDrawing) { + const lat = evt.latlng.lat; + const lng = evt.latlng.lng; + const current = drawingPoints || []; + onChangeDrawingPoints([...current, [lat, lng]]); + } + }, + zoom: () => { + setZoomed(true); + }, + }); + + const isDrawing = !!drawingPoints; + + useEffect(() => { + const ctr = map.getContainer(); + if (ctr) { + ctr.style.cursor = drawingPoints ? 'crosshair' : ''; + } + }, [drawingPoints]); + + useEffect(() => { + if (map && !zoomed) { + const bounds = reactFGref.current?.getBounds(); + if (bounds?.isValid()) { + map.fitBounds(bounds); + setZoomed(true); + } + } + }, [areas, map]); + + return ( + <> + + { + reactFGref.current = fgRef; + }} + > + {drawingPoints && ( + + )} + {drawingPoints && drawingPoints.length > 0 && ( + + { + ev.stopPropagation(); + onFinishDrawing(); + }} + sx={{ + backgroundColor: 'red', + borderRadius: '10px', + height: 20, + transform: 'translate(-25%, -25%)', + width: 20, + }} + /> + + )} + {areas.map((area) => ( + { + onSelectArea(area); + }, + }} + positions={area.points} + /> + ))} + + + ); +}; + +export default MapRenderer; diff --git a/src/features/areas/components/AreasMap.tsx b/src/features/areas/components/AreasMap/index.tsx similarity index 54% rename from src/features/areas/components/AreasMap.tsx rename to src/features/areas/components/AreasMap/index.tsx index 4202b203c9..8a09ec9b0c 100644 --- a/src/features/areas/components/AreasMap.tsx +++ b/src/features/areas/components/AreasMap/index.tsx @@ -1,14 +1,6 @@ import 'leaflet/dist/leaflet.css'; -import { FC, useEffect, useRef, useState } from 'react'; -import { - FeatureGroup as FGComponent, - MapContainer, - Polygon, - Polyline, - TileLayer, - useMap, -} from 'react-leaflet'; -import { FeatureGroup, Map as MapType } from 'leaflet'; +import { FC, useState } from 'react'; +import { MapContainer } from 'react-leaflet'; import { Autocomplete, Box, @@ -17,36 +9,23 @@ import { MenuItem, TextField, } from '@mui/material'; -import { Add, Close, Create, Remove, Save } from '@mui/icons-material'; +import { Close, Create, Save } from '@mui/icons-material'; -import { PointData, ZetkinArea } from '../types'; -import useCreateArea from '../hooks/useCreateArea'; +import { PointData, ZetkinArea } from '../../types'; +import useCreateArea from '../../hooks/useCreateArea'; import { useNumericRouteParams } from 'core/hooks'; -import AreaOverlay from './AreaOverlay'; +import AreaOverlay from '../AreaOverlay'; import { Msg, useMessages } from 'core/i18n'; -import messageIds from '../l10n/messageIds'; -import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; +import messageIds from '../../l10n/messageIds'; +import MapRenderer from './MapRenderer'; interface MapProps { areas: ZetkinArea[]; } -const MapWrapper = ({ - children, -}: { - children: (map: MapType) => JSX.Element; -}) => { - const map = useMap(); - return children(map); -}; - const Map: FC = ({ areas }) => { const messages = useMessages(messageIds); - const mapRef = useRef(null); - const zoomedRef = useRef(false); - const reactFGref = useRef(null); const [drawingPoints, setDrawingPoints] = useState(null); - const drawingRef = useRef(false); const [selectedId, setSelectedId] = useState(''); const [filterText, setFilterText] = useState(''); @@ -55,28 +34,11 @@ const Map: FC = ({ areas }) => { const { orgId } = useNumericRouteParams(); const createArea = useCreateArea(orgId); - useEffect(() => { - const ctr = mapRef.current?.getContainer(); - if (ctr) { - ctr.style.cursor = drawingPoints ? 'crosshair' : ''; - } - }, [drawingPoints]); - - useEffect(() => { - if (!zoomedRef.current) { - const bounds = reactFGref.current?.getBounds(); - if (bounds) { - mapRef.current?.fitBounds(bounds); - } - } - }, [areas]); - function finishDrawing() { if (drawingPoints && drawingPoints.length > 2) { createArea({ points: drawingPoints }); } setDrawingPoints(null); - drawingRef.current = false; } function filterAreas(areas: ZetkinArea[], matchString: string) { @@ -120,12 +82,14 @@ const Map: FC = ({ areas }) => { > + {/* + */} @@ -133,7 +97,6 @@ const Map: FC = ({ areas }) => { - */} @@ -176,6 +176,7 @@ const Map: FC = ({ areas }) => { setDrawingPoints(points)} onFinishDrawing={() => finishDrawing()} onSelectArea={(area) => setSelectedId(area.id)} From ac650a15f375a499f597839c00c48c7eb62d7185 Mon Sep 17 00:00:00 2001 From: kaulfield23 Date: Sun, 1 Sep 2024 15:05:22 +0200 Subject: [PATCH 177/369] Change locked targets number to total match number --- .../components/ActivityList/items/EmailListItem.tsx | 7 ++++--- src/features/emails/layout/EmailLayout.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/features/campaigns/components/ActivityList/items/EmailListItem.tsx b/src/features/campaigns/components/ActivityList/items/EmailListItem.tsx index 551715b82e..3137d22a06 100644 --- a/src/features/campaigns/components/ActivityList/items/EmailListItem.tsx +++ b/src/features/campaigns/components/ActivityList/items/EmailListItem.tsx @@ -27,9 +27,10 @@ const EmailListItem: FC = ({ orgId, emailId }) => { numTargetMatches, numOpened, numSent, - lockedReadyTargets, isLoading: statsLoading, + numBlocked, } = useEmailStats(orgId, emailId); + const endNumber = numTargetMatches - numBlocked.any ?? 0; if (!email) { return null; @@ -42,7 +43,7 @@ const EmailListItem: FC = ({ orgId, emailId }) => { = ({ orgId, emailId }) => { ) : ( = ({ id={messageIds.stats.lockedTargets} values={{ numLocked: - emailStats.num_locked_targets - + emailStats.num_target_matches - emailStats.num_blocked.any, }} /> From 644f1777dc0f675b6dd66eb3eaccb237cdd73249 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sun, 1 Sep 2024 15:12:28 +0200 Subject: [PATCH 178/369] Tweak styling of polygons to be more distinct when selected --- .../areas/components/AreasMap/MapRenderer.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/features/areas/components/AreasMap/MapRenderer.tsx b/src/features/areas/components/AreasMap/MapRenderer.tsx index 83892b22f6..7a7f0c9858 100644 --- a/src/features/areas/components/AreasMap/MapRenderer.tsx +++ b/src/features/areas/components/AreasMap/MapRenderer.tsx @@ -116,21 +116,27 @@ const MapRenderer: FC = ({ return 0; }) .map((area) => { - const color = - selectedId == '' || selectedId == area.id - ? theme.palette.primary.main - : theme.palette.secondary.main; + const selected = selectedId == area.id; + + // The key changes when selected, to force redraw of polygon + // to reflect new state through visual style + const key = area.id + (selected ? '-selected' : '-default'); return ( { onSelectArea(area); }, }} positions={area.points} + weight={selected ? 5 : 2} /> ); })} From 7966d70b6a580f64190a9f678b2fe170b52db31e Mon Sep 17 00:00:00 2001 From: awarn Date: Sun, 1 Sep 2024 15:34:14 +0200 Subject: [PATCH 179/369] Limit max matching tags in filter to number selected --- .../smartSearch/components/filters/PersonTags/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/smartSearch/components/filters/PersonTags/index.tsx b/src/features/smartSearch/components/filters/PersonTags/index.tsx index 63488566f8..b76e498602 100644 --- a/src/features/smartSearch/components/filters/PersonTags/index.tsx +++ b/src/features/smartSearch/components/filters/PersonTags/index.tsx @@ -153,7 +153,7 @@ const PersonTags = ({ minMatchingInput: ( From ddf039a37e25c1d734c6585684294fe05743336b Mon Sep 17 00:00:00 2001 From: kaulfield23 Date: Sun, 1 Sep 2024 15:57:41 +0200 Subject: [PATCH 180/369] Change tagAssigned logic for editing tag value --- src/features/tags/store.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/features/tags/store.ts b/src/features/tags/store.ts index e66d2f9a12..436a4aa4b9 100644 --- a/src/features/tags/store.ts +++ b/src/features/tags/store.ts @@ -35,10 +35,19 @@ const tagsSlice = createSlice({ const [personId, tag] = action.payload; if (!state.tagsByPersonId[personId]) { state.tagsByPersonId[personId] = remoteList(); + state.tagsByPersonId[personId].items.push( + remoteItem(tag.id, { data: tag }) + ); + } + + if (state.tagsByPersonId[personId]) { + const item = state.tagsByPersonId[personId].items.find( + (item) => item.id == tag.id + ); + if (item) { + item.data = tag; + } } - state.tagsByPersonId[personId].items.push( - remoteItem(tag.id, { data: tag }) - ); }, tagCreate: (state) => { state.tagList.isLoading; From ea5d6e4fc9f23fe6f93e582395f5e1a99ee9093a Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sun, 1 Sep 2024 16:03:16 +0200 Subject: [PATCH 181/369] Implement moving of points in an area --- src/features/areas/components/AreaOverlay.tsx | 53 ++++++++++++++++--- .../areas/components/AreasMap/MapRenderer.tsx | 40 ++++++++++++++ .../areas/components/AreasMap/index.tsx | 11 +++- src/features/areas/l10n/messageIds.ts | 7 +++ 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/features/areas/components/AreaOverlay.tsx b/src/features/areas/components/AreaOverlay.tsx index 64473fa0de..a3bde9b3d5 100644 --- a/src/features/areas/components/AreaOverlay.tsx +++ b/src/features/areas/components/AreaOverlay.tsx @@ -2,6 +2,7 @@ import { FC, useCallback, useEffect, useState } from 'react'; import { Close } from '@mui/icons-material'; import { Box, + Button, ClickAwayListener, Divider, Link, @@ -15,15 +16,24 @@ import useAreaMutations from '../hooks/useAreaMutations'; import ZUIPreviewableInput, { ZUIPreviewableMode, } from 'zui/ZUIPreviewableInput'; -import { useMessages } from 'core/i18n'; +import { Msg, useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; type Props = { area: ZetkinArea; + editing: boolean; + onBeginEdit: () => void; + onCancelEdit: () => void; onClose: () => void; }; -const AreaOverlay: FC = ({ area, onClose }) => { +const AreaOverlay: FC = ({ + area, + editing, + onBeginEdit, + onCancelEdit, + onClose, +}) => { const [title, setTitle] = useState(area.title); const [description, setDescription] = useState(area.description); const [fieldEditing, setFieldEditing] = useState< @@ -57,6 +67,8 @@ const AreaOverlay: FC = ({ area, onClose }) => { = ({ area, onClose }) => { - - - {hostAndPath} - - + + + + {hostAndPath} + + + + + {editing && ( + <> + + + + )} + {!editing && ( + + )} + ); }; diff --git a/src/features/areas/components/AreasMap/MapRenderer.tsx b/src/features/areas/components/AreasMap/MapRenderer.tsx index 7a7f0c9858..f0e3ec00c8 100644 --- a/src/features/areas/components/AreasMap/MapRenderer.tsx +++ b/src/features/areas/components/AreasMap/MapRenderer.tsx @@ -15,7 +15,9 @@ import { DivIconMarker } from 'features/events/components/LocationModal/DivIconM type Props = { areas: ZetkinArea[]; drawingPoints: PointData[] | null; + editingArea: ZetkinArea | null; mapRef: MutableRefObject; + onChangeArea: (area: ZetkinArea) => void; onChangeDrawingPoints: (points: PointData[]) => void; onFinishDrawing: () => void; onSelectArea: (area: ZetkinArea) => void; @@ -25,7 +27,9 @@ type Props = { const MapRenderer: FC = ({ areas, drawingPoints, + editingArea, mapRef, + onChangeArea, onChangeDrawingPoints, onFinishDrawing, onSelectArea, @@ -103,7 +107,43 @@ const MapRenderer: FC = ({ /> )} + {!!editingArea && ( + + )} + {editingArea?.points?.map((point, index) => { + return ( + { + onChangeArea({ + ...editingArea, + points: editingArea.points.map((oldPoint, oldIndex) => + oldIndex == index ? evt.target.getLatLng() : oldPoint + ), + }); + }, + }} + position={point} + > + + + ); + })} {areas + .filter((area) => area.id != editingArea?.id) .sort((a0, a1) => { // Always render selected last, so that it gets // rendered on top of the unselected ones in case diff --git a/src/features/areas/components/AreasMap/index.tsx b/src/features/areas/components/AreasMap/index.tsx index efa694ca60..fd0ee48009 100644 --- a/src/features/areas/components/AreasMap/index.tsx +++ b/src/features/areas/components/AreasMap/index.tsx @@ -30,6 +30,7 @@ const Map: FC = ({ areas }) => { const [drawingPoints, setDrawingPoints] = useState(null); const [selectedId, setSelectedId] = useState(''); const [filterText, setFilterText] = useState(''); + const [editingArea, setEditingArea] = useState(null); const selectedArea = areas.find((area) => area.id == selectedId); @@ -165,7 +166,13 @@ const Map: FC = ({ areas }) => { {selectedArea && ( - setSelectedId('')} /> + setEditingArea(selectedArea)} + onCancelEdit={() => setEditingArea(null)} + onClose={() => setSelectedId('')} + /> )} = ({ areas }) => { setEditingArea(area)} onChangeDrawingPoints={(points) => setDrawingPoints(points)} onFinishDrawing={() => finishDrawing()} onSelectArea={(area) => setSelectedId(area.id)} diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 271b6798b0..4a3a8221f4 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -5,6 +5,13 @@ export default makeMessages('feat.areas', { description: m('Empty description'), title: m('Untitled area'), }, + overlay: { + buttons: { + cancel: m('Cancel'), + edit: m('Edit'), + save: m('Save'), + }, + }, tools: { cancel: m('Cancel'), draw: m('Draw'), From 6ce2c28cd159682e09aabbb61d66c036ccba290a Mon Sep 17 00:00:00 2001 From: kaulfield23 Date: Sun, 1 Sep 2024 16:05:53 +0200 Subject: [PATCH 182/369] Change if to else --- src/features/tags/store.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/features/tags/store.ts b/src/features/tags/store.ts index 436a4aa4b9..0b87d2be41 100644 --- a/src/features/tags/store.ts +++ b/src/features/tags/store.ts @@ -38,9 +38,7 @@ const tagsSlice = createSlice({ state.tagsByPersonId[personId].items.push( remoteItem(tag.id, { data: tag }) ); - } - - if (state.tagsByPersonId[personId]) { + } else { const item = state.tagsByPersonId[personId].items.find( (item) => item.id == tag.id ); From 497e02463bda44331d780dede6934eb7f4d06fe4 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sun, 1 Sep 2024 16:13:19 +0200 Subject: [PATCH 183/369] Add API route, hook and store logic for deleting areas --- .../beta/orgs/[orgId]/areas/[areaId]/route.ts | 16 ++++++++++++++++ src/features/areas/hooks/useAreaMutations.ts | 6 +++++- src/features/areas/store.ts | 8 +++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts index 7aa3edaec1..cdba4ff779 100644 --- a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts @@ -51,3 +51,19 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { } ); } + +export async function DELETE(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async () => { + await mongoose.connect(process.env.MONGODB_URL || ''); + await AreaModel.findOneAndDelete({ _id: params.areaId }); + + return new NextResponse(null, { status: 204 }); + } + ); +} diff --git a/src/features/areas/hooks/useAreaMutations.ts b/src/features/areas/hooks/useAreaMutations.ts index c325727dd1..9076ed4846 100644 --- a/src/features/areas/hooks/useAreaMutations.ts +++ b/src/features/areas/hooks/useAreaMutations.ts @@ -1,12 +1,16 @@ import { useApiClient, useAppDispatch } from 'core/hooks'; import { ZetkinArea, ZetkinAreaPostBody } from '../types'; -import { areaUpdated } from '../store'; +import { areaDeleted, areaUpdated } from '../store'; export default function useAreaMutations(orgId: number, areaId: string) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); return { + async deleteArea() { + await apiClient.delete(`/beta/orgs/${orgId}/areas/${areaId}`); + dispatch(areaDeleted(areaId)); + }, async updateArea(data: ZetkinAreaPostBody) { const area = await apiClient.patch( `/beta/orgs/${orgId}/areas/${areaId}`, diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 50600bc3a3..9162d72c6e 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -29,6 +29,12 @@ const areasSlice = createSlice({ state.areaList.items.push(item); }, + areaDeleted: (state, action: PayloadAction) => { + const deletedId = action.payload; + state.areaList.items = state.areaList.items.filter( + (item) => item.id != deletedId + ); + }, areaUpdated: (state, action: PayloadAction) => { const area = action.payload; const item = findOrAddItem(state.areaList, area.id); @@ -50,5 +56,5 @@ const areasSlice = createSlice({ }); export default areasSlice; -export const { areaCreated, areaUpdated, areasLoad, areasLoaded } = +export const { areaCreated, areaDeleted, areaUpdated, areasLoad, areasLoaded } = areasSlice.actions; From 2f255e64521347ac8b8fc5b023e3c4654c5a9e9a Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sun, 1 Sep 2024 16:14:08 +0200 Subject: [PATCH 184/369] Add ellipsis menu with delete option --- src/features/areas/components/AreaOverlay.tsx | 34 +++++++++++++++---- src/features/areas/l10n/messageIds.ts | 1 + 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/features/areas/components/AreaOverlay.tsx b/src/features/areas/components/AreaOverlay.tsx index a3bde9b3d5..6bd3fd2d4f 100644 --- a/src/features/areas/components/AreaOverlay.tsx +++ b/src/features/areas/components/AreaOverlay.tsx @@ -1,4 +1,4 @@ -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useContext, useEffect, useState } from 'react'; import { Close } from '@mui/icons-material'; import { Box, @@ -18,6 +18,8 @@ import ZUIPreviewableInput, { } from 'zui/ZUIPreviewableInput'; import { Msg, useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; +import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; +import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; type Props = { area: ZetkinArea; @@ -39,8 +41,12 @@ const AreaOverlay: FC = ({ const [fieldEditing, setFieldEditing] = useState< 'title' | 'description' | null >(null); - const { updateArea } = useAreaMutations(area.organization.id, area.id); + const { deleteArea, updateArea } = useAreaMutations( + area.organization.id, + area.id + ); const messages = useMessages(messageIds); + const { showConfirmDialog } = useContext(ZUIConfirmDialogContext); const handleDescriptionTextAreaRef = useCallback( (el: HTMLTextAreaElement | null) => { @@ -74,7 +80,7 @@ const AreaOverlay: FC = ({ position: 'absolute', right: '1rem', top: '1rem', - zIndex: 9999, + zIndex: 1000, }} > = ({ )} {!editing && ( - + <> + + { + showConfirmDialog({ + onSubmit: () => { + deleteArea(); + }, + }); + }, + }, + ]} + /> + )} diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 4a3a8221f4..441d79c7a5 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -8,6 +8,7 @@ export default makeMessages('feat.areas', { overlay: { buttons: { cancel: m('Cancel'), + delete: m('Delete'), edit: m('Edit'), save: m('Save'), }, From 5fac02f8c61671e8979a0f9b19fb12929776bb36 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 2 Sep 2024 10:07:11 +0200 Subject: [PATCH 185/369] Render a leaflet map on the page. --- src/app/o/[orgId]/areas/[areaId]/page.tsx | 6 +++-- src/features/areas/components/AreaPage.tsx | 19 +++++++++++--- .../areas/components/PublicAreaMap.tsx | 26 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 src/features/areas/components/PublicAreaMap.tsx diff --git a/src/app/o/[orgId]/areas/[areaId]/page.tsx b/src/app/o/[orgId]/areas/[areaId]/page.tsx index 78b6a45125..066a54abc2 100644 --- a/src/app/o/[orgId]/areas/[areaId]/page.tsx +++ b/src/app/o/[orgId]/areas/[areaId]/page.tsx @@ -1,13 +1,15 @@ +import 'leaflet/dist/leaflet.css'; + import AreaPage from 'features/areas/components/AreaPage'; -interface AreaPageProps { +interface PageProps { params: { areaId: string; orgId: string; }; } -export default function Page({ params }: AreaPageProps) { +export default function Page({ params }: PageProps) { const { orgId, areaId } = params; return ; } diff --git a/src/features/areas/components/AreaPage.tsx b/src/features/areas/components/AreaPage.tsx index 0630e0eb1e..e66fa3968b 100644 --- a/src/features/areas/components/AreaPage.tsx +++ b/src/features/areas/components/AreaPage.tsx @@ -2,11 +2,14 @@ import { Avatar, Box } from '@mui/material'; import { FC } from 'react'; +import dynamic from 'next/dynamic'; import useArea from '../hooks/useArea'; import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFutures from 'zui/ZUIFutures'; +const PublicAreaMap = dynamic(() => import('./PublicAreaMap'), { ssr: false }); + type AreaPageProps = { areaId: number; orgId: number; @@ -19,13 +22,21 @@ const AreaPage: FC = ({ areaId, orgId }) => { return ( {({ data: { area, org } }) => ( - - + <> + {org.title} - {area.id} - + + + + )} ); diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx new file mode 100644 index 0000000000..fce1980fa0 --- /dev/null +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; +import { MapContainer, Polygon, TileLayer } from 'react-leaflet'; +import { latLngBounds } from 'leaflet'; + +import { ZetkinArea } from '../types'; + +type PublicAreaMapProps = { + area: ZetkinArea; +}; + +const PublicAreaMap: FC = ({ area }) => { + return ( + + + + + ); +}; + +export default PublicAreaMap; From 1fb79e7a89df06918745df218cc31fa15f659ab1 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 2 Sep 2024 10:10:18 +0200 Subject: [PATCH 186/369] Set initial zoom level to view the area. --- src/features/areas/components/PublicAreaMap.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index fce1980fa0..4488cf3783 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -11,7 +11,7 @@ type PublicAreaMapProps = { const PublicAreaMap: FC = ({ area }) => { return ( Date: Mon, 2 Sep 2024 11:44:51 +0200 Subject: [PATCH 187/369] Get area data from database. --- .../beta/orgs/[orgId]/areas/[areaId]/route.ts | 47 +++++++++++-------- src/app/o/[orgId]/areas/[areaId]/page.tsx | 2 +- src/features/areas/components/AreaPage.tsx | 2 +- src/features/areas/hooks/useArea.ts | 2 +- src/features/areas/store.ts | 2 +- 5 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts index ce5c53da40..006d18a5fd 100644 --- a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts @@ -12,28 +12,35 @@ type RouteMeta = { }; }; -export function GET() { - const area: ZetkinArea = { - description: null, - id: '2', - organization: { - id: 1, +export async function GET(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin', 'organizer'], }, - points: [ - [55.59361532349994, 12.977986335754396], - [55.5914203134015, 12.97790050506592], - [55.59045010406615, 12.977342605590822], - [55.59007414150065, 12.979617118835451], - [55.58915241158536, 12.983243465423586], - [55.589698175333524, 12.983586788177492], - [55.59359106991554, 12.983479499816896], - ], - title: null, - }; + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const areaModel = await AreaModel.findOne({ _id: params.areaId, orgId }); - return Response.json({ - data: area, - }); + if (!areaModel) { + return new NextResponse(null, { status: 404 }); + } + + const area: ZetkinArea = { + description: areaModel.description, + id: areaModel._id.toString(), + organization: { + id: orgId, + }, + points: areaModel.points, + title: areaModel.title, + }; + + return Response.json({ data: area }); + } + ); } export async function PATCH(request: NextRequest, { params }: RouteMeta) { diff --git a/src/app/o/[orgId]/areas/[areaId]/page.tsx b/src/app/o/[orgId]/areas/[areaId]/page.tsx index 066a54abc2..11d561fa1d 100644 --- a/src/app/o/[orgId]/areas/[areaId]/page.tsx +++ b/src/app/o/[orgId]/areas/[areaId]/page.tsx @@ -11,5 +11,5 @@ interface PageProps { export default function Page({ params }: PageProps) { const { orgId, areaId } = params; - return ; + return ; } diff --git a/src/features/areas/components/AreaPage.tsx b/src/features/areas/components/AreaPage.tsx index e66fa3968b..ff92c3e483 100644 --- a/src/features/areas/components/AreaPage.tsx +++ b/src/features/areas/components/AreaPage.tsx @@ -11,7 +11,7 @@ import ZUIFutures from 'zui/ZUIFutures'; const PublicAreaMap = dynamic(() => import('./PublicAreaMap'), { ssr: false }); type AreaPageProps = { - areaId: number; + areaId: string; orgId: number; }; diff --git a/src/features/areas/hooks/useArea.ts b/src/features/areas/hooks/useArea.ts index b9bac1f707..2d7952e264 100644 --- a/src/features/areas/hooks/useArea.ts +++ b/src/features/areas/hooks/useArea.ts @@ -3,7 +3,7 @@ import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; import { areaLoad, areaLoaded } from '../store'; import { ZetkinArea } from '../types'; -export default function useArea(orgId: number, areaId: number) { +export default function useArea(orgId: number, areaId: string) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const areaItems = useAppSelector((state) => state.areas.areaList.items); diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 498d5860f8..1054c9542c 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -35,7 +35,7 @@ const areasSlice = createSlice({ (item) => item.id != deletedId ); }, - areaLoad: (state, action: PayloadAction) => { + areaLoad: (state, action: PayloadAction) => { const areaId = action.payload; const item = state.areaList.items.find((item) => item.id == areaId); From 4e394ea4be907c119b67892df99ed620dfe75587 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 2 Sep 2024 11:51:50 +0200 Subject: [PATCH 188/369] Use primary theme color for area color. --- src/features/areas/components/PublicAreaMap.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 4488cf3783..02adb4697f 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; import { MapContainer, Polygon, TileLayer } from 'react-leaflet'; import { latLngBounds } from 'leaflet'; +import { useTheme } from '@mui/material'; import { ZetkinArea } from '../types'; @@ -9,6 +10,7 @@ type PublicAreaMapProps = { }; const PublicAreaMap: FC = ({ area }) => { + const theme = useTheme(); return ( = ({ area }) => { attribution='© OpenStreetMap contributors' url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" /> - + ); }; From 592a55a2894f42261863c50ebec92ae20f931ea4 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 2 Sep 2024 14:04:38 +0200 Subject: [PATCH 189/369] Custom zoom controls. --- .../areas/components/PublicAreaMap.tsx | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 02adb4697f..49bf634557 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -1,27 +1,61 @@ -import { FC } from 'react'; +import { FC, useState } from 'react'; +import { latLngBounds, Map } from 'leaflet'; +import { makeStyles } from '@mui/styles'; +import { Add, Remove } from '@mui/icons-material'; +import { Box, Divider, IconButton, useTheme } from '@mui/material'; import { MapContainer, Polygon, TileLayer } from 'react-leaflet'; -import { latLngBounds } from 'leaflet'; -import { useTheme } from '@mui/material'; import { ZetkinArea } from '../types'; +const useStyles = makeStyles((theme) => ({ + zoomControls: { + backgroundColor: theme.palette.common.white, + borderRadius: 2, + display: 'flex', + flexDirection: 'column', + left: 10, + marginTop: 10, + position: 'absolute', + zIndex: 1000, + }, +})); + type PublicAreaMapProps = { area: ZetkinArea; }; const PublicAreaMap: FC = ({ area }) => { const theme = useTheme(); + const classes = useStyles(); + const [map, setMap] = useState(null); + return ( - - - - + <> + {map && ( + + map.zoomIn()}> + + + + map.zoomOut()}> + + + + )} + + + + + ); }; From 8655a53b4197a24ebe9edc796015d91a761801b1 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 2 Sep 2024 14:48:25 +0200 Subject: [PATCH 190/369] Show area title and description in header. --- src/features/areas/components/AreaPage.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/features/areas/components/AreaPage.tsx b/src/features/areas/components/AreaPage.tsx index ff92c3e483..d3157e5a1e 100644 --- a/src/features/areas/components/AreaPage.tsx +++ b/src/features/areas/components/AreaPage.tsx @@ -1,12 +1,14 @@ 'use client'; -import { Avatar, Box } from '@mui/material'; +import { Avatar, Box, Typography } from '@mui/material'; import { FC } from 'react'; import dynamic from 'next/dynamic'; import useArea from '../hooks/useArea'; import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFutures from 'zui/ZUIFutures'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; const PublicAreaMap = dynamic(() => import('./PublicAreaMap'), { ssr: false }); @@ -28,10 +30,19 @@ const AreaPage: FC = ({ areaId, orgId }) => { display="flex" gap={1} height="10vh" + justifyContent="space-between" padding={2} > - - {org.title} + + + {org.title} + + + {area.title ?? } + + {area.description ?? } + + From eba52b9971322df6af33d015397e247bf9ce7ebc Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 4 Sep 2024 12:44:14 +0200 Subject: [PATCH 191/369] Refactor to have MapContainer pass the ref instead of doing it "manually" in MapRenderer. --- src/features/areas/components/AreasMap/MapRenderer.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/features/areas/components/AreasMap/MapRenderer.tsx b/src/features/areas/components/AreasMap/MapRenderer.tsx index f0e3ec00c8..c16184487f 100644 --- a/src/features/areas/components/AreasMap/MapRenderer.tsx +++ b/src/features/areas/components/AreasMap/MapRenderer.tsx @@ -1,6 +1,6 @@ import { Box, useTheme } from '@mui/material'; -import { FeatureGroup, Map } from 'leaflet'; -import { FC, MutableRefObject, useEffect, useRef, useState } from 'react'; +import { FeatureGroup } from 'leaflet'; +import { FC, useEffect, useRef, useState } from 'react'; import { FeatureGroup as FeatureGroupComponent, Polygon, @@ -16,7 +16,6 @@ type Props = { areas: ZetkinArea[]; drawingPoints: PointData[] | null; editingArea: ZetkinArea | null; - mapRef: MutableRefObject; onChangeArea: (area: ZetkinArea) => void; onChangeDrawingPoints: (points: PointData[]) => void; onFinishDrawing: () => void; @@ -28,7 +27,6 @@ const MapRenderer: FC = ({ areas, drawingPoints, editingArea, - mapRef, onChangeArea, onChangeDrawingPoints, onFinishDrawing, @@ -55,10 +53,6 @@ const MapRenderer: FC = ({ const isDrawing = !!drawingPoints; - useEffect(() => { - mapRef.current = map; - }, [map]); - useEffect(() => { const ctr = map.getContainer(); if (ctr) { From 686a26a71ea1ef8bcc9b0f74f788a315f7d5742b Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 4 Sep 2024 12:45:42 +0200 Subject: [PATCH 192/369] Add button and counter to count and display number of activities on an area. --- .../beta/orgs/[orgId]/areas/[areaId]/route.ts | 3 + src/app/beta/orgs/[orgId]/areas/route.ts | 3 + .../areas/components/AreasMap/index.tsx | 2 +- .../areas/components/PublicAreaMap.tsx | 55 ++++++++++++++----- src/features/areas/l10n/messageIds.ts | 3 + src/features/areas/models.ts | 2 + src/features/areas/types.ts | 1 + 7 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts index 006d18a5fd..8b852d2972 100644 --- a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts @@ -31,6 +31,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const area: ZetkinArea = { description: areaModel.description, id: areaModel._id.toString(), + numberOfActions: areaModel.numberOfActions, organization: { id: orgId, }, @@ -59,6 +60,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { { _id: params.areaId }, { description: payload.description, + numberOfActions: payload.numberOfActions, points: payload.points, title: payload.title, }, @@ -73,6 +75,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { data: { description: model.description, id: model._id.toString(), + numberOfActions: model.numberOfActions, organization: { id: orgId, }, diff --git a/src/app/beta/orgs/[orgId]/areas/route.ts b/src/app/beta/orgs/[orgId]/areas/route.ts index 35509d77ed..940870fda5 100644 --- a/src/app/beta/orgs/[orgId]/areas/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/route.ts @@ -25,6 +25,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const areas: ZetkinArea[] = areaModels.map((model) => ({ description: model.description, id: model._id.toString(), + numberOfActions: model.numberOfActions, organization: { id: orgId, }, @@ -51,6 +52,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const model = new AreaModel({ description: payload.description, + numberOfActions: 0, orgId: orgId, points: payload.points, title: payload.title, @@ -62,6 +64,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { data: { description: model.description, id: model._id.toString(), + numberOfActions: model.numberOfActions, organization: { id: orgId, }, diff --git a/src/features/areas/components/AreasMap/index.tsx b/src/features/areas/components/AreasMap/index.tsx index fd0ee48009..5e3460f134 100644 --- a/src/features/areas/components/AreasMap/index.tsx +++ b/src/features/areas/components/AreasMap/index.tsx @@ -175,6 +175,7 @@ const Map: FC = ({ areas }) => { /> )} = ({ areas }) => { areas={filteredAreas} drawingPoints={drawingPoints} editingArea={editingArea} - mapRef={mapRef} onChangeArea={(area) => setEditingArea(area)} onChangeDrawingPoints={(points) => setDrawingPoints(points)} onFinishDrawing={() => finishDrawing()} diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 49bf634557..52fb24e658 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -1,13 +1,25 @@ -import { FC, useState } from 'react'; +import { FC, useRef } from 'react'; import { latLngBounds, Map } from 'leaflet'; import { makeStyles } from '@mui/styles'; import { Add, Remove } from '@mui/icons-material'; -import { Box, Divider, IconButton, useTheme } from '@mui/material'; +import { Box, Button, Divider, IconButton, useTheme } from '@mui/material'; import { MapContainer, Polygon, TileLayer } from 'react-leaflet'; import { ZetkinArea } from '../types'; +import useAreaMutations from '../hooks/useAreaMutations'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; const useStyles = makeStyles((theme) => ({ + counter: { + bottom: 15, + display: 'flex', + gap: 8, + padding: 8, + position: 'absolute', + width: '100%', + zIndex: 1000, + }, zoomControls: { backgroundColor: theme.palette.common.white, borderRadius: 2, @@ -27,23 +39,38 @@ type PublicAreaMapProps = { const PublicAreaMap: FC = ({ area }) => { const theme = useTheme(); const classes = useStyles(); - const [map, setMap] = useState(null); + const mapRef = useRef(null); + const { updateArea } = useAreaMutations(area.organization.id, area.id); return ( <> - {map && ( - - map.zoomIn()}> - - - - map.zoomOut()}> - - + + mapRef.current?.zoomIn()}> + + + + mapRef.current?.zoomOut()}> + + + + + + + {area.numberOfActions} - )} + ({ description: String, + numberOfActions: Number, orgId: { required: true, type: Number }, points: Array, title: String, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 4be1918dee..331eebb0f0 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -3,6 +3,7 @@ export type PointData = [number, number]; export type ZetkinArea = { description: string | null; id: string; + numberOfActions: number; organization: { id: number; }; From 91c89da9435eac8ce3a2828b48edb4e8ca4ea600 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 4 Sep 2024 13:36:23 +0200 Subject: [PATCH 193/369] Show number of activities in the center of the area. --- src/features/areas/components/PublicAreaMap.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 52fb24e658..fa1c2a8edc 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -9,6 +9,7 @@ import { ZetkinArea } from '../types'; import useAreaMutations from '../hooks/useAreaMutations'; import { Msg } from 'core/i18n'; import messageIds from '../l10n/messageIds'; +import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; const useStyles = makeStyles((theme) => ({ counter: { @@ -20,6 +21,16 @@ const useStyles = makeStyles((theme) => ({ width: '100%', zIndex: 1000, }, + number: { + alignItems: 'center', + backgroundColor: 'white', + borderRadius: '2em', + display: 'flex', + height: 30, + justifyContent: 'center', + transform: 'translate(-20%, -60%)', + width: 30, + }, zoomControls: { backgroundColor: theme.palette.common.white, borderRadius: 2, @@ -65,9 +76,6 @@ const PublicAreaMap: FC = ({ area }) => { > - - {area.numberOfActions} - = ({ area }) => { url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" /> + + {area.numberOfActions} + ); From f0d1d4ea19a7c1b987b5b5d7f430edcbc70db3a9 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 4 Sep 2024 13:51:57 +0200 Subject: [PATCH 194/369] Add button to remove activity. --- .../areas/components/PublicAreaMap.tsx | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index fa1c2a8edc..c1b6ca4c0b 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -2,7 +2,14 @@ import { FC, useRef } from 'react'; import { latLngBounds, Map } from 'leaflet'; import { makeStyles } from '@mui/styles'; import { Add, Remove } from '@mui/icons-material'; -import { Box, Button, Divider, IconButton, useTheme } from '@mui/material'; +import { + Box, + Button, + ButtonGroup, + Divider, + IconButton, + useTheme, +} from '@mui/material'; import { MapContainer, Polygon, TileLayer } from 'react-leaflet'; import { ZetkinArea } from '../types'; @@ -65,17 +72,32 @@ const PublicAreaMap: FC = ({ area }) => { - + + + + Date: Wed, 4 Sep 2024 15:19:38 +0200 Subject: [PATCH 195/369] Select area after creating it --- src/features/areas/components/AreasMap/index.tsx | 5 +++-- src/features/areas/hooks/useCreateArea.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/features/areas/components/AreasMap/index.tsx b/src/features/areas/components/AreasMap/index.tsx index fd0ee48009..ba88b6de81 100644 --- a/src/features/areas/components/AreasMap/index.tsx +++ b/src/features/areas/components/AreasMap/index.tsx @@ -37,9 +37,10 @@ const Map: FC = ({ areas }) => { const { orgId } = useNumericRouteParams(); const createArea = useCreateArea(orgId); - function finishDrawing() { + async function finishDrawing() { if (drawingPoints && drawingPoints.length > 2) { - createArea({ points: drawingPoints }); + const area = await createArea({ points: drawingPoints }); + setSelectedId(area.id); } setDrawingPoints(null); } diff --git a/src/features/areas/hooks/useCreateArea.ts b/src/features/areas/hooks/useCreateArea.ts index 21f2b0488b..b3137da63c 100644 --- a/src/features/areas/hooks/useCreateArea.ts +++ b/src/features/areas/hooks/useCreateArea.ts @@ -12,5 +12,7 @@ export default function useCreateArea(orgId: number) { data ); dispatch(areaCreated(created)); + + return created; }; } From 6269e460191a1357bc3235ea2ed08551379ee82f Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 4 Sep 2024 16:15:00 +0200 Subject: [PATCH 196/369] Remove store from Environment (no longer used) --- src/core/env/ClientContext.tsx | 2 +- src/core/env/Environment.ts | 9 +-------- src/pages/_app.tsx | 2 +- src/utils/testing/index.tsx | 7 +++---- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/core/env/ClientContext.tsx b/src/core/env/ClientContext.tsx index d69d943592..516f3b553b 100644 --- a/src/core/env/ClientContext.tsx +++ b/src/core/env/ClientContext.tsx @@ -42,7 +42,7 @@ const ClientContext: FC = ({ messages, user, }) => { - const env = new Environment(store, new BrowserApiClient(), envVars); + const env = new Environment(new BrowserApiClient(), envVars); return ( diff --git a/src/core/env/Environment.ts b/src/core/env/Environment.ts index c8b3007bcf..0cbcfd8fca 100644 --- a/src/core/env/Environment.ts +++ b/src/core/env/Environment.ts @@ -1,5 +1,4 @@ import IApiClient from 'core/api/client/IApiClient'; -import { Store } from '../store'; type EnvVars = { MUIX_LICENSE_KEY: string | null; @@ -8,26 +7,20 @@ type EnvVars = { export default class Environment { private _apiClient: IApiClient; - private _store: Store; private _vars: EnvVars; get apiClient() { return this._apiClient; } - constructor(store: Store, apiClient: IApiClient, envVars?: EnvVars) { + constructor(apiClient: IApiClient, envVars?: EnvVars) { this._apiClient = apiClient; - this._store = store; this._vars = envVars || { MUIX_LICENSE_KEY: null, ZETKIN_APP_DOMAIN: null, }; } - get store() { - return this._store; - } - get vars() { return this._vars; } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index a937427ee0..9d9a6c7548 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -50,7 +50,7 @@ function MyApp({ Component, pageProps }: AppProps): JSX.Element { window.__reactRendered = true; } - const env = new Environment(store, new BrowserApiClient(), envVars || {}); + const env = new Environment(new BrowserApiClient(), envVars || {}); // MUI-X license if (env.vars.MUIX_LICENSE_KEY) { diff --git a/src/utils/testing/index.tsx b/src/utils/testing/index.tsx index 661b7ffaa7..9fd81e45d6 100644 --- a/src/utils/testing/index.tsx +++ b/src/utils/testing/index.tsx @@ -19,7 +19,7 @@ import IApiClient from 'core/api/client/IApiClient'; import RosaLuxemburgUser from '../../../integrationTesting/mockData/users/RosaLuxemburgUser'; import theme from 'theme'; import { UserContext } from 'utils/hooks/useFocusDate'; -import createStore, { Store } from 'core/store'; +import { Store } from 'core/store'; declare module '@mui/styles/defaultTheme' { // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -31,8 +31,7 @@ interface ZetkinAppProvidersProps { } const ZetkinAppProviders: FC = ({ children }) => { - const store = createStore(); - const env = new Environment(store, new BrowserApiClient(), { + const env = new Environment(new BrowserApiClient(), { MUIX_LICENSE_KEY: null, ZETKIN_APP_DOMAIN: 'https://app.zetkin.org', }); @@ -109,7 +108,7 @@ export const makeWrapper = (store: Store) => rpc: jest.fn(), }; - const env = new Environment(store, apiClient); + const env = new Environment(apiClient); return ( From ab451abc9f67455a14728bb16026cc7b06e4eac7 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 4 Sep 2024 16:38:44 +0200 Subject: [PATCH 197/369] Create hook and utility funtion for feature flagging through environment vars --- src/core/env/EnvContext.tsx | 7 +-- src/utils/featureFlags/hasFeature.ts | 13 +++++ src/utils/featureFlags/useFeature.spec.ts | 68 +++++++++++++++++++++++ src/utils/featureFlags/useFeature.ts | 12 ++++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 src/utils/featureFlags/hasFeature.ts create mode 100644 src/utils/featureFlags/useFeature.spec.ts create mode 100644 src/utils/featureFlags/useFeature.ts diff --git a/src/core/env/EnvContext.tsx b/src/core/env/EnvContext.tsx index e4b9937a99..97e9d0ac5f 100644 --- a/src/core/env/EnvContext.tsx +++ b/src/core/env/EnvContext.tsx @@ -1,13 +1,12 @@ -import { createContext, FC, ReactNode } from 'react'; +import { createContext, FC, PropsWithChildren } from 'react'; import Environment from './Environment'; const EnvContext = createContext(null); -interface EnvProviderProps { - children: ReactNode; +type EnvProviderProps = PropsWithChildren & { env: Environment; -} +}; const EnvProvider: FC = ({ children, env }) => { return {children}; diff --git a/src/utils/featureFlags/hasFeature.ts b/src/utils/featureFlags/hasFeature.ts new file mode 100644 index 0000000000..d6df7459e5 --- /dev/null +++ b/src/utils/featureFlags/hasFeature.ts @@ -0,0 +1,13 @@ +export default function hasFeature( + featureLabel: string, + orgId: number, + envVars: Record +): boolean { + const envValue = envVars['FEAT_' + featureLabel]; + + const settings = envValue?.split(',') || []; + + return settings.some((setting) => { + return setting == '*' || setting == orgId.toString(); + }); +} diff --git a/src/utils/featureFlags/useFeature.spec.ts b/src/utils/featureFlags/useFeature.spec.ts new file mode 100644 index 0000000000..b5c0073d4d --- /dev/null +++ b/src/utils/featureFlags/useFeature.spec.ts @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react'; +import { createElement, PropsWithChildren } from 'react'; + +import IApiClient from 'core/api/client/IApiClient'; +import { EnvProvider } from 'core/env/EnvContext'; +import Environment from 'core/env/Environment'; +import useFeature from './useFeature'; + +function getOptsWithEnvVars(vars?: Partial) { + return { + wrapper: ({ children }: PropsWithChildren) => + createElement( + EnvProvider, + { + env: new Environment(null as unknown as IApiClient, { + FEAT_AREAS: null, + MUIX_LICENSE_KEY: null, + ZETKIN_APP_DOMAIN: null, + ...vars, + }), + }, + children + ), + }; +} + +describe('useFeature()', () => { + it('returns false for empty vars', () => { + const opts = getOptsWithEnvVars(); + const { result } = renderHook(() => useFeature('UNKNOWN', 1), opts); + expect(result.current).toBeFalsy(); + }); + + it('returns true when org ID is in feature flag', () => { + const opts = getOptsWithEnvVars({ + FEAT_AREAS: '1', + }); + const { result } = renderHook(() => useFeature('AREAS', 1), opts); + expect(result.current).toBeTruthy(); + }); + + it('returns true when org ID is listed in feature flag', () => { + const opts = getOptsWithEnvVars({ + FEAT_AREAS: '1,2,3', + }); + const { result } = renderHook(() => useFeature('AREAS', 2), opts); + expect(result.current).toBeTruthy(); + }); + + it('returns false when orgId is not listed in feature flag', () => { + const opts = getOptsWithEnvVars({ + FEAT_AREAS: '1,2,3', + }); + const { result } = renderHook(() => useFeature('AREAS', 4), opts); + expect(result.current).toBeFalsy(); + }); + + it.each([1, 2, 34, 567])( + 'returns true for orgId=%p when feature flag is *', + () => { + const opts = getOptsWithEnvVars({ + FEAT_AREAS: '*', + }); + const { result } = renderHook(() => useFeature('AREAS', 1), opts); + expect(result.current).toBeTruthy(); + } + ); +}); diff --git a/src/utils/featureFlags/useFeature.ts b/src/utils/featureFlags/useFeature.ts new file mode 100644 index 0000000000..9d49d13433 --- /dev/null +++ b/src/utils/featureFlags/useFeature.ts @@ -0,0 +1,12 @@ +import useEnv from '../../core/hooks/useEnv'; +import hasFeature from './hasFeature'; + +export default function useFeature( + featureLabel: string, + orgId: number +): boolean { + const env = useEnv(); + + const untypedVars = env.vars as Record; + return hasFeature(featureLabel, orgId, untypedVars); +} From 0a261996006bfeae5cc0fb6450d26da7baa59625 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 4 Sep 2024 16:46:53 +0200 Subject: [PATCH 198/369] Hide areas section when missing the AREAS feature flag --- src/core/env/Environment.ts | 2 ++ src/pages/organize/[orgId]/areas/index.tsx | 12 +++++++++++- src/utils/next.ts | 1 + src/zui/ZUIOrganizeSidebar/index.tsx | 6 ++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/core/env/Environment.ts b/src/core/env/Environment.ts index 0cbcfd8fca..1082b54de9 100644 --- a/src/core/env/Environment.ts +++ b/src/core/env/Environment.ts @@ -1,6 +1,7 @@ import IApiClient from 'core/api/client/IApiClient'; type EnvVars = { + FEAT_AREAS?: string | null; MUIX_LICENSE_KEY: string | null; ZETKIN_APP_DOMAIN: string | null; }; @@ -16,6 +17,7 @@ export default class Environment { constructor(apiClient: IApiClient, envVars?: EnvVars) { this._apiClient = apiClient; this._vars = envVars || { + FEAT_AREAS: null, MUIX_LICENSE_KEY: null, ZETKIN_APP_DOMAIN: null, }; diff --git a/src/pages/organize/[orgId]/areas/index.tsx b/src/pages/organize/[orgId]/areas/index.tsx index 80d67f53ab..04f9ec5334 100644 --- a/src/pages/organize/[orgId]/areas/index.tsx +++ b/src/pages/organize/[orgId]/areas/index.tsx @@ -8,12 +8,22 @@ import { PageWithLayout } from 'utils/types'; import useAreas from 'features/areas/hooks/useAreas'; import { useNumericRouteParams } from 'core/hooks'; import ZUIFuture from 'zui/ZUIFuture'; +import hasFeature from 'utils/featureFlags/hasFeature'; const scaffoldOptions = { authLevelRequired: 2, }; -export const getServerSideProps: GetServerSideProps = scaffold(async () => { +export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { + const { orgId } = ctx.params!; + const hasAreas = hasFeature('AREAS', parseInt(orgId as string), process.env); + + if (!hasAreas) { + return { + notFound: true, + }; + } + return { props: {}, }; diff --git a/src/utils/next.ts b/src/utils/next.ts index d5ffccade0..0c3785f4db 100644 --- a/src/utils/next.ts +++ b/src/utils/next.ts @@ -190,6 +190,7 @@ export const scaffold = result.props = { ...result.props, envVars: { + FEAT_AREAS: process.env.FEAT_AREAS || null, MUIX_LICENSE_KEY: process.env.MUIX_LICENSE_KEY || null, ZETKIN_APP_DOMAIN: process.env.ZETKIN_APP_DOMAIN || null, }, diff --git a/src/zui/ZUIOrganizeSidebar/index.tsx b/src/zui/ZUIOrganizeSidebar/index.tsx index 2f266aebc9..78d8bd81ea 100644 --- a/src/zui/ZUIOrganizeSidebar/index.tsx +++ b/src/zui/ZUIOrganizeSidebar/index.tsx @@ -42,6 +42,7 @@ import SidebarListItem from './SidebarListItem'; import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFuture from 'zui/ZUIFuture'; import ZUIUserAvatar from 'zui/ZUIUserAvatar'; +import useFeature from 'utils/featureFlags/useFeature'; const drawerWidth = 300; @@ -93,6 +94,7 @@ const ZUIOrganizeSidebar = (): JSX.Element => { const [open, setOpen] = useState(lastOpen); const [searchString, setSearchString] = useState(''); const organizationFuture = useOrganization(orgId); + const hasAreas = useFeature('AREAS', orgId); const handleExpansion = () => { setChecked(!checked); @@ -283,6 +285,10 @@ const ZUIOrganizeSidebar = (): JSX.Element => { )} /> {menuItemsMap.map(({ name, icon }) => { + if (name == 'areas' && !hasAreas) { + return null; + } + return ( Date: Wed, 4 Sep 2024 16:53:14 +0200 Subject: [PATCH 199/369] Refactor AREAS feature flag for type safety --- src/pages/organize/[orgId]/areas/index.tsx | 3 ++- src/utils/featureFlags/hasFeature.ts | 2 +- src/utils/featureFlags/index.ts | 4 ++++ src/utils/featureFlags/useFeature.spec.ts | 8 ++++---- src/zui/ZUIOrganizeSidebar/index.tsx | 3 ++- 5 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 src/utils/featureFlags/index.ts diff --git a/src/pages/organize/[orgId]/areas/index.tsx b/src/pages/organize/[orgId]/areas/index.tsx index 04f9ec5334..bf4adedd35 100644 --- a/src/pages/organize/[orgId]/areas/index.tsx +++ b/src/pages/organize/[orgId]/areas/index.tsx @@ -9,6 +9,7 @@ import useAreas from 'features/areas/hooks/useAreas'; import { useNumericRouteParams } from 'core/hooks'; import ZUIFuture from 'zui/ZUIFuture'; import hasFeature from 'utils/featureFlags/hasFeature'; +import { AREAS } from 'utils/featureFlags'; const scaffoldOptions = { authLevelRequired: 2, @@ -16,7 +17,7 @@ const scaffoldOptions = { export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { const { orgId } = ctx.params!; - const hasAreas = hasFeature('AREAS', parseInt(orgId as string), process.env); + const hasAreas = hasFeature(AREAS, parseInt(orgId as string), process.env); if (!hasAreas) { return { diff --git a/src/utils/featureFlags/hasFeature.ts b/src/utils/featureFlags/hasFeature.ts index d6df7459e5..4f9c944a45 100644 --- a/src/utils/featureFlags/hasFeature.ts +++ b/src/utils/featureFlags/hasFeature.ts @@ -3,7 +3,7 @@ export default function hasFeature( orgId: number, envVars: Record ): boolean { - const envValue = envVars['FEAT_' + featureLabel]; + const envValue = envVars[featureLabel]; const settings = envValue?.split(',') || []; diff --git a/src/utils/featureFlags/index.ts b/src/utils/featureFlags/index.ts new file mode 100644 index 0000000000..ed0919168e --- /dev/null +++ b/src/utils/featureFlags/index.ts @@ -0,0 +1,4 @@ +export { default as useFeature } from './useFeature'; +export { default as hasFeature } from './hasFeature'; + +export const AREAS = 'FEAT_AREAS'; diff --git a/src/utils/featureFlags/useFeature.spec.ts b/src/utils/featureFlags/useFeature.spec.ts index b5c0073d4d..ad8e025e4f 100644 --- a/src/utils/featureFlags/useFeature.spec.ts +++ b/src/utils/featureFlags/useFeature.spec.ts @@ -35,7 +35,7 @@ describe('useFeature()', () => { const opts = getOptsWithEnvVars({ FEAT_AREAS: '1', }); - const { result } = renderHook(() => useFeature('AREAS', 1), opts); + const { result } = renderHook(() => useFeature('FEAT_AREAS', 1), opts); expect(result.current).toBeTruthy(); }); @@ -43,7 +43,7 @@ describe('useFeature()', () => { const opts = getOptsWithEnvVars({ FEAT_AREAS: '1,2,3', }); - const { result } = renderHook(() => useFeature('AREAS', 2), opts); + const { result } = renderHook(() => useFeature('FEAT_AREAS', 2), opts); expect(result.current).toBeTruthy(); }); @@ -51,7 +51,7 @@ describe('useFeature()', () => { const opts = getOptsWithEnvVars({ FEAT_AREAS: '1,2,3', }); - const { result } = renderHook(() => useFeature('AREAS', 4), opts); + const { result } = renderHook(() => useFeature('FEAT_AREAS', 4), opts); expect(result.current).toBeFalsy(); }); @@ -61,7 +61,7 @@ describe('useFeature()', () => { const opts = getOptsWithEnvVars({ FEAT_AREAS: '*', }); - const { result } = renderHook(() => useFeature('AREAS', 1), opts); + const { result } = renderHook(() => useFeature('FEAT_AREAS', 1), opts); expect(result.current).toBeTruthy(); } ); diff --git a/src/zui/ZUIOrganizeSidebar/index.tsx b/src/zui/ZUIOrganizeSidebar/index.tsx index 78d8bd81ea..0b3be4551a 100644 --- a/src/zui/ZUIOrganizeSidebar/index.tsx +++ b/src/zui/ZUIOrganizeSidebar/index.tsx @@ -43,6 +43,7 @@ import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFuture from 'zui/ZUIFuture'; import ZUIUserAvatar from 'zui/ZUIUserAvatar'; import useFeature from 'utils/featureFlags/useFeature'; +import { AREAS } from 'utils/featureFlags'; const drawerWidth = 300; @@ -94,7 +95,7 @@ const ZUIOrganizeSidebar = (): JSX.Element => { const [open, setOpen] = useState(lastOpen); const [searchString, setSearchString] = useState(''); const organizationFuture = useOrganization(orgId); - const hasAreas = useFeature('AREAS', orgId); + const hasAreas = useFeature(AREAS, orgId); const handleExpansion = () => { setChecked(!checked); From 1549c9ec47780a34d4c2e8f50fe4f3d46dd2e817 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 4 Sep 2024 18:21:20 +0200 Subject: [PATCH 200/369] Respond 404 on frontend when areas feature is disabled --- src/app/o/[orgId]/areas/[areaId]/page.tsx | 8 ++++++++ src/utils/featureFlags/index.ts | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/o/[orgId]/areas/[areaId]/page.tsx b/src/app/o/[orgId]/areas/[areaId]/page.tsx index 11d561fa1d..45d798c2b7 100644 --- a/src/app/o/[orgId]/areas/[areaId]/page.tsx +++ b/src/app/o/[orgId]/areas/[areaId]/page.tsx @@ -1,6 +1,8 @@ import 'leaflet/dist/leaflet.css'; +import { notFound } from 'next/navigation'; import AreaPage from 'features/areas/components/AreaPage'; +import { AREAS, hasFeature } from 'utils/featureFlags'; interface PageProps { params: { @@ -11,5 +13,11 @@ interface PageProps { export default function Page({ params }: PageProps) { const { orgId, areaId } = params; + const hasAreas = hasFeature(AREAS, parseInt(orgId), process.env); + + if (!hasAreas) { + return notFound(); + } + return ; } diff --git a/src/utils/featureFlags/index.ts b/src/utils/featureFlags/index.ts index ed0919168e..afcb56cf9b 100644 --- a/src/utils/featureFlags/index.ts +++ b/src/utils/featureFlags/index.ts @@ -1,4 +1,3 @@ -export { default as useFeature } from './useFeature'; export { default as hasFeature } from './hasFeature'; export const AREAS = 'FEAT_AREAS'; From c0361aad59e2dd149728eb06bbf6cc86286ec2c4 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 5 Sep 2024 12:58:17 +0200 Subject: [PATCH 201/369] Add markers property to ZetkinArea. --- src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts | 3 +++ src/app/beta/orgs/[orgId]/areas/route.ts | 3 +++ src/features/areas/models.ts | 2 ++ src/features/areas/types.ts | 5 +++++ 4 files changed, 13 insertions(+) diff --git a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts index 8b852d2972..48da493ee9 100644 --- a/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/[areaId]/route.ts @@ -31,6 +31,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const area: ZetkinArea = { description: areaModel.description, id: areaModel._id.toString(), + markers: areaModel.markers, numberOfActions: areaModel.numberOfActions, organization: { id: orgId, @@ -60,6 +61,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { { _id: params.areaId }, { description: payload.description, + markers: payload.markers, numberOfActions: payload.numberOfActions, points: payload.points, title: payload.title, @@ -75,6 +77,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { data: { description: model.description, id: model._id.toString(), + markers: model.markers, numberOfActions: model.numberOfActions, organization: { id: orgId, diff --git a/src/app/beta/orgs/[orgId]/areas/route.ts b/src/app/beta/orgs/[orgId]/areas/route.ts index 940870fda5..4e8d16bbf0 100644 --- a/src/app/beta/orgs/[orgId]/areas/route.ts +++ b/src/app/beta/orgs/[orgId]/areas/route.ts @@ -25,6 +25,7 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { const areas: ZetkinArea[] = areaModels.map((model) => ({ description: model.description, id: model._id.toString(), + markers: model.markers, numberOfActions: model.numberOfActions, organization: { id: orgId, @@ -52,6 +53,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const model = new AreaModel({ description: payload.description, + markers: [], //kanske detta inte går? att den måste ha ett innehåll? numberOfActions: 0, orgId: orgId, points: payload.points, @@ -64,6 +66,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { data: { description: model.description, id: model._id.toString(), + markers: model.markers, numberOfActions: model.numberOfActions, organization: { id: orgId, diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 8b9870da83..1662a2dc64 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -4,6 +4,7 @@ import { ZetkinArea } from './types'; type IZetkinModel = { description: string | null; + markers: ZetkinArea['markers']; numberOfActions: number; orgId: number; points: ZetkinArea['points']; @@ -12,6 +13,7 @@ type IZetkinModel = { const areaSchema = new mongoose.Schema({ description: String, + markers: Array, numberOfActions: Number, orgId: { required: true, type: Number }, points: Array, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 331eebb0f0..005c9df409 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -1,8 +1,13 @@ export type PointData = [number, number]; +type MarkerData = { + numberOfActions: number; + position: { lat: number; lng: number }; +}; export type ZetkinArea = { description: string | null; id: string; + markers: MarkerData[]; numberOfActions: number; organization: { id: number; From 8858c435bf1fa250b855b1b3b4697f4a88d8bea9 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 5 Sep 2024 14:15:47 +0200 Subject: [PATCH 202/369] Render out markers on map. --- .../areas/components/PublicAreaMap.tsx | 89 ++++++++++++++++--- .../LocationModal/DivIconMarker.tsx | 4 +- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index c1b6ca4c0b..54d637f6e7 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -1,4 +1,4 @@ -import { FC, useRef } from 'react'; +import { FC, useRef, useState } from 'react'; import { latLngBounds, Map } from 'leaflet'; import { makeStyles } from '@mui/styles'; import { Add, Remove } from '@mui/icons-material'; @@ -8,6 +8,9 @@ import { ButtonGroup, Divider, IconButton, + ToggleButton, + ToggleButtonGroup, + Typography, useTheme, } from '@mui/material'; import { MapContainer, Polygon, TileLayer } from 'react-leaflet'; @@ -19,6 +22,16 @@ import messageIds from '../l10n/messageIds'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; const useStyles = makeStyles((theme) => ({ + areaNumber: { + alignItems: 'center', + backgroundColor: 'white', + borderRadius: '2em', + display: 'flex', + height: 30, + justifyContent: 'center', + transform: 'translate(-20%, -60%)', + width: 30, + }, counter: { bottom: 15, display: 'flex', @@ -28,15 +41,14 @@ const useStyles = makeStyles((theme) => ({ width: '100%', zIndex: 1000, }, - number: { - alignItems: 'center', - backgroundColor: 'white', - borderRadius: '2em', - display: 'flex', - height: 30, - justifyContent: 'center', - transform: 'translate(-20%, -60%)', - width: 30, + markerNumber: { + bottom: -14, + color: 'blue', + left: '100%', + position: 'absolute', + textAlign: 'center', + transform: 'translate(-50%)', + zIndex: 2000, }, zoomControls: { backgroundColor: theme.palette.common.white, @@ -58,6 +70,7 @@ const PublicAreaMap: FC = ({ area }) => { const theme = useTheme(); const classes = useStyles(); const mapRef = useRef(null); + const [mode, setMode] = useState<'area' | 'markers'>('area'); const { updateArea } = useAreaMutations(area.organization.id, area.id); return ( @@ -72,6 +85,20 @@ const PublicAreaMap: FC = ({ area }) => { + { + if (newMode != null) { + setMode(newMode); + } + }} + sx={{ backgroundColor: theme.palette.background.default }} + value={mode} + > + Area + Markers + - - + } + }} + variant="contained" + > + + = ({ area }) => { url="https://tile.openstreetmap.org/{z}/{x}/{y}.png" /> - {mode == 'area' && ( - - {area.numberOfActions} - - )} - {mode == 'markers' && ( - <> - {area.markers.map((marker, index) => { - const selected = index == selectedIndex; - const key = `marker-${index}-${selected.toString()}`; - - return ( - - - {marker.numberOfActions} - - - + - - - ); - })} - - )} + fill={selected ? '#ED1C55' : 'white'} + stroke="#ED1C55" + strokeWidth="2" + /> + + + ); + })} + ); From 67a69bbb1eec67155b4b7645227bf97440de229a Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Fri, 6 Sep 2024 16:21:55 +0200 Subject: [PATCH 208/369] Show button to add new marker if no marker is selected. --- .../areas/components/PublicAreaMap.tsx | 70 ++++++++++--------- src/features/areas/l10n/messageIds.ts | 4 +- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 714c31567f..d7f7146285 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -12,10 +12,11 @@ import messageIds from '../l10n/messageIds'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; const useStyles = makeStyles((theme) => ({ - counter: { + actionAreaContainer: { bottom: 15, display: 'flex', gap: 8, + justifyContent: 'center', padding: 8, position: 'absolute', width: '100%', @@ -131,39 +132,42 @@ const PublicAreaMap: FC = ({ area }) => {
- - + }} + variant="contained" + > + + + )} Date: Fri, 6 Sep 2024 16:54:48 +0200 Subject: [PATCH 209/369] Add button to open dialog to view info about place. --- .../areas/components/PublicAreaMap.tsx | 43 ++++++++++++++++++- src/features/areas/l10n/messageIds.ts | 1 + 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index d7f7146285..24df4d4df4 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -2,7 +2,14 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { latLngBounds, Map } from 'leaflet'; import { makeStyles } from '@mui/styles'; import { Add, GpsNotFixed, Remove } from '@mui/icons-material'; -import { Box, Button, Divider, IconButton, useTheme } from '@mui/material'; +import { + Box, + Button, + Dialog, + Divider, + IconButton, + useTheme, +} from '@mui/material'; import { MapContainer, Polygon, TileLayer } from 'react-leaflet'; import { ZetkinArea } from '../types'; @@ -28,7 +35,16 @@ const useStyles = makeStyles((theme) => ({ top: '40vh', transform: 'translate(-50%, -50%)', transition: 'opacity 0.1s', - zIndex: 2000, + zIndex: 1200, + }, + infoButtons: { + backgroundColor: theme.palette.background.default, + border: `1px solid ${theme.palette.grey[300]}`, + borderRadius: '4px', + display: 'flex', + flexDirection: 'column', + padding: '8px', + width: '90%', }, zoomControls: { backgroundColor: theme.palette.common.white, @@ -51,10 +67,13 @@ const PublicAreaMap: FC = ({ area }) => { const classes = useStyles(); const { updateArea } = useAreaMutations(area.organization.id, area.id); const [selectedIndex, setSelectedIndex] = useState(-1); + const [anchorEl, setAnchorEl] = useState(null); const mapRef = useRef(null); const crosshairRef = useRef(null); + const showViewPlaceButton = selectedIndex > 0 && !anchorEl; + const updateSelection = useCallback(() => { let nearestIndex = -1; let nearestDistance = Infinity; @@ -133,6 +152,16 @@ const PublicAreaMap: FC = ({ area }) => {
+ {showViewPlaceButton && ( + + + + )} {selectedIndex < 0 && ( )} - {selectedIndex < 0 && ( + {!selectedPlace && ( + +
+ + + )} ); diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 797fac1143..415fded830 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -14,6 +14,17 @@ export default makeMessages('feat.areas', { save: m('Save'), }, }, + place: { + activityHeader: m('Activity'), + closeButton: m('Close'), + description: m('Description'), + empty: { + description: m('Empty description'), + title: m('Untitled place'), + }, + logActivityButton: m('Log activity'), + noActivity: m('No visits have been recorded at this place.'), + }, tools: { cancel: m('Cancel'), draw: m('Draw'), diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 3702738dd1..5a1be32ae3 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -10,6 +10,7 @@ type ZetkinAreaModelType = { }; type ZetkinPlaceModelType = { + description: string | null; orgId: number; position: ZetkinPlace['position']; title: string | null; @@ -25,6 +26,7 @@ const areaSchema = new mongoose.Schema({ }); const placeSchema = new mongoose.Schema({ + description: String, orgId: { required: true, type: Number }, position: Object, title: String, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 20ee32062b..1cb0702521 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -1,6 +1,7 @@ export type PointData = [number, number]; export type Visit = { + id: string; note: string; timestamp: string; visitor_id: number; @@ -17,6 +18,7 @@ export type ZetkinArea = { }; export type ZetkinPlace = { + description: string | null; id: string; orgId: number; position: { lat: number; lng: number }; From 7c26062e31615fd3a2f9e4269c8d32aad5787ccc Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 9 Sep 2024 16:09:36 +0200 Subject: [PATCH 220/369] Add PATCH + UI to log an activity. --- .../orgs/[orgId]/places/[placeId]/route.ts | 55 +++++++++ src/features/areas/components/LogActivity.tsx | 59 ++++++++++ .../areas/components/PlaceDetails.tsx | 81 +++++++++++++ src/features/areas/components/PlaceDialog.tsx | 53 +++++++++ .../areas/components/PublicAreaMap.tsx | 111 +++++------------- src/features/areas/hooks/usePlaceMutations.ts | 16 +++ src/features/areas/l10n/messageIds.ts | 3 + src/features/areas/store.ts | 8 ++ src/features/areas/types.ts | 4 +- 9 files changed, 310 insertions(+), 80 deletions(-) create mode 100644 src/app/beta/orgs/[orgId]/places/[placeId]/route.ts create mode 100644 src/features/areas/components/LogActivity.tsx create mode 100644 src/features/areas/components/PlaceDetails.tsx create mode 100644 src/features/areas/components/PlaceDialog.tsx create mode 100644 src/features/areas/hooks/usePlaceMutations.ts diff --git a/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts b/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts new file mode 100644 index 0000000000..e37f3e1376 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/places/[placeId]/route.ts @@ -0,0 +1,55 @@ +import mongoose from 'mongoose'; +import { NextRequest, NextResponse } from 'next/server'; + +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; +import { PlaceModel } from 'features/areas/models'; + +type RouteMeta = { + params: { + orgId: string; + placeId: string; + }; +}; + +export async function PATCH(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const payload = await request.json(); + + const model = await PlaceModel.findOneAndUpdate( + { _id: params.placeId, orgId }, + { + description: payload.description, + position: payload.position, + title: payload.title, + type: payload.type, + visits: payload.visits, + }, + { new: true } + ); + + if (!model) { + return new NextResponse(null, { status: 404 }); + } + + return NextResponse.json({ + data: { + description: model.description, + id: model._id.toString(), + orgId: orgId, + position: model.position, + title: model.title, + type: model.type, + visits: model.visits, + }, + }); + } + ); +} diff --git a/src/features/areas/components/LogActivity.tsx b/src/features/areas/components/LogActivity.tsx new file mode 100644 index 0000000000..b43dc48532 --- /dev/null +++ b/src/features/areas/components/LogActivity.tsx @@ -0,0 +1,59 @@ +import { FC, useState } from 'react'; +import { Box, Button, TextField } from '@mui/material'; + +import { Msg } from 'core/i18n'; +import { ZetkinPlace } from '../types'; +import usePlaceMutations from '../hooks/usePlaceMutations'; +import messageIds from '../l10n/messageIds'; +import ZUIDateTime from 'zui/ZUIDateTime'; + +type LogActivityProps = { + onCancel: () => void; + orgId: number; + place: ZetkinPlace; +}; + +const LogActivity: FC = ({ onCancel, orgId, place }) => { + const updatePlace = usePlaceMutations(orgId, place.id); + const [note, setNote] = useState(''); + + const timestamp = new Date().toISOString(); + return ( + + + + setNote(ev.target.value)} + placeholder="Note" + sx={{ paddingTop: 1 }} + /> + + + + + + + ); +}; + +export default LogActivity; diff --git a/src/features/areas/components/PlaceDetails.tsx b/src/features/areas/components/PlaceDetails.tsx new file mode 100644 index 0000000000..7903226a62 --- /dev/null +++ b/src/features/areas/components/PlaceDetails.tsx @@ -0,0 +1,81 @@ +import { FC } from 'react'; +import { Box, Button, Typography } from '@mui/material'; + +import { ZetkinPlace } from '../types'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; +import ZUIDateTime from 'zui/ZUIDateTime'; + +type PlaceDetailsProps = { + onClose: () => void; + onLogActivity: () => void; + place: ZetkinPlace; +}; + +const PlaceDetails: FC = ({ + onClose, + onLogActivity, + place, +}) => { + const sortedVisits = place.visits.toSorted((a, b) => { + const dateA = new Date(a.timestamp); + const dateB = new Date(b.timestamp); + if (dateA > dateB) { + return -1; + } else if (dateB > dateA) { + return 1; + } else { + return 0; + } + }); + + return ( + + + + + + + + {place.description || ( + + )} + + + + + + <> + {sortedVisits.length == 0 && ( + + )} + {sortedVisits.map((visit) => ( + + + + + {visit.note} + + ))} + + + + + + + + + + ); +}; + +export default PlaceDetails; diff --git a/src/features/areas/components/PlaceDialog.tsx b/src/features/areas/components/PlaceDialog.tsx new file mode 100644 index 0000000000..d939bd22f5 --- /dev/null +++ b/src/features/areas/components/PlaceDialog.tsx @@ -0,0 +1,53 @@ +import { FC } from 'react'; +import { Box, Dialog, Divider, Typography } from '@mui/material'; + +import PlaceDetails from './PlaceDetails'; +import LogActivity from './LogActivity'; +import { ZetkinPlace } from '../types'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; + +type PlaceDialogProps = { + inLogMode: boolean; + onClose: () => void; + onLogCancel: () => void; + onLogStart: () => void; + open: boolean; + orgId: number; + place: ZetkinPlace | null; +}; + +const PlaceDialog: FC = ({ + inLogMode, + onClose, + onLogCancel, + onLogStart, + open, + orgId, + place, +}) => { + return ( + + + + + {place?.title || } + + + + {place && !inLogMode && ( + + )} + {place && inLogMode && ( + + )} + + + ); +}; + +export default PlaceDialog; diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index c4d495e44c..107440c258 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -5,7 +5,7 @@ import { Add, GpsNotFixed, Remove } from '@mui/icons-material'; import { Box, Button, - Dialog, + ButtonGroup, Divider, IconButton, Typography, @@ -19,7 +19,7 @@ import messageIds from '../l10n/messageIds'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; import usePlaces from '../hooks/usePlaces'; import useCreatePlace from '../hooks/useCreatePlace'; -import ZUIDateTime from 'zui/ZUIDateTime'; +import PlaceDialog from './PlaceDialog'; const useStyles = makeStyles((theme) => ({ actionAreaContainer: { @@ -73,6 +73,7 @@ const PublicAreaMap: FC = ({ area }) => { const [selectedPlace, setSelectedPlace] = useState(null); const [anchorEl, setAnchorEl] = useState(null); + const [inLogMode, setInLogMode] = useState(false); const mapRef = useRef(null); const crosshairRef = useRef(null); @@ -159,12 +160,28 @@ const PublicAreaMap: FC = ({ area }) => { {showViewPlaceButton && ( - + + {selectedPlace.title || } + + + + + + + )} {!selectedPlace && ( @@ -244,82 +261,18 @@ const PublicAreaMap: FC = ({ area }) => { })} - { setAnchorEl(null); setSelectedPlace(null); }} + onLogCancel={() => setInLogMode(false)} + onLogStart={() => setInLogMode(true)} open={!!anchorEl} - > - {selectedPlace && ( - - - - - - {selectedPlace.title || ( - - )} - - - - - - - - - {selectedPlace.description || ( - - )} - - - - - - {selectedPlace.visits.length == 0 && ( - - )} - {selectedPlace.visits.map((visit) => ( - - - - - {visit.note} - - ))} - - - - - - - - - - )} - + orgId={area.organization.id} + place={selectedPlace} + /> ); }; diff --git a/src/features/areas/hooks/usePlaceMutations.ts b/src/features/areas/hooks/usePlaceMutations.ts new file mode 100644 index 0000000000..fe0155a95b --- /dev/null +++ b/src/features/areas/hooks/usePlaceMutations.ts @@ -0,0 +1,16 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinPlace, ZetkinPlacePatchBody } from '../types'; +import { placeUpdated } from '../store'; + +export default function usePlaceMutations(orgId: number, placeId: string) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (data: ZetkinPlacePatchBody) => { + const area = await apiClient.patch( + `/beta/orgs/${orgId}/places/${placeId}`, + data + ); + dispatch(placeUpdated(area)); + }; +} diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 415fded830..4809dc84b9 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -16,6 +16,7 @@ export default makeMessages('feat.areas', { }, place: { activityHeader: m('Activity'), + cancelButton: m('Cancel'), closeButton: m('Close'), description: m('Description'), empty: { @@ -23,7 +24,9 @@ export default makeMessages('feat.areas', { title: m('Untitled place'), }, logActivityButton: m('Log activity'), + logActivityHeader: m<{ title: string }>('Log activity at {title}'), noActivity: m('No visits have been recorded at this place.'), + saveButton: m('Save'), }, tools: { cancel: m('Cancel'), diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 2a3a6aa7d0..353a8d5da3 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -87,6 +87,13 @@ const areasSlice = createSlice({ state.placeList.items.push(item); }, + placeUpdated: (state, action: PayloadAction) => { + const place = action.payload; + const item = findOrAddItem(state.placeList, place.id); + + item.data = place; + item.loaded = new Date().toISOString(); + }, placesLoad: (state) => { state.placeList.isLoading = true; }, @@ -112,4 +119,5 @@ export const { placeCreated, placesLoad, placesLoaded, + placeUpdated, } = areasSlice.actions; diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 1cb0702521..104fd59a7c 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -4,7 +4,6 @@ export type Visit = { id: string; note: string; timestamp: string; - visitor_id: number; }; export type ZetkinArea = { @@ -30,3 +29,6 @@ export type ZetkinPlace = { export type ZetkinAreaPostBody = Partial>; export type ZetkinPlacePostBody = Partial>; +export type ZetkinPlacePatchBody = Omit & { + visits?: Omit[]; +}; From fd04e332f89f3ebdec268be4d82150a1573fcad4 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 10 Sep 2024 13:53:49 +0200 Subject: [PATCH 221/369] Change style of buttons + refactor dialog logic a bit. --- src/features/areas/components/PlaceDialog.tsx | 8 +-- .../areas/components/PublicAreaMap.tsx | 60 ++++++++++++------- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/features/areas/components/PlaceDialog.tsx b/src/features/areas/components/PlaceDialog.tsx index d939bd22f5..739ed6fc63 100644 --- a/src/features/areas/components/PlaceDialog.tsx +++ b/src/features/areas/components/PlaceDialog.tsx @@ -8,7 +8,7 @@ import { Msg } from 'core/i18n'; import messageIds from '../l10n/messageIds'; type PlaceDialogProps = { - inLogMode: boolean; + dialogStep: 'place' | 'log'; onClose: () => void; onLogCancel: () => void; onLogStart: () => void; @@ -18,7 +18,7 @@ type PlaceDialogProps = { }; const PlaceDialog: FC = ({ - inLogMode, + dialogStep, onClose, onLogCancel, onLogStart, @@ -35,14 +35,14 @@ const PlaceDialog: FC = ({ - {place && !inLogMode && ( + {place && dialogStep == 'place' && ( )} - {place && inLogMode && ( + {place && dialogStep == 'log' && ( )} diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 107440c258..e9cf1d9457 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -5,7 +5,6 @@ import { Add, GpsNotFixed, Remove } from '@mui/icons-material'; import { Box, Button, - ButtonGroup, Divider, IconButton, Typography, @@ -73,7 +72,9 @@ const PublicAreaMap: FC = ({ area }) => { const [selectedPlace, setSelectedPlace] = useState(null); const [anchorEl, setAnchorEl] = useState(null); - const [inLogMode, setInLogMode] = useState(false); + + const [dialogStep, setDialogStep] = useState<'place' | 'log'>('place'); + const [returnToMap, setReturnToMap] = useState(false); const mapRef = useRef(null); const crosshairRef = useRef(null); @@ -163,24 +164,28 @@ const PublicAreaMap: FC = ({ area }) => { {selectedPlace.title || } - - - - - + + + )} @@ -262,13 +267,22 @@ const PublicAreaMap: FC = ({ area }) => { { setAnchorEl(null); setSelectedPlace(null); }} - onLogCancel={() => setInLogMode(false)} - onLogStart={() => setInLogMode(true)} + onLogCancel={() => { + if (returnToMap) { + setAnchorEl(null); + } else { + setDialogStep('place'); + } + }} + onLogStart={() => { + setDialogStep('log'); + setReturnToMap(false); + }} open={!!anchorEl} orgId={area.organization.id} place={selectedPlace} From 7cb604aaac40b6596657eea2932309cbe41bfd4a Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 10 Sep 2024 14:18:52 +0200 Subject: [PATCH 222/369] Update map attribution. --- src/features/areas/components/PublicAreaMap.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index e9cf1d9457..f2d654b3ac 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -10,7 +10,12 @@ import { Typography, useTheme, } from '@mui/material'; -import { MapContainer, Polygon, TileLayer } from 'react-leaflet'; +import { + AttributionControl, + MapContainer, + Polygon, + TileLayer, +} from 'react-leaflet'; import { ZetkinArea, ZetkinPlace } from '../types'; import { Msg } from 'core/i18n'; @@ -221,13 +226,15 @@ const PublicAreaMap: FC = ({ area }) => { + From c92d44566362e6b8ded41a7ef93f61ad3eee9437 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 10 Sep 2024 14:21:30 +0200 Subject: [PATCH 223/369] Prevent saving empty notes. --- src/features/areas/components/LogActivity.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/areas/components/LogActivity.tsx b/src/features/areas/components/LogActivity.tsx index b43dc48532..d786c49aff 100644 --- a/src/features/areas/components/LogActivity.tsx +++ b/src/features/areas/components/LogActivity.tsx @@ -40,6 +40,7 @@ const LogActivity: FC = ({ onCancel, orgId, place }) => { - - - - ); -}; - -export default LogActivity; diff --git a/src/features/areas/components/PlaceDetails.tsx b/src/features/areas/components/PlaceDetails.tsx deleted file mode 100644 index 7903226a62..0000000000 --- a/src/features/areas/components/PlaceDetails.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { FC } from 'react'; -import { Box, Button, Typography } from '@mui/material'; - -import { ZetkinPlace } from '../types'; -import { Msg } from 'core/i18n'; -import messageIds from '../l10n/messageIds'; -import ZUIDateTime from 'zui/ZUIDateTime'; - -type PlaceDetailsProps = { - onClose: () => void; - onLogActivity: () => void; - place: ZetkinPlace; -}; - -const PlaceDetails: FC = ({ - onClose, - onLogActivity, - place, -}) => { - const sortedVisits = place.visits.toSorted((a, b) => { - const dateA = new Date(a.timestamp); - const dateB = new Date(b.timestamp); - if (dateA > dateB) { - return -1; - } else if (dateB > dateA) { - return 1; - } else { - return 0; - } - }); - - return ( - - - - - - - - {place.description || ( - - )} - - - - - - <> - {sortedVisits.length == 0 && ( - - )} - {sortedVisits.map((visit) => ( - - - - - {visit.note} - - ))} - - - - - - - - - - ); -}; - -export default PlaceDetails; diff --git a/src/features/areas/components/PlaceDialog.tsx b/src/features/areas/components/PlaceDialog.tsx index 739ed6fc63..0596e4dc58 100644 --- a/src/features/areas/components/PlaceDialog.tsx +++ b/src/features/areas/components/PlaceDialog.tsx @@ -1,31 +1,56 @@ -import { FC } from 'react'; -import { Box, Dialog, Divider, Typography } from '@mui/material'; +import { FC, useState } from 'react'; +import { + Box, + Button, + Dialog, + Divider, + TextField, + Typography, +} from '@mui/material'; -import PlaceDetails from './PlaceDetails'; -import LogActivity from './LogActivity'; import { ZetkinPlace } from '../types'; import { Msg } from 'core/i18n'; import messageIds from '../l10n/messageIds'; +import usePlaceMutations from '../hooks/usePlaceMutations'; +import ZUIDateTime from 'zui/ZUIDateTime'; type PlaceDialogProps = { dialogStep: 'place' | 'log'; onClose: () => void; onLogCancel: () => void; + onLogSave: () => void; onLogStart: () => void; open: boolean; orgId: number; - place: ZetkinPlace | null; + place: ZetkinPlace; }; const PlaceDialog: FC = ({ dialogStep, onClose, onLogCancel, + onLogSave, onLogStart, open, orgId, place, }) => { + const updatePlace = usePlaceMutations(orgId, place.id); + const [note, setNote] = useState(''); + const timestamp = new Date().toISOString(); + + const sortedVisits = place.visits.toSorted((a, b) => { + const dateA = new Date(a.timestamp); + const dateB = new Date(b.timestamp); + if (dateA > dateB) { + return -1; + } else if (dateB > dateA) { + return 1; + } else { + return 0; + } + }); + return ( @@ -35,16 +60,109 @@ const PlaceDialog: FC = ({ - {place && dialogStep == 'place' && ( - - )} - {place && dialogStep == 'log' && ( - - )} + + {place && dialogStep == 'place' && ( + + + + + + {place.description || ( + + )} + + + + + + + {sortedVisits.length == 0 && ( + + )} + {sortedVisits.map((visit) => ( + + + + + {visit.note} + + ))} + + + + )} + {place && dialogStep == 'log' && ( + + + + setNote(ev.target.value)} + placeholder="Note" + sx={{ paddingTop: 1 }} + /> + + + )} + + + + + ); diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index f2d654b3ac..726c3a7745 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -273,27 +273,30 @@ const PublicAreaMap: FC = ({ area }) => { })} - { - setAnchorEl(null); - setSelectedPlace(null); - }} - onLogCancel={() => { - if (returnToMap) { + {selectedPlace && ( + { setAnchorEl(null); - } else { - setDialogStep('place'); - } - }} - onLogStart={() => { - setDialogStep('log'); - setReturnToMap(false); - }} - open={!!anchorEl} - orgId={area.organization.id} - place={selectedPlace} - /> + setSelectedPlace(null); + }} + onLogCancel={() => { + if (returnToMap) { + setAnchorEl(null); + } else { + setDialogStep('place'); + } + }} + onLogSave={() => setDialogStep('place')} + onLogStart={() => { + setDialogStep('log'); + setReturnToMap(false); + }} + open={!!anchorEl} + orgId={area.organization.id} + place={selectedPlace} + /> + )} ); }; From 7ebeaf9005be3a85141ead43c7b684efac7324a3 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 10 Sep 2024 15:22:00 +0200 Subject: [PATCH 225/369] @richardolsson 's bouncing marker. --- .../areas/components/PublicAreaMap.tsx | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 726c3a7745..b81ab4565e 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -26,6 +26,16 @@ import useCreatePlace from '../hooks/useCreatePlace'; import PlaceDialog from './PlaceDialog'; const useStyles = makeStyles((theme) => ({ + '@keyframes ghostMarkerBounce': { + '0%': { + top: -20, + transform: 'scale(1, 0.8)', + }, + '100%': { + top: -40, + transform: 'scale(0.9, 1)', + }, + }, actionAreaContainer: { bottom: 15, display: 'flex', @@ -44,6 +54,16 @@ const useStyles = makeStyles((theme) => ({ transition: 'opacity 0.1s', zIndex: 1200, }, + ghostMarker: { + animationDirection: 'alternate', + animationDuration: '0.4s', + animationIterationCount: 'infinite', + animationName: '$ghostMarkerBounce', + animationTimingFunction: 'cubic-bezier(0,.71,.56,1)', + position: 'absolute', + transition: 'opacity 0.5s', + zIndex: 1000, + }, infoButtons: { backgroundColor: theme.palette.background.default, border: `1px solid ${theme.palette.grey[300]}`, @@ -77,12 +97,13 @@ const PublicAreaMap: FC = ({ area }) => { const [selectedPlace, setSelectedPlace] = useState(null); const [anchorEl, setAnchorEl] = useState(null); - const [dialogStep, setDialogStep] = useState<'place' | 'log'>('place'); const [returnToMap, setReturnToMap] = useState(false); + const [standingStill, setStandingStill] = useState(false); const mapRef = useRef(null); const crosshairRef = useRef(null); + const standingStillTimerRef = useRef(0); const showViewPlaceButton = !!selectedPlace && !anchorEl; @@ -127,12 +148,26 @@ const PublicAreaMap: FC = ({ area }) => { useEffect(() => { const map = mapRef.current; if (map) { + map.on('movestart', () => { + window.clearTimeout(standingStillTimerRef.current); + setStandingStill(false); + }); + map.on('move', () => { updateSelection(); }); + map.on('moveend', () => { + standingStillTimerRef.current = window.setTimeout( + () => setStandingStill(true), + 1300 + ); + }); + return () => { map.off('move'); + map.off('moveend'); + map.off('movestart'); }; } }, [mapRef.current, selectedPlace, places]); @@ -160,6 +195,27 @@ const PublicAreaMap: FC = ({ area }) => { opacity: !selectedPlace ? 1 : 0.3, }} > + + + + + From 87217f482eec5d61cd0028bd53767602e5c4c235 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 10 Sep 2024 15:26:12 +0200 Subject: [PATCH 226/369] Modify so marker only hops when no other marker is selected. --- .../areas/components/PublicAreaMap.tsx | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index b81ab4565e..9f55fa5bd8 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -195,27 +195,29 @@ const PublicAreaMap: FC = ({ area }) => { opacity: !selectedPlace ? 1 : 0.3, }} > - - - - - + {!selectedPlace && ( + + + + + + )} From b852b11e31053c74b8c0f33ee358b6fb233fbb2d Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Tue, 10 Sep 2024 18:49:44 +0200 Subject: [PATCH 227/369] Pan to position when marker och map is clicked --- .../areas/components/PublicAreaMap.tsx | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 9f55fa5bd8..abb7e78c44 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -1,5 +1,5 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { latLngBounds, Map } from 'leaflet'; +import { LatLng, latLngBounds, Map } from 'leaflet'; import { makeStyles } from '@mui/styles'; import { Add, GpsNotFixed, Remove } from '@mui/icons-material'; import { @@ -145,9 +145,42 @@ const PublicAreaMap: FC = ({ area }) => { } }, [mapRef.current, selectedPlace, places]); + const panTo = useCallback( + (pos: LatLng) => { + const map = mapRef.current; + const crosshair = crosshairRef.current; + if (crosshair && map) { + const mapContainer = map.getContainer(); + const mapRect = mapContainer.getBoundingClientRect(); + const markerRect = crosshair.getBoundingClientRect(); + const x = markerRect.x - mapRect.x; + const y = markerRect.y - mapRect.y; + const markerPoint: [number, number] = [ + x + 0.5 * markerRect.width, + y + 0.8 * markerRect.height, + ]; + + const crosshairPos = map.containerPointToLatLng(markerPoint); + const centerPos = map.getCenter(); + const latOffset = centerPos.lat - crosshairPos.lat; + const lngOffset = centerPos.lng - crosshairPos.lng; + const adjustedPos = new LatLng( + pos.lat + latOffset, + pos.lng + lngOffset + ); + map.panTo(adjustedPos, { animate: true }); + } + }, + [mapRef.current, crosshairRef.current] + ); + useEffect(() => { const map = mapRef.current; if (map) { + map.on('click', (evt) => { + panTo(evt.latlng); + }); + map.on('movestart', () => { window.clearTimeout(standingStillTimerRef.current); setStandingStill(false); @@ -304,6 +337,11 @@ const PublicAreaMap: FC = ({ area }) => { return ( { + panTo(evt.latlng); + }, + }} iconAnchor={[11, 33]} position={{ lat: place.position.lat, From 800095ec5092c9732e827bf35808d5a28d86da6a Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Tue, 10 Sep 2024 18:50:39 +0200 Subject: [PATCH 228/369] Tweak animation of bouncy marker to fade in slowly but fade out fast --- src/features/areas/components/PublicAreaMap.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index abb7e78c44..bb428dcd51 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -61,7 +61,6 @@ const useStyles = makeStyles((theme) => ({ animationName: '$ghostMarkerBounce', animationTimingFunction: 'cubic-bezier(0,.71,.56,1)', position: 'absolute', - transition: 'opacity 0.5s', zIndex: 1000, }, infoButtons: { @@ -231,7 +230,10 @@ const PublicAreaMap: FC = ({ area }) => { {!selectedPlace && ( Date: Tue, 10 Sep 2024 19:25:13 +0200 Subject: [PATCH 229/369] Replace ref with state to force re-render after map initializes --- .../areas/components/PublicAreaMap.tsx | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index bb428dcd51..9518d81c5c 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -100,7 +100,7 @@ const PublicAreaMap: FC = ({ area }) => { const [returnToMap, setReturnToMap] = useState(false); const [standingStill, setStandingStill] = useState(false); - const mapRef = useRef(null); + const [map, setMap] = useState(null); const crosshairRef = useRef(null); const standingStillTimerRef = useRef(0); @@ -110,7 +110,6 @@ const PublicAreaMap: FC = ({ area }) => { let nearestPlace: ZetkinPlace | null = null; let nearestDistance = Infinity; - const map = mapRef.current; const crosshair = crosshairRef.current; if (map && crosshair) { @@ -142,11 +141,10 @@ const PublicAreaMap: FC = ({ area }) => { setSelectedPlace(null); } } - }, [mapRef.current, selectedPlace, places]); + }, [map, selectedPlace, places]); const panTo = useCallback( (pos: LatLng) => { - const map = mapRef.current; const crosshair = crosshairRef.current; if (crosshair && map) { const mapContainer = map.getContainer(); @@ -170,11 +168,10 @@ const PublicAreaMap: FC = ({ area }) => { map.panTo(adjustedPos, { animate: true }); } }, - [mapRef.current, crosshairRef.current] + [map, crosshairRef.current] ); useEffect(() => { - const map = mapRef.current; if (map) { map.on('click', (evt) => { panTo(evt.latlng); @@ -202,7 +199,7 @@ const PublicAreaMap: FC = ({ area }) => { map.off('movestart'); }; } - }, [mapRef.current, selectedPlace, places]); + }, [map, selectedPlace, places, panTo, updateSelection]); useEffect(() => { updateSelection(); @@ -211,11 +208,11 @@ const PublicAreaMap: FC = ({ area }) => { return ( <> - mapRef.current?.zoomIn()}> + map?.zoomIn()}> - mapRef.current?.zoomOut()}> + map?.zoomOut()}> @@ -291,7 +288,7 @@ const PublicAreaMap: FC = ({ area }) => { setMap(map)} attributionControl={false} bounds={latLngBounds(area.points)} minZoom={1} From 559e595d743a89a817a3f38e63d26c7c11514d9e Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Tue, 10 Sep 2024 19:27:44 +0200 Subject: [PATCH 230/369] Delay bouncy marker longer when there are other places --- src/features/areas/components/PublicAreaMap.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 9518d81c5c..e8b527dceb 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -187,9 +187,14 @@ const PublicAreaMap: FC = ({ area }) => { }); map.on('moveend', () => { + // When the map contains no places, show the bouncy marker + // quickly, but once there are places, wait longer before + // showing the bouncy marker. + const delay = places.length ? 10000 : 1300; + standingStillTimerRef.current = window.setTimeout( () => setStandingStill(true), - 1300 + delay ); }); From c37ef2c957466e39a46c892618ff3050a4cc59d2 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 11 Sep 2024 13:35:58 +0200 Subject: [PATCH 231/369] Route, store and hook to create a canvass assignment. --- .../[projectId]/canvassassignments/route.ts | 43 +++++++++++++++++++ .../areas/hooks/useCreateCanvassAssignment.ts | 23 ++++++++++ src/features/areas/models.ts | 20 +++++++++ src/features/areas/store.ts | 21 ++++++++- src/features/areas/types.ts | 11 ++++- src/utils/testing/mocks/mockState.ts | 1 + 6 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/route.ts create mode 100644 src/features/areas/hooks/useCreateCanvassAssignment.ts diff --git a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/route.ts b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/route.ts new file mode 100644 index 0000000000..85e91ac564 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/route.ts @@ -0,0 +1,43 @@ +import mongoose from 'mongoose'; +import { NextRequest, NextResponse } from 'next/server'; + +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; +import { CanvassAssignmentModel } from 'features/areas/models'; + +type RouteMeta = { + params: { + orgId: string; + }; +}; + +export async function POST(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const payload = await request.json(); + + const model = new CanvassAssignmentModel({ + campId: payload.campId, + orgId: orgId, + title: payload.title, + }); + + await model.save(); + + return NextResponse.json({ + data: { + campId: model.campId, + id: model._id.toString(), + orgId: orgId, + title: model.title, + }, + }); + } + ); +} diff --git a/src/features/areas/hooks/useCreateCanvassAssignment.ts b/src/features/areas/hooks/useCreateCanvassAssignment.ts new file mode 100644 index 0000000000..6cb154fdd5 --- /dev/null +++ b/src/features/areas/hooks/useCreateCanvassAssignment.ts @@ -0,0 +1,23 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { + ZetkinCanvassAssignment, + ZetkinCanvassAssignmentPostBody, +} from '../types'; +import { canvassAssignmentCreate, canvassAssignmentCreated } from '../store'; + +export default function useCreateCanvassAssignment( + orgId: number, + campId: number +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (data: ZetkinCanvassAssignmentPostBody) => { + dispatch(canvassAssignmentCreate()); + const created = await apiClient.post( + `/beta/orgs/${orgId}/projects/${campId}/canvassassignments`, + data + ); + dispatch(canvassAssignmentCreated(created)); + }; +} diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 5a1be32ae3..1c69bc4c8e 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -18,6 +18,12 @@ type ZetkinPlaceModelType = { visits: ZetkinPlace['visits']; }; +type ZetkinCanvassAssignmentModelType = { + campId: number; + orgId: number; + title: string | null; +}; + const areaSchema = new mongoose.Schema({ description: String, orgId: { required: true, type: Number }, @@ -34,6 +40,13 @@ const placeSchema = new mongoose.Schema({ visits: Array, }); +const canvassAssignmentSchema = + new mongoose.Schema({ + campId: { required: true, type: Number }, + orgId: { required: true, type: Number }, + title: String, + }); + export const AreaModel: mongoose.Model = mongoose.models.Area || mongoose.model('Area', areaSchema); @@ -41,3 +54,10 @@ export const AreaModel: mongoose.Model = export const PlaceModel: mongoose.Model = mongoose.models.Place || mongoose.model('Place', placeSchema); + +export const CanvassAssignmentModel: mongoose.Model = + mongoose.models.CanvassAssignment || + mongoose.model( + 'CanvassAssignment', + canvassAssignmentSchema + ); diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 353a8d5da3..be6ebd23d8 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -6,15 +6,17 @@ import { remoteList, RemoteList, } from 'utils/storeUtils'; -import { ZetkinArea, ZetkinPlace } from './types'; +import { ZetkinArea, ZetkinCanvassAssignment, ZetkinPlace } from './types'; export interface AreasStoreSlice { areaList: RemoteList; + canvassAssignmentList: RemoteList; placeList: RemoteList; } const initialState: AreasStoreSlice = { areaList: remoteList(), + canvassAssignmentList: remoteList(), placeList: remoteList(), }; @@ -78,6 +80,21 @@ const areasSlice = createSlice({ state.areaList.loaded = timestamp; state.areaList.items.forEach((item) => (item.loaded = timestamp)); }, + canvassAssignmentCreate: (state) => { + state.canvassAssignmentList.isLoading = true; + }, + canvassAssignmentCreated: ( + state, + action: PayloadAction + ) => { + const canvassAssignment = action.payload; + const item = remoteItem(canvassAssignment.id, { + data: canvassAssignment, + loaded: new Date().toISOString(), + }); + + state.canvassAssignmentList.items.push(item); + }, placeCreated: (state, action: PayloadAction) => { const place = action.payload; const item = remoteItem(place.id, { @@ -116,6 +133,8 @@ export const { areasLoad, areasLoaded, areaUpdated, + canvassAssignmentCreate, + canvassAssignmentCreated, placeCreated, placesLoad, placesLoaded, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 104fd59a7c..9701398815 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -26,9 +26,18 @@ export type ZetkinPlace = { visits: Visit[]; }; -export type ZetkinAreaPostBody = Partial>; +export type ZetkinCanvassAssignment = { + campId: number; + id: string; + orgId: number; + title: string | null; +}; +export type ZetkinAreaPostBody = Partial>; export type ZetkinPlacePostBody = Partial>; export type ZetkinPlacePatchBody = Omit & { visits?: Omit[]; }; +export type ZetkinCanvassAssignmentPostBody = Partial< + Omit +>; diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index d197f8ab53..5b1ccb121e 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -5,6 +5,7 @@ export default function mockState(overrides?: RootState) { const emptyState: RootState = { areas: { areaList: remoteList(), + canvassAssignmentList: remoteList(), placeList: remoteList(), }, breadcrumbs: { From 19185cfbc58447ec4c61e6a4ce2d9312563fd150 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 11 Sep 2024 14:27:48 +0200 Subject: [PATCH 232/369] Basic UI to create canvass assignment and re-route to its page. --- .../[canvassAssId]/route.ts | 44 +++++++++++++++++ src/core/store.ts | 15 +++++- .../areas/hooks/useCanvassAssignment.ts | 28 +++++++++++ .../areas/hooks/useCreateCanvassAssignment.ts | 3 +- .../areas/layouts/CanvassAssignmentLayout.tsx | 9 ++++ src/features/areas/store.ts | 38 ++++++++++++-- .../components/CampaignActionButtons.tsx | 15 ++++++ .../[canvassAssId]/index.tsx | 49 +++++++++++++++++++ 8 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/route.ts create mode 100644 src/features/areas/hooks/useCanvassAssignment.ts create mode 100644 src/features/areas/layouts/CanvassAssignmentLayout.tsx create mode 100644 src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx diff --git a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/route.ts new file mode 100644 index 0000000000..054e037851 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/route.ts @@ -0,0 +1,44 @@ +import mongoose from 'mongoose'; +import { NextRequest, NextResponse } from 'next/server'; + +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; +import { CanvassAssignmentModel } from 'features/areas/models'; +import { ZetkinCanvassAssignment } from 'features/areas/types'; + +type RouteMeta = { + params: { + canvassAssId: string; + orgId: string; + }; +}; + +export async function GET(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin', 'organizer'], + }, + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const canvassAssignmentModel = await CanvassAssignmentModel.findOne({ + _id: params.canvassAssId, + orgId, + }); + + if (!canvassAssignmentModel) { + return new NextResponse(null, { status: 404 }); + } + + const canvassAssignment: ZetkinCanvassAssignment = { + campId: canvassAssignmentModel.campId, + id: canvassAssignmentModel._id.toString(), + orgId: orgId, + title: canvassAssignmentModel.title, + }; + + return Response.json({ data: canvassAssignment }); + } + ); +} diff --git a/src/core/store.ts b/src/core/store.ts index 3ab45e1406..2fd2d65d84 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -49,7 +49,10 @@ import tagsSlice, { TagsStoreSlice } from 'features/tags/store'; import tasksSlice, { TasksStoreSlice } from 'features/tasks/store'; import userSlice, { UserStoreSlice } from 'features/user/store'; import viewsSlice, { ViewsStoreSlice } from 'features/views/store'; -import areasSlice, { AreasStoreSlice } from 'features/areas/store'; +import areasSlice, { + AreasStoreSlice, + canvassAssignmentCreated, +} from 'features/areas/store'; export interface RootState { areas: AreasStoreSlice; @@ -101,6 +104,16 @@ const reducer = { const listenerMiddleware = createListenerMiddleware(); +listenerMiddleware.startListening({ + actionCreator: canvassAssignmentCreated, + effect: (action) => { + const canvassAssignment = action.payload; + Router.push( + `/organize/${canvassAssignment.orgId}/projects/${canvassAssignment.campId}/canvassassignments/${canvassAssignment.id}` + ); + }, +}); + listenerMiddleware.startListening({ actionCreator: campaignDeleted, effect: (action) => { diff --git a/src/features/areas/hooks/useCanvassAssignment.ts b/src/features/areas/hooks/useCanvassAssignment.ts new file mode 100644 index 0000000000..f59e51bfb7 --- /dev/null +++ b/src/features/areas/hooks/useCanvassAssignment.ts @@ -0,0 +1,28 @@ +import { loadItemIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { ZetkinCanvassAssignment } from '../types'; +import { canvassAssignmentLoad, canvassAssignmentLoaded } from '../store'; + +export default function useCanvassAssignment( + orgId: number, + campId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const canvassAssignmenList = useAppSelector( + (state) => state.areas.canvassAssignmentList.items + ); + const canvassAssignmentItem = canvassAssignmenList.find( + (item) => item.id == canvassAssId + ); + + return loadItemIfNecessary(canvassAssignmentItem, dispatch, { + actionOnLoad: () => canvassAssignmentLoad(canvassAssId), + actionOnSuccess: (data) => canvassAssignmentLoaded(data), + loader: () => + apiClient.get( + `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}` + ), + }); +} diff --git a/src/features/areas/hooks/useCreateCanvassAssignment.ts b/src/features/areas/hooks/useCreateCanvassAssignment.ts index 6cb154fdd5..4b10f0a6a9 100644 --- a/src/features/areas/hooks/useCreateCanvassAssignment.ts +++ b/src/features/areas/hooks/useCreateCanvassAssignment.ts @@ -3,7 +3,7 @@ import { ZetkinCanvassAssignment, ZetkinCanvassAssignmentPostBody, } from '../types'; -import { canvassAssignmentCreate, canvassAssignmentCreated } from '../store'; +import { canvassAssignmentCreated } from '../store'; export default function useCreateCanvassAssignment( orgId: number, @@ -13,7 +13,6 @@ export default function useCreateCanvassAssignment( const dispatch = useAppDispatch(); return async (data: ZetkinCanvassAssignmentPostBody) => { - dispatch(canvassAssignmentCreate()); const created = await apiClient.post( `/beta/orgs/${orgId}/projects/${campId}/canvassassignments`, data diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/areas/layouts/CanvassAssignmentLayout.tsx new file mode 100644 index 0000000000..f6f9c65f6b --- /dev/null +++ b/src/features/areas/layouts/CanvassAssignmentLayout.tsx @@ -0,0 +1,9 @@ +import { FC, ReactNode } from 'react'; + +import DefaultLayout from 'utils/layout/DefaultLayout'; + +const CanvassAssignmentLayout: FC<{ children: ReactNode }> = ({ children }) => { + return {children}; +}; + +export default CanvassAssignmentLayout; diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index be6ebd23d8..9bb3076729 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -80,9 +80,6 @@ const areasSlice = createSlice({ state.areaList.loaded = timestamp; state.areaList.items.forEach((item) => (item.loaded = timestamp)); }, - canvassAssignmentCreate: (state) => { - state.canvassAssignmentList.isLoading = true; - }, canvassAssignmentCreated: ( state, action: PayloadAction @@ -95,6 +92,38 @@ const areasSlice = createSlice({ state.canvassAssignmentList.items.push(item); }, + canvassAssignmentLoad: (state, action: PayloadAction) => { + const canvassAssId = action.payload; + const item = state.canvassAssignmentList.items.find( + (item) => item.id == canvassAssId + ); + + if (item) { + item.isLoading = true; + } else { + state.canvassAssignmentList.items = + state.canvassAssignmentList.items.concat([ + remoteItem(canvassAssId, { isLoading: true }), + ]); + } + }, + canvassAssignmentLoaded: ( + state, + action: PayloadAction + ) => { + const canvassAssignment = action.payload; + const item = state.canvassAssignmentList.items.find( + (item) => item.id == canvassAssignment.id + ); + + if (!item) { + throw new Error('Finished loading item that never started loading'); + } + + item.data = canvassAssignment; + item.isLoading = false; + item.loaded = new Date().toISOString(); + }, placeCreated: (state, action: PayloadAction) => { const place = action.payload; const item = remoteItem(place.id, { @@ -133,8 +162,9 @@ export const { areasLoad, areasLoaded, areaUpdated, - canvassAssignmentCreate, canvassAssignmentCreated, + canvassAssignmentLoad, + canvassAssignmentLoaded, placeCreated, placesLoad, placesLoaded, diff --git a/src/features/campaigns/components/CampaignActionButtons.tsx b/src/features/campaigns/components/CampaignActionButtons.tsx index e85dedfe72..ff1676e52b 100644 --- a/src/features/campaigns/components/CampaignActionButtons.tsx +++ b/src/features/campaigns/components/CampaignActionButtons.tsx @@ -7,6 +7,7 @@ import { EmailOutlined, Event, HeadsetMic, + Map, OpenInNew, Settings, } from '@mui/icons-material'; @@ -28,6 +29,7 @@ import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; import ZUIDialog from 'zui/ZUIDialog'; import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; import { Msg, useMessages } from 'core/i18n'; +import useCreateCanvassAssignment from 'features/areas/hooks/useCreateCanvassAssignment'; enum CAMPAIGN_MENU_ITEMS { EDIT_CAMPAIGN = 'editCampaign', @@ -51,6 +53,10 @@ const CampaignActionButtons: React.FunctionComponent< const [editCampaignDialogOpen, setEditCampaignDialogOpen] = useState(false); const [createTaskDialogOpen, setCreateTaskDialogOpen] = useState(false); + const createCanvassAssignment = useCreateCanvassAssignment( + orgId, + campaign.id + ); const createEvent = useCreateEvent(orgId); const { createCallAssignment, createSurvey } = useCreateCampaignActivity( orgId, @@ -84,6 +90,15 @@ const CampaignActionButtons: React.FunctionComponent< }; const menuItems = [ + { + icon: , + label: 'Create canvass assignment', + onClick: () => + createCanvassAssignment({ + campId: campaign.id, + title: 'Untitled Canvass Assignment', + }), + }, { icon: , label: messages.linkGroup.createEvent(), diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx new file mode 100644 index 0000000000..e5dc8f9aa4 --- /dev/null +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -0,0 +1,49 @@ +import { GetServerSideProps } from 'next'; + +import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; +import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; +import { scaffold } from 'utils/next'; +import { PageWithLayout } from 'utils/types'; +import ZUIFuture from 'zui/ZUIFuture'; + +const scaffoldOptions = { + authLevelRequired: 2, +}; + +export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { + const { orgId, campId, canvassAssId } = ctx.params!; + return { + props: { campId, canvassAssId, orgId }, + }; +}, scaffoldOptions); + +interface CanvassAssignmentPageProps { + orgId: string; + campId: string; + canvassAssId: string; +} + +const CanvassAssignmentPage: PageWithLayout = ({ + orgId, + campId, + canvassAssId, +}) => { + const canvassAssignmentFuture = useCanvassAssignment( + parseInt(orgId), + parseInt(campId), + canvassAssId + ); + return ( + + {(canvassAssignment) => { + return
{canvassAssignment.title}
; + }} +
+ ); +}; + +CanvassAssignmentPage.getLayout = function getLayout(page) { + return {page}; +}; + +export default CanvassAssignmentPage; From fb05d31b231b9be1dd9ab33ef84b731e088fa834 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 11 Sep 2024 16:04:05 +0200 Subject: [PATCH 233/369] Connect a person to a canvass assignment as an assignee. --- .../assignees/[assigneeId]/route.ts | 37 ++++++++++++++++ src/features/areas/hooks/useAddAssignee.ts | 20 +++++++++ .../areas/layouts/CanvassAssignmentLayout.tsx | 26 +++++++++-- src/features/areas/models.ts | 17 +++++++ src/features/areas/store.ts | 36 ++++++++++++++- src/features/areas/types.ts | 5 +++ .../[canvassAssId]/index.tsx | 44 ++++++++++++++++++- src/utils/testing/mocks/mockState.ts | 1 + 8 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts create mode 100644 src/features/areas/hooks/useAddAssignee.ts diff --git a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts new file mode 100644 index 0000000000..16a1f430ab --- /dev/null +++ b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts @@ -0,0 +1,37 @@ +import mongoose from 'mongoose'; +import { NextRequest, NextResponse } from 'next/server'; + +import { CanvassAssigneeModel } from 'features/areas/models'; +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; + +type RouteMeta = { + params: { + assigneeId: string; + orgId: string; + }; +}; + +export async function PUT(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async () => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const model = new CanvassAssigneeModel({ + id: params.assigneeId, + }); + + await model.save(); + + return NextResponse.json({ + data: { + id: model.id, + }, + }); + } + ); +} diff --git a/src/features/areas/hooks/useAddAssignee.ts b/src/features/areas/hooks/useAddAssignee.ts new file mode 100644 index 0000000000..e534f06e0f --- /dev/null +++ b/src/features/areas/hooks/useAddAssignee.ts @@ -0,0 +1,20 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinCanvassAssignee } from '../types'; +import { assigneeAdd, assigneeAdded } from '../store'; + +export default function useAddAssignee( + orgId: number, + campId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (assigneeId: number) => { + dispatch(assigneeAdd([canvassAssId, assigneeId])); + const assignee = await apiClient.put( + `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/assignees/${assigneeId}` + ); + dispatch(assigneeAdded([canvassAssId, assignee])); + }; +} diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/areas/layouts/CanvassAssignmentLayout.tsx index f6f9c65f6b..620a15563e 100644 --- a/src/features/areas/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/areas/layouts/CanvassAssignmentLayout.tsx @@ -1,9 +1,29 @@ import { FC, ReactNode } from 'react'; -import DefaultLayout from 'utils/layout/DefaultLayout'; +import TabbedLayout from 'utils/layout/TabbedLayout'; -const CanvassAssignmentLayout: FC<{ children: ReactNode }> = ({ children }) => { - return {children}; +type CanvassAssignmentLayoutProps = { + campId: number; + canvassAssId: string; + children: ReactNode; + orgId: number; +}; + +const CanvassAssignmentLayout: FC = ({ + children, + orgId, + campId, + canvassAssId, +}) => { + return ( + + {children} + + ); }; export default CanvassAssignmentLayout; diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 1c69bc4c8e..3b5232f80f 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -24,6 +24,11 @@ type ZetkinCanvassAssignmentModelType = { title: string | null; }; +type ZetkinCanvassAssigneeModelType = { + areaUrl: string; + id: number; +}; + const areaSchema = new mongoose.Schema({ description: String, orgId: { required: true, type: Number }, @@ -47,6 +52,11 @@ const canvassAssignmentSchema = title: String, }); +const canvassAssigneeSchema = + new mongoose.Schema({ + id: { required: true, type: Number }, + }); + export const AreaModel: mongoose.Model = mongoose.models.Area || mongoose.model('Area', areaSchema); @@ -61,3 +71,10 @@ export const CanvassAssignmentModel: mongoose.Model = + mongoose.models.CanvassAssignee || + mongoose.model( + 'CanvassAssignee', + canvassAssigneeSchema + ); diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 9bb3076729..4d53a3638e 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -6,16 +6,26 @@ import { remoteList, RemoteList, } from 'utils/storeUtils'; -import { ZetkinArea, ZetkinCanvassAssignment, ZetkinPlace } from './types'; +import { + ZetkinArea, + ZetkinCanvassAssignee, + ZetkinCanvassAssignment, + ZetkinPlace, +} from './types'; export interface AreasStoreSlice { areaList: RemoteList; + assigneesByCanvassAssignmentId: Record< + string, + RemoteList + >; canvassAssignmentList: RemoteList; placeList: RemoteList; } const initialState: AreasStoreSlice = { areaList: remoteList(), + assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), placeList: remoteList(), }; @@ -80,6 +90,28 @@ const areasSlice = createSlice({ state.areaList.loaded = timestamp; state.areaList.items.forEach((item) => (item.loaded = timestamp)); }, + assigneeAdd: (state, action: PayloadAction<[string, number]>) => { + const [canvassAssId, assigneeId] = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId].items.push( + remoteItem(assigneeId, { isLoading: true }) + ); + }, + assigneeAdded: ( + state, + action: PayloadAction<[string, ZetkinCanvassAssignee]> + ) => { + const [canvassAssId, assignee] = action.payload; + + state.assigneesByCanvassAssignmentId[canvassAssId].items = + state.assigneesByCanvassAssignmentId[canvassAssId].items + .filter((c) => c.id != assignee.id) + .concat([remoteItem(assignee.id, { data: assignee })]); + }, canvassAssignmentCreated: ( state, action: PayloadAction @@ -162,6 +194,8 @@ export const { areasLoad, areasLoaded, areaUpdated, + assigneeAdd, + assigneeAdded, canvassAssignmentCreated, canvassAssignmentLoad, canvassAssignmentLoaded, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 9701398815..c4567b1445 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -41,3 +41,8 @@ export type ZetkinPlacePatchBody = Omit & { export type ZetkinCanvassAssignmentPostBody = Partial< Omit >; +export type ZetkinCanvassAssignmentPatchbody = ZetkinCanvassAssignmentPostBody; + +export type ZetkinCanvassAssignee = { + id: number; +}; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index e5dc8f9aa4..b19d5f7337 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,10 +1,13 @@ import { GetServerSideProps } from 'next'; +import { useState } from 'react'; +import { Box, Button, TextField, Typography } from '@mui/material'; import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; import ZUIFuture from 'zui/ZUIFuture'; +import useAddAssignee from 'features/areas/hooks/useAddAssignee'; const scaffoldOptions = { authLevelRequired: 2, @@ -28,6 +31,14 @@ const CanvassAssignmentPage: PageWithLayout = ({ campId, canvassAssId, }) => { + const [personId, setPersonId] = useState(null); + + const addAssignee = useAddAssignee( + parseInt(orgId), + parseInt(campId), + canvassAssId + ); + const canvassAssignmentFuture = useCanvassAssignment( parseInt(orgId), parseInt(campId), @@ -36,14 +47,43 @@ const CanvassAssignmentPage: PageWithLayout = ({ return ( {(canvassAssignment) => { - return
{canvassAssignment.title}
; + return ( + + {canvassAssignment.title} + + Add a person Id + { + const value = ev.target.value; + if (value) { + setPersonId(parseInt(value)); + } + }} + type="number" + value={personId} + /> + + + + ); }}
); }; CanvassAssignmentPage.getLayout = function getLayout(page) { - return {page}; + return ( + {page} + ); }; export default CanvassAssignmentPage; diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index 5b1ccb121e..e5bf71c72a 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -5,6 +5,7 @@ export default function mockState(overrides?: RootState) { const emptyState: RootState = { areas: { areaList: remoteList(), + assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), placeList: remoteList(), }, From e197a8946ababa9ee6f74469e7021908d319dc52 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 12 Sep 2024 07:57:48 +0200 Subject: [PATCH 234/369] Fix type error introduced by recent merges --- src/utils/types/zetkin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index 80e25b504e..7845735414 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -531,9 +531,8 @@ export interface ZetkinEmail { theme: EmailTheme | null; id: number; locked: string | null; - processed?: string | null; - published: string | null; processed: string | null; + published: string | null; subject: string | null; organization: { id: number; title: string }; content: string | null; From 36484300ba2dbf5783edfba55ebf3d137b8e1d86 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 12 Sep 2024 10:38:18 +0200 Subject: [PATCH 235/369] Add an area url to an assignee. --- .../assignees/[assigneeId]/route.ts | 40 +++++++++++ .../[canvassAssId]/assignees/route.ts | 38 +++++++++++ .../areas/hooks/useAssigneeMutations.ts | 27 ++++++++ src/features/areas/hooks/useAssignees.ts | 25 +++++++ src/features/areas/models.ts | 3 + src/features/areas/store.ts | 45 +++++++++++++ src/features/areas/types.ts | 4 ++ .../[canvassAssId]/index.tsx | 67 +++++++++++++++++-- 8 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/route.ts create mode 100644 src/features/areas/hooks/useAssigneeMutations.ts create mode 100644 src/features/areas/hooks/useAssignees.ts diff --git a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts index 16a1f430ab..637d97164c 100644 --- a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts +++ b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts @@ -7,6 +7,7 @@ import asOrgAuthorized from 'utils/api/asOrgAuthorized'; type RouteMeta = { params: { assigneeId: string; + canvassAssId: string; orgId: string; }; }; @@ -22,6 +23,7 @@ export async function PUT(request: NextRequest, { params }: RouteMeta) { await mongoose.connect(process.env.MONGODB_URL || ''); const model = new CanvassAssigneeModel({ + canvassAssId: params.canvassAssId, id: params.assigneeId, }); @@ -35,3 +37,41 @@ export async function PUT(request: NextRequest, { params }: RouteMeta) { } ); } + +export async function PATCH(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async () => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const payload = await request.json(); + + const model = await CanvassAssigneeModel.findOneAndUpdate( + { + canvassAssId: params.canvassAssId, + id: params.assigneeId, + }, + { + areaUrl: payload.areaUrl, + id: params.assigneeId, + }, + { new: true } + ); + + if (!model) { + return new NextResponse(null, { status: 404 }); + } + + return NextResponse.json({ + data: { + areaUrl: model.areaUrl, + id: model.id, + }, + }); + } + ); +} diff --git a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/route.ts b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/route.ts new file mode 100644 index 0000000000..25b67453d6 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/route.ts @@ -0,0 +1,38 @@ +import mongoose from 'mongoose'; +import { NextRequest } from 'next/server'; + +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; +import { CanvassAssigneeModel } from 'features/areas/models'; +import { ZetkinCanvassAssignee } from 'features/areas/types'; + +type RouteMeta = { + params: { + canvassAssId: string; + orgId: string; + }; +}; + +export async function GET(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin', 'organizer'], + }, + async () => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const assigneeModels = await CanvassAssigneeModel.find({ + canvassAssId: params.canvassAssId, + }); + const assignees: ZetkinCanvassAssignee[] = assigneeModels.map( + (model) => ({ + areaUrl: model.areaUrl, + id: model.id, + }) + ); + + return Response.json({ data: assignees }); + } + ); +} diff --git a/src/features/areas/hooks/useAssigneeMutations.ts b/src/features/areas/hooks/useAssigneeMutations.ts new file mode 100644 index 0000000000..2991ad2d03 --- /dev/null +++ b/src/features/areas/hooks/useAssigneeMutations.ts @@ -0,0 +1,27 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { + ZetkinCanvassAssignee, + ZetkinCanvassAssigneePatchBody, +} from '../types'; +import { assigneeUpdated } from '../store'; + +export default function useAssigneeMutations( + orgId: number, + campId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (assigneeId: number, data: ZetkinCanvassAssigneePatchBody) => { + const updated = await apiClient.patch< + ZetkinCanvassAssignee, + ZetkinCanvassAssigneePatchBody + >( + `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/assignees/${assigneeId}`, + data + ); + + dispatch(assigneeUpdated([canvassAssId, updated])); + }; +} diff --git a/src/features/areas/hooks/useAssignees.ts b/src/features/areas/hooks/useAssignees.ts new file mode 100644 index 0000000000..df5bf7d710 --- /dev/null +++ b/src/features/areas/hooks/useAssignees.ts @@ -0,0 +1,25 @@ +import { loadListIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { ZetkinCanvassAssignee } from '../types'; +import { assigneesLoad, assigneesLoaded } from '../store'; + +export default function useAssignees( + orgId: number, + campId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const assigneeList = useAppSelector( + (state) => state.areas.assigneesByCanvassAssignmentId[canvassAssId] + ); + + return loadListIfNecessary(assigneeList, dispatch, { + actionOnLoad: () => assigneesLoad(canvassAssId), + actionOnSuccess: (data) => assigneesLoaded([canvassAssId, data]), + loader: () => + apiClient.get( + `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/assignees` + ), + }); +} diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 3b5232f80f..095af04256 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -26,6 +26,7 @@ type ZetkinCanvassAssignmentModelType = { type ZetkinCanvassAssigneeModelType = { areaUrl: string; + canvassAssId: string; id: number; }; @@ -54,6 +55,8 @@ const canvassAssignmentSchema = const canvassAssigneeSchema = new mongoose.Schema({ + areaUrl: String, + canvassAssId: String, id: { required: true, type: Number }, }); diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 4d53a3638e..aa930497d4 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -107,11 +107,53 @@ const areasSlice = createSlice({ ) => { const [canvassAssId, assignee] = action.payload; + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + state.assigneesByCanvassAssignmentId[canvassAssId].items = state.assigneesByCanvassAssignmentId[canvassAssId].items .filter((c) => c.id != assignee.id) .concat([remoteItem(assignee.id, { data: assignee })]); }, + assigneeUpdated: ( + state, + action: PayloadAction<[string, ZetkinCanvassAssignee]> + ) => { + const [canvassAssId, assignee] = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId].items + .filter((item) => item.id == assignee.id) + .concat([remoteItem(assignee.id, { data: assignee })]); + }, + assigneesLoad: (state, action: PayloadAction) => { + const canvassAssId = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId].isLoading = true; + }, + assigneesLoaded: ( + state, + action: PayloadAction<[string, ZetkinCanvassAssignee[]]> + ) => { + const [canvassAssId, assignees] = action.payload; + + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); + } + + state.assigneesByCanvassAssignmentId[canvassAssId] = + remoteList(assignees); + state.assigneesByCanvassAssignmentId[canvassAssId].loaded = + new Date().toISOString(); + }, canvassAssignmentCreated: ( state, action: PayloadAction @@ -196,6 +238,9 @@ export const { areaUpdated, assigneeAdd, assigneeAdded, + assigneeUpdated, + assigneesLoad, + assigneesLoaded, canvassAssignmentCreated, canvassAssignmentLoad, canvassAssignmentLoaded, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index c4567b1445..1f4163d368 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -44,5 +44,9 @@ export type ZetkinCanvassAssignmentPostBody = Partial< export type ZetkinCanvassAssignmentPatchbody = ZetkinCanvassAssignmentPostBody; export type ZetkinCanvassAssignee = { + areaUrl: string; id: number; }; +export type ZetkinCanvassAssigneePatchBody = Partial< + Omit +>; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index b19d5f7337..9a914cc7ad 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,13 +1,15 @@ import { GetServerSideProps } from 'next'; -import { useState } from 'react'; +import { FC, useState } from 'react'; import { Box, Button, TextField, Typography } from '@mui/material'; import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import ZUIFuture from 'zui/ZUIFuture'; import useAddAssignee from 'features/areas/hooks/useAddAssignee'; +import useAssignees from 'features/areas/hooks/useAssignees'; +import ZUIFutures from 'zui/ZUIFutures'; +import useAssigneeMutations from 'features/areas/hooks/useAssigneeMutations'; const scaffoldOptions = { authLevelRequired: 2, @@ -20,6 +22,30 @@ export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { }; }, scaffoldOptions); +const AssigneeListItem: FC<{ + assigneeId: number; + onAddUrl: (url: string) => void; +}> = ({ assigneeId, onAddUrl }) => { + const [url, setUrl] = useState(''); + return ( + + {assigneeId} + setUrl(ev.target.value)} value={url} /> + + + ); +}; + interface CanvassAssignmentPageProps { orgId: string; campId: string; @@ -39,14 +65,31 @@ const CanvassAssignmentPage: PageWithLayout = ({ canvassAssId ); + const updateAssignee = useAssigneeMutations( + parseInt(orgId), + parseInt(campId), + canvassAssId + ); + const canvassAssignmentFuture = useCanvassAssignment( parseInt(orgId), parseInt(campId), canvassAssId ); + + const assigneesFuture = useAssignees( + parseInt(orgId), + parseInt(campId), + canvassAssId + ); return ( - - {(canvassAssignment) => { + + {({ data: { canvassAssignment, assignees } }) => { return ( {canvassAssignment.title} @@ -70,13 +113,25 @@ const CanvassAssignmentPage: PageWithLayout = ({ }} variant="contained" > - Do it + Add assignee + + Ids of people that have been added as assignees + {assignees.map((assignee) => ( + + updateAssignee(assignee.id, { areaUrl: url }) + } + /> + ))} + ); }} - + ); }; From 0d96a14c50c4626eac9fff241204bf249f30f3e7 Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Thu, 12 Sep 2024 11:07:04 +0200 Subject: [PATCH 236/369] add placeCard component --- src/features/areas/components/PlaceCard.tsx | 118 ++++++++++++++++++++ src/features/areas/l10n/messageIds.ts | 10 ++ 2 files changed, 128 insertions(+) create mode 100644 src/features/areas/components/PlaceCard.tsx diff --git a/src/features/areas/components/PlaceCard.tsx b/src/features/areas/components/PlaceCard.tsx new file mode 100644 index 0000000000..6cd38e5250 --- /dev/null +++ b/src/features/areas/components/PlaceCard.tsx @@ -0,0 +1,118 @@ +import { + Box, + Button, + Card, + CardActions, + CardContent, + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + TextField, +} from '@mui/material'; +import { FC, useState } from 'react'; +import { LatLng } from 'leaflet'; +import { makeStyles } from '@mui/styles'; + +import messageIds from '../l10n/messageIds'; +import useCreatePlace from '../hooks/useCreatePlace'; +import { Msg, useMessages } from 'core/i18n'; + +export const useStyles = makeStyles(() => ({ + card: { + bottom: 15, + display: 'flex', + gap: 8, + justifyContent: 'center', + padding: 8, + position: 'absolute', + width: '100%', + zIndex: 1000, + }, +})); + +type AddPlaceDialogProps = { + onClose: () => void; + orgId: number; + point: LatLng | null; +}; + +export const PlaceCard: FC = ({ + onClose, + orgId, + point, +}) => { + const classes = useStyles(); + const messages = useMessages(messageIds); + const createPlace = useCreatePlace(orgId); + const [title, setTitle] = useState(''); + const [type, setType] = useState(''); + + const handleChange = (event: SelectChangeEvent) => { + setType(event.target.value); + }; + + const placeholderText = () => { + if (type === 'address') { + return messages.placeCard.placeholderAddress(); + } else if (type === 'misc') { + return messages.placeCard.placeholderMisc(); + } + return messages.placeCard.placeholderTitle(); + }; + + return ( + + + + + + + + + setTitle(ev.target.value)} + placeholder={placeholderText()} + sx={{ paddingTop: 1 }} + /> + + + + + + + + + ); +}; diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 4809dc84b9..7e1d4407d6 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -28,6 +28,16 @@ export default makeMessages('feat.areas', { noActivity: m('No visits have been recorded at this place.'), saveButton: m('Save'), }, + placeCard: { + address: m('Address'), + cancel: m('Cancel'), + createPlace: m('Create place'), + inputLabel: m('Type of place'), + misc: m('Misc'), + placeholderAddress: m('Enter address here'), + placeholderMisc: m('Enter visits here'), + placeholderTitle: m('Enter title here'), + }, tools: { cancel: m('Cancel'), draw: m('Draw'), From 1f047b81fb71511a8375752dae7b6bf6fed65707 Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Thu, 12 Sep 2024 11:08:49 +0200 Subject: [PATCH 237/369] import PlaceCard and logic to display it, add animation when placeCard is displayed --- .../areas/components/PublicAreaMap.tsx | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index e8b527dceb..40bd07d68b 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -22,8 +22,8 @@ import { Msg } from 'core/i18n'; import messageIds from '../l10n/messageIds'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; import usePlaces from '../hooks/usePlaces'; -import useCreatePlace from '../hooks/useCreatePlace'; import PlaceDialog from './PlaceDialog'; +import { PlaceCard } from './PlaceCard'; const useStyles = makeStyles((theme) => ({ '@keyframes ghostMarkerBounce': { @@ -91,7 +91,6 @@ type PublicAreaMapProps = { const PublicAreaMap: FC = ({ area }) => { const theme = useTheme(); const classes = useStyles(); - const createPlace = useCreatePlace(area.organization.id); const places = usePlaces(area.organization.id).data || []; const [selectedPlace, setSelectedPlace] = useState(null); @@ -99,6 +98,8 @@ const PublicAreaMap: FC = ({ area }) => { const [dialogStep, setDialogStep] = useState<'place' | 'log'>('place'); const [returnToMap, setReturnToMap] = useState(false); const [standingStill, setStandingStill] = useState(false); + const [showCard, setShowCard] = useState(false); + const [point, setPoint] = useState(null); const [map, setMap] = useState(null); const crosshairRef = useRef(null); @@ -229,7 +230,7 @@ const PublicAreaMap: FC = ({ area }) => { opacity: !selectedPlace ? 1 : 0.3, }} > - {!selectedPlace && ( + {!selectedPlace && !showCard && ( = ({ area }) => { )} + {!selectedPlace && showCard && ( + + + + + + )} @@ -289,7 +316,7 @@ const PublicAreaMap: FC = ({ area }) => { )} - {!selectedPlace && ( + {!selectedPlace && !showCard && ( )} @@ -425,8 +406,29 @@ const PublicAreaMap: FC = ({ area }) => { onClose={() => { setIsCreating(false); }} - orgId={area.organization.id} - point={point} + onCreate={(title, type) => { + const crosshair = crosshairRef.current; + const mapContainer = map?.getContainer(); + if (crosshair && mapContainer) { + const mapRect = mapContainer.getBoundingClientRect(); + const markerRect = crosshair.getBoundingClientRect(); + const x = markerRect.x - mapRect.x; + const y = markerRect.y - mapRect.y; + const markerPoint: [number, number] = [ + x + 0.5 * markerRect.width, + y + 0.8 * markerRect.height, + ]; + + const point = map?.containerPointToLatLng(markerPoint); + if (point) { + createPlace({ + position: point, + title, + type: type === 'address' ? 'address' : 'misc', + }); + } + } + }} /> )} From 389806bdf76fc6fb07d5b20d11c370b87575f6ad Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 12 Sep 2024 15:47:52 +0200 Subject: [PATCH 240/369] Rename everything to do with "assignee" to "individualCanvassAssignment". --- .../[canvassAssId]/assignees/route.ts | 38 ---- .../[personId]}/route.ts | 18 +- .../individualcanvassassignments/route.ts | 40 +++++ src/features/areas/hooks/useAddAssignee.ts | 20 --- .../useAddIndividualCanvassAssignment.ts | 21 +++ .../areas/hooks/useAssigneeMutations.ts | 27 --- src/features/areas/hooks/useAssignees.ts | 25 --- ...useIndividualCanvassAssignmentMutations.ts | 30 ++++ .../hooks/useIndividualCanvassAssignments.ts | 30 ++++ src/features/areas/models.ts | 20 +-- src/features/areas/store.ts | 167 ++++++++++-------- src/features/areas/types.ts | 11 +- .../[canvassAssId]/index.tsx | 42 ++--- src/utils/testing/mocks/mockState.ts | 2 +- 14 files changed, 263 insertions(+), 228 deletions(-) delete mode 100644 src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/route.ts rename src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/{assignees/[assigneeId] => individualcanvassassignments/[personId]}/route.ts (76%) create mode 100644 src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/individualcanvassassignments/route.ts delete mode 100644 src/features/areas/hooks/useAddAssignee.ts create mode 100644 src/features/areas/hooks/useAddIndividualCanvassAssignment.ts delete mode 100644 src/features/areas/hooks/useAssigneeMutations.ts delete mode 100644 src/features/areas/hooks/useAssignees.ts create mode 100644 src/features/areas/hooks/useIndividualCanvassAssignmentMutations.ts create mode 100644 src/features/areas/hooks/useIndividualCanvassAssignments.ts diff --git a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/route.ts b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/route.ts deleted file mode 100644 index 25b67453d6..0000000000 --- a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import mongoose from 'mongoose'; -import { NextRequest } from 'next/server'; - -import asOrgAuthorized from 'utils/api/asOrgAuthorized'; -import { CanvassAssigneeModel } from 'features/areas/models'; -import { ZetkinCanvassAssignee } from 'features/areas/types'; - -type RouteMeta = { - params: { - canvassAssId: string; - orgId: string; - }; -}; - -export async function GET(request: NextRequest, { params }: RouteMeta) { - return asOrgAuthorized( - { - orgId: params.orgId, - request: request, - roles: ['admin', 'organizer'], - }, - async () => { - await mongoose.connect(process.env.MONGODB_URL || ''); - - const assigneeModels = await CanvassAssigneeModel.find({ - canvassAssId: params.canvassAssId, - }); - const assignees: ZetkinCanvassAssignee[] = assigneeModels.map( - (model) => ({ - areaUrl: model.areaUrl, - id: model.id, - }) - ); - - return Response.json({ data: assignees }); - } - ); -} diff --git a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/individualcanvassassignments/[personId]/route.ts similarity index 76% rename from src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts rename to src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/individualcanvassassignments/[personId]/route.ts index 637d97164c..8b355baaf4 100644 --- a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/assignees/[assigneeId]/route.ts +++ b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/individualcanvassassignments/[personId]/route.ts @@ -1,14 +1,14 @@ import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { CanvassAssigneeModel } from 'features/areas/models'; +import { IndividualCanvassAssignmentModel } from 'features/areas/models'; import asOrgAuthorized from 'utils/api/asOrgAuthorized'; type RouteMeta = { params: { - assigneeId: string; canvassAssId: string; orgId: string; + personId: string; }; }; @@ -22,16 +22,17 @@ export async function PUT(request: NextRequest, { params }: RouteMeta) { async () => { await mongoose.connect(process.env.MONGODB_URL || ''); - const model = new CanvassAssigneeModel({ + const model = new IndividualCanvassAssignmentModel({ canvassAssId: params.canvassAssId, - id: params.assigneeId, + personId: params.personId, }); await model.save(); return NextResponse.json({ data: { - id: model.id, + id: model._id.toString(), + personId: model.personId, }, }); } @@ -50,14 +51,13 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); - const model = await CanvassAssigneeModel.findOneAndUpdate( + const model = await IndividualCanvassAssignmentModel.findOneAndUpdate( { canvassAssId: params.canvassAssId, - id: params.assigneeId, + personId: params.personId, }, { areaUrl: payload.areaUrl, - id: params.assigneeId, }, { new: true } ); @@ -69,7 +69,7 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { return NextResponse.json({ data: { areaUrl: model.areaUrl, - id: model.id, + id: model._id.toString(), }, }); } diff --git a/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/individualcanvassassignments/route.ts b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/individualcanvassassignments/route.ts new file mode 100644 index 0000000000..7ea484eea2 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/projects/[projectId]/canvassassignments/[canvassAssId]/individualcanvassassignments/route.ts @@ -0,0 +1,40 @@ +import mongoose from 'mongoose'; +import { NextRequest } from 'next/server'; + +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; +import { IndividualCanvassAssignmentModel } from 'features/areas/models'; +import { ZetkinIndividualCanvassAssignment } from 'features/areas/types'; + +type RouteMeta = { + params: { + canvassAssId: string; + orgId: string; + }; +}; + +export async function GET(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin', 'organizer'], + }, + async () => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const individualCanvassAssingmentModels = + await IndividualCanvassAssignmentModel.find({ + canvassAssId: params.canvassAssId, + }); + + const individualCanvassAssignments: ZetkinIndividualCanvassAssignment[] = + individualCanvassAssingmentModels.map((model) => ({ + areaUrl: model.areaUrl, + id: model._id.toString(), + personId: model.personId, + })); + + return Response.json({ data: individualCanvassAssignments }); + } + ); +} diff --git a/src/features/areas/hooks/useAddAssignee.ts b/src/features/areas/hooks/useAddAssignee.ts deleted file mode 100644 index e534f06e0f..0000000000 --- a/src/features/areas/hooks/useAddAssignee.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useApiClient, useAppDispatch } from 'core/hooks'; -import { ZetkinCanvassAssignee } from '../types'; -import { assigneeAdd, assigneeAdded } from '../store'; - -export default function useAddAssignee( - orgId: number, - campId: number, - canvassAssId: string -) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - - return async (assigneeId: number) => { - dispatch(assigneeAdd([canvassAssId, assigneeId])); - const assignee = await apiClient.put( - `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/assignees/${assigneeId}` - ); - dispatch(assigneeAdded([canvassAssId, assignee])); - }; -} diff --git a/src/features/areas/hooks/useAddIndividualCanvassAssignment.ts b/src/features/areas/hooks/useAddIndividualCanvassAssignment.ts new file mode 100644 index 0000000000..7f4ad5adf0 --- /dev/null +++ b/src/features/areas/hooks/useAddIndividualCanvassAssignment.ts @@ -0,0 +1,21 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinIndividualCanvassAssignment } from '../types'; +import { individualAssignmentAdd, individualAssignmentAdded } from '../store'; + +export default function useAddIndividualCanvassAssignment( + orgId: number, + campId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (personId: number) => { + dispatch(individualAssignmentAdd([canvassAssId, personId])); + const individualCanvassAss = + await apiClient.put( + `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/individualcanvassassignments/${personId}` + ); + dispatch(individualAssignmentAdded([canvassAssId, individualCanvassAss])); + }; +} diff --git a/src/features/areas/hooks/useAssigneeMutations.ts b/src/features/areas/hooks/useAssigneeMutations.ts deleted file mode 100644 index 2991ad2d03..0000000000 --- a/src/features/areas/hooks/useAssigneeMutations.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useApiClient, useAppDispatch } from 'core/hooks'; -import { - ZetkinCanvassAssignee, - ZetkinCanvassAssigneePatchBody, -} from '../types'; -import { assigneeUpdated } from '../store'; - -export default function useAssigneeMutations( - orgId: number, - campId: number, - canvassAssId: string -) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - - return async (assigneeId: number, data: ZetkinCanvassAssigneePatchBody) => { - const updated = await apiClient.patch< - ZetkinCanvassAssignee, - ZetkinCanvassAssigneePatchBody - >( - `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/assignees/${assigneeId}`, - data - ); - - dispatch(assigneeUpdated([canvassAssId, updated])); - }; -} diff --git a/src/features/areas/hooks/useAssignees.ts b/src/features/areas/hooks/useAssignees.ts deleted file mode 100644 index df5bf7d710..0000000000 --- a/src/features/areas/hooks/useAssignees.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { loadListIfNecessary } from 'core/caching/cacheUtils'; -import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; -import { ZetkinCanvassAssignee } from '../types'; -import { assigneesLoad, assigneesLoaded } from '../store'; - -export default function useAssignees( - orgId: number, - campId: number, - canvassAssId: string -) { - const apiClient = useApiClient(); - const dispatch = useAppDispatch(); - const assigneeList = useAppSelector( - (state) => state.areas.assigneesByCanvassAssignmentId[canvassAssId] - ); - - return loadListIfNecessary(assigneeList, dispatch, { - actionOnLoad: () => assigneesLoad(canvassAssId), - actionOnSuccess: (data) => assigneesLoaded([canvassAssId, data]), - loader: () => - apiClient.get( - `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/assignees` - ), - }); -} diff --git a/src/features/areas/hooks/useIndividualCanvassAssignmentMutations.ts b/src/features/areas/hooks/useIndividualCanvassAssignmentMutations.ts new file mode 100644 index 0000000000..eb153bc8ae --- /dev/null +++ b/src/features/areas/hooks/useIndividualCanvassAssignmentMutations.ts @@ -0,0 +1,30 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { + ZetkinIndividualCanvassAssignment, + ZetkinIndividualCanvassAssignmentPatchBody, +} from '../types'; +import { individualAssignmentUpdated } from '../store'; + +export default function useIndividualCanvassAssignmentMutations( + orgId: number, + campId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async ( + personId: number, + data: ZetkinIndividualCanvassAssignmentPatchBody + ) => { + const updated = await apiClient.patch< + ZetkinIndividualCanvassAssignment, + ZetkinIndividualCanvassAssignmentPatchBody + >( + `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/individualcanvassassignments/${personId}`, + data + ); + + dispatch(individualAssignmentUpdated([canvassAssId, updated])); + }; +} diff --git a/src/features/areas/hooks/useIndividualCanvassAssignments.ts b/src/features/areas/hooks/useIndividualCanvassAssignments.ts new file mode 100644 index 0000000000..89e6b46a2d --- /dev/null +++ b/src/features/areas/hooks/useIndividualCanvassAssignments.ts @@ -0,0 +1,30 @@ +import { loadListIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { ZetkinIndividualCanvassAssignment } from '../types'; +import { + individualAssignmentsLoad, + individualAssignmentsLoaded, +} from '../store'; + +export default function useIndividualCanvassAssignments( + orgId: number, + campId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const assigneeList = useAppSelector( + (state) => + state.areas.individualAssignmentsByCanvassAssignmentId[canvassAssId] + ); + + return loadListIfNecessary(assigneeList, dispatch, { + actionOnLoad: () => individualAssignmentsLoad(canvassAssId), + actionOnSuccess: (data) => + individualAssignmentsLoaded([canvassAssId, data]), + loader: () => + apiClient.get( + `/beta/orgs/${orgId}/projects/${campId}/canvassassignments/${canvassAssId}/individualcanvassassignments` + ), + }); +} diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 095af04256..2098aabbba 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -24,10 +24,10 @@ type ZetkinCanvassAssignmentModelType = { title: string | null; }; -type ZetkinCanvassAssigneeModelType = { +type ZetkinIndividualCanvassAssignmentModelType = { areaUrl: string; canvassAssId: string; - id: number; + personId: number; }; const areaSchema = new mongoose.Schema({ @@ -53,11 +53,11 @@ const canvassAssignmentSchema = title: String, }); -const canvassAssigneeSchema = - new mongoose.Schema({ +const individualCanvassAssignmentSchema = + new mongoose.Schema({ areaUrl: String, canvassAssId: String, - id: { required: true, type: Number }, + personId: { required: true, type: Number }, }); export const AreaModel: mongoose.Model = @@ -75,9 +75,9 @@ export const CanvassAssignmentModel: mongoose.Model = - mongoose.models.CanvassAssignee || - mongoose.model( - 'CanvassAssignee', - canvassAssigneeSchema +export const IndividualCanvassAssignmentModel: mongoose.Model = + mongoose.models.IndividualCanvassAssignment || + mongoose.model( + 'IndividualCanvassAssignment', + individualCanvassAssignmentSchema ); diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index aa930497d4..f50c2e67fe 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -8,25 +8,27 @@ import { } from 'utils/storeUtils'; import { ZetkinArea, - ZetkinCanvassAssignee, + ZetkinIndividualCanvassAssignment, ZetkinCanvassAssignment, ZetkinPlace, } from './types'; export interface AreasStoreSlice { areaList: RemoteList; - assigneesByCanvassAssignmentId: Record< + canvassAssignmentList: RemoteList; + individualAssignmentsByCanvassAssignmentId: Record< string, - RemoteList + RemoteList >; - canvassAssignmentList: RemoteList; + //myAssignmentsList: RemoteList; placeList: RemoteList; } const initialState: AreasStoreSlice = { areaList: remoteList(), - assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), + individualAssignmentsByCanvassAssignmentId: {}, + //myAssignmentsList: remoteList(), placeList: remoteList(), }; @@ -90,70 +92,6 @@ const areasSlice = createSlice({ state.areaList.loaded = timestamp; state.areaList.items.forEach((item) => (item.loaded = timestamp)); }, - assigneeAdd: (state, action: PayloadAction<[string, number]>) => { - const [canvassAssId, assigneeId] = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId].items.push( - remoteItem(assigneeId, { isLoading: true }) - ); - }, - assigneeAdded: ( - state, - action: PayloadAction<[string, ZetkinCanvassAssignee]> - ) => { - const [canvassAssId, assignee] = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId].items = - state.assigneesByCanvassAssignmentId[canvassAssId].items - .filter((c) => c.id != assignee.id) - .concat([remoteItem(assignee.id, { data: assignee })]); - }, - assigneeUpdated: ( - state, - action: PayloadAction<[string, ZetkinCanvassAssignee]> - ) => { - const [canvassAssId, assignee] = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId].items - .filter((item) => item.id == assignee.id) - .concat([remoteItem(assignee.id, { data: assignee })]); - }, - assigneesLoad: (state, action: PayloadAction) => { - const canvassAssId = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId].isLoading = true; - }, - assigneesLoaded: ( - state, - action: PayloadAction<[string, ZetkinCanvassAssignee[]]> - ) => { - const [canvassAssId, assignees] = action.payload; - - if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { - state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); - } - - state.assigneesByCanvassAssignmentId[canvassAssId] = - remoteList(assignees); - state.assigneesByCanvassAssignmentId[canvassAssId].loaded = - new Date().toISOString(); - }, canvassAssignmentCreated: ( state, action: PayloadAction @@ -198,6 +136,87 @@ const areasSlice = createSlice({ item.isLoading = false; item.loaded = new Date().toISOString(); }, + individualAssignmentAdd: ( + state, + action: PayloadAction<[string, number]> + ) => { + const [canvassAssId, personId] = action.payload; + + if (!state.individualAssignmentsByCanvassAssignmentId[canvassAssId]) { + state.individualAssignmentsByCanvassAssignmentId[canvassAssId] = + remoteList(); + } + + state.individualAssignmentsByCanvassAssignmentId[canvassAssId].items.push( + remoteItem(personId, { isLoading: true }) + ); + }, + individualAssignmentAdded: ( + state, + action: PayloadAction<[string, ZetkinIndividualCanvassAssignment]> + ) => { + const [canvassAssId, individualAssignment] = action.payload; + + if (!state.individualAssignmentsByCanvassAssignmentId[canvassAssId]) { + state.individualAssignmentsByCanvassAssignmentId[canvassAssId] = + remoteList(); + } + + state.individualAssignmentsByCanvassAssignmentId[canvassAssId].items = + state.individualAssignmentsByCanvassAssignmentId[canvassAssId].items + .filter((c) => c.id != individualAssignment.personId) + .concat([ + remoteItem(individualAssignment.id, { + data: individualAssignment, + }), + ]); + }, + individualAssignmentUpdated: ( + state, + action: PayloadAction<[string, ZetkinIndividualCanvassAssignment]> + ) => { + const [canvassAssId, individualAssignment] = action.payload; + + if (!state.individualAssignmentsByCanvassAssignmentId[canvassAssId]) { + state.individualAssignmentsByCanvassAssignmentId[canvassAssId] = + remoteList(); + } + + state.individualAssignmentsByCanvassAssignmentId[canvassAssId].items + .filter((item) => item.id == individualAssignment.personId) + .concat([ + remoteItem(individualAssignment.id, { + data: individualAssignment, + }), + ]); + }, + individualAssignmentsLoad: (state, action: PayloadAction) => { + const canvassAssId = action.payload; + + if (!state.individualAssignmentsByCanvassAssignmentId[canvassAssId]) { + state.individualAssignmentsByCanvassAssignmentId[canvassAssId] = + remoteList(); + } + + state.individualAssignmentsByCanvassAssignmentId[canvassAssId].isLoading = + true; + }, + individualAssignmentsLoaded: ( + state, + action: PayloadAction<[string, ZetkinIndividualCanvassAssignment[]]> + ) => { + const [canvassAssId, individualAssignments] = action.payload; + + if (!state.individualAssignmentsByCanvassAssignmentId[canvassAssId]) { + state.individualAssignmentsByCanvassAssignmentId[canvassAssId] = + remoteList(); + } + + state.individualAssignmentsByCanvassAssignmentId[canvassAssId] = + remoteList(individualAssignments); + state.individualAssignmentsByCanvassAssignmentId[canvassAssId].loaded = + new Date().toISOString(); + }, placeCreated: (state, action: PayloadAction) => { const place = action.payload; const item = remoteItem(place.id, { @@ -236,11 +255,11 @@ export const { areasLoad, areasLoaded, areaUpdated, - assigneeAdd, - assigneeAdded, - assigneeUpdated, - assigneesLoad, - assigneesLoaded, + individualAssignmentAdd, + individualAssignmentAdded, + individualAssignmentUpdated, + individualAssignmentsLoad, + individualAssignmentsLoaded, canvassAssignmentCreated, canvassAssignmentLoad, canvassAssignmentLoaded, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 1f4163d368..dd8e1acca7 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -43,10 +43,13 @@ export type ZetkinCanvassAssignmentPostBody = Partial< >; export type ZetkinCanvassAssignmentPatchbody = ZetkinCanvassAssignmentPostBody; -export type ZetkinCanvassAssignee = { +export type ZetkinIndividualCanvassAssignment = { areaUrl: string; - id: number; + id: string; + personId: number; }; -export type ZetkinCanvassAssigneePatchBody = Partial< - Omit +export type ZetkinIndividualCanvassAssignmentPostBody = Partial< + Omit >; +export type ZetkinIndividualCanvassAssignmentPatchBody = + ZetkinIndividualCanvassAssignmentPostBody; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 9a914cc7ad..c3926bc6b9 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -6,10 +6,10 @@ import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import useAddAssignee from 'features/areas/hooks/useAddAssignee'; -import useAssignees from 'features/areas/hooks/useAssignees'; +import useAddIndividualCanvassAssignment from 'features/areas/hooks/useAddIndividualCanvassAssignment'; +import useIndividualCanvassAssignments from 'features/areas/hooks/useIndividualCanvassAssignments'; import ZUIFutures from 'zui/ZUIFutures'; -import useAssigneeMutations from 'features/areas/hooks/useAssigneeMutations'; +import useIndividualCanvassAssignmentMutations from 'features/areas/hooks/useIndividualCanvassAssignmentMutations'; const scaffoldOptions = { authLevelRequired: 2, @@ -22,14 +22,14 @@ export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { }; }, scaffoldOptions); -const AssigneeListItem: FC<{ - assigneeId: number; +const IndividualCanvassAssignmentListItem: FC<{ + individualCanvassAssId: string; onAddUrl: (url: string) => void; -}> = ({ assigneeId, onAddUrl }) => { +}> = ({ individualCanvassAssId, onAddUrl }) => { const [url, setUrl] = useState(''); return ( - {assigneeId} + {individualCanvassAssId} setUrl(ev.target.value)} value={url} /> - Ids of people that have been added as assignees - {assignees.map((assignee) => ( - ( + - updateAssignee(assignee.id, { areaUrl: url }) + updateIndividualCanvassAss(individualCanvassAss.personId, { + areaUrl: url, + }) } /> ))} diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index e5bf71c72a..c29d3ac5cc 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -5,8 +5,8 @@ export default function mockState(overrides?: RootState) { const emptyState: RootState = { areas: { areaList: remoteList(), - assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), + individualAssignmentsByCanvassAssignmentId: {}, placeList: remoteList(), }, breadcrumbs: { From 43a398b5b02b4d59e8a828d6fd854be429e078bf Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 12 Sep 2024 17:19:43 +0200 Subject: [PATCH 241/369] List individual assignments of a person on their personal page. --- .../beta/users/me/canvassassignments/route.ts | 41 +++++++++++++++++++ src/app/my/canvassassignments/page.tsx | 21 ++++++++++ src/features/areas/components/ProfilePage.tsx | 19 +++++++++ .../areas/hooks/useMyCanvassAssignments.ts | 21 ++++++++++ src/features/areas/store.ts | 22 +++++++++- src/utils/testing/mocks/mockState.ts | 1 + 6 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/app/beta/users/me/canvassassignments/route.ts create mode 100644 src/app/my/canvassassignments/page.tsx create mode 100644 src/features/areas/components/ProfilePage.tsx create mode 100644 src/features/areas/hooks/useMyCanvassAssignments.ts diff --git a/src/app/beta/users/me/canvassassignments/route.ts b/src/app/beta/users/me/canvassassignments/route.ts new file mode 100644 index 0000000000..5f379127e2 --- /dev/null +++ b/src/app/beta/users/me/canvassassignments/route.ts @@ -0,0 +1,41 @@ +import mongoose from 'mongoose'; +import { NextRequest, NextResponse } from 'next/server'; +import { IncomingHttpHeaders } from 'http'; + +import { IndividualCanvassAssignmentModel } from 'features/areas/models'; +import { ZetkinIndividualCanvassAssignment } from 'features/areas/types'; +import BackendApiClient from 'core/api/client/BackendApiClient'; +import { ZetkinUser } from 'utils/types/zetkin'; +import { ApiClientError } from 'core/api/errors'; + +export async function GET(request: NextRequest) { + const headers: IncomingHttpHeaders = {}; + request.headers.forEach((value, key) => (headers[key] = value)); + const apiClient = new BackendApiClient(headers); + + try { + const currentUser = await apiClient.get(`/api/users/me`); + + await mongoose.connect(process.env.MONGODB_URL || ''); + + const individualCanvassAssignmentModels = + await IndividualCanvassAssignmentModel.find({ + personId: currentUser.id, + }); + + const individualCanvassAssignments: ZetkinIndividualCanvassAssignment[] = + individualCanvassAssignmentModels.map((model) => ({ + areaUrl: model.areaUrl, + id: model._id.toString(), + personId: model.personId, + })); + + return Response.json({ data: individualCanvassAssignments }); + } catch (err) { + if (err instanceof ApiClientError) { + return new NextResponse(null, { status: err.status }); + } else { + return new NextResponse(null, { status: 500 }); + } + } +} diff --git a/src/app/my/canvassassignments/page.tsx b/src/app/my/canvassassignments/page.tsx new file mode 100644 index 0000000000..bd8e824ff6 --- /dev/null +++ b/src/app/my/canvassassignments/page.tsx @@ -0,0 +1,21 @@ +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import BackendApiClient from 'core/api/client/BackendApiClient'; +import ProfilePage from 'features/areas/components/ProfilePage'; +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 notFound(); + } +} diff --git a/src/features/areas/components/ProfilePage.tsx b/src/features/areas/components/ProfilePage.tsx new file mode 100644 index 0000000000..3ca3cbd467 --- /dev/null +++ b/src/features/areas/components/ProfilePage.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { FC } from 'react'; + +import useMyCanvassAssignments from 'features/areas/hooks/useMyCanvassAssignments'; + +const ProfilePage: FC = () => { + const myCanvassAssignments = useMyCanvassAssignments().data || []; + + return ( +
+ {myCanvassAssignments.map((assignment) => ( +

{`${assignment.id} ${assignment.areaUrl}`}

+ ))} +
+ ); +}; + +export default ProfilePage; diff --git a/src/features/areas/hooks/useMyCanvassAssignments.ts b/src/features/areas/hooks/useMyCanvassAssignments.ts new file mode 100644 index 0000000000..dcfe7ba6b9 --- /dev/null +++ b/src/features/areas/hooks/useMyCanvassAssignments.ts @@ -0,0 +1,21 @@ +import { loadListIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { myAssignmentsLoad, myAssignmentsLoaded } from '../store'; +import { ZetkinIndividualCanvassAssignment } from '../types'; + +export default function useMyCanvassAssignments() { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const myAssignments = useAppSelector( + (state) => state.areas.myAssignmentsList + ); + + return loadListIfNecessary(myAssignments, dispatch, { + actionOnLoad: () => myAssignmentsLoad(), + actionOnSuccess: (data) => myAssignmentsLoaded(data), + loader: () => + apiClient.get( + '/beta/users/me/canvassassignments' + ), + }); +} diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index f50c2e67fe..2ae351c7a1 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -20,7 +20,7 @@ export interface AreasStoreSlice { string, RemoteList >; - //myAssignmentsList: RemoteList; + myAssignmentsList: RemoteList; placeList: RemoteList; } @@ -28,7 +28,7 @@ const initialState: AreasStoreSlice = { areaList: remoteList(), canvassAssignmentList: remoteList(), individualAssignmentsByCanvassAssignmentId: {}, - //myAssignmentsList: remoteList(), + myAssignmentsList: remoteList(), placeList: remoteList(), }; @@ -217,6 +217,22 @@ const areasSlice = createSlice({ state.individualAssignmentsByCanvassAssignmentId[canvassAssId].loaded = new Date().toISOString(); }, + myAssignmentsLoad: (state) => { + state.myAssignmentsList.isLoading = true; + }, + myAssignmentsLoaded: ( + state, + action: PayloadAction + ) => { + const individualAssignments = action.payload; + const timestamp = new Date().toISOString(); + + state.myAssignmentsList = remoteList(individualAssignments); + state.myAssignmentsList.loaded = timestamp; + state.myAssignmentsList.items.forEach( + (item) => (item.loaded = timestamp) + ); + }, placeCreated: (state, action: PayloadAction) => { const place = action.payload; const item = remoteItem(place.id, { @@ -260,6 +276,8 @@ export const { individualAssignmentUpdated, individualAssignmentsLoad, individualAssignmentsLoaded, + myAssignmentsLoad, + myAssignmentsLoaded, canvassAssignmentCreated, canvassAssignmentLoad, canvassAssignmentLoaded, diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index c29d3ac5cc..e7018b552e 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -7,6 +7,7 @@ export default function mockState(overrides?: RootState) { areaList: remoteList(), canvassAssignmentList: remoteList(), individualAssignmentsByCanvassAssignmentId: {}, + myAssignmentsList: remoteList(), placeList: remoteList(), }, breadcrumbs: { From ff3194f4bbec37083399858bc7505516daed35b7 Mon Sep 17 00:00:00 2001 From: neta Date: Fri, 13 Sep 2024 17:29:23 +0300 Subject: [PATCH 242/369] Issue 2019/add default country code in edit form validation --- src/features/profile/hooks/useEditPerson.ts | 11 +++++++++-- src/zui/ZUICreatePerson/PersonalInfoForm.tsx | 11 ++++++++++- src/zui/ZUICreatePerson/checkInvalidFields.ts | 12 ++++++++---- src/zui/ZUICreatePerson/index.tsx | 12 +++++++++--- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/features/profile/hooks/useEditPerson.ts b/src/features/profile/hooks/useEditPerson.ts index 3fe1d0d160..0a77cd9cbb 100644 --- a/src/features/profile/hooks/useEditPerson.ts +++ b/src/features/profile/hooks/useEditPerson.ts @@ -1,8 +1,10 @@ import { useState } from 'react'; +import { CountryCode } from 'libphonenumber-js'; import checkInvalidFields from 'zui/ZUICreatePerson/checkInvalidFields'; import useCustomFields from './useCustomFields'; import { ZetkinPerson, ZetkinUpdatePerson } from 'utils/types/zetkin'; +import useOrganization from '../../organizations/hooks/useOrganization'; export default function useEditPerson( initialValues: ZetkinPerson, @@ -10,8 +12,13 @@ export default function useEditPerson( ) { const customFields = useCustomFields(orgId).data ?? []; const [fieldsToUpdate, setFieldsToUpdate] = useState({}); - - const invalidFields = checkInvalidFields(customFields, fieldsToUpdate); + const organization = useOrganization(orgId).data; + const countryCode = organization?.country as CountryCode; + const invalidFields = checkInvalidFields( + customFields, + fieldsToUpdate, + countryCode + ); const onFieldValueChange = ( field: keyof ZetkinUpdatePerson, diff --git a/src/zui/ZUICreatePerson/PersonalInfoForm.tsx b/src/zui/ZUICreatePerson/PersonalInfoForm.tsx index bf8bbb3021..a0a76f1cb7 100644 --- a/src/zui/ZUICreatePerson/PersonalInfoForm.tsx +++ b/src/zui/ZUICreatePerson/PersonalInfoForm.tsx @@ -11,6 +11,7 @@ import { } from '@mui/material'; import dayjs, { Dayjs } from 'dayjs'; import { FC, useEffect, useRef, useState } from 'react'; +import { CountryCode } from 'libphonenumber-js'; import checkInvalidFields from './checkInvalidFields'; import formatUrl from 'utils/formatUrl'; @@ -24,6 +25,7 @@ import { useNumericRouteParams } from 'core/hooks'; import useTags from 'features/tags/hooks/useTags'; import { Msg, useMessages } from 'core/i18n'; import { ZetkinCreatePerson, ZetkinTag } from 'utils/types/zetkin'; +import useOrganization from '../../features/organizations/hooks/useOrganization'; dayjs.extend(utc); @@ -35,6 +37,7 @@ interface PersonalInfoFormProps { personalInfo: ZetkinCreatePerson; tags: number[]; } + const PersonalInfoForm: FC = ({ onChange, personalInfo, @@ -44,6 +47,8 @@ const PersonalInfoForm: FC = ({ const globalMessages = useMessages(globalMessageIds); const messages = useMessages(messageIds); const inputRef = useRef(); + const organization = useOrganization(orgId).data; + const countryCode = organization?.country as CountryCode; const [showAllClickedType, setShowAllClickedType] = useState(null); @@ -66,7 +71,11 @@ const PersonalInfoForm: FC = ({ inputRef.current?.focus(); } }, [showAllClickedType]); - const invalidFields = checkInvalidFields(customFields, personalInfo); + const invalidFields = checkInvalidFields( + customFields, + personalInfo, + countryCode + ); return ( void; @@ -39,7 +41,8 @@ const ZUICreatePerson: FC = ({ const fullScreen = useMediaQuery(theme.breakpoints.down('md')); const customFields = useCustomFields(orgId).data; const createPerson = useCreatePerson(orgId); - + const organization = useOrganization(orgId).data; + const countryCode = organization?.country as CountryCode; const [tags, setTags] = useState([]); const [personalInfo, setPersonalInfo] = useState({}); @@ -116,8 +119,11 @@ const ZUICreatePerson: FC = ({ disabled={ personalInfo.first_name === undefined || personalInfo.last_name === undefined || - checkInvalidFields(customFields || [], personalInfo) - .length !== 0 + checkInvalidFields( + customFields || [], + personalInfo, + countryCode + ).length !== 0 } onClick={async (e) => { const person = await createPerson(personalInfo, tags); From b2c26177ff74d0784fe821578855c82d3154c9be Mon Sep 17 00:00:00 2001 From: KraftKatten Date: Sat, 14 Sep 2024 11:43:02 +0200 Subject: [PATCH 243/369] #2100 Add avatars to merge duplicate icons --- .../duplicates/components/ConfigureModal.tsx | 1 + .../FieldSettings/FieldSettingsRow.tsx | 37 ++++++++++++++++++- .../components/FieldSettings/index.tsx | 9 ++++- .../duplicates/hooks/useDuplicates.tsx | 6 ++- src/zui/ZUIAvatar/index.tsx | 3 +- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/features/duplicates/components/ConfigureModal.tsx b/src/features/duplicates/components/ConfigureModal.tsx index c852e79832..ea61c732b6 100644 --- a/src/features/duplicates/components/ConfigureModal.tsx +++ b/src/features/duplicates/components/ConfigureModal.tsx @@ -89,6 +89,7 @@ const ConfigureModal: FC = ({ width="50%" > { setOverrides({ ...overrides, [`${field}`]: value }); diff --git a/src/features/duplicates/components/FieldSettings/FieldSettingsRow.tsx b/src/features/duplicates/components/FieldSettings/FieldSettingsRow.tsx index fb27bc3e98..31f50f2b18 100644 --- a/src/features/duplicates/components/FieldSettings/FieldSettingsRow.tsx +++ b/src/features/duplicates/components/FieldSettings/FieldSettingsRow.tsx @@ -12,14 +12,19 @@ import globalMessageIds from 'core/i18n/globalMessageIds'; import messageIds from 'features/duplicates/l10n/messageIds'; import { NATIVE_PERSON_FIELDS } from 'features/views/components/types'; import { Msg, useMessages } from 'core/i18n'; +import { useNumericRouteParams } from 'core/hooks'; +import { ZetkinPerson } from 'utils/types/zetkin'; +import ZUIAvatar from 'zui/ZUIAvatar'; interface FieldSettingsRowProps { + duplicates: ZetkinPerson[]; field: NATIVE_PERSON_FIELDS; onChange: (selectedValue: string) => void; values: string[]; } const FieldSettingsRow: FC = ({ + duplicates, field, onChange, values, @@ -27,6 +32,7 @@ const FieldSettingsRow: FC = ({ const theme = useTheme(); const messages = useMessages(messageIds); const [selectedValue, setSelectedValue] = useState(values[0]); + const { orgId } = useNumericRouteParams(); const getLabel = (value: string) => { if (field === NATIVE_PERSON_FIELDS.GENDER) { @@ -50,6 +56,25 @@ const FieldSettingsRow: FC = ({ return value; }; + const getAvatars = (value: String) => { + const peopleWithMatchingValues = duplicates.filter( + (person) => person[field] == value + ); + + return ( + + {peopleWithMatchingValues.map((person) => { + return ( + + ); + })} + + ); + }; + return ( = ({ setSelectedValue(event.target.value); onChange(event.target.value); }} + renderValue={() => getLabel(selectedValue)} value={selectedValue} > {values.map((value, index) => ( - + {getLabel(value)} + {getAvatars(value)} ))} diff --git a/src/features/duplicates/components/FieldSettings/index.tsx b/src/features/duplicates/components/FieldSettings/index.tsx index f1e22410f6..47d2617869 100644 --- a/src/features/duplicates/components/FieldSettings/index.tsx +++ b/src/features/duplicates/components/FieldSettings/index.tsx @@ -5,13 +5,19 @@ import FieldSettingsRow from './FieldSettingsRow'; import messageIds from 'features/duplicates/l10n/messageIds'; import { NATIVE_PERSON_FIELDS } from 'features/views/components/types'; import { useMessages } from 'core/i18n'; +import { ZetkinPerson } from 'utils/types/zetkin'; interface FieldSettingsProps { + duplicates: ZetkinPerson[]; fieldValues: Record; onChange: (field: NATIVE_PERSON_FIELDS, selectedValue: string) => void; } -const FieldSettings: FC = ({ fieldValues, onChange }) => { +const FieldSettings: FC = ({ + duplicates, + fieldValues, + onChange, +}) => { const theme = useTheme(); const messages = useMessages(messageIds); @@ -47,6 +53,7 @@ const FieldSettings: FC = ({ fieldValues, onChange }) => { <> {field !== NATIVE_PERSON_FIELDS.FIRST_NAME && } diff --git a/src/features/duplicates/hooks/useDuplicates.tsx b/src/features/duplicates/hooks/useDuplicates.tsx index 7655207e6e..9a3f5dd095 100644 --- a/src/features/duplicates/hooks/useDuplicates.tsx +++ b/src/features/duplicates/hooks/useDuplicates.tsx @@ -1,5 +1,9 @@ import { loadListIfNecessary } from 'core/caching/cacheUtils'; -import { potentialDuplicatesLoad, potentialDuplicatesLoaded } from '../store'; +import { + PotentialDuplicate, + potentialDuplicatesLoad, + potentialDuplicatesLoaded, +} from '../store'; import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; export default function useDuplicates(orgId: number) { diff --git a/src/zui/ZUIAvatar/index.tsx b/src/zui/ZUIAvatar/index.tsx index ae2dcedd3c..88d094bef5 100644 --- a/src/zui/ZUIAvatar/index.tsx +++ b/src/zui/ZUIAvatar/index.tsx @@ -2,13 +2,14 @@ import { Avatar } from '@mui/material'; interface ZUIAvatarProps { url: string; - size: 'sm' | 'md' | 'lg'; + size: 'xs' | 'sm' | 'md' | 'lg'; } const SIZES = { lg: 50, md: 40, sm: 30, + xs: 20, }; const ZUIAvatar: React.FC = ({ url, size }) => { From f0ac576ef6a3fbd271189c546f7a22d26c7883ca Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Sat, 14 Sep 2024 11:49:05 +0200 Subject: [PATCH 244/369] Remove unnecessary dependencies --- package.json | 2 -- yarn.lock | 15 +-------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/package.json b/package.json index 2685da9831..f109d9464d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "is-url": "^1.2.4", "isomorphic-dompurify": "^0.19.0", "leaflet": "^1.9.3", - "leaflet-draw": "^1.0.4", "letterparser": "^0.0.8", "libphonenumber-js": "^1.10.51", "lodash": "^4.17.21", @@ -83,7 +82,6 @@ "react-final-form": "^6.5.9", "react-intl": "^6.1.0", "react-leaflet": "^4.2.1", - "react-leaflet-draw": "^0.20.4", "react-redux": "^8.0.4", "remark-gfm": "^3.0.1", "remark-parse": "^10.0.1", diff --git a/yarn.lock b/yarn.lock index bcce40d450..fe95e536c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10668,11 +10668,6 @@ lazy-universal-dotenv@^4.0.0: dotenv "^16.0.0" dotenv-expand "^10.0.0" -leaflet-draw@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/leaflet-draw/-/leaflet-draw-1.0.4.tgz#45be92f378ed253e7202fdeda1fcc71885198d46" - integrity sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ== - leaflet@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.3.tgz#52ec436954964e2d3d39e0d433da4b2500d74414" @@ -10790,7 +10785,7 @@ locate-path@^7.1.0: dependencies: p-locate "^6.0.0" -lodash-es@^4.17.15, lodash-es@^4.17.21: +lodash-es@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== @@ -13348,14 +13343,6 @@ react-is@^18.0.0, react-is@^18.2.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== -react-leaflet-draw@^0.20.4: - version "0.20.4" - resolved "https://registry.yarnpkg.com/react-leaflet-draw/-/react-leaflet-draw-0.20.4.tgz#e3f68dc783cbe00d24ff3f2e02d5208c49f7c971" - integrity sha512-u5JHdow2Z9G2AveyUEOTWHXhdhzXdEVQifkNfSaVbEn0gvD+2xW03TQN444zVqovDBvIrBcVWo1VajL4zgl6yg== - dependencies: - fast-deep-equal "^3.1.3" - lodash-es "^4.17.15" - react-leaflet@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-4.2.1.tgz#c300e9eccaf15cb40757552e181200aa10b94780" From fcf5df1b2a9bbbef153ba34e469078591e55d980 Mon Sep 17 00:00:00 2001 From: KraftKatten Date: Sat, 14 Sep 2024 12:01:15 +0200 Subject: [PATCH 245/369] #2100 Fix linting issues --- .../components/FieldSettings/FieldSettingsRow.tsx | 9 +++++---- .../duplicates/components/FieldSettings/index.tsx | 2 +- src/features/duplicates/hooks/useDuplicates.tsx | 6 +----- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/features/duplicates/components/FieldSettings/FieldSettingsRow.tsx b/src/features/duplicates/components/FieldSettings/FieldSettingsRow.tsx index 31f50f2b18..640791cef9 100644 --- a/src/features/duplicates/components/FieldSettings/FieldSettingsRow.tsx +++ b/src/features/duplicates/components/FieldSettings/FieldSettingsRow.tsx @@ -56,16 +56,17 @@ const FieldSettingsRow: FC = ({ return value; }; - const getAvatars = (value: String) => { + const getAvatars = (value: string) => { const peopleWithMatchingValues = duplicates.filter( (person) => person[field] == value ); return ( - {peopleWithMatchingValues.map((person) => { + {peopleWithMatchingValues.map((person, index) => { return ( @@ -120,12 +121,12 @@ const FieldSettingsRow: FC = ({ {values.map((value, index) => ( {getLabel(value)} {getAvatars(value)} diff --git a/src/features/duplicates/components/FieldSettings/index.tsx b/src/features/duplicates/components/FieldSettings/index.tsx index 47d2617869..4415b8dadb 100644 --- a/src/features/duplicates/components/FieldSettings/index.tsx +++ b/src/features/duplicates/components/FieldSettings/index.tsx @@ -53,8 +53,8 @@ const FieldSettings: FC = ({ <> {field !== NATIVE_PERSON_FIELDS.FIRST_NAME && } onChange(field as NATIVE_PERSON_FIELDS, selectedValue) diff --git a/src/features/duplicates/hooks/useDuplicates.tsx b/src/features/duplicates/hooks/useDuplicates.tsx index 9a3f5dd095..7655207e6e 100644 --- a/src/features/duplicates/hooks/useDuplicates.tsx +++ b/src/features/duplicates/hooks/useDuplicates.tsx @@ -1,9 +1,5 @@ import { loadListIfNecessary } from 'core/caching/cacheUtils'; -import { - PotentialDuplicate, - potentialDuplicatesLoad, - potentialDuplicatesLoaded, -} from '../store'; +import { potentialDuplicatesLoad, potentialDuplicatesLoaded } from '../store'; import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; export default function useDuplicates(orgId: number) { From 882c77b02e1a15cd19d1c876f1cd8c660571459e Mon Sep 17 00:00:00 2001 From: awarn Date: Sat, 14 Sep 2024 14:09:19 +0200 Subject: [PATCH 246/369] Add privacy policy as environment var --- .env.development | 2 ++ .env.test | 2 ++ src/features/surveys/l10n/messageIds.ts | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.env.development b/.env.development index 5b9b6db6b5..18ca5ab228 100644 --- a/.env.development +++ b/.env.development @@ -1,5 +1,7 @@ ZETKIN_APP_DOMAIN=http://www.dev.zetkin.org +ZETKIN_PRIVACY_POLICY_LINK=https://zetkin.org/privacy + # Zetkin API settings ZETKIN_API_DOMAIN=dev.zetkin.org ZETKIN_API_HOST=api.dev.zetkin.org diff --git a/.env.test b/.env.test index 6a92af0dd8..c7e7c834bf 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,5 @@ +ZETKIN_PRIVACY_POLICY_LINK=https://zetkin.org/privacy + # Zetkin API settings ZETKIN_API_DOMAIN=dev.zetkin.org ZETKIN_API_HOST=localhost diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 054c19c136..73f9ebc233 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -174,7 +174,9 @@ export default makeMessages('feat.surveys', { 'Something went wrong when submitting your answers. Please try again later.' ), policy: { - link: m('https://zetkin.org/privacy'), + link: m( + process.env.ZETKIN_PRIVACY_POLICY_LINK || 'https://zetkin.org/privacy' + ), text: m('Click to read the full Zetkin Privacy Policy'), }, required: m('required'), From 11cc02d7b84bd626de23e75593df76fe23fd6ecf Mon Sep 17 00:00:00 2001 From: sarlinkle Date: Sat, 14 Sep 2024 14:22:23 +0200 Subject: [PATCH 247/369] Add sorting to import organization-configuration --- .../Configure/Configuration/OrgConfig.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/features/import/components/ImportDialog/Configure/Configuration/OrgConfig.tsx b/src/features/import/components/ImportDialog/Configure/Configuration/OrgConfig.tsx index d26e3c3088..aa634439ea 100644 --- a/src/features/import/components/ImportDialog/Configure/Configuration/OrgConfig.tsx +++ b/src/features/import/components/ImportDialog/Configure/Configuration/OrgConfig.tsx @@ -29,6 +29,17 @@ const OrgConfig: FC = ({ uiDataColumn }) => { if (!activeOrgs.length) { return null; } + const org = activeOrgs.find((org) => org.id == orgId); + + const sortedActiveOrgs = activeOrgs + .filter((org) => org.id != orgId) + .sort((orgA, orgB) => { + return orgA.title.localeCompare(orgB.title); + }); + + if (org) { + sortedActiveOrgs.unshift(org); + } return ( = ({ uiDataColumn }) => { numRows={uiDataColumn.numRowsByUniqueValue[uniqueValue]} onDeselectOrg={() => deselectOrg(uniqueValue)} onSelectOrg={(orgId) => selectOrg(orgId, uniqueValue)} - orgs={activeOrgs} + orgs={sortedActiveOrgs} selectedOrgId={getSelectedOrgId(uniqueValue)} title={uniqueValue.toString()} /> From 7713331b14cb00dd152c2ca595ed19b831d531ab Mon Sep 17 00:00:00 2001 From: awarn Date: Sat, 14 Sep 2024 16:44:25 +0200 Subject: [PATCH 248/369] Remove translation for privacy policy link --- .../surveys/components/surveyForm/SurveyPrivacyPolicy.tsx | 8 +++++--- src/features/surveys/l10n/messageIds.ts | 3 --- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx index 5222051536..a2f469725f 100644 --- a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx +++ b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx @@ -14,14 +14,13 @@ import SurveyContainer from './SurveyContainer'; import SurveyOption from './SurveyOption'; import SurveySubheading from './SurveySubheading'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; -import { Msg, useMessages } from 'core/i18n'; +import { Msg } from 'core/i18n'; export type SurveyPrivacyPolicyProps = { survey: ZetkinSurveyExtended; }; const SurveyPrivacyPolicy: FC = ({ survey }) => { - const messages = useMessages(messageIds); return ( @@ -46,7 +45,10 @@ const SurveyPrivacyPolicy: FC = ({ survey }) => { diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 73f9ebc233..6680c05941 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -174,9 +174,6 @@ export default makeMessages('feat.surveys', { 'Something went wrong when submitting your answers. Please try again later.' ), policy: { - link: m( - process.env.ZETKIN_PRIVACY_POLICY_LINK || 'https://zetkin.org/privacy' - ), text: m('Click to read the full Zetkin Privacy Policy'), }, required: m('required'), From 72059ffe323e48e83ec39cf7a514a4ec15895b46 Mon Sep 17 00:00:00 2001 From: awarn Date: Sat, 14 Sep 2024 23:20:21 +0200 Subject: [PATCH 249/369] Add dialog to move events between campaigns --- .../events/components/EventActionButtons.tsx | 20 +- .../components/EventChangeCampaignDialog.tsx | 193 ++++++++++++++++++ .../events/hooks/useEventMutations.ts | 8 + src/features/events/l10n/messageIds.ts | 7 + 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/features/events/components/EventChangeCampaignDialog.tsx diff --git a/src/features/events/components/EventActionButtons.tsx b/src/features/events/components/EventActionButtons.tsx index 6ee17c6afb..124b339142 100644 --- a/src/features/events/components/EventActionButtons.tsx +++ b/src/features/events/components/EventActionButtons.tsx @@ -1,12 +1,13 @@ import { useRouter } from 'next/router'; import { Box, Button } from '@mui/material'; import { + ArrowForward, CancelOutlined, ContentCopy, Delete, RestoreOutlined, } from '@mui/icons-material'; -import React, { useContext } from 'react'; +import React, { useContext, useState } from 'react'; import dayjs from 'dayjs'; import messageIds from '../l10n/messageIds'; @@ -17,6 +18,7 @@ import { ZetkinEvent } from 'utils/types/zetkin'; import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; import ZUIDatePicker from 'zui/ZUIDatePicker'; import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; +import EventMoveDialog from './EventChangeCampaignDialog'; interface EventActionButtonsProps { event: ZetkinEvent; @@ -32,6 +34,7 @@ const EventActionButtons: React.FunctionComponent = ({ const { cancelEvent, deleteEvent, restoreEvent, setPublished } = useEventMutations(orgId, event.id); const duplicateEvent = useDuplicateEvent(orgId, event.id); + const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); const published = !!event.published && new Date(event.published) <= new Date(); @@ -64,6 +67,10 @@ const EventActionButtons: React.FunctionComponent = ({ event.cancelled ? restoreEvent() : cancelEvent(); }; + const handleMove = () => { + setIsMoveDialogOpen(true); + }; + return ( @@ -115,6 +122,11 @@ const EventActionButtons: React.FunctionComponent = ({ onSelect: handleDuplicate, startIcon: , }, + { + label: <>{messages.eventActionButtons.move()}, + onSelect: handleMove, + startIcon: , + }, { label: <>{messages.eventActionButtons.delete()}, onSelect: () => { @@ -137,6 +149,12 @@ const EventActionButtons: React.FunctionComponent = ({ + + setIsMoveDialogOpen(false)} + event={event} + isOpen={isMoveDialogOpen} + /> ); }; diff --git a/src/features/events/components/EventChangeCampaignDialog.tsx b/src/features/events/components/EventChangeCampaignDialog.tsx new file mode 100644 index 0000000000..03f797ab9f --- /dev/null +++ b/src/features/events/components/EventChangeCampaignDialog.tsx @@ -0,0 +1,193 @@ +import { Architecture, Close, Search } from '@mui/icons-material'; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + IconButton, + List, + ListItem, + TextField, + useMediaQuery, +} from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import { useContext, useState } from 'react'; +import { useRouter } from 'next/router'; + +import theme from 'theme'; +import { useMessages } from 'core/i18n'; +import ZUISnackbarContext from 'zui/ZUISnackbarContext'; +import useCampaigns from 'features/campaigns/hooks/useCampaigns'; +import { ZetkinCampaign, ZetkinEvent } from 'utils/types/zetkin'; +import useEventMutations from '../hooks/useEventMutations'; +import messageIds from 'features/events/l10n/messageIds'; + +interface EventActionButtonsProps { + event: ZetkinEvent; + isOpen: boolean; + close: () => void; +} + +const useStyles = makeStyles(() => ({ + list: { + listStyle: 'none', + }, + listItem: {}, +})); + +const EventMoveDialog: React.FunctionComponent = ({ + event, + isOpen, + close, +}) => { + const classes = useStyles(); + const messages = useMessages(messageIds); + + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + + const router = useRouter(); + const { showSnackbar } = useContext(ZUISnackbarContext); + const { changeEventCampaign } = useEventMutations( + event.organization.id, + event.id + ); + + const [campaignFilter, setCampaignFilter] = useState(''); + const [isLoadingCampaign, setIsLoadingCampaign] = useState(0); + + const { data: campaigns } = useCampaigns(event.organization.id); + campaigns?.reverse(); + + const filteredCampaigns = campaigns + ?.filter((campaign) => + campaign.title.toLowerCase().includes(campaignFilter) + ) + .filter((campaign) => campaign.id !== event.campaign?.id); + + const onSearchChange = (value: string) => { + setCampaignFilter(value); + }; + + const handleMove = async (campaign: ZetkinCampaign) => { + setIsLoadingCampaign(campaign.id); + + changeEventCampaign(campaign.id); + + showSnackbar( + 'success', + messages.eventChangeCampaign.changedMessage({ + campaignTitle: campaign.title, + }) + ); + + router.push( + `/organize/${campaign.organization.id}/projects/${campaign.id}/events/${event.id}` + ); + + handleClose(); + }; + + const handleClose = () => { + setIsLoadingCampaign(0); + setCampaignFilter(''); + close(); + }; + + return ( + { + close(); + }} + open={isOpen} + > + + + + {messages.eventChangeCampaign.dialogTitle()} + + + + + + + + + , + }} + onChange={(ev) => onSearchChange(ev.target.value)} + value={campaignFilter} + variant="outlined" + /> + + + + {Array.isArray(filteredCampaigns) && + filteredCampaigns.map((campaign) => { + return ( + + + + + + + {campaign.title} + + + {!isLoadingCampaign && ( + + )} + {isLoadingCampaign === campaign.id && ( + + )} + + + + ); + })} + + + + + + ); +}; + +export default EventMoveDialog; diff --git a/src/features/events/hooks/useEventMutations.ts b/src/features/events/hooks/useEventMutations.ts index 0f6f8f9337..f8ec22c170 100644 --- a/src/features/events/hooks/useEventMutations.ts +++ b/src/features/events/hooks/useEventMutations.ts @@ -22,6 +22,7 @@ export type ZetkinEventPostBody = ZetkinEventPatchBody; type useEventMutationsReturn = { cancelEvent: () => void; + changeEventCampaign: (campaignId: number) => void; deleteEvent: () => void; publishEvent: () => void; restoreEvent: () => void; @@ -62,6 +63,12 @@ export default function useEventMutations( }); }; + const changeEventCampaign = (campaignId: number) => { + updateEvent({ + campaign_id: campaignId, + }); + }; + const setPublished = (published: string | null) => { updateEvent({ cancelled: null, @@ -90,6 +97,7 @@ export default function useEventMutations( return { cancelEvent, + changeEventCampaign, deleteEvent, publishEvent, restoreEvent, diff --git a/src/features/events/l10n/messageIds.ts b/src/features/events/l10n/messageIds.ts index f23924b64f..df739a242c 100644 --- a/src/features/events/l10n/messageIds.ts +++ b/src/features/events/l10n/messageIds.ts @@ -19,6 +19,7 @@ export default makeMessages('feat.events', { cancel: m('Cancel'), delete: m('Delete'), duplicate: m('Duplicate'), + move: m('Move'), publish: m('Publish'), restore: m('Restore'), unpublish: m('Unpublish'), @@ -30,6 +31,12 @@ export default makeMessages('feat.events', { '"{eventTitle}" will be restored.' ), }, + eventChangeCampaign: { + changedMessage: m<{ campaignTitle: string }>( + 'Event moved to {campaignTitle}' + ), + dialogTitle: m('Move event'), + }, eventContactCard: { header: m('Contact'), noContact: m('No Contact Assigned!'), From d12a0e8ae091eea42f40f9fa17ea8b37a8be3c19 Mon Sep 17 00:00:00 2001 From: awarn Date: Sat, 14 Sep 2024 23:40:46 +0200 Subject: [PATCH 250/369] Improve event campaign change error handling --- .../components/EventChangeCampaignDialog.tsx | 51 ++++++++++++------- src/features/events/l10n/messageIds.ts | 5 +- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/features/events/components/EventChangeCampaignDialog.tsx b/src/features/events/components/EventChangeCampaignDialog.tsx index 03f797ab9f..01fc87356d 100644 --- a/src/features/events/components/EventChangeCampaignDialog.tsx +++ b/src/features/events/components/EventChangeCampaignDialog.tsx @@ -1,5 +1,6 @@ import { Architecture, Close, Search } from '@mui/icons-material'; import { + Alert, Box, Button, CircularProgress, @@ -17,7 +18,7 @@ import { useContext, useState } from 'react'; import { useRouter } from 'next/router'; import theme from 'theme'; -import { useMessages } from 'core/i18n'; +import { Msg, useMessages } from 'core/i18n'; import ZUISnackbarContext from 'zui/ZUISnackbarContext'; import useCampaigns from 'features/campaigns/hooks/useCampaigns'; import { ZetkinCampaign, ZetkinEvent } from 'utils/types/zetkin'; @@ -44,16 +45,17 @@ const EventMoveDialog: React.FunctionComponent = ({ }) => { const classes = useStyles(); const messages = useMessages(messageIds); - - const fullScreen = useMediaQuery(theme.breakpoints.down('md')); - const router = useRouter(); const { showSnackbar } = useContext(ZUISnackbarContext); + const [error, setError] = useState(false); + const { changeEventCampaign } = useEventMutations( event.organization.id, event.id ); + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + const [campaignFilter, setCampaignFilter] = useState(''); const [isLoadingCampaign, setIsLoadingCampaign] = useState(0); @@ -73,20 +75,25 @@ const EventMoveDialog: React.FunctionComponent = ({ const handleMove = async (campaign: ZetkinCampaign) => { setIsLoadingCampaign(campaign.id); - changeEventCampaign(campaign.id); - - showSnackbar( - 'success', - messages.eventChangeCampaign.changedMessage({ - campaignTitle: campaign.title, - }) - ); - - router.push( - `/organize/${campaign.organization.id}/projects/${campaign.id}/events/${event.id}` - ); - - handleClose(); + try { + changeEventCampaign(campaign.id); + + showSnackbar( + 'success', + messages.eventChangeCampaign.success({ + campaignTitle: campaign.title, + }) + ); + + router.push( + `/organize/${campaign.organization.id}/projects/${campaign.id}/events/${event.id}` + ); + + handleClose(); + } catch (error) { + setIsLoadingCampaign(0); + setError(true); + } }; const handleClose = () => { @@ -127,7 +134,7 @@ const EventMoveDialog: React.FunctionComponent = ({ - + = ({ variant="outlined" /> + {error && ( + + + + )} + ( - 'Event moved to {campaignTitle}' - ), dialogTitle: m('Move event'), + error: m('Error: Could not move the event to the selected project'), + success: m<{ campaignTitle: string }>('Event moved to "{campaignTitle}"'), }, eventContactCard: { header: m('Contact'), From bc662364569c8aefcdc983f6b4e8f1f1c3b7db1d Mon Sep 17 00:00:00 2001 From: awarn Date: Sun, 15 Sep 2024 08:32:12 +0200 Subject: [PATCH 251/369] Fix event change campaign dialog naming issues --- .../components/EventChangeCampaignDialog.tsx | 16 +++++++--------- src/features/events/l10n/messageIds.ts | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/features/events/components/EventChangeCampaignDialog.tsx b/src/features/events/components/EventChangeCampaignDialog.tsx index 01fc87356d..3e6448936d 100644 --- a/src/features/events/components/EventChangeCampaignDialog.tsx +++ b/src/features/events/components/EventChangeCampaignDialog.tsx @@ -38,11 +38,9 @@ const useStyles = makeStyles(() => ({ listItem: {}, })); -const EventMoveDialog: React.FunctionComponent = ({ - event, - isOpen, - close, -}) => { +const EventChangeCampaignDialog: React.FunctionComponent< + EventActionButtonsProps +> = ({ event, isOpen, close }) => { const classes = useStyles(); const messages = useMessages(messageIds); const router = useRouter(); @@ -80,7 +78,7 @@ const EventMoveDialog: React.FunctionComponent = ({ showSnackbar( 'success', - messages.eventChangeCampaign.success({ + messages.eventChangeCampaignDialog.success({ campaignTitle: campaign.title, }) ); @@ -121,7 +119,7 @@ const EventMoveDialog: React.FunctionComponent = ({ > - {messages.eventChangeCampaign.dialogTitle()} + {messages.eventChangeCampaignDialog.dialogTitle()} @@ -148,7 +146,7 @@ const EventMoveDialog: React.FunctionComponent = ({ {error && ( - + )} @@ -203,4 +201,4 @@ const EventMoveDialog: React.FunctionComponent = ({ ); }; -export default EventMoveDialog; +export default EventChangeCampaignDialog; diff --git a/src/features/events/l10n/messageIds.ts b/src/features/events/l10n/messageIds.ts index 5e01048a7d..829b56c815 100644 --- a/src/features/events/l10n/messageIds.ts +++ b/src/features/events/l10n/messageIds.ts @@ -31,7 +31,7 @@ export default makeMessages('feat.events', { '"{eventTitle}" will be restored.' ), }, - eventChangeCampaign: { + eventChangeCampaignDialog: { dialogTitle: m('Move event'), error: m('Error: Could not move the event to the selected project'), success: m<{ campaignTitle: string }>('Event moved to "{campaignTitle}"'), From 8a44594ccea8c1394febd6752ef0a2f9a8ee32b3 Mon Sep 17 00:00:00 2001 From: awarn Date: Sun, 15 Sep 2024 08:34:46 +0200 Subject: [PATCH 252/369] Fix style and text of event change campaign dialog button --- src/features/events/components/EventChangeCampaignDialog.tsx | 3 +-- src/features/events/l10n/messageIds.ts | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/events/components/EventChangeCampaignDialog.tsx b/src/features/events/components/EventChangeCampaignDialog.tsx index 3e6448936d..e190cb2add 100644 --- a/src/features/events/components/EventChangeCampaignDialog.tsx +++ b/src/features/events/components/EventChangeCampaignDialog.tsx @@ -178,11 +178,10 @@ const EventChangeCampaignDialog: React.FunctionComponent< {!isLoadingCampaign && ( )} {isLoadingCampaign === campaign.id && ( diff --git a/src/features/events/l10n/messageIds.ts b/src/features/events/l10n/messageIds.ts index 829b56c815..c99b07c0a5 100644 --- a/src/features/events/l10n/messageIds.ts +++ b/src/features/events/l10n/messageIds.ts @@ -34,6 +34,7 @@ export default makeMessages('feat.events', { eventChangeCampaignDialog: { dialogTitle: m('Move event'), error: m('Error: Could not move the event to the selected project'), + moveButtonLabel: m('Move'), success: m<{ campaignTitle: string }>('Event moved to "{campaignTitle}"'), }, eventContactCard: { From c69955f4b54a723d1ee934e5e2fb4f4b95c9cbac Mon Sep 17 00:00:00 2001 From: awarn Date: Sun, 15 Sep 2024 08:38:32 +0200 Subject: [PATCH 253/369] Handle event change campaign as a promise --- .../events/components/EventChangeCampaignDialog.tsx | 2 +- src/features/events/hooks/useEventMutations.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/features/events/components/EventChangeCampaignDialog.tsx b/src/features/events/components/EventChangeCampaignDialog.tsx index e190cb2add..62c8316632 100644 --- a/src/features/events/components/EventChangeCampaignDialog.tsx +++ b/src/features/events/components/EventChangeCampaignDialog.tsx @@ -74,7 +74,7 @@ const EventChangeCampaignDialog: React.FunctionComponent< setIsLoadingCampaign(campaign.id); try { - changeEventCampaign(campaign.id); + await changeEventCampaign(campaign.id); showSnackbar( 'success', diff --git a/src/features/events/hooks/useEventMutations.ts b/src/features/events/hooks/useEventMutations.ts index f8ec22c170..c20d3baa8c 100644 --- a/src/features/events/hooks/useEventMutations.ts +++ b/src/features/events/hooks/useEventMutations.ts @@ -22,14 +22,14 @@ export type ZetkinEventPostBody = ZetkinEventPatchBody; type useEventMutationsReturn = { cancelEvent: () => void; - changeEventCampaign: (campaignId: number) => void; + changeEventCampaign: (campaignId: number) => Promise; deleteEvent: () => void; publishEvent: () => void; restoreEvent: () => void; setPublished: (published: string | null) => void; setTitle: (title: string) => void; setType: (id: number | null) => void; - updateEvent: (data: ZetkinEventPatchBody) => void; + updateEvent: (data: ZetkinEventPatchBody) => Promise; }; export default function useEventMutations( @@ -64,7 +64,7 @@ export default function useEventMutations( }; const changeEventCampaign = (campaignId: number) => { - updateEvent({ + return updateEvent({ campaign_id: campaignId, }); }; @@ -88,7 +88,7 @@ export default function useEventMutations( const updateEvent = (data: ZetkinEventPatchBody) => { dispatch(eventUpdate([eventId, Object.keys(data)])); - apiClient + return apiClient .patch(`/api/orgs/${orgId}/actions/${eventId}`, data) .then((event) => { dispatch(eventUpdated(event)); From ebf19e2f50fb3b7d83e8ffba0d8c14427231dafb Mon Sep 17 00:00:00 2001 From: awarn Date: Sun, 15 Sep 2024 08:41:20 +0200 Subject: [PATCH 254/369] Simplify null check of campaign list --- .../components/EventChangeCampaignDialog.tsx | 65 +++++++++---------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/src/features/events/components/EventChangeCampaignDialog.tsx b/src/features/events/components/EventChangeCampaignDialog.tsx index 62c8316632..8d676f69fb 100644 --- a/src/features/events/components/EventChangeCampaignDialog.tsx +++ b/src/features/events/components/EventChangeCampaignDialog.tsx @@ -156,42 +156,41 @@ const EventChangeCampaignDialog: React.FunctionComponent< }} > - {Array.isArray(filteredCampaigns) && - filteredCampaigns.map((campaign) => { - return ( - { + return ( + + - - - - - - {campaign.title} - - - {!isLoadingCampaign && ( - - )} - {isLoadingCampaign === campaign.id && ( - - )} + + + + {campaign.title} + + + {!isLoadingCampaign && ( + + )} + {isLoadingCampaign === campaign.id && ( + + )} - - ); - })} + + + ); + })} From c86e39b54932265681cbfac3773a7a112d116781 Mon Sep 17 00:00:00 2001 From: awarn Date: Sun, 15 Sep 2024 08:49:11 +0200 Subject: [PATCH 255/369] Fix a naming issue in event change campaign dialog --- src/features/events/components/EventChangeCampaignDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/events/components/EventChangeCampaignDialog.tsx b/src/features/events/components/EventChangeCampaignDialog.tsx index 8d676f69fb..268ebf50c7 100644 --- a/src/features/events/components/EventChangeCampaignDialog.tsx +++ b/src/features/events/components/EventChangeCampaignDialog.tsx @@ -135,7 +135,7 @@ const EventChangeCampaignDialog: React.FunctionComponent< , }} @@ -159,7 +159,7 @@ const EventChangeCampaignDialog: React.FunctionComponent< {filteredCampaigns?.map((campaign) => { return ( Date: Mon, 16 Sep 2024 08:35:25 +0200 Subject: [PATCH 256/369] add Edit modal step in PlaceDialog and implemente it in PublicAreaMap --- src/features/areas/components/PlaceDialog.tsx | 112 ++++++++++++++++-- .../areas/components/PublicAreaMap.tsx | 7 +- src/features/areas/l10n/messageIds.ts | 5 + 3 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/features/areas/components/PlaceDialog.tsx b/src/features/areas/components/PlaceDialog.tsx index 0596e4dc58..984b49d762 100644 --- a/src/features/areas/components/PlaceDialog.tsx +++ b/src/features/areas/components/PlaceDialog.tsx @@ -4,19 +4,25 @@ import { Button, Dialog, Divider, + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, TextField, Typography, } from '@mui/material'; import { ZetkinPlace } from '../types'; -import { Msg } from 'core/i18n'; +import { Msg, useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; import usePlaceMutations from '../hooks/usePlaceMutations'; import ZUIDateTime from 'zui/ZUIDateTime'; type PlaceDialogProps = { - dialogStep: 'place' | 'log'; + dialogStep: 'place' | 'log' | 'edit'; onClose: () => void; + onEdit: () => void; onLogCancel: () => void; onLogSave: () => void; onLogStart: () => void; @@ -28,6 +34,7 @@ type PlaceDialogProps = { const PlaceDialog: FC = ({ dialogStep, onClose, + onEdit, onLogCancel, onLogSave, onLogStart, @@ -36,8 +43,18 @@ const PlaceDialog: FC = ({ place, }) => { const updatePlace = usePlaceMutations(orgId, place.id); - const [note, setNote] = useState(''); + const messages = useMessages(messageIds); const timestamp = new Date().toISOString(); + const [note, setNote] = useState(''); + const [description, setDescription] = useState( + place.description ?? '' + ); + const [title, setTitle] = useState(place.title ?? ''); + const [type, setType] = useState<'address' | 'misc'>(place.type); + + const handleChange = (event: SelectChangeEvent) => { + setType(event.target.value as 'address' | 'misc'); + }; const sortedVisits = place.visits.toSorted((a, b) => { const dateA = new Date(a.timestamp); @@ -54,13 +71,77 @@ const PlaceDialog: FC = ({ return ( - - - {place?.title || } - + + {dialogStep !== 'edit' && ( + <> + + {place?.title || } + + {dialogStep === 'place' && ( + + )} + + )} + {dialogStep === 'edit' && ( + + + + )} + {dialogStep === 'edit' && ( + + setTitle(ev.target.value)} + /> + + + + + + + setDescription(ev.target.value)} + rows={5} + /> + + )} {place && dialogStep == 'place' && ( = ({ - - ); -}; - interface CanvassAssignmentPageProps { orgId: string; - campId: string; canvassAssId: string; } const CanvassAssignmentPage: PageWithLayout = ({ orgId, - campId, canvassAssId, }) => { const [personId, setPersonId] = useState(null); - const addIndividualCanvassAss = useAddIndividualCanvassAssignment( - parseInt(orgId), - parseInt(campId), - canvassAssId - ); - - const updateIndividualCanvassAss = useIndividualCanvassAssignmentMutations( - parseInt(orgId), - parseInt(campId), - canvassAssId - ); + const addAssignee = useAddAssignee(parseInt(orgId), canvassAssId); const canvassAssignmentFuture = useCanvassAssignment( parseInt(orgId), - parseInt(campId), canvassAssId ); - const individualCanvassAssignmentsFuture = useIndividualCanvassAssignments( - parseInt(orgId), - parseInt(campId), - canvassAssId - ); + const assigneesFuture = useAssignees(parseInt(orgId), canvassAssId); return ( - {({ data: { canvassAssignment, individualCanvassAssignments } }) => { + {({ data: { canvassAssignment, assignees } }) => { return ( {canvassAssignment.title} @@ -108,26 +66,19 @@ const CanvassAssignmentPage: PageWithLayout = ({ + Ids of people that have been added - {individualCanvassAssignments.map((individualCanvassAss) => ( - - updateIndividualCanvassAss(individualCanvassAss.personId, { - areaUrl: url, - }) - } - /> + {assignees.map((assignee) => ( + {assignee.id} ))} diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index e7018b552e..ba667c89dc 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -5,8 +5,8 @@ export default function mockState(overrides?: RootState) { const emptyState: RootState = { areas: { areaList: remoteList(), + assigneeByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), - individualAssignmentsByCanvassAssignmentId: {}, myAssignmentsList: remoteList(), placeList: remoteList(), }, From 45e54cacd3df74e2f843df8f490621715ffad336 Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:04:18 +0200 Subject: [PATCH 258/369] create getMarkerValues utility function and implement it --- .../areas/components/PublicAreaMap.tsx | 45 +++++++------------ src/features/areas/utils/getMarkerValues.tsx | 20 +++++++++ 2 files changed, 35 insertions(+), 30 deletions(-) create mode 100644 src/features/areas/utils/getMarkerValues.tsx diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 6b9e449e49..73ffa7a6a9 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -25,6 +25,7 @@ import { DivIconMarker } from 'features/events/components/LocationModal/DivIconM import PlaceDialog from './PlaceDialog'; import useCreatePlace from '../hooks/useCreatePlace'; import usePlaces from '../hooks/usePlaces'; +import getMarkerValues from '../utils/getMarkerValues'; const useStyles = makeStyles((theme) => ({ '@keyframes ghostMarkerBounce': { @@ -117,18 +118,12 @@ const PublicAreaMap: FC = ({ area }) => { const crosshair = crosshairRef.current; if (map && crosshair) { - const mapContainer = map.getContainer(); - const markerRect = crosshair.getBoundingClientRect(); - const mapRect = mapContainer.getBoundingClientRect(); - const x = markerRect.x - mapRect.x; - const y = markerRect.y - mapRect.y; - const markerX = x + 0.5 * markerRect.width; - const markerY = y + 0.5 * markerRect.height; + const markers = getMarkerValues(map, crosshair); places.forEach((place) => { const screenPos = map.latLngToContainerPoint(place.position); - const dx = screenPos.x - markerX; - const dy = screenPos.y - markerY; + const dx = screenPos.x - markers.markerX; + const dy = screenPos.y - markers.markerY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < nearestDistance) { @@ -151,17 +146,12 @@ const PublicAreaMap: FC = ({ area }) => { (pos: LatLng) => { const crosshair = crosshairRef.current; if (crosshair && map) { - const mapContainer = map.getContainer(); - const mapRect = mapContainer.getBoundingClientRect(); - const markerRect = crosshair.getBoundingClientRect(); - const x = markerRect.x - mapRect.x; - const y = markerRect.y - mapRect.y; - const markerPoint: [number, number] = [ - x + 0.5 * markerRect.width, - y + 0.8 * markerRect.height, - ]; + const markers = getMarkerValues(map, crosshair); - const crosshairPos = map.containerPointToLatLng(markerPoint); + const crosshairPos = map.containerPointToLatLng([ + markers.markerX, + markers.markerY, + ]); const centerPos = map.getCenter(); const latOffset = centerPos.lat - crosshairPos.lat; const lngOffset = centerPos.lng - crosshairPos.lng; @@ -413,18 +403,13 @@ const PublicAreaMap: FC = ({ area }) => { }} onCreate={(title, type) => { const crosshair = crosshairRef.current; - const mapContainer = map?.getContainer(); - if (crosshair && mapContainer) { - const mapRect = mapContainer.getBoundingClientRect(); - const markerRect = crosshair.getBoundingClientRect(); - const x = markerRect.x - mapRect.x; - const y = markerRect.y - mapRect.y; - const markerPoint: [number, number] = [ - x + 0.5 * markerRect.width, - y + 0.8 * markerRect.height, - ]; + if (crosshair && map) { + const markers = getMarkerValues(map, crosshair); - const point = map?.containerPointToLatLng(markerPoint); + const point = map?.containerPointToLatLng([ + markers.markerX, + markers.markerY, + ]); if (point) { createPlace({ position: point, diff --git a/src/features/areas/utils/getMarkerValues.tsx b/src/features/areas/utils/getMarkerValues.tsx new file mode 100644 index 0000000000..7b47a5dafb --- /dev/null +++ b/src/features/areas/utils/getMarkerValues.tsx @@ -0,0 +1,20 @@ +import { Map } from 'leaflet'; + +type Marker = { + markerX: number; + markerY: number; +}; + +const getMarkerValues = (map: Map, crosshair: HTMLDivElement): Marker => { + const mapContainer = map.getContainer(); + const markerRect = crosshair.getBoundingClientRect(); + const mapRect = mapContainer.getBoundingClientRect(); + const x = markerRect.x - mapRect.x; + const y = markerRect.y - mapRect.y; + const markerX = x + 0.5 * markerRect.width; + const markerY = y + 0.5 * markerRect.height; + + return { markerX, markerY }; +}; + +export default getMarkerValues; From 1ff9804baf27fd3425dbb6c81724ea978649f741 Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Mon, 16 Sep 2024 10:19:21 +0200 Subject: [PATCH 259/369] add svg marker icon in utils folder and implemente it in PublicAreaMap --- .../areas/components/PublicAreaMap.tsx | 52 ++----------------- src/features/areas/utils/markerIcon.tsx | 24 +++++++++ 2 files changed, 28 insertions(+), 48 deletions(-) create mode 100644 src/features/areas/utils/markerIcon.tsx diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index 73ffa7a6a9..ddcccd4918 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -26,6 +26,7 @@ import PlaceDialog from './PlaceDialog'; import useCreatePlace from '../hooks/useCreatePlace'; import usePlaces from '../hooks/usePlaces'; import getMarkerValues from '../utils/getMarkerValues'; +import MarkerIcon from '../utils/markerIcon'; const useStyles = makeStyles((theme) => ({ '@keyframes ghostMarkerBounce': { @@ -231,22 +232,7 @@ const PublicAreaMap: FC = ({ area }) => { transition: `opacity ${standingStill ? 0.8 : 0.2}s`, }} > - - - + )} {!selectedPlace && isCreating && ( @@ -257,22 +243,7 @@ const PublicAreaMap: FC = ({ area }) => { transition: `opacity 0.8s`, }} > - - - + )} @@ -348,22 +319,7 @@ const PublicAreaMap: FC = ({ area }) => { lng: place.position.lng, }} > - - - +
); })} diff --git a/src/features/areas/utils/markerIcon.tsx b/src/features/areas/utils/markerIcon.tsx new file mode 100644 index 0000000000..639465d2aa --- /dev/null +++ b/src/features/areas/utils/markerIcon.tsx @@ -0,0 +1,24 @@ +interface MarkerIconProps { + selected: boolean; +} + +const MarkerIcon: React.FC = ({ selected }) => ( + + + +); + +export default MarkerIcon; From 596b5c3852d3e8489512df63bd5ccb9db182380f Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 16 Sep 2024 10:18:55 +0200 Subject: [PATCH 260/369] Localise some strings. --- src/features/areas/l10n/messageIds.ts | 8 ++++++++ src/features/areas/layouts/CanvassAssignmentLayout.tsx | 5 ++++- .../campaigns/components/CampaignActionButtons.tsx | 4 ++-- src/features/campaigns/l10n/messageIds.ts | 1 + .../[campId]/canvassassignments/[canvassAssId]/index.tsx | 6 +++++- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 4809dc84b9..d823323cb9 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -2,6 +2,14 @@ import { m, makeMessages } from 'core/i18n'; export default makeMessages('feat.areas', { addNewPlaceButton: m('Add new place'), + canvassAssignment: { + empty: { + title: m('Untitled canvass assignment'), + }, + tabs: { + overview: m('Overview'), + }, + }, empty: { description: m('Empty description'), title: m('Untitled area'), diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/areas/layouts/CanvassAssignmentLayout.tsx index 620a15563e..b1f54aef5b 100644 --- a/src/features/areas/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/areas/layouts/CanvassAssignmentLayout.tsx @@ -1,6 +1,8 @@ import { FC, ReactNode } from 'react'; +import { useMessages } from 'core/i18n'; import TabbedLayout from 'utils/layout/TabbedLayout'; +import messageIds from '../l10n/messageIds'; type CanvassAssignmentLayoutProps = { campId: number; @@ -15,11 +17,12 @@ const CanvassAssignmentLayout: FC = ({ campId, canvassAssId, }) => { + const messages = useMessages(messageIds); return ( {children} diff --git a/src/features/campaigns/components/CampaignActionButtons.tsx b/src/features/campaigns/components/CampaignActionButtons.tsx index 236bcc403e..6a15926032 100644 --- a/src/features/campaigns/components/CampaignActionButtons.tsx +++ b/src/features/campaigns/components/CampaignActionButtons.tsx @@ -89,11 +89,11 @@ const CampaignActionButtons: React.FunctionComponent< const menuItems = [ { icon: , - label: 'Create canvass assignment', + label: messages.linkGroup.createCanvassAssignment(), onClick: () => createCanvassAssignment({ campId: campaign.id, - title: 'Untitled Canvass Assignment', + title: null, }), }, { diff --git a/src/features/campaigns/l10n/messageIds.ts b/src/features/campaigns/l10n/messageIds.ts index 762fede54b..866eaab703 100644 --- a/src/features/campaigns/l10n/messageIds.ts +++ b/src/features/campaigns/l10n/messageIds.ts @@ -129,6 +129,7 @@ export default makeMessages('feat.campaigns', { linkGroup: { createActivity: m('Create activity'), createCallAssignment: m('Create call assignment'), + createCanvassAssignment: m('Create canvass assignment'), createEmail: m('Create email'), createEvent: m('Create event'), createSurvey: m('Create survey'), diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 7af7bb7e52..5ea780498a 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -9,6 +9,8 @@ import { PageWithLayout } from 'utils/types'; import useAddAssignee from 'features/areas/hooks/useAddAssignee'; import useAssignees from 'features/areas/hooks/useAssignees'; import ZUIFutures from 'zui/ZUIFutures'; +import { Msg } from 'core/i18n'; +import messageIds from 'features/areas/l10n/messageIds'; const scaffoldOptions = { authLevelRequired: 2, @@ -50,7 +52,9 @@ const CanvassAssignmentPage: PageWithLayout = ({ {({ data: { canvassAssignment, assignees } }) => { return ( - {canvassAssignment.title} + {canvassAssignment.title || ( + + )} Add a person Id Date: Mon, 16 Sep 2024 10:30:58 +0200 Subject: [PATCH 261/369] Show title in header. --- src/features/areas/layouts/CanvassAssignmentLayout.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/areas/layouts/CanvassAssignmentLayout.tsx index b1f54aef5b..0b712f64fd 100644 --- a/src/features/areas/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/areas/layouts/CanvassAssignmentLayout.tsx @@ -3,6 +3,7 @@ import { FC, ReactNode } from 'react'; import { useMessages } from 'core/i18n'; import TabbedLayout from 'utils/layout/TabbedLayout'; import messageIds from '../l10n/messageIds'; +import useCanvassAssignment from '../hooks/useCanvassAssignment'; type CanvassAssignmentLayoutProps = { campId: number; @@ -18,11 +19,20 @@ const CanvassAssignmentLayout: FC = ({ canvassAssId, }) => { const messages = useMessages(messageIds); + const canvassAssignment = useCanvassAssignment(orgId, canvassAssId).data; + + if (!canvassAssignment) { + return null; + } + return ( {children} From 86de8b453fdfb2bcc18b41e66a6e3a90bd219ea3 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 16 Sep 2024 10:34:15 +0200 Subject: [PATCH 262/369] Add breadcrumb --- src/features/breadcrumbs/l10n/messageIds.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/breadcrumbs/l10n/messageIds.ts b/src/features/breadcrumbs/l10n/messageIds.ts index bbd7c83363..81a5b88171 100644 --- a/src/features/breadcrumbs/l10n/messageIds.ts +++ b/src/features/breadcrumbs/l10n/messageIds.ts @@ -10,6 +10,7 @@ export default makeMessages('feat.breadcrumbs', { callassignments: m('Call assignments'), callers: m('Callers'), campaigns: m('Projects'), + canvassassignments: m('Canvass assignments'), closed: m('Closed'), compose: m('Compose'), conversation: m('Conversation'), From 24eb604f157e31dbf6c7fc572a20b992b2bfc434 Mon Sep 17 00:00:00 2001 From: awarn Date: Mon, 16 Sep 2024 10:53:47 +0200 Subject: [PATCH 263/369] Fix wrongly named component import --- src/features/events/components/EventActionButtons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/events/components/EventActionButtons.tsx b/src/features/events/components/EventActionButtons.tsx index 124b339142..4d5d961b32 100644 --- a/src/features/events/components/EventActionButtons.tsx +++ b/src/features/events/components/EventActionButtons.tsx @@ -18,7 +18,7 @@ import { ZetkinEvent } from 'utils/types/zetkin'; import { ZUIConfirmDialogContext } from 'zui/ZUIConfirmDialogProvider'; import ZUIDatePicker from 'zui/ZUIDatePicker'; import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; -import EventMoveDialog from './EventChangeCampaignDialog'; +import EventChangeCampaignDialog from './EventChangeCampaignDialog'; interface EventActionButtonsProps { event: ZetkinEvent; @@ -150,7 +150,7 @@ const EventActionButtons: React.FunctionComponent = ({ - setIsMoveDialogOpen(false)} event={event} isOpen={isMoveDialogOpen} From 3d6767b4319289fbf49c9fc8731abe7d7e250338 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 16 Sep 2024 10:58:50 +0200 Subject: [PATCH 264/369] Use ZUIPersonSelect to add assignee. --- src/features/areas/l10n/messageIds.ts | 1 + .../[canvassAssId]/index.tsx | 50 +++++++------------ src/zui/l10n/messageIds.ts | 3 ++ 3 files changed, 22 insertions(+), 32 deletions(-) diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index d823323cb9..0f8f383cdb 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -3,6 +3,7 @@ import { m, makeMessages } from 'core/i18n'; export default makeMessages('feat.areas', { addNewPlaceButton: m('Add new place'), canvassAssignment: { + addAssignee: m('Add assignee'), empty: { title: m('Untitled canvass assignment'), }, diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 5ea780498a..4a5e4afa95 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,6 +1,5 @@ +import { Box } from '@mui/material'; import { GetServerSideProps } from 'next'; -import { useState } from 'react'; -import { Box, Button, TextField, Typography } from '@mui/material'; import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; @@ -9,8 +8,10 @@ import { PageWithLayout } from 'utils/types'; import useAddAssignee from 'features/areas/hooks/useAddAssignee'; import useAssignees from 'features/areas/hooks/useAssignees'; import ZUIFutures from 'zui/ZUIFutures'; -import { Msg } from 'core/i18n'; +import { useMessages } from 'core/i18n'; import messageIds from 'features/areas/l10n/messageIds'; +import zuiMessageIds from 'zui/l10n/messageIds'; +import { MUIOnlyPersonSelect as ZUIPersonSelect } from 'zui/ZUIPersonSelect'; const scaffoldOptions = { authLevelRequired: 2, @@ -32,10 +33,10 @@ const CanvassAssignmentPage: PageWithLayout = ({ orgId, canvassAssId, }) => { - const [personId, setPersonId] = useState(null); + const zuiMessages = useMessages(zuiMessageIds); + const messages = useMessages(messageIds); const addAssignee = useAddAssignee(parseInt(orgId), canvassAssId); - const canvassAssignmentFuture = useCanvassAssignment( parseInt(orgId), canvassAssId @@ -52,33 +53,18 @@ const CanvassAssignmentPage: PageWithLayout = ({ {({ data: { canvassAssignment, assignees } }) => { return ( - {canvassAssignment.title || ( - - )} - - Add a person Id - { - const value = ev.target.value; - if (value) { - setPersonId(parseInt(value)); - } - }} - type="number" - value={personId} - /> - - - + addAssignee(person.id)} + placeholder={messages.canvassAssignment.addAssignee()} + selectedPerson={null} + submitLabel={zuiMessages.createPerson.submitLabel.assign()} + title={zuiMessages.createPerson.title.assignToCanvassAssignment({ + canvassAss: + canvassAssignment.title || + messages.canvassAssignment.empty.title(), + })} + variant="outlined" + /> Ids of people that have been added {assignees.map((assignee) => ( diff --git a/src/zui/l10n/messageIds.ts b/src/zui/l10n/messageIds.ts index 50567b5f3a..10dc8333ed 100644 --- a/src/zui/l10n/messageIds.ts +++ b/src/zui/l10n/messageIds.ts @@ -47,6 +47,9 @@ export default makeMessages('zui', { 'Create person and add to {journey}' ), addToList: m<{ list: string }>('Create person and add to {list}'), + assignToCanvassAssignment: m<{ canvassAss: string }>( + 'Create person and assign to {canvassAss}' + ), assignToJourney: m<{ journey: string }>( 'Create person and assign to {journey}' ), From 91d40b63c3c7197546538f423d4495bef37704e9 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Mon, 16 Sep 2024 11:07:33 +0200 Subject: [PATCH 265/369] List people. --- .../[canvassAssId]/index.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 4a5e4afa95..33f1d71abc 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,4 +1,5 @@ import { Box } from '@mui/material'; +import { FC } from 'react'; import { GetServerSideProps } from 'next'; import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; @@ -12,6 +13,8 @@ import { useMessages } from 'core/i18n'; import messageIds from 'features/areas/l10n/messageIds'; import zuiMessageIds from 'zui/l10n/messageIds'; import { MUIOnlyPersonSelect as ZUIPersonSelect } from 'zui/ZUIPersonSelect'; +import ZUIPerson from 'zui/ZUIPerson'; +import usePerson from 'features/profile/hooks/usePerson'; const scaffoldOptions = { authLevelRequired: 2, @@ -24,6 +27,16 @@ export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { }; }, scaffoldOptions); +const Assignee: FC<{ id: number; orgId: number }> = ({ id, orgId }) => { + const person = usePerson(orgId, id).data; + if (!person) { + return null; + } + return ( + + ); +}; + interface CanvassAssignmentPageProps { orgId: string; canvassAssId: string; @@ -66,9 +79,13 @@ const CanvassAssignmentPage: PageWithLayout = ({ variant="outlined" /> - Ids of people that have been added + Assignees {assignees.map((assignee) => ( - {assignee.id} + ))} From e09f762cc9c55a1740452d33ff1b37029f6307f0 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 16 Sep 2024 11:40:48 +0200 Subject: [PATCH 266/369] Include theme_id when duplicating email --- src/features/emails/rpc/copyEmail.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/emails/rpc/copyEmail.ts b/src/features/emails/rpc/copyEmail.ts index 364413fbfa..2985fdc2f2 100644 --- a/src/features/emails/rpc/copyEmail.ts +++ b/src/features/emails/rpc/copyEmail.ts @@ -36,6 +36,7 @@ async function handle(params: Params, apiClient: IApiClient) { campaign_id: email.campaign?.id || null, content: email.content, subject: email.subject, + theme_id: email.theme?.id || null, title: email.title, } ); From bd2a4c46f4a072e275b196d9c84389312145e8e5 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Mon, 16 Sep 2024 11:43:52 +0200 Subject: [PATCH 267/369] Use locked targets as target count after an email has been sent --- .../campaigns/components/ActivityList/items/EmailListItem.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/campaigns/components/ActivityList/items/EmailListItem.tsx b/src/features/campaigns/components/ActivityList/items/EmailListItem.tsx index 3137d22a06..85504d08f6 100644 --- a/src/features/campaigns/components/ActivityList/items/EmailListItem.tsx +++ b/src/features/campaigns/components/ActivityList/items/EmailListItem.tsx @@ -28,6 +28,7 @@ const EmailListItem: FC = ({ orgId, emailId }) => { numOpened, numSent, isLoading: statsLoading, + numLockedTargets, numBlocked, } = useEmailStats(orgId, emailId); const endNumber = numTargetMatches - numBlocked.any ?? 0; @@ -43,7 +44,7 @@ const EmailListItem: FC = ({ orgId, emailId }) => { Date: Mon, 16 Sep 2024 12:46:29 +0200 Subject: [PATCH 268/369] rename markers to markerPos, and utility function to getCrosshairPositionOnMap() --- .../areas/components/PublicAreaMap.tsx | 20 +++++++++---------- ...lues.tsx => getCrosshairPositionOnMap.tsx} | 7 +++++-- 2 files changed, 15 insertions(+), 12 deletions(-) rename src/features/areas/utils/{getMarkerValues.tsx => getCrosshairPositionOnMap.tsx} (77%) diff --git a/src/features/areas/components/PublicAreaMap.tsx b/src/features/areas/components/PublicAreaMap.tsx index ddcccd4918..c014d5d9fc 100644 --- a/src/features/areas/components/PublicAreaMap.tsx +++ b/src/features/areas/components/PublicAreaMap.tsx @@ -25,7 +25,7 @@ import { DivIconMarker } from 'features/events/components/LocationModal/DivIconM import PlaceDialog from './PlaceDialog'; import useCreatePlace from '../hooks/useCreatePlace'; import usePlaces from '../hooks/usePlaces'; -import getMarkerValues from '../utils/getMarkerValues'; +import getCrosshairPositionOnMap from '../utils/getCrosshairPositionOnMap'; import MarkerIcon from '../utils/markerIcon'; const useStyles = makeStyles((theme) => ({ @@ -119,12 +119,12 @@ const PublicAreaMap: FC = ({ area }) => { const crosshair = crosshairRef.current; if (map && crosshair) { - const markers = getMarkerValues(map, crosshair); + const markerPos = getCrosshairPositionOnMap(map, crosshair); places.forEach((place) => { const screenPos = map.latLngToContainerPoint(place.position); - const dx = screenPos.x - markers.markerX; - const dy = screenPos.y - markers.markerY; + const dx = screenPos.x - markerPos.markerX; + const dy = screenPos.y - markerPos.markerY; const dist = Math.sqrt(dx * dx + dy * dy); if (dist < nearestDistance) { @@ -147,11 +147,11 @@ const PublicAreaMap: FC = ({ area }) => { (pos: LatLng) => { const crosshair = crosshairRef.current; if (crosshair && map) { - const markers = getMarkerValues(map, crosshair); + const markerPos = getCrosshairPositionOnMap(map, crosshair); const crosshairPos = map.containerPointToLatLng([ - markers.markerX, - markers.markerY, + markerPos.markerX, + markerPos.markerY, ]); const centerPos = map.getCenter(); const latOffset = centerPos.lat - crosshairPos.lat; @@ -360,11 +360,11 @@ const PublicAreaMap: FC = ({ area }) => { onCreate={(title, type) => { const crosshair = crosshairRef.current; if (crosshair && map) { - const markers = getMarkerValues(map, crosshair); + const markerPos = getCrosshairPositionOnMap(map, crosshair); const point = map?.containerPointToLatLng([ - markers.markerX, - markers.markerY, + markerPos.markerX, + markerPos.markerY, ]); if (point) { createPlace({ diff --git a/src/features/areas/utils/getMarkerValues.tsx b/src/features/areas/utils/getCrosshairPositionOnMap.tsx similarity index 77% rename from src/features/areas/utils/getMarkerValues.tsx rename to src/features/areas/utils/getCrosshairPositionOnMap.tsx index 7b47a5dafb..1d494eacb6 100644 --- a/src/features/areas/utils/getMarkerValues.tsx +++ b/src/features/areas/utils/getCrosshairPositionOnMap.tsx @@ -5,7 +5,10 @@ type Marker = { markerY: number; }; -const getMarkerValues = (map: Map, crosshair: HTMLDivElement): Marker => { +const getCrosshairPositionOnMap = ( + map: Map, + crosshair: HTMLDivElement +): Marker => { const mapContainer = map.getContainer(); const markerRect = crosshair.getBoundingClientRect(); const mapRect = mapContainer.getBoundingClientRect(); @@ -17,4 +20,4 @@ const getMarkerValues = (map: Map, crosshair: HTMLDivElement): Marker => { return { markerX, markerY }; }; -export default getMarkerValues; +export default getCrosshairPositionOnMap; From 466aff554ce3bd1b1cc9fd40dae34bc0cc72b176 Mon Sep 17 00:00:00 2001 From: awarn Date: Mon, 16 Sep 2024 21:07:17 +0200 Subject: [PATCH 269/369] Fallback on translation for policy link --- .../components/surveyForm/SurveyPrivacyPolicy.tsx | 15 ++++++--------- src/features/surveys/l10n/messageIds.ts | 1 + 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx index a2f469725f..7b9bb74416 100644 --- a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx +++ b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx @@ -14,13 +14,17 @@ import SurveyContainer from './SurveyContainer'; import SurveyOption from './SurveyOption'; import SurveySubheading from './SurveySubheading'; import { ZetkinSurveyExtended } from 'utils/types/zetkin'; -import { Msg } from 'core/i18n'; +import { Msg, useMessages } from 'core/i18n'; export type SurveyPrivacyPolicyProps = { survey: ZetkinSurveyExtended; }; const SurveyPrivacyPolicy: FC = ({ survey }) => { + const messages = useMessages(messageIds); + const privacyUrl = + process.env.ZETKIN_PRIVACY_POLICY_LINK || messages.surveyForm.policy.link(); + return ( @@ -44,14 +48,7 @@ const SurveyPrivacyPolicy: FC = ({ survey }) => { /> - + diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 6680c05941..054c19c136 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -174,6 +174,7 @@ export default makeMessages('feat.surveys', { 'Something went wrong when submitting your answers. Please try again later.' ), policy: { + link: m('https://zetkin.org/privacy'), text: m('Click to read the full Zetkin Privacy Policy'), }, required: m('required'), From 6c9a29e8158b05f6afc9b96ca60649d7b2862cc2 Mon Sep 17 00:00:00 2001 From: Niklas Vanhainen Date: Tue, 17 Sep 2024 00:08:29 +0200 Subject: [PATCH 270/369] Only fetch pending duplicates. --- src/features/duplicates/hooks/useDuplicates.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/duplicates/hooks/useDuplicates.tsx b/src/features/duplicates/hooks/useDuplicates.tsx index 7655207e6e..209b1b30b2 100644 --- a/src/features/duplicates/hooks/useDuplicates.tsx +++ b/src/features/duplicates/hooks/useDuplicates.tsx @@ -12,6 +12,6 @@ export default function useDuplicates(orgId: number) { return loadListIfNecessary(duplicatesList, dispatch, { actionOnLoad: () => potentialDuplicatesLoad(), actionOnSuccess: (duplicates) => potentialDuplicatesLoaded(duplicates), - loader: () => apiClient.get(`/api/orgs/${orgId}/people/duplicates`), + loader: () => apiClient.get(`/api/orgs/${orgId}/people/duplicates?filter=status==pending`), }); } From d8e7a61549ad0aecce7fbb3022d9c3e6a44b2513 Mon Sep 17 00:00:00 2001 From: Niklas Vanhainen Date: Tue, 17 Sep 2024 00:19:21 +0200 Subject: [PATCH 271/369] Fix formatting --- src/features/duplicates/hooks/useDuplicates.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/duplicates/hooks/useDuplicates.tsx b/src/features/duplicates/hooks/useDuplicates.tsx index 209b1b30b2..4d92e14c0d 100644 --- a/src/features/duplicates/hooks/useDuplicates.tsx +++ b/src/features/duplicates/hooks/useDuplicates.tsx @@ -12,6 +12,9 @@ export default function useDuplicates(orgId: number) { return loadListIfNecessary(duplicatesList, dispatch, { actionOnLoad: () => potentialDuplicatesLoad(), actionOnSuccess: (duplicates) => potentialDuplicatesLoaded(duplicates), - loader: () => apiClient.get(`/api/orgs/${orgId}/people/duplicates?filter=status==pending`), + loader: () => + apiClient.get( + `/api/orgs/${orgId}/people/duplicates?filter=status==pending` + ), }); } From 7f3a82486fdce5930c65bbd60e54fa97770d5c44 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 17 Sep 2024 13:45:24 +0200 Subject: [PATCH 272/369] Rename property on store to plural. --- src/features/areas/hooks/useAssignees.ts | 2 +- src/features/areas/store.ts | 39 ++++++++++++------------ src/utils/testing/mocks/mockState.ts | 2 +- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/features/areas/hooks/useAssignees.ts b/src/features/areas/hooks/useAssignees.ts index 8d59b461af..e0f707fbda 100644 --- a/src/features/areas/hooks/useAssignees.ts +++ b/src/features/areas/hooks/useAssignees.ts @@ -7,7 +7,7 @@ export default function useAssignees(orgId: number, canvassAssId: string) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); const assigneeList = useAppSelector( - (state) => state.areas.assigneeByCanvassAssignmentId[canvassAssId] + (state) => state.areas.assigneesByCanvassAssignmentId[canvassAssId] ); return loadListIfNecessary(assigneeList, dispatch, { diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 3c1ce2eac4..a67039711d 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -16,7 +16,7 @@ import { export interface AreasStoreSlice { areaList: RemoteList; canvassAssignmentList: RemoteList; - assigneeByCanvassAssignmentId: Record< + assigneesByCanvassAssignmentId: Record< string, RemoteList >; @@ -26,7 +26,7 @@ export interface AreasStoreSlice { const initialState: AreasStoreSlice = { areaList: remoteList(), - assigneeByCanvassAssignmentId: {}, + assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), myAssignmentsList: remoteList(), placeList: remoteList(), @@ -95,11 +95,11 @@ const areasSlice = createSlice({ assigneeAdd: (state, action: PayloadAction<[string, number]>) => { const [canvassAssId, assigneeId] = action.payload; - if (!state.assigneeByCanvassAssignmentId[canvassAssId]) { - state.assigneeByCanvassAssignmentId[canvassAssId] = remoteList(); + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); } - state.assigneeByCanvassAssignmentId[canvassAssId].items.push( + state.assigneesByCanvassAssignmentId[canvassAssId].items.push( remoteItem(assigneeId, { isLoading: true }) ); }, @@ -109,12 +109,12 @@ const areasSlice = createSlice({ ) => { const [canvassAssId, assignee] = action.payload; - if (!state.assigneeByCanvassAssignmentId[canvassAssId]) { - state.assigneeByCanvassAssignmentId[canvassAssId] = remoteList(); + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); } - state.assigneeByCanvassAssignmentId[canvassAssId].items = - state.assigneeByCanvassAssignmentId[canvassAssId].items + state.assigneesByCanvassAssignmentId[canvassAssId].items = + state.assigneesByCanvassAssignmentId[canvassAssId].items .filter((item) => item.id != assignee.id) .concat([ remoteItem(assignee.canvassAssId, { @@ -128,11 +128,11 @@ const areasSlice = createSlice({ ) => { const [canvassAssId, assignee] = action.payload; - if (!state.assigneeByCanvassAssignmentId[canvassAssId]) { - state.assigneeByCanvassAssignmentId[canvassAssId] = remoteList(); + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); } - state.assigneeByCanvassAssignmentId[canvassAssId].items + state.assigneesByCanvassAssignmentId[canvassAssId].items .filter((item) => item.id == assignee.id) .concat([ remoteItem(assignee.canvassAssId, { @@ -143,11 +143,11 @@ const areasSlice = createSlice({ assigneesLoad: (state, action: PayloadAction) => { const canvassAssId = action.payload; - if (!state.assigneeByCanvassAssignmentId[canvassAssId]) { - state.assigneeByCanvassAssignmentId[canvassAssId] = remoteList(); + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); } - state.assigneeByCanvassAssignmentId[canvassAssId].isLoading = true; + state.assigneesByCanvassAssignmentId[canvassAssId].isLoading = true; }, assigneesLoaded: ( state, @@ -155,12 +155,13 @@ const areasSlice = createSlice({ ) => { const [canvassAssId, assignees] = action.payload; - if (!state.assigneeByCanvassAssignmentId[canvassAssId]) { - state.assigneeByCanvassAssignmentId[canvassAssId] = remoteList(); + if (!state.assigneesByCanvassAssignmentId[canvassAssId]) { + state.assigneesByCanvassAssignmentId[canvassAssId] = remoteList(); } - state.assigneeByCanvassAssignmentId[canvassAssId] = remoteList(assignees); - state.assigneeByCanvassAssignmentId[canvassAssId].loaded = + state.assigneesByCanvassAssignmentId[canvassAssId] = + remoteList(assignees); + state.assigneesByCanvassAssignmentId[canvassAssId].loaded = new Date().toISOString(); }, canvassAssignmentCreated: ( diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index ba667c89dc..9a7b502973 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -5,7 +5,7 @@ export default function mockState(overrides?: RootState) { const emptyState: RootState = { areas: { areaList: remoteList(), - assigneeByCanvassAssignmentId: {}, + assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), myAssignmentsList: remoteList(), placeList: remoteList(), From dc638d8cc6fb9be9e8ec5f799c85de235edb6627 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 17 Sep 2024 14:06:50 +0200 Subject: [PATCH 273/369] Fix bug where campId was not properly added to the model. --- src/features/areas/models.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 3f0fd1ad35..787678fdea 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -45,6 +45,7 @@ const placeSchema = new mongoose.Schema({ const canvassAssignmentSchema = new mongoose.Schema({ + campId: Number, orgId: { required: true, type: Number }, title: String, }); From 21109b5fd61e1c1486009e57224f681ee67fae7e Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 17 Sep 2024 14:53:21 +0200 Subject: [PATCH 274/369] In the UI, prevent adding already added assignee. --- .../[campId]/canvassassignments/[canvassAssId]/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 33f1d71abc..19a440b353 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -67,6 +67,9 @@ const CanvassAssignmentPage: PageWithLayout = ({ return ( + assignees.some((assignee) => assignee.id == option.id) + } onChange={(person) => addAssignee(person.id)} placeholder={messages.canvassAssignment.addAssignee()} selectedPerson={null} From 841b9a4d60afd87bf0d03243647614fcf9e2be2a Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 17 Sep 2024 14:56:16 +0200 Subject: [PATCH 275/369] Only show list of assignees if there are assignees. --- src/features/areas/l10n/messageIds.ts | 1 + .../[canvassAssId]/index.tsx | 28 +++++++++++-------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 0f8f383cdb..411b47a1ee 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -4,6 +4,7 @@ export default makeMessages('feat.areas', { addNewPlaceButton: m('Add new place'), canvassAssignment: { addAssignee: m('Add assignee'), + assigneesTitle: m('Assignees'), empty: { title: m('Untitled canvass assignment'), }, diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 19a440b353..2b70addb12 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { FC } from 'react'; import { GetServerSideProps } from 'next'; @@ -9,7 +9,7 @@ import { PageWithLayout } from 'utils/types'; import useAddAssignee from 'features/areas/hooks/useAddAssignee'; import useAssignees from 'features/areas/hooks/useAssignees'; import ZUIFutures from 'zui/ZUIFutures'; -import { useMessages } from 'core/i18n'; +import { Msg, useMessages } from 'core/i18n'; import messageIds from 'features/areas/l10n/messageIds'; import zuiMessageIds from 'zui/l10n/messageIds'; import { MUIOnlyPersonSelect as ZUIPersonSelect } from 'zui/ZUIPersonSelect'; @@ -81,16 +81,20 @@ const CanvassAssignmentPage: PageWithLayout = ({ })} variant="outlined" /> - - Assignees - {assignees.map((assignee) => ( - - ))} - + {assignees.length > 0 && ( + + + + + {assignees.map((assignee) => ( + + ))} + + )} ); }} From 26baefd8bdd39a8a5bdc76fbfe2352ced16d277b Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Tue, 17 Sep 2024 15:11:09 +0200 Subject: [PATCH 276/369] UI and hook to update title of canvass assignment. --- .../[canvassAssId]/route.ts | 36 +++++++++++++++++++ .../hooks/useCanvassAssignmentMutations.ts | 23 ++++++++++++ .../areas/layouts/CanvassAssignmentLayout.tsx | 13 ++++++- src/features/areas/store.ts | 11 ++++++ 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/features/areas/hooks/useCanvassAssignmentMutations.ts diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts index 054e037851..73a3604d08 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts @@ -42,3 +42,39 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { } ); } + +export async function PATCH(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const payload = await request.json(); + + const model = await CanvassAssignmentModel.findOneAndUpdate( + { _id: params.canvassAssId }, + { + title: payload.title, + }, + { new: true } + ); + + if (!model) { + return new NextResponse(null, { status: 404 }); + } + + return NextResponse.json({ + data: { + campId: model.campId, + id: model._id.toString(), + orgId, + title: model.title, + }, + }); + } + ); +} diff --git a/src/features/areas/hooks/useCanvassAssignmentMutations.ts b/src/features/areas/hooks/useCanvassAssignmentMutations.ts new file mode 100644 index 0000000000..047545a4fd --- /dev/null +++ b/src/features/areas/hooks/useCanvassAssignmentMutations.ts @@ -0,0 +1,23 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { + ZetkinCanvassAssignment, + ZetkinCanvassAssignmentPatchbody, +} from '../types'; +import { canvassAssignmentUpdated } from '../store'; + +export default function useCanvassAssignmentMutations( + orgId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (data: ZetkinCanvassAssignmentPatchbody) => { + const updated = await apiClient.patch< + ZetkinCanvassAssignment, + ZetkinCanvassAssignmentPatchbody + >(`/beta/orgs/${orgId}/canvassassignments/${canvassAssId}`, data); + + dispatch(canvassAssignmentUpdated(updated)); + }; +} diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/areas/layouts/CanvassAssignmentLayout.tsx index 0b712f64fd..ceaf6d9821 100644 --- a/src/features/areas/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/areas/layouts/CanvassAssignmentLayout.tsx @@ -4,6 +4,8 @@ import { useMessages } from 'core/i18n'; import TabbedLayout from 'utils/layout/TabbedLayout'; import messageIds from '../l10n/messageIds'; import useCanvassAssignment from '../hooks/useCanvassAssignment'; +import ZUIEditTextinPlace from 'zui/ZUIEditTextInPlace'; +import useCanvassAssignmentMutations from '../hooks/useCanvassAssignmentMutations'; type CanvassAssignmentLayoutProps = { campId: number; @@ -20,6 +22,10 @@ const CanvassAssignmentLayout: FC = ({ }) => { const messages = useMessages(messageIds); const canvassAssignment = useCanvassAssignment(orgId, canvassAssId).data; + const updateCanvassAssignment = useCanvassAssignmentMutations( + orgId, + canvassAssId + ); if (!canvassAssignment) { return null; @@ -31,7 +37,12 @@ const CanvassAssignmentLayout: FC = ({ defaultTab="/" tabs={[{ href: '/', label: messages.canvassAssignment.tabs.overview() }]} title={ - canvassAssignment.title || messages.canvassAssignment.empty.title() + updateCanvassAssignment({ title: newTitle })} + value={ + canvassAssignment.title || messages.canvassAssignment.empty.title() + } + /> } > {children} diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index a67039711d..11d74fb6f9 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -208,6 +208,16 @@ const areasSlice = createSlice({ item.isLoading = false; item.loaded = new Date().toISOString(); }, + canvassAssignmentUpdated: ( + state, + action: PayloadAction + ) => { + const assignment = action.payload; + const item = findOrAddItem(state.canvassAssignmentList, assignment.id); + + item.data = assignment; + item.loaded = new Date().toISOString(); + }, myAssignmentsLoad: (state) => { state.myAssignmentsList.isLoading = true; }, @@ -272,6 +282,7 @@ export const { canvassAssignmentCreated, canvassAssignmentLoad, canvassAssignmentLoaded, + canvassAssignmentUpdated, placeCreated, placesLoad, placesLoaded, From 8f8917b00239639d630b983ee2b438746dddd6e4 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 18 Sep 2024 11:12:32 +0200 Subject: [PATCH 277/369] Endpoint to GET canvass sessions. Co-authored-by: Richard Olsson --- .../[canvassAssId]/sessions/route.ts | 69 +++++++++++++++++++ src/features/areas/models.ts | 10 +++ src/features/areas/types.ts | 11 +++ src/utils/api/asOrgAuthorized.ts | 2 + 4 files changed, 92 insertions(+) create mode 100644 src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts new file mode 100644 index 0000000000..589aab43c4 --- /dev/null +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts @@ -0,0 +1,69 @@ +import mongoose from 'mongoose'; +import { NextRequest, NextResponse } from 'next/server'; + +import { AreaModel, CanvassAssignmentModel } from 'features/areas/models'; +import { ZetkinCanvassSession } from 'features/areas/types'; +import asOrgAuthorized from 'utils/api/asOrgAuthorized'; +import { ZetkinPerson } from 'utils/types/zetkin'; + +type RouteMeta = { + params: { + canvassAssId: string; + orgId: string; + }; +}; + +export async function GET(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async ({ apiClient, orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const model = await CanvassAssignmentModel.findOne({ + _id: params.canvassAssId, + }); + + if (!model) { + return new NextResponse(null, { status: 404 }); + } + + const sessions: ZetkinCanvassSession[] = []; + + for await (const sessionData of model.sessions) { + const person = await apiClient.get( + `/api/orgs/${orgId}/people/${sessionData.personId}` + ); + const area = await AreaModel.findOne({ + _id: sessionData.areaId, + }); + + if (area && person) { + sessions.push({ + area: { + description: area.description, + id: area._id.toString(), + organization: { + id: orgId, + }, + points: area.points, + title: area.title, + }, + assignee: person, + assignment: { + id: model._id.toString(), + title: model.title, + }, + }); + } + } + + return NextResponse.json({ + data: sessions, + }); + } + ); +} diff --git a/src/features/areas/models.ts b/src/features/areas/models.ts index 787678fdea..a5e3d15751 100644 --- a/src/features/areas/models.ts +++ b/src/features/areas/models.ts @@ -22,6 +22,10 @@ type ZetkinCanvassAssignmentModelType = { campId: number; id: number; orgId: number; + sessions: { + areaId: string; + personId: number; + }[]; title: string | null; }; @@ -47,6 +51,12 @@ const canvassAssignmentSchema = new mongoose.Schema({ campId: Number, orgId: { required: true, type: Number }, + sessions: [ + { + areaId: String, + personId: Number, + }, + ], title: String, }); diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index f8b32be654..ec0478c51e 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -1,3 +1,5 @@ +import { ZetkinPerson } from 'utils/types/zetkin'; + export type PointData = [number, number]; export type Visit = { @@ -33,6 +35,15 @@ export type ZetkinCanvassAssignment = { title: string | null; }; +export type ZetkinCanvassSession = { + area: ZetkinArea; + assignee: ZetkinPerson; + assignment: { + id: string; + title: string | null; + }; +}; + export type ZetkinAreaPostBody = Partial>; export type ZetkinPlacePostBody = Partial>; export type ZetkinPlacePatchBody = Omit & { diff --git a/src/utils/api/asOrgAuthorized.ts b/src/utils/api/asOrgAuthorized.ts index 5b9f8769cf..1913f9a657 100644 --- a/src/utils/api/asOrgAuthorized.ts +++ b/src/utils/api/asOrgAuthorized.ts @@ -6,6 +6,7 @@ import { ZetkinMembership } from 'utils/types/zetkin'; import { ApiClientError } from 'core/api/errors'; type GuardedFnProps = { + apiClient: BackendApiClient; orgId: number; role: string | null; }; @@ -40,6 +41,7 @@ export default async function asOrgAuthorized( } return fn({ + apiClient: apiClient, orgId: membership.organization.id, role: membership.role, }); From da0993daa0bb6731d43af09d9aa700998dca1b77 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 18 Sep 2024 11:28:01 +0200 Subject: [PATCH 278/369] Endpoint to POST a canvass session. made by @richardolsson :) --- .../[canvassAssId]/sessions/route.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts index 589aab43c4..3e4ad2d235 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts @@ -67,3 +67,63 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { } ); } + +export async function POST(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async ({ apiClient, orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const assignment = await CanvassAssignmentModel.findOne({ + _id: params.canvassAssId, + }); + + if (!assignment) { + return new NextResponse(null, { status: 404 }); + } + + const payload = await request.json(); + const sessionData = { + areaId: payload.areaId as string, + personId: payload.personId as number, + }; + + const person = await apiClient.get( + `/api/orgs/${orgId}/people/${sessionData.personId}` + ); + + const area = await AreaModel.findOne({ + _id: sessionData.areaId, + }); + + if (area && person) { + assignment.sessions.push(sessionData); + + await assignment.save(); + + return NextResponse.json({ + area: { + description: area.description, + id: area._id.toString(), + organization: { + id: orgId, + }, + points: area.points, + title: area.title, + }, + assignee: person, + assignment: { + id: assignment._id.toString(), + title: assignment.title, + }, + }); + } else { + return new NextResponse(null, { status: 404 }); + } + } + ); +} From fc756299404d1c4c74a09cc9e8bc71a34f9857a8 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 18 Sep 2024 12:25:07 +0200 Subject: [PATCH 279/369] First rough draft of UI for adding person + area as canvass session. --- .../[canvassAssId]/index.tsx | 149 ++++++++++-------- 1 file changed, 81 insertions(+), 68 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 2b70addb12..40125f829f 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,20 +1,15 @@ -import { Box, Typography } from '@mui/material'; -import { FC } from 'react'; +import { Box, Button } from '@mui/material'; +import { useEffect, useState } from 'react'; import { GetServerSideProps } from 'next'; -import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import useAddAssignee from 'features/areas/hooks/useAddAssignee'; -import useAssignees from 'features/areas/hooks/useAssignees'; -import ZUIFutures from 'zui/ZUIFutures'; -import { Msg, useMessages } from 'core/i18n'; -import messageIds from 'features/areas/l10n/messageIds'; -import zuiMessageIds from 'zui/l10n/messageIds'; import { MUIOnlyPersonSelect as ZUIPersonSelect } from 'zui/ZUIPersonSelect'; -import ZUIPerson from 'zui/ZUIPerson'; -import usePerson from 'features/profile/hooks/usePerson'; +import { ZetkinCanvassSession } from 'features/areas/types'; +import useAreas from 'features/areas/hooks/useAreas'; +import ZUIFuture from 'zui/ZUIFuture'; +import ZUIDialog from 'zui/ZUIDialog'; const scaffoldOptions = { authLevelRequired: 2, @@ -27,16 +22,6 @@ export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { }; }, scaffoldOptions); -const Assignee: FC<{ id: number; orgId: number }> = ({ id, orgId }) => { - const person = usePerson(orgId, id).data; - if (!person) { - return null; - } - return ( - - ); -}; - interface CanvassAssignmentPageProps { orgId: string; canvassAssId: string; @@ -46,59 +31,87 @@ const CanvassAssignmentPage: PageWithLayout = ({ orgId, canvassAssId, }) => { - const zuiMessages = useMessages(zuiMessageIds); - const messages = useMessages(messageIds); + const [sessions, setSessions] = useState(null); + const [selectedAreaId, setSelectedAreaId] = useState(null); + const [selectedPersonId, setSelectedPersonId] = useState(null); + const areasFuture = useAreas(parseInt(orgId)); + const [adding, setAdding] = useState(false); - const addAssignee = useAddAssignee(parseInt(orgId), canvassAssId); - const canvassAssignmentFuture = useCanvassAssignment( - parseInt(orgId), - canvassAssId - ); + async function loadSessions() { + const res = await fetch( + `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/sessions` + ); + const payload = await res.json(); + setSessions(payload.data as ZetkinCanvassSession[]); + } + + useEffect(() => { + loadSessions(); + }, []); - const assigneesFuture = useAssignees(parseInt(orgId), canvassAssId); return ( - - {({ data: { canvassAssignment, assignees } }) => { + + + {sessions?.map((session, index) => { return ( - - - assignees.some((assignee) => assignee.id == option.id) - } - onChange={(person) => addAssignee(person.id)} - placeholder={messages.canvassAssignment.addAssignee()} - selectedPerson={null} - submitLabel={zuiMessages.createPerson.submitLabel.assign()} - title={zuiMessages.createPerson.title.assignToCanvassAssignment({ - canvassAss: - canvassAssignment.title || - messages.canvassAssignment.empty.title(), - })} - variant="outlined" - /> - {assignees.length > 0 && ( - - - - - {assignees.map((assignee) => ( - - ))} - - )} + + {session.assignee.first_name} + {session.area.title || session.area.id} ); - }} - + })} + setAdding(false)} open={adding}> + + {(areas) => ( + <> + + { + setSelectedPersonId(person.id); + }} + selectedPerson={null} + /> + + + + + + + )} + + + ); }; From bed424d5f508459a4e1313f8e3a1c17a488def4f Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 18 Sep 2024 13:21:34 +0200 Subject: [PATCH 280/369] Add hook to create canvass session and save in the store. --- .../areas/hooks/useCreateCanvassSession.ts | 19 ++++++++++++ src/features/areas/store.ts | 16 ++++++++++ src/features/areas/types.ts | 5 ++++ .../[canvassAssId]/index.tsx | 29 +++++++++---------- src/utils/testing/mocks/mockState.ts | 1 + 5 files changed, 55 insertions(+), 15 deletions(-) create mode 100644 src/features/areas/hooks/useCreateCanvassSession.ts diff --git a/src/features/areas/hooks/useCreateCanvassSession.ts b/src/features/areas/hooks/useCreateCanvassSession.ts new file mode 100644 index 0000000000..411dd691de --- /dev/null +++ b/src/features/areas/hooks/useCreateCanvassSession.ts @@ -0,0 +1,19 @@ +import { useApiClient, useAppDispatch } from 'core/hooks'; +import { ZetkinCanvassSession, ZetkinCanvassSessionPostBody } from '../types'; +import { canvassSessionCreated } from '../store'; + +export default function useCreateCanvassSession( + orgId: number, + canvassAssId: string +) { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + + return async (data: ZetkinCanvassSessionPostBody) => { + const created = await apiClient.post< + ZetkinCanvassSession, + ZetkinCanvassSessionPostBody + >(`/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/sessions`, data); + dispatch(canvassSessionCreated(created)); + }; +} diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 11d74fb6f9..fe8d0ba6e7 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -10,12 +10,14 @@ import { ZetkinArea, ZetkinCanvassAssignee, ZetkinCanvassAssignment, + ZetkinCanvassSession, ZetkinPlace, } from './types'; export interface AreasStoreSlice { areaList: RemoteList; canvassAssignmentList: RemoteList; + canvassSessionList: RemoteList; assigneesByCanvassAssignmentId: Record< string, RemoteList @@ -28,6 +30,7 @@ const initialState: AreasStoreSlice = { areaList: remoteList(), assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), + canvassSessionList: remoteList(), myAssignmentsList: remoteList(), placeList: remoteList(), }; @@ -218,6 +221,18 @@ const areasSlice = createSlice({ item.data = assignment; item.loaded = new Date().toISOString(); }, + canvassSessionCreated: ( + state, + action: PayloadAction + ) => { + const session = action.payload; + const item = remoteItem(session.assignment.id, { + data: { ...session, id: session.assignment.id }, + loaded: new Date().toISOString(), + }); + + state.canvassSessionList.items.push(item); + }, myAssignmentsLoad: (state) => { state.myAssignmentsList.isLoading = true; }, @@ -283,6 +298,7 @@ export const { canvassAssignmentLoad, canvassAssignmentLoaded, canvassAssignmentUpdated, + canvassSessionCreated, placeCreated, placesLoad, placesLoaded, diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index ec0478c51e..435bc6dc32 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -44,6 +44,11 @@ export type ZetkinCanvassSession = { }; }; +export type ZetkinCanvassSessionPostBody = { + areaId: string; + personId: number; +}; + export type ZetkinAreaPostBody = Partial>; export type ZetkinPlacePostBody = Partial>; export type ZetkinPlacePatchBody = Omit & { diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 40125f829f..f8cb2a00eb 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -10,6 +10,7 @@ import { ZetkinCanvassSession } from 'features/areas/types'; import useAreas from 'features/areas/hooks/useAreas'; import ZUIFuture from 'zui/ZUIFuture'; import ZUIDialog from 'zui/ZUIDialog'; +import useCreateCanvassSession from 'features/areas/hooks/useCreateCanvassSession'; const scaffoldOptions = { authLevelRequired: 2, @@ -37,6 +38,11 @@ const CanvassAssignmentPage: PageWithLayout = ({ const areasFuture = useAreas(parseInt(orgId)); const [adding, setAdding] = useState(false); + const createCanvassSession = useCreateCanvassSession( + parseInt(orgId), + canvassAssId + ); + async function loadSessions() { const res = await fetch( `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/sessions` @@ -87,22 +93,15 @@ const CanvassAssignmentPage: PageWithLayout = ({ - {sessions?.map((session, index) => { + {sessions.map((session, index) => { return ( {session.assignee.first_name} @@ -99,7 +91,6 @@ const CanvassAssignmentPage: PageWithLayout = ({ personId: selectedPersonId, }); - loadSessions(); setAdding(false); } }} From 21d347b4b7704806ce3e2e703855855d9d16f51d Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 18 Sep 2024 14:27:04 +0200 Subject: [PATCH 282/369] List people with the amount of assigned sessions. Co-authored-by: Richard Olsson --- .../[canvassAssId]/index.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 8a77cd23d1..a1a8385cbc 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -11,6 +11,9 @@ import ZUIFuture from 'zui/ZUIFuture'; import ZUIDialog from 'zui/ZUIDialog'; import useCreateCanvassSession from 'features/areas/hooks/useCreateCanvassSession'; import useCanvassSessions from 'features/areas/hooks/useCanvassSessions'; +import ZUIPerson from 'zui/ZUIPerson'; +import { ZetkinCanvassSession } from 'features/areas/types'; +import { ZetkinPerson } from 'utils/types/zetkin'; const scaffoldOptions = { authLevelRequired: 2, @@ -46,15 +49,36 @@ const CanvassAssignmentPage: PageWithLayout = ({ const sessions = allSessions.filter( (session) => session.assignment.id === canvassAssId ); + const sessionByPersonId: Record< + number, + { + person: ZetkinPerson; + sessions: ZetkinCanvassSession[]; + } + > = {}; + + sessions.forEach((session) => { + if (!sessionByPersonId[session.assignee.id]) { + sessionByPersonId[session.assignee.id] = { + person: session.assignee, + sessions: [session], + }; + } else { + sessionByPersonId[session.assignee.id].sessions.push(session); + } + }); return ( - {sessions.map((session, index) => { + {Object.values(sessionByPersonId).map(({ sessions, person }, index) => { return ( - - {session.assignee.first_name} - {session.area.title || session.area.id} + + + {sessions.length} ); })} From 6c4835d9112f4d921fea6c5130cc8021388e1811 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 18 Sep 2024 15:46:12 +0200 Subject: [PATCH 283/369] Create plan tab and show map with areas. --- src/features/areas/components/PlanMap.tsx | 147 ++++++++++++++++++ .../areas/components/PlanMapRenderer.tsx | 83 ++++++++++ .../areas/layouts/CanvassAssignmentLayout.tsx | 6 +- src/features/breadcrumbs/l10n/messageIds.ts | 1 + .../[canvassAssId]/plan.tsx | 54 +++++++ 5 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/features/areas/components/PlanMap.tsx create mode 100644 src/features/areas/components/PlanMapRenderer.tsx create mode 100644 src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx diff --git a/src/features/areas/components/PlanMap.tsx b/src/features/areas/components/PlanMap.tsx new file mode 100644 index 0000000000..88352f3106 --- /dev/null +++ b/src/features/areas/components/PlanMap.tsx @@ -0,0 +1,147 @@ +import { FC, useRef, useState } from 'react'; +import { Map as MapType } from 'leaflet'; +import { MapContainer } from 'react-leaflet'; +import { + Autocomplete, + Box, + Button, + ButtonGroup, + MenuItem, + TextField, +} from '@mui/material'; +import { Add, Remove } from '@mui/icons-material'; + +import { ZetkinArea } from '../types'; +import { useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; +import AreaOverlay from './AreaOverlay'; +import PlanMapRenderer from './PlanMapRenderer'; + +type PlanMapProps = { + areas: ZetkinArea[]; +}; + +const PlanMap: FC = ({ areas }) => { + const messages = useMessages(messageIds); + + const mapRef = useRef(null); + + const [selectedId, setSelectedId] = useState(''); + const [filterText, setFilterText] = useState(''); + const [editingArea, setEditingArea] = useState(null); + + const selectedArea = areas.find((area) => area.id == selectedId); + + function filterAreas(areas: ZetkinArea[], matchString: string) { + const inputValue = matchString.trim().toLowerCase(); + if (inputValue.length == 0) { + return areas.concat(); + } + + return areas.filter((area) => { + const areaTitle = area.title || messages.empty.title(); + const areaDesc = area.description || messages.empty.description(); + + return ( + areaTitle.toLowerCase().includes(inputValue) || + areaDesc.toLowerCase().includes(inputValue) + ); + }); + } + + return ( + + + + + + + + + + + filterAreas(options, state.inputValue) + } + getOptionLabel={(option) => option.id} + inputValue={filterText} + onChange={(ev, area) => { + if (area) { + setSelectedId(area.id); + setFilterText(''); + } + }} + onInputChange={(ev, value, reason) => { + if (reason == 'input') { + setFilterText(value); + } + }} + options={areas} + renderInput={(params) => ( + + )} + renderOption={(props, area) => ( + + {area.title || messages.empty.title()} + + )} + value={null} + /> + + + + {selectedArea && ( + setEditingArea(selectedArea)} + onCancelEdit={() => setEditingArea(null)} + onClose={() => setSelectedId('')} + /> + )} + + setSelectedId(newId)} + selectedId={selectedId} + /> + + + + ); +}; + +export default PlanMap; diff --git a/src/features/areas/components/PlanMapRenderer.tsx b/src/features/areas/components/PlanMapRenderer.tsx new file mode 100644 index 0000000000..8e5c81b2a9 --- /dev/null +++ b/src/features/areas/components/PlanMapRenderer.tsx @@ -0,0 +1,83 @@ +import { FC, useEffect, useRef, useState } from 'react'; +import { + AttributionControl, + FeatureGroup, + Polygon, + TileLayer, + useMapEvents, +} from 'react-leaflet'; +import { FeatureGroup as FeatureGroupType } from 'leaflet'; +import { useTheme } from '@mui/styles'; + +import { ZetkinArea } from '../types'; + +type PlanMapRendererProps = { + areas: ZetkinArea[]; + onSelectedIdChange: (newId: string) => void; + selectedId: string; +}; + +const PlanMapRenderer: FC = ({ + areas, + selectedId, + onSelectedIdChange, +}) => { + const theme = useTheme(); + const reactFGref = useRef(null); + + const [zoomed, setZoomed] = useState(false); + + const map = useMapEvents({ + zoom: () => { + setZoomed(true); + }, + }); + + useEffect(() => { + if (map && !zoomed) { + const bounds = reactFGref.current?.getBounds(); + if (bounds?.isValid()) { + map.fitBounds(bounds); + setZoomed(true); + } + } + }, [areas, map]); + return ( + <> + + + { + reactFGref.current = fgRef; + }} + > + {areas.map((area) => { + const selected = selectedId == area.id; + + // The key changes when selected, to force redraw of polygon + // to reflect new state through visual style + const key = area.id + (selected ? '-selected' : '-default'); + + return ( + { + onSelectedIdChange(area.id); + }, + }} + positions={area.points} + weight={selected ? 5 : 2} + /> + ); + })} + + + ); +}; + +export default PlanMapRenderer; diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/areas/layouts/CanvassAssignmentLayout.tsx index ceaf6d9821..569e02ff11 100644 --- a/src/features/areas/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/areas/layouts/CanvassAssignmentLayout.tsx @@ -35,7 +35,11 @@ const CanvassAssignmentLayout: FC = ({ updateCanvassAssignment({ title: newTitle })} diff --git a/src/features/breadcrumbs/l10n/messageIds.ts b/src/features/breadcrumbs/l10n/messageIds.ts index 81a5b88171..1585d6ed01 100644 --- a/src/features/breadcrumbs/l10n/messageIds.ts +++ b/src/features/breadcrumbs/l10n/messageIds.ts @@ -29,6 +29,7 @@ export default makeMessages('feat.breadcrumbs', { organize: m('Organize'), participants: m('Participants'), people: m('People'), + plan: m('Plan'), projects: m('Projects'), questions: m('Questions'), settings: m('Settings'), diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx new file mode 100644 index 0000000000..62e1e4641b --- /dev/null +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx @@ -0,0 +1,54 @@ +import 'leaflet/dist/leaflet.css'; +import { Box } from '@mui/material'; +import dynamic from 'next/dynamic'; +import { GetServerSideProps } from 'next'; + +import { scaffold } from 'utils/next'; +import { PageWithLayout } from 'utils/types'; +import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; +import useAreas from 'features/areas/hooks/useAreas'; +import useServerSide from 'core/useServerSide'; + +const PlanMap = dynamic( + () => import('../../../../../../../features/areas/components/PlanMap'), + { ssr: false } +); + +const scaffoldOptions = { + authLevelRequired: 2, +}; + +export const getServerSideProps: GetServerSideProps = scaffold(async (ctx) => { + const { orgId, campId, canvassAssId } = ctx.params!; + return { + props: { campId, canvassAssId, orgId }, + }; +}, scaffoldOptions); + +interface PlanPageProps { + orgId: string; + canvassAssId: string; +} + +const PlanPage: PageWithLayout = ({ orgId }) => { + const areas = useAreas(parseInt(orgId)).data || []; + + const isServer = useServerSide(); + if (isServer) { + return null; + } + + return ( + + + + ); +}; + +PlanPage.getLayout = function getLayout(page) { + return ( + {page} + ); +}; + +export default PlanPage; From 8e894fc4c7e8880c72ce23b880b599255196cc34 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 18 Sep 2024 17:08:46 +0200 Subject: [PATCH 284/369] Add XS size for ZUIAvatar --- src/zui/ZUIAvatar/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/zui/ZUIAvatar/index.tsx b/src/zui/ZUIAvatar/index.tsx index ae2dcedd3c..88d094bef5 100644 --- a/src/zui/ZUIAvatar/index.tsx +++ b/src/zui/ZUIAvatar/index.tsx @@ -2,13 +2,14 @@ import { Avatar } from '@mui/material'; interface ZUIAvatarProps { url: string; - size: 'sm' | 'md' | 'lg'; + size: 'xs' | 'sm' | 'md' | 'lg'; } const SIZES = { lg: 50, md: 40, sm: 30, + xs: 20, }; const ZUIAvatar: React.FC = ({ url, size }) => { From 7ee499bedba05ae5ff06ff9ebf4a0e715e97b2aa Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 18 Sep 2024 17:09:41 +0200 Subject: [PATCH 285/369] Render canvass sessions on plan map --- src/features/areas/components/PlanMap.tsx | 6 +- .../areas/components/PlanMapRenderer.tsx | 118 ++++++++++++++++-- .../[canvassAssId]/plan.tsx | 9 +- 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/src/features/areas/components/PlanMap.tsx b/src/features/areas/components/PlanMap.tsx index 88352f3106..80ebc4a96a 100644 --- a/src/features/areas/components/PlanMap.tsx +++ b/src/features/areas/components/PlanMap.tsx @@ -11,7 +11,7 @@ import { } from '@mui/material'; import { Add, Remove } from '@mui/icons-material'; -import { ZetkinArea } from '../types'; +import { ZetkinArea, ZetkinCanvassSession } from '../types'; import { useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; import AreaOverlay from './AreaOverlay'; @@ -19,9 +19,10 @@ import PlanMapRenderer from './PlanMapRenderer'; type PlanMapProps = { areas: ZetkinArea[]; + sessions: ZetkinCanvassSession[]; }; -const PlanMap: FC = ({ areas }) => { +const PlanMap: FC = ({ areas, sessions }) => { const messages = useMessages(messageIds); const mapRef = useRef(null); @@ -137,6 +138,7 @@ const PlanMap: FC = ({ areas }) => { areas={areas} onSelectedIdChange={(newId) => setSelectedId(newId)} selectedId={selectedId} + sessions={sessions} /> diff --git a/src/features/areas/components/PlanMapRenderer.tsx b/src/features/areas/components/PlanMapRenderer.tsx index 8e5c81b2a9..103bbfcdb6 100644 --- a/src/features/areas/components/PlanMapRenderer.tsx +++ b/src/features/areas/components/PlanMapRenderer.tsx @@ -8,27 +8,34 @@ import { } from 'react-leaflet'; import { FeatureGroup as FeatureGroupType } from 'leaflet'; import { useTheme } from '@mui/styles'; +import { Box } from '@mui/material'; -import { ZetkinArea } from '../types'; +import { ZetkinArea, ZetkinCanvassSession } from '../types'; +import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; +import ZUIAvatar from 'zui/ZUIAvatar'; type PlanMapRendererProps = { areas: ZetkinArea[]; onSelectedIdChange: (newId: string) => void; selectedId: string; + sessions: ZetkinCanvassSession[]; }; const PlanMapRenderer: FC = ({ areas, selectedId, + sessions, onSelectedIdChange, }) => { const theme = useTheme(); const reactFGref = useRef(null); const [zoomed, setZoomed] = useState(false); + const [zoom, setZoom] = useState(0); const map = useMapEvents({ zoom: () => { + setZoom(map.getZoom()); setZoomed(true); }, }); @@ -57,22 +64,107 @@ const PlanMapRenderer: FC = ({ {areas.map((area) => { const selected = selectedId == area.id; + const mid: [number, number] = [0, 0]; + if (area.points.length) { + area.points + .map((input) => { + if ('lat' in input && 'lng' in input) { + return [input.lat as number, input.lng as number]; + } else { + return input; + } + }) + .forEach((point) => { + mid[0] += point[0]; + mid[1] += point[1]; + }); + + mid[0] /= area.points.length; + mid[1] /= area.points.length; + } + + const detailed = zoom >= 15; + + const people = sessions + .filter((session) => session.area.id == area.id) + .map((session) => session.assignee); + + const hasPeople = !!people.length; + // The key changes when selected, to force redraw of polygon // to reflect new state through visual style - const key = area.id + (selected ? '-selected' : '-default'); + const key = + area.id + + (selected ? '-selected' : '-default') + + (hasPeople ? '-assigned' : ''); return ( - { - onSelectedIdChange(area.id); - }, - }} - positions={area.points} - weight={selected ? 5 : 2} - /> + <> + {hasPeople && ( + + {detailed && ( + + + {people.map((person) => ( + + = 16 ? 'sm' : 'xs'} + url={`/api/orgs/1/people/${person.id}/avatar`} + /> + + ))} + + + )} + {!detailed && ( + + {people.length} + + )} + + )} + { + onSelectedIdChange(area.id); + }, + }} + positions={area.points} + weight={selected ? 5 : 2} + /> + ); })} diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx index 62e1e4641b..02717983bf 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx @@ -8,6 +8,8 @@ import { PageWithLayout } from 'utils/types'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import useAreas from 'features/areas/hooks/useAreas'; import useServerSide from 'core/useServerSide'; +import useCanvassSessions from 'features/areas/hooks/useCanvassSessions'; +import ZUIFuture from 'zui/ZUIFuture'; const PlanMap = dynamic( () => import('../../../../../../../features/areas/components/PlanMap'), @@ -30,8 +32,9 @@ interface PlanPageProps { canvassAssId: string; } -const PlanPage: PageWithLayout = ({ orgId }) => { +const PlanPage: PageWithLayout = ({ canvassAssId, orgId }) => { const areas = useAreas(parseInt(orgId)).data || []; + const sessionsFuture = useCanvassSessions(parseInt(orgId), canvassAssId); const isServer = useServerSide(); if (isServer) { @@ -40,7 +43,9 @@ const PlanPage: PageWithLayout = ({ orgId }) => { return ( - + + {(sessions) => } + ); }; From 38a960d9940f6308cc0d2e32c24ac820c5e398e0 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 18 Sep 2024 17:28:12 +0200 Subject: [PATCH 286/369] Replace AreaOverlay with AreaPlanningOverlay on plan page --- .../areas/components/AreaPlanningOverlay.tsx | 63 +++++++++++++++++++ src/features/areas/components/PlanMap.tsx | 10 +-- 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 src/features/areas/components/AreaPlanningOverlay.tsx diff --git a/src/features/areas/components/AreaPlanningOverlay.tsx b/src/features/areas/components/AreaPlanningOverlay.tsx new file mode 100644 index 0000000000..0c75f7ab12 --- /dev/null +++ b/src/features/areas/components/AreaPlanningOverlay.tsx @@ -0,0 +1,63 @@ +import { FC } from 'react'; +import { Close } from '@mui/icons-material'; +import { Box, Divider, Paper, Typography } from '@mui/material'; + +import { ZetkinArea } from '../types'; +import { useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; + +type Props = { + area: ZetkinArea; + onClose: () => void; +}; + +const AreaPlanningOverlay: FC = ({ area, onClose }) => { + const messages = useMessages(messageIds); + + return ( + + + + + {area.title || messages.empty.title()} + + { + onClose(); + }} + sx={{ + cursor: 'pointer', + }} + /> + + + + {area.description?.trim().length + ? area.description + : messages.empty.description()} + + + + + + ); +}; + +export default AreaPlanningOverlay; diff --git a/src/features/areas/components/PlanMap.tsx b/src/features/areas/components/PlanMap.tsx index 80ebc4a96a..24be1c9a61 100644 --- a/src/features/areas/components/PlanMap.tsx +++ b/src/features/areas/components/PlanMap.tsx @@ -14,8 +14,8 @@ import { Add, Remove } from '@mui/icons-material'; import { ZetkinArea, ZetkinCanvassSession } from '../types'; import { useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; -import AreaOverlay from './AreaOverlay'; import PlanMapRenderer from './PlanMapRenderer'; +import AreaPlanningOverlay from './AreaPlanningOverlay'; type PlanMapProps = { areas: ZetkinArea[]; @@ -29,7 +29,6 @@ const PlanMap: FC = ({ areas, sessions }) => { const [selectedId, setSelectedId] = useState(''); const [filterText, setFilterText] = useState(''); - const [editingArea, setEditingArea] = useState(null); const selectedArea = areas.find((area) => area.id == selectedId); @@ -118,11 +117,8 @@ const PlanMap: FC = ({ areas, sessions }) => { {selectedArea && ( - setEditingArea(selectedArea)} - onCancelEdit={() => setEditingArea(null)} + setSelectedId('')} /> )} From d3246c82e11be5cae5c61ec60ddae4eb70c31858 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 18 Sep 2024 17:39:30 +0200 Subject: [PATCH 287/369] Tweak mouse interaction to properly select areas on plan map --- src/features/areas/components/PlanMap.tsx | 1 + src/features/areas/components/PlanMapRenderer.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/areas/components/PlanMap.tsx b/src/features/areas/components/PlanMap.tsx index 24be1c9a61..472f9e42dd 100644 --- a/src/features/areas/components/PlanMap.tsx +++ b/src/features/areas/components/PlanMap.tsx @@ -118,6 +118,7 @@ const PlanMap: FC = ({ areas, sessions }) => { {selectedArea && ( setSelectedId('')} /> diff --git a/src/features/areas/components/PlanMapRenderer.tsx b/src/features/areas/components/PlanMapRenderer.tsx index 103bbfcdb6..475f050bb1 100644 --- a/src/features/areas/components/PlanMapRenderer.tsx +++ b/src/features/areas/components/PlanMapRenderer.tsx @@ -103,7 +103,7 @@ const PlanMapRenderer: FC = ({ {hasPeople && ( {detailed && ( - + = ({ fontWeight: 'bold', height: 30, justifyContent: 'center', + pointerEvents: 'none', transform: 'translate(-50%, -50%)', width: 30, }} From 80390d219d61ed6bb94f2c2995f73452ff84fa15 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 18 Sep 2024 17:40:14 +0200 Subject: [PATCH 288/369] Render assignees in planning map overlay --- .../areas/components/AreaPlanningOverlay.tsx | 16 +++++++++++++++- src/features/areas/components/PlanMap.tsx | 3 +++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/features/areas/components/AreaPlanningOverlay.tsx b/src/features/areas/components/AreaPlanningOverlay.tsx index 0c75f7ab12..703e5f23a1 100644 --- a/src/features/areas/components/AreaPlanningOverlay.tsx +++ b/src/features/areas/components/AreaPlanningOverlay.tsx @@ -5,13 +5,16 @@ import { Box, Divider, Paper, Typography } from '@mui/material'; import { ZetkinArea } from '../types'; import { useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; +import { ZetkinPerson } from 'utils/types/zetkin'; +import ZUIPerson from 'zui/ZUIPerson'; type Props = { area: ZetkinArea; + assignees: ZetkinPerson[]; onClose: () => void; }; -const AreaPlanningOverlay: FC = ({ area, onClose }) => { +const AreaPlanningOverlay: FC = ({ area, assignees, onClose }) => { const messages = useMessages(messageIds); return ( @@ -56,6 +59,17 @@ const AreaPlanningOverlay: FC = ({ area, onClose }) => { + + Assignees + {assignees.map((assignee) => ( + + + + ))} + ); }; diff --git a/src/features/areas/components/PlanMap.tsx b/src/features/areas/components/PlanMap.tsx index 472f9e42dd..9698a7c0e3 100644 --- a/src/features/areas/components/PlanMap.tsx +++ b/src/features/areas/components/PlanMap.tsx @@ -120,6 +120,9 @@ const PlanMap: FC = ({ areas, sessions }) => { session.area.id == selectedArea.id) + .map((session) => session.assignee)} onClose={() => setSelectedId('')} /> )} From 09ae43fddacda25a733b3c2f0d8cd5633d7f5e48 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 18 Sep 2024 18:04:54 +0200 Subject: [PATCH 289/369] Fix problems in API route and store for canvass sessions --- .../[canvassAssId]/sessions/route.ts | 26 ++++++------ .../areas/hooks/useCanvassSessions.ts | 9 +++-- src/features/areas/store.ts | 40 ++++++++++++------- src/utils/testing/mocks/mockState.ts | 2 +- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts index 3e4ad2d235..04656ec2ad 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/sessions/route.ts @@ -106,19 +106,21 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { await assignment.save(); return NextResponse.json({ - area: { - description: area.description, - id: area._id.toString(), - organization: { - id: orgId, + data: { + area: { + description: area.description, + id: area._id.toString(), + organization: { + id: orgId, + }, + points: area.points, + title: area.title, + }, + assignee: person, + assignment: { + id: assignment._id.toString(), + title: assignment.title, }, - points: area.points, - title: area.title, - }, - assignee: person, - assignment: { - id: assignment._id.toString(), - title: assignment.title, }, }); } else { diff --git a/src/features/areas/hooks/useCanvassSessions.ts b/src/features/areas/hooks/useCanvassSessions.ts index 26fa7e6640..0bff66bdad 100644 --- a/src/features/areas/hooks/useCanvassSessions.ts +++ b/src/features/areas/hooks/useCanvassSessions.ts @@ -9,12 +9,15 @@ export default function useCanvassSessions( ) { const apiClient = useApiClient(); const dispatch = useAppDispatch(); - const sessions = useAppSelector((state) => state.areas.canvassSessionList); + const sessions = useAppSelector( + (state) => state.areas.sessionsByAssignmentId[canvassAssId] + ); return loadListIfNecessary(sessions, dispatch, { - actionOnLoad: () => dispatch(canvassSessionsLoad()), + actionOnLoad: () => dispatch(canvassSessionsLoad(canvassAssId)), - actionOnSuccess: (data) => dispatch(canvassSessionsLoaded(data)), + actionOnSuccess: (data) => + dispatch(canvassSessionsLoaded([canvassAssId, data])), loader: () => apiClient.get( `/beta/orgs/${orgId}/canvassassignments/${canvassAssId}/sessions` diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 3a74c2d6e7..8437eac97a 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -17,7 +17,10 @@ import { export interface AreasStoreSlice { areaList: RemoteList; canvassAssignmentList: RemoteList; - canvassSessionList: RemoteList; + sessionsByAssignmentId: Record< + string, + RemoteList + >; assigneesByCanvassAssignmentId: Record< string, RemoteList @@ -30,9 +33,9 @@ const initialState: AreasStoreSlice = { areaList: remoteList(), assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), - canvassSessionList: remoteList(), myAssignmentsList: remoteList(), placeList: remoteList(), + sessionsByAssignmentId: {}, }; const areasSlice = createSlice({ @@ -226,30 +229,37 @@ const areasSlice = createSlice({ action: PayloadAction ) => { const session = action.payload; + if (!state.sessionsByAssignmentId[session.assignment.id]) { + state.sessionsByAssignmentId[session.assignment.id] = remoteList(); + } const item = remoteItem(session.assignment.id, { - data: { ...session, id: session.assignment.id }, + data: { ...session, id: session.assignee.id }, loaded: new Date().toISOString(), }); - state.canvassSessionList.items.push(item); + state.sessionsByAssignmentId[session.assignment.id].items.push(item); }, - canvassSessionsLoad: (state) => { - state.canvassSessionList.isLoading = true; + canvassSessionsLoad: (state, action: PayloadAction) => { + const assignmentId = action.payload; + + if (!state.sessionsByAssignmentId[assignmentId]) { + state.sessionsByAssignmentId[assignmentId] = remoteList(); + } + + state.sessionsByAssignmentId[assignmentId].isLoading = true; }, canvassSessionsLoaded: ( state, - action: PayloadAction + action: PayloadAction<[string, ZetkinCanvassSession[]]> ) => { - const sessions = action.payload; - const timestamp = new Date().toISOString(); + const [assignmentId, sessions] = action.payload; - state.canvassSessionList = remoteList( - sessions.map((session) => ({ ...session, id: session.assignment.id })) - ); - state.canvassSessionList.loaded = timestamp; - state.canvassSessionList.items.forEach( - (item) => (item.loaded = timestamp) + state.sessionsByAssignmentId[assignmentId] = remoteList( + sessions.map((session) => ({ ...session, id: session.assignee.id })) ); + + state.sessionsByAssignmentId[assignmentId].loaded = + new Date().toISOString(); }, myAssignmentsLoad: (state) => { state.myAssignmentsList.isLoading = true; diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index f5b3f1e8f9..9b8c1ff471 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -7,9 +7,9 @@ export default function mockState(overrides?: RootState) { areaList: remoteList(), assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), - canvassSessionList: remoteList(), myAssignmentsList: remoteList(), placeList: remoteList(), + sessionsByAssignmentId: {}, }, breadcrumbs: { crumbsByPath: {}, From e9df0f974cf945bb0f6b791a61071576bef9d9aa Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 18 Sep 2024 18:05:41 +0200 Subject: [PATCH 290/369] Add UI for creating canvass sessions --- .../areas/components/AreaPlanningOverlay.tsx | 16 +++++++++++++++- src/features/areas/components/PlanMap.tsx | 11 ++++++++++- .../canvassassignments/[canvassAssId]/plan.tsx | 18 +++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/features/areas/components/AreaPlanningOverlay.tsx b/src/features/areas/components/AreaPlanningOverlay.tsx index 703e5f23a1..9a676ad165 100644 --- a/src/features/areas/components/AreaPlanningOverlay.tsx +++ b/src/features/areas/components/AreaPlanningOverlay.tsx @@ -7,14 +7,21 @@ import { useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; import { ZetkinPerson } from 'utils/types/zetkin'; import ZUIPerson from 'zui/ZUIPerson'; +import { MUIOnlyPersonSelect as ZUIPersonSelect } from 'zui/ZUIPersonSelect'; type Props = { area: ZetkinArea; assignees: ZetkinPerson[]; + onAddAssignee: (person: ZetkinPerson) => void; onClose: () => void; }; -const AreaPlanningOverlay: FC = ({ area, assignees, onClose }) => { +const AreaPlanningOverlay: FC = ({ + area, + assignees, + onAddAssignee, + onClose, +}) => { const messages = useMessages(messageIds); return ( @@ -69,6 +76,13 @@ const AreaPlanningOverlay: FC = ({ area, assignees, onClose }) => { /> ))} + Add assignee + ); diff --git a/src/features/areas/components/PlanMap.tsx b/src/features/areas/components/PlanMap.tsx index 9698a7c0e3..b3808d6e96 100644 --- a/src/features/areas/components/PlanMap.tsx +++ b/src/features/areas/components/PlanMap.tsx @@ -16,13 +16,19 @@ import { useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; import PlanMapRenderer from './PlanMapRenderer'; import AreaPlanningOverlay from './AreaPlanningOverlay'; +import { ZetkinPerson } from 'utils/types/zetkin'; type PlanMapProps = { areas: ZetkinArea[]; + onAddAssigneeToArea: (area: ZetkinArea, person: ZetkinPerson) => void; sessions: ZetkinCanvassSession[]; }; -const PlanMap: FC = ({ areas, sessions }) => { +const PlanMap: FC = ({ + areas, + onAddAssigneeToArea, + sessions, +}) => { const messages = useMessages(messageIds); const mapRef = useRef(null); @@ -123,6 +129,9 @@ const PlanMap: FC = ({ areas, sessions }) => { assignees={sessions .filter((session) => session.area.id == selectedArea.id) .map((session) => session.assignee)} + onAddAssignee={(person) => { + onAddAssigneeToArea(selectedArea, person); + }} onClose={() => setSelectedId('')} /> )} diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx index 02717983bf..4c0fa15012 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/plan.tsx @@ -10,6 +10,7 @@ import useAreas from 'features/areas/hooks/useAreas'; import useServerSide from 'core/useServerSide'; import useCanvassSessions from 'features/areas/hooks/useCanvassSessions'; import ZUIFuture from 'zui/ZUIFuture'; +import useCreateCanvassSession from 'features/areas/hooks/useCreateCanvassSession'; const PlanMap = dynamic( () => import('../../../../../../../features/areas/components/PlanMap'), @@ -35,6 +36,10 @@ interface PlanPageProps { const PlanPage: PageWithLayout = ({ canvassAssId, orgId }) => { const areas = useAreas(parseInt(orgId)).data || []; const sessionsFuture = useCanvassSessions(parseInt(orgId), canvassAssId); + const createCanvassSession = useCreateCanvassSession( + parseInt(orgId), + canvassAssId + ); const isServer = useServerSide(); if (isServer) { @@ -44,7 +49,18 @@ const PlanPage: PageWithLayout = ({ canvassAssId, orgId }) => { return ( - {(sessions) => } + {(sessions) => ( + { + createCanvassSession({ + areaId: area.id, + personId: person.id, + }); + }} + sessions={sessions} + /> + )} ); From 91edd32b9ab7d7768bcc64e1d030260cb6f74da4 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Wed, 18 Sep 2024 18:12:50 +0200 Subject: [PATCH 291/369] Internationalize and add empty state for assignees --- .../areas/components/AreaPlanningOverlay.tsx | 51 ++++++++++++------- src/features/areas/l10n/messageIds.ts | 5 ++ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/features/areas/components/AreaPlanningOverlay.tsx b/src/features/areas/components/AreaPlanningOverlay.tsx index 9a676ad165..b505184fb5 100644 --- a/src/features/areas/components/AreaPlanningOverlay.tsx +++ b/src/features/areas/components/AreaPlanningOverlay.tsx @@ -3,7 +3,7 @@ import { Close } from '@mui/icons-material'; import { Box, Divider, Paper, Typography } from '@mui/material'; import { ZetkinArea } from '../types'; -import { useMessages } from 'core/i18n'; +import { Msg, useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; import { ZetkinPerson } from 'utils/types/zetkin'; import ZUIPerson from 'zui/ZUIPerson'; @@ -67,22 +67,39 @@ const AreaPlanningOverlay: FC = ({ - Assignees - {assignees.map((assignee) => ( - - - - ))} - Add assignee - + + + + + {!assignees.length && ( + + + + )} + {assignees.map((assignee) => ( + + + + ))} + + + + + + + ); diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 411b47a1ee..8d59dd3383 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -38,6 +38,11 @@ export default makeMessages('feat.areas', { noActivity: m('No visits have been recorded at this place.'), saveButton: m('Save'), }, + planOverlay: { + addAssignee: m('Add assignee'), + assignees: m('Assignees'), + noAssignees: m('No assignees'), + }, tools: { cancel: m('Cancel'), draw: m('Draw'), From 98b157478fbb75cfc9e98021b967b94630269c11 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 06:54:34 +0200 Subject: [PATCH 292/369] Reshape responses from beta canvass API to mimic patterns from live API --- .../canvassassignments/[canvassAssId]/route.ts | 8 ++++---- .../beta/orgs/[orgId]/canvassassignments/route.ts | 6 +++--- src/core/store.ts | 2 +- src/features/areas/types.ts | 14 +++++++++++--- .../campaigns/components/CampaignActionButtons.tsx | 2 +- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts index 73a3604d08..073731dca9 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/[canvassAssId]/route.ts @@ -32,9 +32,9 @@ export async function GET(request: NextRequest, { params }: RouteMeta) { } const canvassAssignment: ZetkinCanvassAssignment = { - campId: canvassAssignmentModel.campId, + campaign: { id: canvassAssignmentModel.campId }, id: canvassAssignmentModel._id.toString(), - orgId: orgId, + organization: { id: orgId }, title: canvassAssignmentModel.title, }; @@ -69,9 +69,9 @@ export async function PATCH(request: NextRequest, { params }: RouteMeta) { return NextResponse.json({ data: { - campId: model.campId, + campaign: { id: model.campId }, id: model._id.toString(), - orgId, + organization: { id: orgId }, title: model.title, }, }); diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts index 85e91ac564..61e42aef84 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts @@ -23,7 +23,7 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { const payload = await request.json(); const model = new CanvassAssignmentModel({ - campId: payload.campId, + campId: payload.campaign_id, orgId: orgId, title: payload.title, }); @@ -32,9 +32,9 @@ export async function POST(request: NextRequest, { params }: RouteMeta) { return NextResponse.json({ data: { - campId: model.campId, + campaign: { id: model.campId }, id: model._id.toString(), - orgId: orgId, + organization: { id: orgId }, title: model.title, }, }); diff --git a/src/core/store.ts b/src/core/store.ts index 2fd2d65d84..7458f48391 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -109,7 +109,7 @@ listenerMiddleware.startListening({ effect: (action) => { const canvassAssignment = action.payload; Router.push( - `/organize/${canvassAssignment.orgId}/projects/${canvassAssignment.campId}/canvassassignments/${canvassAssignment.id}` + `/organize/${canvassAssignment.organization.id}/projects/${canvassAssignment.campaign.id}/canvassassignments/${canvassAssignment.id}` ); }, }); diff --git a/src/features/areas/types.ts b/src/features/areas/types.ts index 435bc6dc32..6f991f5e8d 100644 --- a/src/features/areas/types.ts +++ b/src/features/areas/types.ts @@ -29,9 +29,13 @@ export type ZetkinPlace = { }; export type ZetkinCanvassAssignment = { - campId: number; + campaign: { + id: number; + }; id: string; - orgId: number; + organization: { + id: number; + }; title: string | null; }; @@ -55,9 +59,13 @@ export type ZetkinPlacePatchBody = Omit & { visits?: Omit[]; }; export type ZetkinCanvassAssignmentPostBody = Partial< + Omit +> & { + campaign_id: number; +}; +export type ZetkinCanvassAssignmentPatchbody = Partial< Omit >; -export type ZetkinCanvassAssignmentPatchbody = ZetkinCanvassAssignmentPostBody; export type ZetkinCanvassAssignee = { canvassAssId: string; diff --git a/src/features/campaigns/components/CampaignActionButtons.tsx b/src/features/campaigns/components/CampaignActionButtons.tsx index 6a15926032..c19f0aadd7 100644 --- a/src/features/campaigns/components/CampaignActionButtons.tsx +++ b/src/features/campaigns/components/CampaignActionButtons.tsx @@ -92,7 +92,7 @@ const CampaignActionButtons: React.FunctionComponent< label: messages.linkGroup.createCanvassAssignment(), onClick: () => createCanvassAssignment({ - campId: campaign.id, + campaign_id: campaign.id, title: null, }), }, From 4a415bef442bf4fb92b0855ae42186ba6df3ae6b Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 06:58:56 +0200 Subject: [PATCH 293/369] Add API endpoint to retrieve all canvass assignments --- .../orgs/[orgId]/canvassassignments/route.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts index 61e42aef84..63a87d7091 100644 --- a/src/app/beta/orgs/[orgId]/canvassassignments/route.ts +++ b/src/app/beta/orgs/[orgId]/canvassassignments/route.ts @@ -10,6 +10,34 @@ type RouteMeta = { }; }; +export async function GET(request: NextRequest, { params }: RouteMeta) { + return asOrgAuthorized( + { + orgId: params.orgId, + request: request, + roles: ['admin'], + }, + async ({ orgId }) => { + await mongoose.connect(process.env.MONGODB_URL || ''); + + const assignments = CanvassAssignmentModel.find({ orgId: orgId }); + + return NextResponse.json({ + data: (await assignments).map((assignment) => ({ + campaign: { + id: assignment.campId, + }, + id: assignment._id.toString(), + organization: { + id: orgId, + }, + title: assignment.title, + })), + }); + } + ); +} + export async function POST(request: NextRequest, { params }: RouteMeta) { return asOrgAuthorized( { From 309a5ffb7c8ee34f9c0aabc013fa43e5e50dc546 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 07:00:53 +0200 Subject: [PATCH 294/369] Add hook and store logic for loading canvass assignments as activities --- .../hooks/useCanvassAssignmentActivities.ts | 50 +++++++++++++++++++ src/features/areas/store.ts | 12 +++++ .../campaigns/hooks/useClusteredActivities.ts | 2 + src/features/campaigns/types.ts | 8 +++ 4 files changed, 72 insertions(+) create mode 100644 src/features/areas/hooks/useCanvassAssignmentActivities.ts diff --git a/src/features/areas/hooks/useCanvassAssignmentActivities.ts b/src/features/areas/hooks/useCanvassAssignmentActivities.ts new file mode 100644 index 0000000000..ba7ded870e --- /dev/null +++ b/src/features/areas/hooks/useCanvassAssignmentActivities.ts @@ -0,0 +1,50 @@ +import { + ErrorFuture, + IFuture, + LoadingFuture, + ResolvedFuture, +} from 'core/caching/futures'; +import { ZetkinCanvassAssignment } from '../types'; +import { loadListIfNecessary } from 'core/caching/cacheUtils'; +import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; +import { canvassAssignmentsLoad, canvassAssignmentsLoaded } from '../store'; +import { ACTIVITIES, CampaignActivity } from 'features/campaigns/types'; + +export default function useCanvassAssignmentActivities( + orgId: number, + campId?: number +): IFuture { + const apiClient = useApiClient(); + const dispatch = useAppDispatch(); + const list = useAppSelector((state) => state.areas.canvassAssignmentList); + + const future = loadListIfNecessary(list, dispatch, { + actionOnLoad: () => canvassAssignmentsLoad(), + actionOnSuccess: (data) => canvassAssignmentsLoaded(data), + loader: () => + apiClient.get( + `/beta/orgs/${orgId}/canvassassignments` + ), + }); + + if (future.error) { + return new ErrorFuture(future.error); + } else if (future.data) { + const now = new Date(); + return new ResolvedFuture( + future.data + .filter((assignment) => { + // TODO: This should happen on server using separate API paths + return !campId || assignment.campaign.id == campId; + }) + .map((assignment) => ({ + data: assignment, + kind: ACTIVITIES.CANVASS_ASSIGNMENT, + visibleFrom: now, // TODO: Use data from API + visibleUntil: null, + })) + ); + } else { + return new LoadingFuture(); + } +} diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 8437eac97a..42a9b8e999 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -224,6 +224,16 @@ const areasSlice = createSlice({ item.data = assignment; item.loaded = new Date().toISOString(); }, + canvassAssignmentsLoad: (state) => { + state.canvassAssignmentList.isLoading = true; + }, + canvassAssignmentsLoaded: ( + state, + action: PayloadAction + ) => { + state.canvassAssignmentList = remoteList(action.payload); + state.canvassAssignmentList.loaded = new Date().toISOString(); + }, canvassSessionCreated: ( state, action: PayloadAction @@ -326,6 +336,8 @@ export const { canvassAssignmentLoad, canvassAssignmentLoaded, canvassAssignmentUpdated, + canvassAssignmentsLoad, + canvassAssignmentsLoaded, canvassSessionCreated, canvassSessionsLoad, canvassSessionsLoaded, diff --git a/src/features/campaigns/hooks/useClusteredActivities.ts b/src/features/campaigns/hooks/useClusteredActivities.ts index e3a6d90869..41225de452 100644 --- a/src/features/campaigns/hooks/useClusteredActivities.ts +++ b/src/features/campaigns/hooks/useClusteredActivities.ts @@ -4,6 +4,7 @@ import { ACTIVITIES, CallAssignmentActivity, CampaignActivity, + CanvassAssignmentActivity, EmailActivity, EventActivity, SurveyActivity, @@ -39,6 +40,7 @@ export type ClusteredEvent = export type NonEventActivity = | CallAssignmentActivity + | CanvassAssignmentActivity | SurveyActivity | TaskActivity | EmailActivity; diff --git a/src/features/campaigns/types.ts b/src/features/campaigns/types.ts index 8bd1164ca1..25ef3444ad 100644 --- a/src/features/campaigns/types.ts +++ b/src/features/campaigns/types.ts @@ -1,3 +1,4 @@ +import { ZetkinCanvassAssignment } from 'features/areas/types'; import { ZetkinCallAssignment, ZetkinEmail, @@ -8,6 +9,7 @@ import { export enum ACTIVITIES { CALL_ASSIGNMENT = 'callAssignment', + CANVASS_ASSIGNMENT = 'canvassAssignment', EMAIL = 'email', EVENT = 'event', SURVEY = 'survey', @@ -24,6 +26,11 @@ export type CallAssignmentActivity = CampaignActivityBase & { kind: ACTIVITIES.CALL_ASSIGNMENT; }; +export type CanvassAssignmentActivity = CampaignActivityBase & { + data: ZetkinCanvassAssignment; + kind: ACTIVITIES.CANVASS_ASSIGNMENT; +}; + export type SurveyActivity = CampaignActivityBase & { data: ZetkinSurvey; kind: ACTIVITIES.SURVEY; @@ -46,6 +53,7 @@ export type EmailActivity = CampaignActivityBase & { export type CampaignActivity = | CallAssignmentActivity + | CanvassAssignmentActivity | EmailActivity | EventActivity | SurveyActivity From ac9123b8c7a268c3692b8f5af39a5bdca7b3e139 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 07:10:15 +0200 Subject: [PATCH 295/369] Render canvass assignments in activities lists --- .../ActivityList/FilterActivities.tsx | 11 ++++++ .../components/ActivityList/index.tsx | 11 ++++++ .../items/CanvassAssignmentListItem.tsx | 35 +++++++++++++++++++ .../campaigns/hooks/useActivityList.ts | 8 +++++ .../projects/[campId]/activities/index.tsx | 1 + 5 files changed, 66 insertions(+) create mode 100644 src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx diff --git a/src/features/campaigns/components/ActivityList/FilterActivities.tsx b/src/features/campaigns/components/ActivityList/FilterActivities.tsx index 0f983ccb7c..edfb229e00 100644 --- a/src/features/campaigns/components/ActivityList/FilterActivities.tsx +++ b/src/features/campaigns/components/ActivityList/FilterActivities.tsx @@ -75,6 +75,17 @@ const FilterActivities = ({ } label={messages.all.filter.calls()} /> + + } + label={messages.all.filter.canvasses()} + /> { ); + } else if (activity.kind == ACTIVITIES.CANVASS_ASSIGNMENT) { + return ( + + {index > 0 && } + + + ); } else if (isEventCluster(activity)) { return ( diff --git a/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx b/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx new file mode 100644 index 0000000000..e82d730c54 --- /dev/null +++ b/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import { Map } from '@mui/icons-material'; + +import ActivityListItem, { STATUS_COLORS } from './ActivityListItem'; +import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; + +type Props = { + caId: string; + orgId: number; +}; + +const CanvassAssignmentListItem: FC = ({ caId, orgId }) => { + const { data: assignment } = useCanvassAssignment(orgId, caId); + + if (!assignment) { + return null; + } + + const color = STATUS_COLORS.GRAY; + + return ( + + ); +}; + +export default CanvassAssignmentListItem; diff --git a/src/features/campaigns/hooks/useActivityList.ts b/src/features/campaigns/hooks/useActivityList.ts index 41f8e2a0eb..6c5c2a63f4 100644 --- a/src/features/campaigns/hooks/useActivityList.ts +++ b/src/features/campaigns/hooks/useActivityList.ts @@ -1,3 +1,4 @@ +import useCanvassAssignmentActivities from 'features/areas/hooks/useCanvassAssignmentActivities'; import { CampaignActivity } from '../types'; import useCallAssignmentActivities from './useCallAssignmentActivities'; import useEmailActivities from './useEmailActivities'; @@ -20,12 +21,17 @@ export default function useActivityList( orgId, campId ); + const canvassAssignmentActivitiesFuture = useCanvassAssignmentActivities( + orgId, + campId + ); const taskActivitiesFuture = useTaskActivities(orgId, campId); const eventActivitiesFuture = useEventActivities(orgId, campId); const emailActivitiesFuture = useEmailActivities(orgId, campId); if ( callAssignmentActivitiesFuture.isLoading || + canvassAssignmentActivitiesFuture.isLoading || surveyActivitiesFuture.isLoading || taskActivitiesFuture.isLoading || eventActivitiesFuture.isLoading || @@ -34,6 +40,7 @@ export default function useActivityList( return new LoadingFuture(); } else if ( callAssignmentActivitiesFuture.error || + canvassAssignmentActivitiesFuture.error || surveyActivitiesFuture.error || taskActivitiesFuture.error || eventActivitiesFuture.error || @@ -46,6 +53,7 @@ export default function useActivityList( activities.push( ...(surveyActivitiesFuture.data || []), ...(callAssignmentActivitiesFuture.data || []), + ...(canvassAssignmentActivitiesFuture.data || []), ...(taskActivitiesFuture.data || []), ...(eventActivitiesFuture.data || []), ...(emailActivitiesFuture.data || []) diff --git a/src/pages/organize/[orgId]/projects/[campId]/activities/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/activities/index.tsx index 913155d73d..e74f6f7a48 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/activities/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/activities/index.tsx @@ -49,6 +49,7 @@ const CampaignActivitiesPage: PageWithLayout< const [searchString, setSearchString] = useState(''); const [filters, setFilters] = useState([ ACTIVITIES.CALL_ASSIGNMENT, + ACTIVITIES.CANVASS_ASSIGNMENT, ACTIVITIES.SURVEY, ACTIVITIES.TASK, ACTIVITIES.EMAIL, From 9d81721e1b481cef193439abbdb9f15c798d3e62 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 10:16:49 +0200 Subject: [PATCH 296/369] Fix bugs in activities listing of canvass assignments --- .../components/ActivityList/items/CanvassAssignmentListItem.tsx | 2 +- src/pages/organize/[orgId]/projects/activities/index.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx b/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx index e82d730c54..812a42c92a 100644 --- a/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx +++ b/src/features/campaigns/components/ActivityList/items/CanvassAssignmentListItem.tsx @@ -24,7 +24,7 @@ const CanvassAssignmentListItem: FC = ({ caId, orgId }) => { endNumber={''} href={`/organize/${orgId}/projects/${ assignment?.campaign?.id ?? 'standalone' - }/callassignments/${caId}`} + }/canvassassignments/${caId}`} PrimaryIcon={Map} SecondaryIcon={Map} title={assignment?.title || ''} diff --git a/src/pages/organize/[orgId]/projects/activities/index.tsx b/src/pages/organize/[orgId]/projects/activities/index.tsx index 106fb583ac..113c341d49 100644 --- a/src/pages/organize/[orgId]/projects/activities/index.tsx +++ b/src/pages/organize/[orgId]/projects/activities/index.tsx @@ -36,6 +36,7 @@ const CampaignActivitiesPage: PageWithLayout = () => { const [searchString, setSearchString] = useState(''); const [filters, setFilters] = useState([ ACTIVITIES.CALL_ASSIGNMENT, + ACTIVITIES.CANVASS_ASSIGNMENT, ACTIVITIES.SURVEY, ACTIVITIES.TASK, ACTIVITIES.EMAIL, From fccf1cb29c8159b33579ddd7fbfe6685a92a282a Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 10:45:46 +0200 Subject: [PATCH 297/369] Split up canvassers and overview tabs --- src/features/areas/l10n/messageIds.ts | 2 + .../areas/layouts/CanvassAssignmentLayout.tsx | 1 + .../[canvassAssId]/canvassers.tsx | 79 +++++++++++++ .../[canvassAssId]/index.tsx | 106 +----------------- 4 files changed, 88 insertions(+), 100 deletions(-) create mode 100644 src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 8d59dd3383..754d524370 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -9,7 +9,9 @@ export default makeMessages('feat.areas', { title: m('Untitled canvass assignment'), }, tabs: { + canvassers: m('Canvassers'), overview: m('Overview'), + plan: m('Plan'), }, }, empty: { diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/areas/layouts/CanvassAssignmentLayout.tsx index 569e02ff11..d76645c36d 100644 --- a/src/features/areas/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/areas/layouts/CanvassAssignmentLayout.tsx @@ -39,6 +39,7 @@ const CanvassAssignmentLayout: FC = ({ tabs={[ { href: '/', label: messages.canvassAssignment.tabs.overview() }, { href: '/plan', label: 'Plan' }, + { href: '/canvassers', label: 'Canvassers' }, ]} title={ { + const { orgId, campId, canvassAssId } = ctx.params!; + return { + props: { campId, canvassAssId, orgId }, + }; +}, scaffoldOptions); + +type Props = { + canvassAssId: string; + orgId: string; +}; + +const CanvassAssignmentPage: PageWithLayout = ({ + orgId, + canvassAssId, +}) => { + const allSessions = + useCanvassSessions(parseInt(orgId), canvassAssId).data || []; + const sessions = allSessions.filter( + (session) => session.assignment.id === canvassAssId + ); + const sessionByPersonId: Record< + number, + { + person: ZetkinPerson; + sessions: ZetkinCanvassSession[]; + } + > = {}; + + sessions.forEach((session) => { + if (!sessionByPersonId[session.assignee.id]) { + sessionByPersonId[session.assignee.id] = { + person: session.assignee, + sessions: [session], + }; + } else { + sessionByPersonId[session.assignee.id].sessions.push(session); + } + }); + + return ( + + {Object.values(sessionByPersonId).map(({ sessions, person }, index) => { + return ( + + + {sessions.length} + + ); + })} + + ); +}; + +CanvassAssignmentPage.getLayout = function getLayout(page) { + return ( + {page} + ); +}; + +export default CanvassAssignmentPage; diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index a1a8385cbc..0852bea936 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,19 +1,11 @@ -import { Box, Button } from '@mui/material'; -import { useState } from 'react'; +import { Typography } from '@mui/material'; import { GetServerSideProps } from 'next'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import { MUIOnlyPersonSelect as ZUIPersonSelect } from 'zui/ZUIPersonSelect'; -import useAreas from 'features/areas/hooks/useAreas'; import ZUIFuture from 'zui/ZUIFuture'; -import ZUIDialog from 'zui/ZUIDialog'; -import useCreateCanvassSession from 'features/areas/hooks/useCreateCanvassSession'; -import useCanvassSessions from 'features/areas/hooks/useCanvassSessions'; -import ZUIPerson from 'zui/ZUIPerson'; -import { ZetkinCanvassSession } from 'features/areas/types'; -import { ZetkinPerson } from 'utils/types/zetkin'; +import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; const scaffoldOptions = { authLevelRequired: 2, @@ -35,97 +27,11 @@ const CanvassAssignmentPage: PageWithLayout = ({ orgId, canvassAssId, }) => { - const [selectedAreaId, setSelectedAreaId] = useState(null); - const [selectedPersonId, setSelectedPersonId] = useState(null); - const [adding, setAdding] = useState(false); - - const areasFuture = useAreas(parseInt(orgId)); - const createCanvassSession = useCreateCanvassSession( - parseInt(orgId), - canvassAssId - ); - const allSessions = - useCanvassSessions(parseInt(orgId), canvassAssId).data || []; - const sessions = allSessions.filter( - (session) => session.assignment.id === canvassAssId - ); - const sessionByPersonId: Record< - number, - { - person: ZetkinPerson; - sessions: ZetkinCanvassSession[]; - } - > = {}; - - sessions.forEach((session) => { - if (!sessionByPersonId[session.assignee.id]) { - sessionByPersonId[session.assignee.id] = { - person: session.assignee, - sessions: [session], - }; - } else { - sessionByPersonId[session.assignee.id].sessions.push(session); - } - }); - + const assignmentFuture = useCanvassAssignment(parseInt(orgId), canvassAssId); return ( - - - {Object.values(sessionByPersonId).map(({ sessions, person }, index) => { - return ( - - - {sessions.length} - - ); - })} - setAdding(false)} open={adding}> - - {(areas) => ( - <> - - { - setSelectedPersonId(person.id); - }} - selectedPerson={null} - /> - - - - - - - )} - - - + + {(assignment) => {assignment.title}} + ); }; From e667a61da45c3e17bae68655e02f12b2a15be90d Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 11:11:54 +0200 Subject: [PATCH 298/369] Conditionally use fixedHeight on canvass assignment tabs --- src/features/areas/layouts/CanvassAssignmentLayout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/areas/layouts/CanvassAssignmentLayout.tsx b/src/features/areas/layouts/CanvassAssignmentLayout.tsx index d76645c36d..75b1a8e197 100644 --- a/src/features/areas/layouts/CanvassAssignmentLayout.tsx +++ b/src/features/areas/layouts/CanvassAssignmentLayout.tsx @@ -1,4 +1,5 @@ import { FC, ReactNode } from 'react'; +import { useRouter } from 'next/router'; import { useMessages } from 'core/i18n'; import TabbedLayout from 'utils/layout/TabbedLayout'; @@ -20,6 +21,7 @@ const CanvassAssignmentLayout: FC = ({ campId, canvassAssId, }) => { + const path = useRouter().pathname; const messages = useMessages(messageIds); const canvassAssignment = useCanvassAssignment(orgId, canvassAssId).data; const updateCanvassAssignment = useCanvassAssignmentMutations( @@ -27,6 +29,8 @@ const CanvassAssignmentLayout: FC = ({ canvassAssId ); + const isPlanTab = path.endsWith('/plan'); + if (!canvassAssignment) { return null; } @@ -35,7 +39,7 @@ const CanvassAssignmentLayout: FC = ({ Date: Thu, 19 Sep 2024 11:12:12 +0200 Subject: [PATCH 299/369] List canvassers similarly to how callers are listed in call assignments --- src/features/areas/l10n/messageIds.ts | 4 + .../[canvassAssId]/canvassers.tsx | 92 ++++++++++++++----- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 754d524370..0a164cd151 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -5,6 +5,10 @@ export default makeMessages('feat.areas', { canvassAssignment: { addAssignee: m('Add assignee'), assigneesTitle: m('Assignees'), + canvassers: { + areasColumn: m('Areas'), + nameColumn: m('Name'), + }, empty: { title: m('Untitled canvass assignment'), }, diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx index 827bd24f6c..0c3e158eb6 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/canvassers.tsx @@ -1,13 +1,16 @@ -import { Box } from '@mui/material'; import { GetServerSideProps } from 'next'; +import { DataGridPro, GridColDef } from '@mui/x-data-grid-pro'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; import useCanvassSessions from 'features/areas/hooks/useCanvassSessions'; -import ZUIPerson from 'zui/ZUIPerson'; import { ZetkinCanvassSession } from 'features/areas/types'; import { ZetkinPerson } from 'utils/types/zetkin'; +import ZUIAvatar from 'zui/ZUIAvatar'; +import { useMessages } from 'core/i18n'; +import messageIds from 'features/areas/l10n/messageIds'; +import ZUIPersonHoverCard from 'zui/ZUIPersonHoverCard'; const scaffoldOptions = { authLevelRequired: 2, @@ -25,48 +28,87 @@ type Props = { orgId: string; }; +type CanvasserInfo = { + id: number; + person: ZetkinPerson; + sessions: ZetkinCanvassSession[]; +}; + const CanvassAssignmentPage: PageWithLayout = ({ orgId, canvassAssId, }) => { + const messages = useMessages(messageIds); const allSessions = useCanvassSessions(parseInt(orgId), canvassAssId).data || []; const sessions = allSessions.filter( (session) => session.assignment.id === canvassAssId ); - const sessionByPersonId: Record< - number, - { - person: ZetkinPerson; - sessions: ZetkinCanvassSession[]; - } - > = {}; + + const sessionsByPersonId: Record = {}; sessions.forEach((session) => { - if (!sessionByPersonId[session.assignee.id]) { - sessionByPersonId[session.assignee.id] = { + if (!sessionsByPersonId[session.assignee.id]) { + sessionsByPersonId[session.assignee.id] = { + id: session.assignee.id, person: session.assignee, sessions: [session], }; } else { - sessionByPersonId[session.assignee.id].sessions.push(session); + sessionsByPersonId[session.assignee.id].sessions.push(session); } }); + const canvassers = Object.values(sessionsByPersonId); + + const columns: GridColDef[] = [ + { + disableColumnMenu: true, + field: 'id', + headerName: ' ', + renderCell: (params) => ( + + + + ), + sortable: false, + }, + { + field: 'name', + flex: 1, + headerName: messages.canvassAssignment.canvassers.nameColumn(), + valueGetter: (params) => + `${params.row.person.first_name} ${params.row.person.last_name}`, + }, + { + align: 'left', + field: 'areas', + flex: 1, + headerAlign: 'left', + headerName: messages.canvassAssignment.canvassers.areasColumn(), + type: 'number', + valueGetter: (params) => params.row.sessions.length, + }, + ]; + return ( - - {Object.values(sessionByPersonId).map(({ sessions, person }, index) => { - return ( - - - {sessions.length} - - ); - })} - + ); }; From 0ba557edc822e1929b2d6a4df4a24cd9afe2e43e Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 11:28:45 +0200 Subject: [PATCH 300/369] Add back SSR fix lost in recent merge conflict --- src/app/layout.tsx | 1 + src/core/env/ClientContext.tsx | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c7653068dd..46d7b89fbf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -35,6 +35,7 @@ export default async function RootLayout({ MUIX_LICENSE_KEY: process.env.MUIX_LICENSE_KEY || null, ZETKIN_APP_DOMAIN: process.env.ZETKIN_APP_DOMAIN || null, }} + headers={headersObject} lang={lang} messages={messages} user={user} diff --git a/src/core/env/ClientContext.tsx b/src/core/env/ClientContext.tsx index 516f3b553b..34720137c1 100644 --- a/src/core/env/ClientContext.tsx +++ b/src/core/env/ClientContext.tsx @@ -18,6 +18,7 @@ import { store } from 'core/store'; import { themeWithLocale } from '../../theme'; import { UserProvider } from './UserContext'; import { ZetkinUser } from 'utils/types/zetkin'; +import BackendApiClient from 'core/api/client/BackendApiClient'; declare module '@mui/styles/defaultTheme' { // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -30,6 +31,7 @@ type ClientContextProps = { MUIX_LICENSE_KEY: string | null; ZETKIN_APP_DOMAIN: string | null; }; + headers: Record; lang: string; messages: MessageList; user: ZetkinUser | null; @@ -38,11 +40,19 @@ type ClientContextProps = { const ClientContext: FC = ({ children, envVars, + headers, lang, messages, user, }) => { - const env = new Environment(new BrowserApiClient(), envVars); + const onServer = typeof window == 'undefined'; + + const apiClient = onServer + ? new BackendApiClient(headers) + : new BrowserApiClient(); + + const env = new Environment(apiClient, envVars); + return ( From 484401b2daac8e2b1aca5391693651a2e3dbd83b Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 14:29:57 +0200 Subject: [PATCH 301/369] Add filters for assigned and unassigned aras in plan map --- src/features/areas/components/PlanMap.tsx | 17 ++++++++++++++++- .../areas/components/PlanMapRenderer.tsx | 15 +++++++++++++++ src/features/areas/l10n/messageIds.ts | 4 ++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/features/areas/components/PlanMap.tsx b/src/features/areas/components/PlanMap.tsx index b3808d6e96..3d7dc92c7b 100644 --- a/src/features/areas/components/PlanMap.tsx +++ b/src/features/areas/components/PlanMap.tsx @@ -6,6 +6,7 @@ import { Box, Button, ButtonGroup, + Chip, MenuItem, TextField, } from '@mui/material'; @@ -30,6 +31,8 @@ const PlanMap: FC = ({ sessions, }) => { const messages = useMessages(messageIds); + const [filterAssigned, setFilterAssigned] = useState(false); + const [filterUnassigned, setFilterUnassigned] = useState(false); const mapRef = useRef(null); @@ -85,7 +88,17 @@ const PlanMap: FC = ({ - + + setFilterAssigned(!filterAssigned)} + /> + setFilterUnassigned(!filterUnassigned)} + /> filterAreas(options, state.inputValue) @@ -145,6 +158,8 @@ const PlanMap: FC = ({ > setSelectedId(newId)} selectedId={selectedId} sessions={sessions} diff --git a/src/features/areas/components/PlanMapRenderer.tsx b/src/features/areas/components/PlanMapRenderer.tsx index 475f050bb1..f6021e9e6a 100644 --- a/src/features/areas/components/PlanMapRenderer.tsx +++ b/src/features/areas/components/PlanMapRenderer.tsx @@ -16,6 +16,8 @@ import ZUIAvatar from 'zui/ZUIAvatar'; type PlanMapRendererProps = { areas: ZetkinArea[]; + filterAssigned: boolean; + filterUnassigned: boolean; onSelectedIdChange: (newId: string) => void; selectedId: string; sessions: ZetkinCanvassSession[]; @@ -23,6 +25,8 @@ type PlanMapRendererProps = { const PlanMapRenderer: FC = ({ areas, + filterAssigned, + filterUnassigned, selectedId, sessions, onSelectedIdChange, @@ -49,6 +53,9 @@ const PlanMapRenderer: FC = ({ } } }, [areas, map]); + + const showAll = !filterAssigned && !filterUnassigned; + return ( <> @@ -91,6 +98,14 @@ const PlanMapRenderer: FC = ({ const hasPeople = !!people.length; + if (!showAll) { + if (hasPeople && !filterAssigned) { + return null; + } else if (!hasPeople && !filterUnassigned) { + return null; + } + } + // The key changes when selected, to force redraw of polygon // to reflect new state through visual style const key = diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 0a164cd151..a2366ba775 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -12,6 +12,10 @@ export default makeMessages('feat.areas', { empty: { title: m('Untitled canvass assignment'), }, + planFilters: { + assigned: m('Assigned'), + unassigned: m('Unassigned'), + }, tabs: { canvassers: m('Canvassers'), overview: m('Overview'), From 17ba8f3c47dcc0b878ab6583955bfc803fd7cb5c Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 14:56:21 +0200 Subject: [PATCH 302/369] Sort areas so that bigger ones are on the bottom --- .../areas/components/AreasMap/MapRenderer.tsx | 18 +- .../areas/components/PlanMapRenderer.tsx | 242 ++++++++++-------- src/features/areas/utils/objToPoint.ts | 11 + 3 files changed, 161 insertions(+), 110 deletions(-) create mode 100644 src/features/areas/utils/objToPoint.ts diff --git a/src/features/areas/components/AreasMap/MapRenderer.tsx b/src/features/areas/components/AreasMap/MapRenderer.tsx index c16184487f..fdec1c4671 100644 --- a/src/features/areas/components/AreasMap/MapRenderer.tsx +++ b/src/features/areas/components/AreasMap/MapRenderer.tsx @@ -1,5 +1,5 @@ import { Box, useTheme } from '@mui/material'; -import { FeatureGroup } from 'leaflet'; +import { bounds, FeatureGroup } from 'leaflet'; import { FC, useEffect, useRef, useState } from 'react'; import { FeatureGroup as FeatureGroupComponent, @@ -11,6 +11,7 @@ import { import { PointData, ZetkinArea } from 'features/areas/types'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; +import objToPoint from 'features/areas/utils/objToPoint'; type Props = { areas: ZetkinArea[]; @@ -146,8 +147,21 @@ const MapRenderer: FC = ({ return 1; } else if (a1.id == selectedId) { return -1; + } else { + // When none of the two areas are selected, sort them + // by size, so that big ones are underneith and the + // smaller ones can be clicked. + const bounds0 = bounds(a0.points.map(objToPoint)); + const bounds1 = bounds(a1.points.map(objToPoint)); + + const dimensions0 = bounds0.getSize(); + const dimensions1 = bounds1.getSize(); + + const size0 = dimensions0.x * dimensions0.y; + const size1 = dimensions1.x * dimensions1.y; + + return size1 - size0; } - return 0; }) .map((area) => { const selected = selectedId == area.id; diff --git a/src/features/areas/components/PlanMapRenderer.tsx b/src/features/areas/components/PlanMapRenderer.tsx index f6021e9e6a..b178e78b1f 100644 --- a/src/features/areas/components/PlanMapRenderer.tsx +++ b/src/features/areas/components/PlanMapRenderer.tsx @@ -6,13 +6,14 @@ import { TileLayer, useMapEvents, } from 'react-leaflet'; -import { FeatureGroup as FeatureGroupType } from 'leaflet'; +import { bounds, FeatureGroup as FeatureGroupType } from 'leaflet'; import { useTheme } from '@mui/styles'; import { Box } from '@mui/material'; import { ZetkinArea, ZetkinCanvassSession } from '../types'; import { DivIconMarker } from 'features/events/components/LocationModal/DivIconMarker'; import ZUIAvatar from 'zui/ZUIAvatar'; +import objToPoint from '../utils/objToPoint'; type PlanMapRendererProps = { areas: ZetkinArea[]; @@ -68,121 +69,146 @@ const PlanMapRenderer: FC = ({ reactFGref.current = fgRef; }} > - {areas.map((area) => { - const selected = selectedId == area.id; - - const mid: [number, number] = [0, 0]; - if (area.points.length) { - area.points - .map((input) => { - if ('lat' in input && 'lng' in input) { - return [input.lat as number, input.lng as number]; - } else { - return input; - } - }) - .forEach((point) => { - mid[0] += point[0]; - mid[1] += point[1]; - }); - - mid[0] /= area.points.length; - mid[1] /= area.points.length; - } - - const detailed = zoom >= 15; - - const people = sessions - .filter((session) => session.area.id == area.id) - .map((session) => session.assignee); - - const hasPeople = !!people.length; - - if (!showAll) { - if (hasPeople && !filterAssigned) { - return null; - } else if (!hasPeople && !filterUnassigned) { - return null; + {areas + .sort((a0, a1) => { + // Always render selected last, so that it gets + // rendered on top of the unselected ones in case + // there are overlaps. + if (a0.id == selectedId) { + return 1; + } else if (a1.id == selectedId) { + return -1; + } else { + // When none of the two areas are selected, sort them + // by size, so that big ones are underneith and the + // smaller ones can be clicked. + const bounds0 = bounds(a0.points.map(objToPoint)); + const bounds1 = bounds(a1.points.map(objToPoint)); + + const dimensions0 = bounds0.getSize(); + const dimensions1 = bounds1.getSize(); + + const size0 = dimensions0.x * dimensions0.y; + const size1 = dimensions1.x * dimensions1.y; + + return size1 - size0; } - } - - // The key changes when selected, to force redraw of polygon - // to reflect new state through visual style - const key = - area.id + - (selected ? '-selected' : '-default') + - (hasPeople ? '-assigned' : ''); - - return ( - <> - {hasPeople && ( - - {detailed && ( - + }) + .map((area) => { + const selected = selectedId == area.id; + + const mid: [number, number] = [0, 0]; + if (area.points.length) { + area.points + .map((input) => { + if ('lat' in input && 'lng' in input) { + return [input.lat as number, input.lng as number]; + } else { + return input; + } + }) + .forEach((point) => { + mid[0] += point[0]; + mid[1] += point[1]; + }); + + mid[0] /= area.points.length; + mid[1] /= area.points.length; + } + + const detailed = zoom >= 15; + + const people = sessions + .filter((session) => session.area.id == area.id) + .map((session) => session.assignee); + + const hasPeople = !!people.length; + + if (!showAll) { + if (hasPeople && !filterAssigned) { + return null; + } else if (!hasPeople && !filterUnassigned) { + return null; + } + } + + // The key changes when selected, to force redraw of polygon + // to reflect new state through visual style + const key = + area.id + + (selected ? '-selected' : '-default') + + (hasPeople ? '-assigned' : ''); + + return ( + <> + {hasPeople && ( + + {detailed && ( + + + {people.map((person) => ( + + = 16 ? 'sm' : 'xs'} + url={`/api/orgs/1/people/${person.id}/avatar`} + /> + + ))} + + + )} + {!detailed && ( - {people.map((person) => ( - - = 16 ? 'sm' : 'xs'} - url={`/api/orgs/1/people/${person.id}/avatar`} - /> - - ))} + {people.length} - - )} - {!detailed && ( - - {people.length} - - )} - - )} - { - onSelectedIdChange(area.id); - }, - }} - positions={area.points} - weight={selected ? 5 : 2} - /> - - ); - })} + )} + + )} + { + onSelectedIdChange(area.id); + }, + }} + positions={area.points} + weight={selected ? 5 : 2} + /> + + ); + })} ); diff --git a/src/features/areas/utils/objToPoint.ts b/src/features/areas/utils/objToPoint.ts new file mode 100644 index 0000000000..ff2ffaa4fe --- /dev/null +++ b/src/features/areas/utils/objToPoint.ts @@ -0,0 +1,11 @@ +import { point, Point } from 'leaflet'; + +export default function objToPoint( + input: [number, number] | { lat: number; lng: number } +): Point { + if ('lat' in input) { + return point([input.lat, input.lng]); + } else { + return point(input); + } +} From 3b96101e0507ad66b67fb05445d16852c60f9b64 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 14:59:17 +0200 Subject: [PATCH 303/369] Disable selecting while drawing --- src/features/areas/components/AreasMap/MapRenderer.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/areas/components/AreasMap/MapRenderer.tsx b/src/features/areas/components/AreasMap/MapRenderer.tsx index fdec1c4671..21a6c8d66c 100644 --- a/src/features/areas/components/AreasMap/MapRenderer.tsx +++ b/src/features/areas/components/AreasMap/MapRenderer.tsx @@ -180,7 +180,9 @@ const MapRenderer: FC = ({ } eventHandlers={{ click: () => { - onSelectArea(area); + if (!isDrawing) { + onSelectArea(area); + } }, }} positions={area.points} From bdc8caa4f028a1a3284860f595fcd6c5055bc7b0 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 15:17:37 +0200 Subject: [PATCH 304/369] Include canvass assignments in activity overviews --- .../ActivitiesOverviewCard.tsx | 11 ++++++ .../CanvassAssignmentOverviewListItem.tsx | 37 +++++++++++++++++++ .../items/OverviewListItem.tsx | 26 +++++++------ .../campaigns/hooks/useActivityOverview.ts | 6 +++ 4 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx diff --git a/src/features/campaigns/components/ActivitiesOverview/ActivitiesOverviewCard.tsx b/src/features/campaigns/components/ActivitiesOverview/ActivitiesOverviewCard.tsx index 91007e25aa..7b7f54b36d 100644 --- a/src/features/campaigns/components/ActivitiesOverview/ActivitiesOverviewCard.tsx +++ b/src/features/campaigns/components/ActivitiesOverview/ActivitiesOverviewCard.tsx @@ -18,6 +18,7 @@ import { Msg, useMessages } from 'core/i18n'; import useClusteredActivities, { CLUSTER_TYPE, } from 'features/campaigns/hooks/useClusteredActivities'; +import CanvassAssignmentOverviewListItem from './items/CanvassAssignmentOverviewListItem'; type OverviewListProps = { activities: CampaignActivity[]; @@ -62,6 +63,16 @@ const ActivitiesOverviewCard: FC = ({ /> ); + } else if (activity.kind === ACTIVITIES.CANVASS_ASSIGNMENT) { + return ( + + {index > 0 && } + + + ); } else if (isEventCluster(activity)) { return ( diff --git a/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx b/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx new file mode 100644 index 0000000000..3220f3a921 --- /dev/null +++ b/src/features/campaigns/components/ActivitiesOverview/items/CanvassAssignmentOverviewListItem.tsx @@ -0,0 +1,37 @@ +import { FC } from 'react'; +import { Map } from '@mui/icons-material'; + +import { CanvassAssignmentActivity } from 'features/campaigns/types'; +import getStatusColor from 'features/campaigns/utils/getStatusColor'; +import OverviewListItem from './OverviewListItem'; + +type Props = { + activity: CanvassAssignmentActivity; + focusDate: Date | null; +}; + +const CanvassAssignmentOverviewListItem: FC = ({ + activity, + focusDate, +}) => { + const assignment = activity.data; + + return ( + + ); +}; + +export default CanvassAssignmentOverviewListItem; diff --git a/src/features/campaigns/components/ActivitiesOverview/items/OverviewListItem.tsx b/src/features/campaigns/components/ActivitiesOverview/items/OverviewListItem.tsx index 5a4dd81a03..b9e96462bd 100644 --- a/src/features/campaigns/components/ActivitiesOverview/items/OverviewListItem.tsx +++ b/src/features/campaigns/components/ActivitiesOverview/items/OverviewListItem.tsx @@ -68,7 +68,7 @@ interface OverviewListItemProps { >; SecondaryIcon: OverridableComponent< SvgIconTypeMap, 'svg'> - >; + > | null; color: STATUS_COLORS; endDate: CampaignActivity['visibleUntil']; startDate: CampaignActivity['visibleFrom']; @@ -197,17 +197,19 @@ const OverviewListItem = ({ {meta && {meta}} - } - label={ - typeof endNumber === 'number' ? ( - - ) : ( - endNumber - ) - } - /> + {SecondaryIcon && ( + } + label={ + typeof endNumber === 'number' ? ( + + ) : ( + endNumber + ) + } + /> + )} diff --git a/src/features/campaigns/hooks/useActivityOverview.ts b/src/features/campaigns/hooks/useActivityOverview.ts index 51efe56c45..1862b776a9 100644 --- a/src/features/campaigns/hooks/useActivityOverview.ts +++ b/src/features/campaigns/hooks/useActivityOverview.ts @@ -1,5 +1,6 @@ import { isSameDate } from 'utils/dateUtils'; import useCallAssignmentActivities from './useCallAssignmentActivities'; +import useCanvassAssignmentActivities from 'features/areas/hooks/useCanvassAssignmentActivities'; import useEmailActivities from './useEmailActivities'; import useEventsFromDateRange from 'features/events/hooks/useEventsFromDateRange'; import useSurveyActivities from './useSurveyActivities'; @@ -33,9 +34,12 @@ export default function useActivitiyOverview( campId ); const emailActivitiesFuture = useEmailActivities(orgId, campId); + const canvassAssignmentAcitivitiesFuture = + useCanvassAssignmentActivities(orgId); if ( callAssignmentActivitiesFuture.isLoading || + canvassAssignmentAcitivitiesFuture.isLoading || surveyActivitiesFuture.isLoading || taskActivitiesFuture.isLoading || emailActivitiesFuture.isLoading @@ -43,6 +47,7 @@ export default function useActivitiyOverview( return new LoadingFuture(); } else if ( callAssignmentActivitiesFuture.error || + canvassAssignmentAcitivitiesFuture.error || surveyActivitiesFuture.error || taskActivitiesFuture.error || emailActivitiesFuture.error @@ -56,6 +61,7 @@ export default function useActivitiyOverview( ...(taskActivitiesFuture.data || []), ...(surveyActivitiesFuture.data || []), ...(callAssignmentActivitiesFuture.data || []), + ...(canvassAssignmentAcitivitiesFuture.data || []), ...(emailActivitiesFuture.data || []) ); From b4f21375966a076cb77ac380de0e3f3cd17187f5 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 19 Sep 2024 15:59:10 +0200 Subject: [PATCH 305/369] Create canvass assignment overview page based on concepts from call assignments --- src/features/areas/l10n/messageIds.ts | 8 ++ .../[canvassAssId]/index.tsx | 96 ++++++++++++++++++- 2 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index a2366ba775..eb6a059fa0 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -12,6 +12,14 @@ export default makeMessages('feat.areas', { empty: { title: m('Untitled canvass assignment'), }, + overview: { + areas: { + defineButton: m('Plan now'), + editButton: m('Edit plan'), + subtitle: m('This assignment has not been planned yet.'), + title: m('Areas'), + }, + }, planFilters: { assigned: m('Assigned'), unassigned: m('Unassigned'), diff --git a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx index 0852bea936..1d8680110f 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/canvassassignments/[canvassAssId]/index.tsx @@ -1,11 +1,18 @@ -import { Typography } from '@mui/material'; +import { Box, Button, Card, Divider, Typography } from '@mui/material'; import { GetServerSideProps } from 'next'; +import { Edit } from '@mui/icons-material'; +import { useRouter } from 'next/router'; +import { makeStyles } from '@mui/styles'; import CanvassAssignmentLayout from 'features/areas/layouts/CanvassAssignmentLayout'; import { scaffold } from 'utils/next'; import { PageWithLayout } from 'utils/types'; -import ZUIFuture from 'zui/ZUIFuture'; import useCanvassAssignment from 'features/areas/hooks/useCanvassAssignment'; +import { Msg } from 'core/i18n'; +import messageIds from 'features/areas/l10n/messageIds'; +import useCanvassSessions from 'features/areas/hooks/useCanvassSessions'; +import ZUIFutures from 'zui/ZUIFutures'; +import ZUIAnimatedNumber from 'zui/ZUIAnimatedNumber'; const scaffoldOptions = { authLevelRequired: 2, @@ -23,15 +30,94 @@ interface CanvassAssignmentPageProps { canvassAssId: string; } +const useStyles = makeStyles((theme) => ({ + chip: { + backgroundColor: theme.palette.statusColors.gray, + borderRadius: '1em', + color: theme.palette.text.secondary, + display: 'flex', + fontSize: '1.8em', + lineHeight: 'normal', + marginRight: '0.1em', + overflow: 'hidden', + padding: '0.2em 0.7em', + }, +})); + const CanvassAssignmentPage: PageWithLayout = ({ orgId, canvassAssId, }) => { const assignmentFuture = useCanvassAssignment(parseInt(orgId), canvassAssId); + const sessionsFuture = useCanvassSessions(parseInt(orgId), canvassAssId); + const classes = useStyles(); + const router = useRouter(); + return ( - - {(assignment) => {assignment.title}} - + + {({ data: { assignment, sessions } }) => { + const areaIds = new Set(sessions.map((session) => session.area.id)); + const areaCount = areaIds.size; + + const planUrl = `/organize/${orgId}/projects/${ + assignment.campaign.id || 'standalone' + }/canvassassignments/${assignment.id}/plan`; + + return ( + + + + + + {!!areaCount && ( + + {(animatedValue) => ( + {animatedValue} + )} + + )} + + + {areaCount > 0 ? ( + + + + ) : ( + + + + + + + + + )} + + ); + }} + ); }; From 41df5ad1a02c9ba08763ffcd4e77714227eb8a43 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 19 Sep 2024 16:14:42 +0200 Subject: [PATCH 306/369] Get all sessions of the currently logged in user. --- .../beta/users/me/canvassassignments/route.ts | 73 +++++++++++++------ src/app/my/canvassassignments/page.tsx | 4 +- .../components/MyCanvassAssignmentsPage.tsx | 32 ++++++++ src/features/areas/components/ProfilePage.tsx | 19 ----- ...Assignments.ts => useMyCanvassSessions.ts} | 12 ++- src/features/areas/store.ts | 21 +++--- src/utils/testing/mocks/mockState.ts | 2 +- 7 files changed, 104 insertions(+), 59 deletions(-) create mode 100644 src/features/areas/components/MyCanvassAssignmentsPage.tsx delete mode 100644 src/features/areas/components/ProfilePage.tsx rename src/features/areas/hooks/{useMyCanvassAssignments.ts => useMyCanvassSessions.ts} (61%) diff --git a/src/app/beta/users/me/canvassassignments/route.ts b/src/app/beta/users/me/canvassassignments/route.ts index 15c5211833..b8e4ae30e5 100644 --- a/src/app/beta/users/me/canvassassignments/route.ts +++ b/src/app/beta/users/me/canvassassignments/route.ts @@ -1,38 +1,69 @@ +import { IncomingHttpHeaders } from 'http'; import mongoose from 'mongoose'; import { NextRequest, NextResponse } from 'next/server'; -import { IncomingHttpHeaders } from 'http'; -import { CanvassAssigneeModel } from 'features/areas/models'; -import { ZetkinCanvassAssignee } from 'features/areas/types'; import BackendApiClient from 'core/api/client/BackendApiClient'; -import { ZetkinUser } from 'utils/types/zetkin'; -import { ApiClientError } from 'core/api/errors'; +import { AreaModel, CanvassAssignmentModel } from 'features/areas/models'; +import { ZetkinCanvassSession } from 'features/areas/types'; +import { ZetkinMembership, ZetkinPerson } from 'utils/types/zetkin'; export async function GET(request: NextRequest) { const headers: IncomingHttpHeaders = {}; request.headers.forEach((value, key) => (headers[key] = value)); const apiClient = new BackendApiClient(headers); - try { - const currentUser = await apiClient.get(`/api/users/me`); + await mongoose.connect(process.env.MONGODB_URL || ''); + const memberships = await apiClient.get( + `/api/users/me/memberships` + ); + + const sessions: ZetkinCanvassSession[] = []; + + for await (const membership of memberships) { + const { id: personId } = membership.profile; - await mongoose.connect(process.env.MONGODB_URL || ''); + const person = await apiClient.get( + `/api/orgs/${membership.organization.id}/people/${personId}` + ); - const models = await CanvassAssigneeModel.find({ - id: currentUser.id, + const assignmentModels = await CanvassAssignmentModel.find({ + orgId: membership.organization.id, + 'sessions.personId': { $eq: personId }, }); - const assignees: ZetkinCanvassAssignee[] = models.map((model) => ({ - canvassAssId: model.canvassAssId, - id: model.id, - })); - - return Response.json({ data: assignees }); - } catch (err) { - if (err instanceof ApiClientError) { - return new NextResponse(null, { status: err.status }); - } else { - return new NextResponse(null, { status: 500 }); + for await (const assignment of assignmentModels) { + for await (const sessionData of assignment.sessions) { + if (sessionData.personId == personId) { + const orgId = assignment.orgId; + + const area = await AreaModel.findOne({ + _id: sessionData.areaId, + }); + + if (area) { + sessions.push({ + area: { + description: area.description, + id: area._id.toString(), + organization: { + id: orgId, + }, + points: area.points, + title: area.title, + }, + assignee: person, + assignment: { + id: assignment._id.toString(), + title: assignment.title, + }, + }); + } + } + } } } + + return NextResponse.json({ + data: sessions, + }); } diff --git a/src/app/my/canvassassignments/page.tsx b/src/app/my/canvassassignments/page.tsx index bd8e824ff6..0316351083 100644 --- a/src/app/my/canvassassignments/page.tsx +++ b/src/app/my/canvassassignments/page.tsx @@ -2,7 +2,7 @@ import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import BackendApiClient from 'core/api/client/BackendApiClient'; -import ProfilePage from 'features/areas/components/ProfilePage'; +import MyCanvassAssignmentsPage from 'features/areas/components/MyCanvassAssignmentsPage'; import { ZetkinOrganization } from 'utils/types/zetkin'; export default async function Page() { @@ -14,7 +14,7 @@ export default async function Page() { try { await apiClient.get(`/api/users/me`); - return ; + return ; } catch (err) { return notFound(); } diff --git a/src/features/areas/components/MyCanvassAssignmentsPage.tsx b/src/features/areas/components/MyCanvassAssignmentsPage.tsx new file mode 100644 index 0000000000..3da449523f --- /dev/null +++ b/src/features/areas/components/MyCanvassAssignmentsPage.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { FC } from 'react'; + +import useMyCanvassSessions from 'features/areas/hooks/useMyCanvassSessions'; +import { ZetkinCanvassSession } from '../types'; + +const MyCanvassAssignmentsPage: FC = () => { + const mySessions = useMyCanvassSessions().data || []; + + const sessionByPersonId: Record = {}; + mySessions.forEach((session) => { + if (!sessionByPersonId[session.assignee.id]) { + sessionByPersonId[session.assignee.id] = [session]; + } else { + sessionByPersonId[session.assignee.id].push(session); + } + }); + + return ( +
+ MY SESSIONS + {mySessions.map((session) => ( +

{`${session.assignment.title} ${session.area.title}`}

+ ))} +
+ ); +}; + +export default MyCanvassAssignmentsPage; diff --git a/src/features/areas/components/ProfilePage.tsx b/src/features/areas/components/ProfilePage.tsx deleted file mode 100644 index 1a427fd5e2..0000000000 --- a/src/features/areas/components/ProfilePage.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; - -import { FC } from 'react'; - -import useMyCanvassAssignments from 'features/areas/hooks/useMyCanvassAssignments'; - -const ProfilePage: FC = () => { - const myCanvassAssignments = useMyCanvassAssignments().data || []; - - return ( -
- {myCanvassAssignments.map((assignment) => ( -

{`${assignment.canvassAssId}`}

- ))} -
- ); -}; - -export default ProfilePage; diff --git a/src/features/areas/hooks/useMyCanvassAssignments.ts b/src/features/areas/hooks/useMyCanvassSessions.ts similarity index 61% rename from src/features/areas/hooks/useMyCanvassAssignments.ts rename to src/features/areas/hooks/useMyCanvassSessions.ts index 1d6e3e4a92..5a4cd0375c 100644 --- a/src/features/areas/hooks/useMyCanvassAssignments.ts +++ b/src/features/areas/hooks/useMyCanvassSessions.ts @@ -1,20 +1,18 @@ import { loadListIfNecessary } from 'core/caching/cacheUtils'; import { useApiClient, useAppDispatch, useAppSelector } from 'core/hooks'; import { myAssignmentsLoad, myAssignmentsLoaded } from '../store'; -import { ZetkinCanvassAssignee } from '../types'; +import { ZetkinCanvassSession } from '../types'; -export default function useMyCanvassAssignments() { +export default function useMyCanvassSessions() { const apiClient = useApiClient(); const dispatch = useAppDispatch(); - const myAssignments = useAppSelector( - (state) => state.areas.myAssignmentsList - ); + const mySessions = useAppSelector((state) => state.areas.mySessionsList); - return loadListIfNecessary(myAssignments, dispatch, { + return loadListIfNecessary(mySessions, dispatch, { actionOnLoad: () => myAssignmentsLoad(), actionOnSuccess: (data) => myAssignmentsLoaded(data), loader: () => - apiClient.get( + apiClient.get( '/beta/users/me/canvassassignments' ), }); diff --git a/src/features/areas/store.ts b/src/features/areas/store.ts index 42a9b8e999..a157b20882 100644 --- a/src/features/areas/store.ts +++ b/src/features/areas/store.ts @@ -25,7 +25,7 @@ export interface AreasStoreSlice { string, RemoteList >; - myAssignmentsList: RemoteList; + mySessionsList: RemoteList; placeList: RemoteList; } @@ -33,7 +33,7 @@ const initialState: AreasStoreSlice = { areaList: remoteList(), assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), - myAssignmentsList: remoteList(), + mySessionsList: remoteList(), placeList: remoteList(), sessionsByAssignmentId: {}, }; @@ -272,20 +272,23 @@ const areasSlice = createSlice({ new Date().toISOString(); }, myAssignmentsLoad: (state) => { - state.myAssignmentsList.isLoading = true; + state.mySessionsList.isLoading = true; }, myAssignmentsLoaded: ( state, - action: PayloadAction + action: PayloadAction ) => { - const assignees = action.payload; + const sessions = action.payload; const timestamp = new Date().toISOString(); - state.myAssignmentsList = remoteList(assignees); - state.myAssignmentsList.loaded = timestamp; - state.myAssignmentsList.items.forEach( - (item) => (item.loaded = timestamp) + state.mySessionsList = remoteList( + sessions.map((session) => ({ + ...session, + id: `${session.assignment.id} ${session.assignee.id}`, + })) ); + state.mySessionsList.loaded = timestamp; + state.mySessionsList.items.forEach((item) => (item.loaded = timestamp)); }, placeCreated: (state, action: PayloadAction) => { const place = action.payload; diff --git a/src/utils/testing/mocks/mockState.ts b/src/utils/testing/mocks/mockState.ts index 7023258e17..7da607ac85 100644 --- a/src/utils/testing/mocks/mockState.ts +++ b/src/utils/testing/mocks/mockState.ts @@ -7,7 +7,7 @@ export default function mockState(overrides?: RootState) { areaList: remoteList(), assigneesByCanvassAssignmentId: {}, canvassAssignmentList: remoteList(), - myAssignmentsList: remoteList(), + mySessionsList: remoteList(), placeList: remoteList(), sessionsByAssignmentId: {}, }, From 2cc97535d943d07d427918f6f4869100ca81bf44 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 19 Sep 2024 17:00:04 +0200 Subject: [PATCH 307/369] Show each assignment on a card in activist page. --- .../components/MyCanvassAssignmentsPage.tsx | 65 +++++++++++++++---- src/features/areas/l10n/messageIds.ts | 3 + 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/features/areas/components/MyCanvassAssignmentsPage.tsx b/src/features/areas/components/MyCanvassAssignmentsPage.tsx index 3da449523f..139b1aff98 100644 --- a/src/features/areas/components/MyCanvassAssignmentsPage.tsx +++ b/src/features/areas/components/MyCanvassAssignmentsPage.tsx @@ -1,31 +1,72 @@ 'use client'; import { FC } from 'react'; +import { Box, Card, CardContent, Typography } from '@mui/material'; import useMyCanvassSessions from 'features/areas/hooks/useMyCanvassSessions'; import { ZetkinCanvassSession } from '../types'; +import useCanvassAssignment from '../hooks/useCanvassAssignment'; +import { Msg } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; +import useOrganization from 'features/organizations/hooks/useOrganization'; +import ZUIFutures from 'zui/ZUIFutures'; + +const CanvassAssignmentCard: FC<{ + assignmentId: string; + orgId: number; +}> = ({ orgId, assignmentId }) => { + const assignmentFuture = useCanvassAssignment(orgId, assignmentId); + const organizationFuture = useOrganization(orgId); + return ( + + {({ data: { assignment, organization } }) => ( + + + + {assignment.title || ( + + )} + + {organization.title} + + + )} + + ); +}; const MyCanvassAssignmentsPage: FC = () => { const mySessions = useMyCanvassSessions().data || []; - const sessionByPersonId: Record = {}; + const sessionsByAssignmentId: Record = {}; mySessions.forEach((session) => { - if (!sessionByPersonId[session.assignee.id]) { - sessionByPersonId[session.assignee.id] = [session]; + if (!sessionsByAssignmentId[session.assignment.id]) { + sessionsByAssignmentId[session.assignment.id] = [session]; } else { - sessionByPersonId[session.assignee.id].push(session); + sessionsByAssignmentId[session.assignment.id].push(session); } }); return ( -
- MY SESSIONS - {mySessions.map((session) => ( -

{`${session.assignment.title} ${session.area.title}`}

- ))} -
+ + + + + {Object.values(sessionsByAssignmentId).map((sessions) => { + return ( + + ); + })} + ); }; diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index 61b7c3f8b9..b80f1ea66c 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -5,6 +5,9 @@ export default makeMessages('feat.areas', { canvassAssignment: { addAssignee: m('Add assignee'), assigneesTitle: m('Assignees'), + canvassing: { + title: m('Canvassing'), + }, empty: { title: m('Untitled canvass assignment'), }, From fb9250d1f3ac0b8bce110e256039332e329aea9c Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Thu, 19 Sep 2024 17:07:16 +0200 Subject: [PATCH 308/369] Go to the map. --- .../components/MyCanvassAssignmentsPage.tsx | 24 +++++++++++++++++-- src/features/areas/l10n/messageIds.ts | 1 + 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/features/areas/components/MyCanvassAssignmentsPage.tsx b/src/features/areas/components/MyCanvassAssignmentsPage.tsx index 139b1aff98..c7806cf57c 100644 --- a/src/features/areas/components/MyCanvassAssignmentsPage.tsx +++ b/src/features/areas/components/MyCanvassAssignmentsPage.tsx @@ -1,7 +1,15 @@ 'use client'; import { FC } from 'react'; -import { Box, Card, CardContent, Typography } from '@mui/material'; +import { useRouter } from 'next/navigation'; +import { + Box, + Button, + Card, + CardActions, + CardContent, + Typography, +} from '@mui/material'; import useMyCanvassSessions from 'features/areas/hooks/useMyCanvassSessions'; import { ZetkinCanvassSession } from '../types'; @@ -12,11 +20,14 @@ import useOrganization from 'features/organizations/hooks/useOrganization'; import ZUIFutures from 'zui/ZUIFutures'; const CanvassAssignmentCard: FC<{ + areaId: string; assignmentId: string; orgId: number; -}> = ({ orgId, assignmentId }) => { +}> = ({ areaId, orgId, assignmentId }) => { + const router = useRouter(); const assignmentFuture = useCanvassAssignment(orgId, assignmentId); const organizationFuture = useOrganization(orgId); + return ( {organization.title} + + + )} @@ -61,6 +80,7 @@ const MyCanvassAssignmentsPage: FC = () => { return ( diff --git a/src/features/areas/l10n/messageIds.ts b/src/features/areas/l10n/messageIds.ts index b80f1ea66c..40ca53ea9f 100644 --- a/src/features/areas/l10n/messageIds.ts +++ b/src/features/areas/l10n/messageIds.ts @@ -6,6 +6,7 @@ export default makeMessages('feat.areas', { addAssignee: m('Add assignee'), assigneesTitle: m('Assignees'), canvassing: { + goToMapButton: m('Go to map'), title: m('Canvassing'), }, empty: { From a4872ca9d31edd44b285f6a0ef4785c807014f9b Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Fri, 20 Sep 2024 07:29:50 +0200 Subject: [PATCH 309/369] Add canvass assignments to archives --- src/features/campaigns/hooks/useActivityArchive.ts | 8 ++++++++ .../organize/[orgId]/projects/[campId]/archive/index.tsx | 1 + src/pages/organize/[orgId]/projects/archive/index.tsx | 1 + 3 files changed, 10 insertions(+) diff --git a/src/features/campaigns/hooks/useActivityArchive.ts b/src/features/campaigns/hooks/useActivityArchive.ts index a47a165fb7..2c9dcaedbc 100644 --- a/src/features/campaigns/hooks/useActivityArchive.ts +++ b/src/features/campaigns/hooks/useActivityArchive.ts @@ -1,3 +1,4 @@ +import useCanvassAssignmentActivities from 'features/areas/hooks/useCanvassAssignmentActivities'; import { CampaignActivity } from '../types'; import useCallAssignmentActivities from './useCallAssignmentActivities'; import useEmailActivities from './useEmailActivities'; @@ -16,6 +17,10 @@ export default function useActivityArchive( campId?: number ): IFuture { const surveyActivitiesFuture = useSurveyActivities(orgId, campId); + const canvassAssignmentActivitiesFuture = useCanvassAssignmentActivities( + orgId, + campId + ); const callAssignmentActivitiesFuture = useCallAssignmentActivities( orgId, campId @@ -26,6 +31,7 @@ export default function useActivityArchive( if ( callAssignmentActivitiesFuture.isLoading || + canvassAssignmentActivitiesFuture.isLoading || surveyActivitiesFuture.isLoading || taskActivitiesFuture.isLoading || eventActivitiesFuture.isLoading || @@ -34,6 +40,7 @@ export default function useActivityArchive( return new LoadingFuture(); } else if ( callAssignmentActivitiesFuture.error || + canvassAssignmentActivitiesFuture.error || surveyActivitiesFuture.error || taskActivitiesFuture.error || eventActivitiesFuture.error || @@ -46,6 +53,7 @@ export default function useActivityArchive( activities.push( ...(surveyActivitiesFuture.data || []), ...(callAssignmentActivitiesFuture.data || []), + ...(canvassAssignmentActivitiesFuture.data || []), ...(taskActivitiesFuture.data || []), ...(eventActivitiesFuture.data || []), ...(emailActivitiesFuture.data || []) diff --git a/src/pages/organize/[orgId]/projects/[campId]/archive/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/archive/index.tsx index d1b4e95b02..5981a34742 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/archive/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/archive/index.tsx @@ -37,6 +37,7 @@ const CampaignArchivePage: PageWithLayout = () => { const [filters, setFilters] = useState([ ACTIVITIES.CALL_ASSIGNMENT, + ACTIVITIES.CANVASS_ASSIGNMENT, ACTIVITIES.SURVEY, ACTIVITIES.TASK, ACTIVITIES.EMAIL, diff --git a/src/pages/organize/[orgId]/projects/archive/index.tsx b/src/pages/organize/[orgId]/projects/archive/index.tsx index e09c3a4f9e..338f38b5cd 100644 --- a/src/pages/organize/[orgId]/projects/archive/index.tsx +++ b/src/pages/organize/[orgId]/projects/archive/index.tsx @@ -36,6 +36,7 @@ const ActivitiesArchivePage: PageWithLayout = () => { const [searchString, setSearchString] = useState(''); const [filters, setFilters] = useState([ ACTIVITIES.CALL_ASSIGNMENT, + ACTIVITIES.CANVASS_ASSIGNMENT, ACTIVITIES.SURVEY, ACTIVITIES.TASK, ACTIVITIES.EMAIL, From 17a7447d4270a9328b510cc6dfdf55b3bcd21907 Mon Sep 17 00:00:00 2001 From: neta Date: Sat, 21 Sep 2024 14:46:39 +0300 Subject: [PATCH 310/369] Issue 2129/Changing event date should change both start and end date --- src/features/events/components/EventOverviewCard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/events/components/EventOverviewCard/index.tsx b/src/features/events/components/EventOverviewCard/index.tsx index 4e75464045..edddeab1a0 100644 --- a/src/features/events/components/EventOverviewCard/index.tsx +++ b/src/features/events/components/EventOverviewCard/index.tsx @@ -149,7 +149,7 @@ const EventOverviewCard: FC = ({ data, orgId }) => { newStartDate.utc().toDate() ); setStartDate(startDateString); - if (newStartDate > dayjs(endDate)) { + if (newStartDate > dayjs(endDate) || !showEndDate) { setEndDate(startDateString); } } From 638904132e3d39c9a19bb902df86bc4c7f628044 Mon Sep 17 00:00:00 2001 From: neta Date: Sat, 21 Sep 2024 15:31:30 +0300 Subject: [PATCH 311/369] Issue 2142/Lists: Deleting a list in a folder navigates back to the root --- src/features/views/hooks/useViewMutations.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/features/views/hooks/useViewMutations.ts b/src/features/views/hooks/useViewMutations.ts index 70d103ff7c..898d35d3c7 100644 --- a/src/features/views/hooks/useViewMutations.ts +++ b/src/features/views/hooks/useViewMutations.ts @@ -1,5 +1,3 @@ -import Router from 'next/router'; - import { PromiseFuture } from 'core/caching/futures'; import { ZetkinView } from '../components/types'; import { @@ -31,7 +29,6 @@ export default function useViewMutations( const deleteView = async (viewId: number): Promise => { await apiClient.delete(`/api/orgs/${orgId}/people/views/${viewId}`); - Router.push(`/organize/${orgId}/people`); dispatch(viewDeleted(viewId)); }; From 5691e4f3d27c19a5fe9843b1c33f30a616aaf1ab Mon Sep 17 00:00:00 2001 From: WULCAN <7813515+WULCAN@users.noreply.github.com> Date: Sun, 22 Sep 2024 11:03:24 +0200 Subject: [PATCH 312/369] Delete api/stats/calls The use was removed in 3cfdeaeb0b85fc0eef9f09dc1a63f973b981c0c6 for https://github.com/zetkin/app.zetkin.org/pull/1587 --- src/pages/api/stats/calls/date.ts | 112 --------------------------- src/pages/api/stats/calls/hour.ts | 122 ------------------------------ 2 files changed, 234 deletions(-) delete mode 100644 src/pages/api/stats/calls/date.ts delete mode 100644 src/pages/api/stats/calls/hour.ts diff --git a/src/pages/api/stats/calls/date.ts b/src/pages/api/stats/calls/date.ts deleted file mode 100644 index 2aebc13110..0000000000 --- a/src/pages/api/stats/calls/date.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; - -import { createApiFetch } from 'utils/apiFetch'; - -export interface DateStats { - date: string; - calls: number; - conversations: number; -} - -export interface ZetkinCaller { - id: number; - name: string; -} - -interface ZetkinCall { - allocation_time: string; - caller: ZetkinCaller; - state: number; -} - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -): Promise { - const { assignment, caller, org } = req.query; - - const apiFetch = createApiFetch(req.headers); - - const callsRes = await apiFetch( - `/orgs/${org}/call_assignments/${assignment}/calls` - ); - const callsData = await callsRes.json(); - const sortedCalls: ZetkinCall[] = callsData.data.sort( - (c0: ZetkinCall, c1: ZetkinCall) => { - return ( - new Date(c0.allocation_time).getTime() - - new Date(c1.allocation_time).getTime() - ); - } - ); - - const dates: DateStats[] = []; - - const lastDate = new Date( - sortedCalls[sortedCalls.length - 1].allocation_time - ); - - // Extend timeframe backwards to at least 14 days - let firstDate = new Date(sortedCalls[0].allocation_time); - if (lastDate.getTime() - firstDate.getTime() < 14 * 24 * 60 * 60 * 1000) { - firstDate = new Date(lastDate); - firstDate.setDate(firstDate.getDate() - 14); - } - - const callerIds: Set = new Set(); - const callers: ZetkinCaller[] = []; - - let callIdx = 0; - const curDate = firstDate; - while (curDate <= lastDate) { - const dateStats: DateStats = { - calls: 0, - conversations: 0, - date: curDate.toISOString().slice(0, 10), - }; - - dates.push(dateStats); - - while (callIdx < sortedCalls.length) { - const curCall = sortedCalls[callIdx]; - const curCallDateStr = curCall.allocation_time.slice(0, 10); - - if (curCallDateStr != dateStats.date) { - break; - } - - // Add caller if not seen before - if (!callerIds.has(curCall.caller.id)) { - callers.push(curCall.caller); - callerIds.add(curCall.caller.id); - } - - // Skip calls that are just allocated - if (curCall.state == 0) { - callIdx++; - continue; - } - - // Skip calls belonging to wrong caller - if (caller && curCall.caller.id.toString() !== caller) { - callIdx++; - continue; - } - - // Add stats - dateStats.calls++; - if (curCall.state == 1) { - dateStats.conversations++; - } - - callIdx++; - } - - curDate.setDate(curDate.getDate() + 1); - } - - res.status(200).json({ - callers, - dates, - }); -} diff --git a/src/pages/api/stats/calls/hour.ts b/src/pages/api/stats/calls/hour.ts deleted file mode 100644 index 33e9ed3eaf..0000000000 --- a/src/pages/api/stats/calls/hour.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { NextApiRequest, NextApiResponse } from 'next'; - -import { createApiFetch } from 'utils/apiFetch'; - -export interface DateStats { - date: string; - calls: number; - conversations: number; -} - -interface ZetkinCaller { - id: number; - name: string; -} - -interface ZetkinCall { - allocation_time: string; - caller: ZetkinCaller; - state: number; -} - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -): Promise { - const { assignment, caller, org } = req.query; - - const apiFetch = createApiFetch(req.headers); - - const callsRes = await apiFetch( - `/orgs/${org}/call_assignments/${assignment}/calls` - ); - const callsData = await callsRes.json(); - const sortedCalls: ZetkinCall[] = callsData.data.sort( - (c0: ZetkinCall, c1: ZetkinCall) => { - return ( - new Date(c0.allocation_time).getTime() - - new Date(c1.allocation_time).getTime() - ); - } - ); - - const dates: DateStats[] = []; - - const lastDate = new Date( - sortedCalls[sortedCalls.length - 1].allocation_time - ); - - lastDate.setMinutes(60); - - // Start 48 hours before last hour - const startDate = new Date(lastDate); - startDate.setDate(lastDate.getDate() - 2); - - const callerIds: Set = new Set(); - const callers: ZetkinCaller[] = []; - - let callIdx = 0; - const curDate = startDate; - curDate.setMinutes(0); - curDate.setSeconds(0); - curDate.setMilliseconds(0); - - while (curDate <= lastDate) { - const dateStats: DateStats = { - calls: 0, - conversations: 0, - date: curDate.toISOString().slice(0, 16), - }; - - dates.push(dateStats); - - while (callIdx < sortedCalls.length) { - const curCall = sortedCalls[callIdx]; - const curCallDate = new Date(curCall.allocation_time); - curCallDate.setMinutes(0); - curCallDate.setSeconds(0); - curCallDate.setMilliseconds(0); - const curCallDateStr = curCallDate.toISOString().slice(0, 16); - - if (curCallDate < curDate) { - callIdx++; - continue; - } else if (curCallDateStr != dateStats.date) { - break; - } - - // Add caller if not seen before - if (!callerIds.has(curCall.caller.id)) { - callers.push(curCall.caller); - callerIds.add(curCall.caller.id); - } - - // Skip calls that are just allocated - if (curCall.state == 0) { - callIdx++; - continue; - } - - // Skip calls belonging to wrong caller - if (caller && curCall.caller.id.toString() !== caller) { - callIdx++; - continue; - } - - // Add stats - dateStats.calls++; - if (curCall.state == 1) { - dateStats.conversations++; - } - - callIdx++; - } - - curDate.setHours(curDate.getHours() + 1); - } - - res.status(200).json({ - callers, - dates, - }); -} From 4e7cd543286cc1368c4ec132f8c6bef1a87778a7 Mon Sep 17 00:00:00 2001 From: kaulfield23 Date: Sun, 22 Sep 2024 12:40:42 +0200 Subject: [PATCH 313/369] Change if statement so that it can add new tag for existing person in redux --- src/features/tags/store.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/features/tags/store.ts b/src/features/tags/store.ts index 0b87d2be41..6a2534401f 100644 --- a/src/features/tags/store.ts +++ b/src/features/tags/store.ts @@ -35,16 +35,17 @@ const tagsSlice = createSlice({ const [personId, tag] = action.payload; if (!state.tagsByPersonId[personId]) { state.tagsByPersonId[personId] = remoteList(); + } + const item = state.tagsByPersonId[personId].items.find( + (item) => item.id == tag.id + ); + + if (item) { + item.data = tag; + } else { state.tagsByPersonId[personId].items.push( remoteItem(tag.id, { data: tag }) ); - } else { - const item = state.tagsByPersonId[personId].items.find( - (item) => item.id == tag.id - ); - if (item) { - item.data = tag; - } } }, tagCreate: (state) => { From 69b3fbc2d0bd1798da4b4495484fc158ce1014eb Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Tue, 24 Sep 2024 07:10:08 +0200 Subject: [PATCH 314/369] Create RPC for encoding join form data into an encrypted string using Iron --- package.json | 1 + src/core/rpc/index.ts | 2 + .../joinForms/rpc/getJoinFormEmbedData.ts | 84 +++++++++++++++++++ src/features/joinForms/types.ts | 17 +++- yarn.lock | 42 ++++++++++ 5 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/features/joinForms/rpc/getJoinFormEmbedData.ts diff --git a/package.json b/package.json index 74b264a4ca..2195f49222 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@emotion/cache": "^11.11.0", "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", + "@hapi/iron": "^7.0.1", "@messageformat/parser": "^5.1.0", "@mui/base": "^5.0.0-alpha.99", "@mui/icons-material": "^5.10.6", diff --git a/src/core/rpc/index.ts b/src/core/rpc/index.ts index cce1ec302d..526d0d1e9a 100644 --- a/src/core/rpc/index.ts +++ b/src/core/rpc/index.ts @@ -18,6 +18,7 @@ import { updateEventsDef } from 'features/events/rpc/updateEvents'; import { getEmailInsightsDef } from 'features/emails/rpc/getEmailInsights'; import { renderEmailDef } from 'features/emails/rpc/renderEmail/server'; import { createCallAssignmentDef } from 'features/callAssignments/rpc/createCallAssignment'; +import { getJoinFormEmbedDataDef } from 'features/joinForms/rpc/getJoinFormEmbedData'; export function createRPCRouter() { const rpcRouter = new RPCRouter(); @@ -41,6 +42,7 @@ export function createRPCRouter() { rpcRouter.register(getEmailInsightsDef); rpcRouter.register(renderEmailDef); rpcRouter.register(createCallAssignmentDef); + rpcRouter.register(getJoinFormEmbedDataDef); return rpcRouter; } diff --git a/src/features/joinForms/rpc/getJoinFormEmbedData.ts b/src/features/joinForms/rpc/getJoinFormEmbedData.ts new file mode 100644 index 0000000000..33b1c52a3a --- /dev/null +++ b/src/features/joinForms/rpc/getJoinFormEmbedData.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +import Iron from '@hapi/iron'; + +import IApiClient from 'core/api/client/IApiClient'; +import { makeRPCDef } from 'core/rpc/types'; +import { EmbeddedJoinFormData, ZetkinJoinForm } from '../types'; +import { NATIVE_PERSON_FIELDS } from 'features/views/components/types'; +import { ZetkinCustomField } from 'utils/types/zetkin'; + +const paramsSchema = z.object({ + formId: z.number(), + orgId: z.number(), +}); + +type Params = z.input; +type Result = { + data: string; +}; + +export const getJoinFormEmbedDataDef = { + handler: handle, + name: 'getJoinFormEmbedData', + schema: paramsSchema, +}; + +export default makeRPCDef(getJoinFormEmbedDataDef.name); + +async function handle(params: Params, apiClient: IApiClient): Promise { + const { formId, orgId } = params; + + const joinForm = await apiClient.get( + `/api/orgs/${orgId}/join_forms/${formId}` + ); + + const nativeSlugs = Object.values(NATIVE_PERSON_FIELDS) as string[]; + const hasCustomFields = joinForm.fields.some((slug) => + nativeSlugs.includes(slug) + ); + + const customFields = hasCustomFields + ? await apiClient.get( + `/api/orgs/${orgId}/people/fields` + ) + : []; + + const data: EmbeddedJoinFormData = { + fields: joinForm.fields.map((slug) => { + const isNative = nativeSlugs.includes(slug); + + if (isNative) { + // This cast is safe because we are only in this execution + // branch if the slug was one of the native field slugs + const typedSlug = slug as NATIVE_PERSON_FIELDS; + return { + s: typedSlug, + }; + } else { + const field = customFields.find((field) => field.slug == slug); + + if (!field) { + throw new Error('Referencing unknown custom field'); + } + + return { + l: field.title, + s: slug, + t: field.type, + }; + } + }), + token: joinForm.submit_token, + }; + + const password = process.env.SESSION_PASSWORD; + if (!password) { + throw new Error('Missing password'); + } + + const sealed = await Iron.seal(data, password, Iron.defaults); + + return { + data: sealed, + }; +} diff --git a/src/features/joinForms/types.ts b/src/features/joinForms/types.ts index 69f78c99cc..46c3d2c3c8 100644 --- a/src/features/joinForms/types.ts +++ b/src/features/joinForms/types.ts @@ -1,4 +1,8 @@ -import { ZetkinPerson } from 'utils/types/zetkin'; +import { + CUSTOM_FIELD_TYPE, + ZetkinPerson, + ZetkinPersonNativeFields, +} from 'utils/types/zetkin'; export type ZetkinJoinForm = { description: string; @@ -41,3 +45,14 @@ export type ZetkinJoinSubmission = { }; export type ZetkinJoinSubmissionPatchBody = Pick; + +type EmbeddedJoinFormDataField = + | { + s: keyof ZetkinPersonNativeFields; + } + | { l: string; s: string; t: CUSTOM_FIELD_TYPE }; + +export type EmbeddedJoinFormData = { + fields: EmbeddedJoinFormDataField[]; + token: string; +}; diff --git a/yarn.lock b/yarn.lock index dd287a909c..77cb27b5ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2098,6 +2098,48 @@ intl-messageformat "10.1.4" tslib "2.4.0" +"@hapi/b64@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@hapi/b64/-/b64-6.0.1.tgz#786b47dc070e14465af49e2428c1025bd06ed3df" + integrity sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw== + dependencies: + "@hapi/hoek" "^11.0.2" + +"@hapi/boom@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685" + integrity sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA== + dependencies: + "@hapi/hoek" "^11.0.2" + +"@hapi/bourne@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" + integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== + +"@hapi/cryptiles@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@hapi/cryptiles/-/cryptiles-6.0.1.tgz#7868a9d4233567ed66f0a9caf85fdcc56e980621" + integrity sha512-9GM9ECEHfR8lk5ASOKG4+4ZsEzFqLfhiryIJ2ISePVB92OHLp/yne4m+zn7z9dgvM98TLpiFebjDFQ0UHcqxXQ== + dependencies: + "@hapi/boom" "^10.0.1" + +"@hapi/hoek@^11.0.2": + version "11.0.4" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-11.0.4.tgz#42a7f244fd3dd777792bfb74b8c6340ae9182f37" + integrity sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ== + +"@hapi/iron@^7.0.1": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@hapi/iron/-/iron-7.0.1.tgz#f74bace8dad9340c7c012c27c078504f070f14b5" + integrity sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ== + dependencies: + "@hapi/b64" "^6.0.1" + "@hapi/boom" "^10.0.1" + "@hapi/bourne" "^3.0.0" + "@hapi/cryptiles" "^6.0.1" + "@hapi/hoek" "^11.0.2" + "@humanwhocodes/config-array@^0.11.14": version "0.11.14" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.14.tgz#d78e481a039f7566ecc9660b4ea7fe6b1fec442b" From df673fd7830bf03aaa7e3269d02434280471a3e8 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Tue, 24 Sep 2024 07:21:21 +0200 Subject: [PATCH 315/369] Create ellipsis menu item for generating an embed URL --- .../joinForms/components/JoinFormListItem.tsx | 51 ++++++++++++++++++- src/features/joinForms/l10n/messageIds.ts | 5 ++ src/zui/ZUISnackbarContext.tsx | 6 +-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/features/joinForms/components/JoinFormListItem.tsx b/src/features/joinForms/components/JoinFormListItem.tsx index 908571f667..fa944c97d7 100644 --- a/src/features/joinForms/components/JoinFormListItem.tsx +++ b/src/features/joinForms/components/JoinFormListItem.tsx @@ -1,9 +1,16 @@ -import { FormatListBulleted } from '@mui/icons-material'; +import { useContext } from 'react'; +import { FormatListBulleted, OpenInNew } from '@mui/icons-material'; import makeStyles from '@mui/styles/makeStyles'; -import { Box, Theme, Typography } from '@mui/material'; +import { Box, Button, Theme, Typography } from '@mui/material'; import theme from 'theme'; import { ZetkinJoinForm } from '../types'; +import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; +import { useApiClient } from 'core/hooks'; +import getJoinFormEmbedData from '../rpc/getJoinFormEmbedData'; +import ZUISnackbarContext from 'zui/ZUISnackbarContext'; +import { Msg, useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; const useStyles = makeStyles((theme) => ({ container: { @@ -50,6 +57,9 @@ type Props = { const JoinFormListItem = ({ form, onClick }: Props) => { const classes = useStyles(); + const apiClient = useApiClient(); + const { showSnackbar } = useContext(ZUISnackbarContext); + const messages = useMessages(messageIds); return ( { */}
+ + { + ev.stopPropagation(); + const data = await apiClient.rpc(getJoinFormEmbedData, { + formId: form.id, + orgId: form.organization.id, + }); + + const url = `${location.protocol}//${location.host}/o/${form.organization.id}/embedjoinform/${data.data}`; + navigator.clipboard.writeText(url); + + showSnackbar( + 'success', + <> + + + + ); + }, + }, + ]} + /> + ); }; diff --git a/src/features/joinForms/l10n/messageIds.ts b/src/features/joinForms/l10n/messageIds.ts index 29241beb1c..98b8d63bc5 100644 --- a/src/features/joinForms/l10n/messageIds.ts +++ b/src/features/joinForms/l10n/messageIds.ts @@ -2,6 +2,11 @@ import { m, makeMessages } from 'core/i18n'; export default makeMessages('feat.joinForms', { defaultTitle: m('Untitled form'), + embedding: { + copyLink: m('Copy embed URL'), + linkCopied: m('Embed URL copied.'), + openLink: m('Visit now'), + }, formPane: { labels: { addField: m('Add field'), diff --git a/src/zui/ZUISnackbarContext.tsx b/src/zui/ZUISnackbarContext.tsx index 0b41ed3e55..eaf8172037 100644 --- a/src/zui/ZUISnackbarContext.tsx +++ b/src/zui/ZUISnackbarContext.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-empty-function */ import { Snackbar } from '@mui/material'; import { Alert, AlertColor } from '@mui/material'; -import { createContext, useState } from 'react'; +import { createContext, ReactNode, useState } from 'react'; import { useMessages } from 'core/i18n'; import messageIds from './l10n/messageIds'; @@ -9,7 +9,7 @@ import messageIds from './l10n/messageIds'; interface ZUISnackbarContextProps { isOpen: boolean; hideSnackbar: () => void; - showSnackbar: (severity: AlertColor, message?: string) => void; + showSnackbar: (severity: AlertColor, message?: ReactNode) => void; } const ZUISnackbarContext = createContext({ @@ -28,7 +28,7 @@ const ZUISnackbarProvider: React.FunctionComponent = ({ const messages = useMessages(messageIds); const [snackbarOpen, setSnackbarOpen] = useState(false); const [snackbarState, setSnackbarState] = useState<{ - message: string; + message: ReactNode; severity: AlertColor; }>(); From 43c1d1e67c848005b268cb8cc60e5c3e857b1af2 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Tue, 24 Sep 2024 16:07:53 +0200 Subject: [PATCH 316/369] Implement embeddable join form frontend --- .../[orgId]/embedjoinform/[formData]/page.tsx | 39 ++++++++++ .../actions/submitEmbeddedJoinForm.ts | 40 ++++++++++ .../joinForms/components/EmbeddedJoinForm.tsx | 74 +++++++++++++++++++ src/features/joinForms/l10n/messageIds.ts | 2 + .../joinForms/rpc/getJoinFormEmbedData.ts | 2 + src/features/joinForms/types.ts | 4 + 6 files changed, 161 insertions(+) create mode 100644 src/app/o/[orgId]/embedjoinform/[formData]/page.tsx create mode 100644 src/features/joinForms/actions/submitEmbeddedJoinForm.ts create mode 100644 src/features/joinForms/components/EmbeddedJoinForm.tsx diff --git a/src/app/o/[orgId]/embedjoinform/[formData]/page.tsx b/src/app/o/[orgId]/embedjoinform/[formData]/page.tsx new file mode 100644 index 0000000000..1719e26cc7 --- /dev/null +++ b/src/app/o/[orgId]/embedjoinform/[formData]/page.tsx @@ -0,0 +1,39 @@ +import Iron from '@hapi/iron'; +import { notFound } from 'next/navigation'; + +import EmbeddedJoinForm from 'features/joinForms/components/EmbeddedJoinForm'; +import { EmbeddedJoinFormData } from 'features/joinForms/types'; + +type PageProps = { + params: { + formData: string; + orgId: string; + }; + searchParams: { + css?: string; + }; +}; + +export default async function Page({ params, searchParams }: PageProps) { + const { formData } = params; + + try { + const formDataStr = decodeURIComponent(formData); + const formDataObj = (await Iron.unseal( + formDataStr, + process.env.SESSION_PASSWORD || '', + Iron.defaults + )) as EmbeddedJoinFormData; + + return ( +
+ + {searchParams.css && ( + + )} +
+ ); + } catch (err) { + return notFound(); + } +} diff --git a/src/features/joinForms/actions/submitEmbeddedJoinForm.ts b/src/features/joinForms/actions/submitEmbeddedJoinForm.ts new file mode 100644 index 0000000000..8fbf281de8 --- /dev/null +++ b/src/features/joinForms/actions/submitEmbeddedJoinForm.ts @@ -0,0 +1,40 @@ +'use server'; + +import Iron from '@hapi/iron'; +import { headers } from 'next/headers'; + +import BackendApiClient from 'core/api/client/BackendApiClient'; +import { EmbeddedJoinFormData, EmbeddedJoinFormStatus } from '../types'; + +export default async function submitJoinForm( + prevState: EmbeddedJoinFormStatus, + inputFormData: FormData +) { + const headersList = headers(); + const headersEntries = headersList.entries(); + const headersObject = Object.fromEntries(headersEntries); + const apiClient = new BackendApiClient(headersObject); + + const encrypted = inputFormData.get('__joinFormData')?.toString() || ''; + const joinFormInfo = (await Iron.unseal( + encrypted, + process.env.SESSION_PASSWORD || '', + Iron.defaults + )) as EmbeddedJoinFormData; + + const outputFormData: Record = {}; + + joinFormInfo.fields.forEach((field) => { + outputFormData[field.s] = inputFormData.get(field.s)?.toString() || ''; + }); + + await apiClient.post( + `/api/orgs/${joinFormInfo.orgId}/join_forms/${joinFormInfo.formId}/submissions`, + { + form_data: outputFormData, + submit_token: joinFormInfo.token, + } + ); + + return 'submitted'; +} diff --git a/src/features/joinForms/components/EmbeddedJoinForm.tsx b/src/features/joinForms/components/EmbeddedJoinForm.tsx new file mode 100644 index 0000000000..f187dfb2aa --- /dev/null +++ b/src/features/joinForms/components/EmbeddedJoinForm.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { FC } from 'react'; +// Type definitions for the new experimental stuff like useFormState in +// react-dom are lagging behind the implementation so it's necessary to silence +// the TypeScript error about the lack of type definitions here in order to +// import this. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { useFormState } from 'react-dom'; + +import { EmbeddedJoinFormData, EmbeddedJoinFormStatus } from '../types'; +import { Msg, useMessages } from 'core/i18n'; +import globalMessageIds from 'core/i18n/globalMessageIds'; +import submitJoinForm from '../actions/submitEmbeddedJoinForm'; +import messageIds from '../l10n/messageIds'; + +type Props = { + encrypted: string; + fields: EmbeddedJoinFormData['fields']; +}; + +const EmbeddedJoinForm: FC = ({ encrypted, fields }) => { + const globalMessages = useMessages(globalMessageIds); + + const [status, action] = useFormState( + submitJoinForm, + 'editing' + ); + + // For some reason, the version of react (or @types/react) that we use + // does not understand server action functions as properties to the form, + // but upgrading to the most recent versions causes type-related problems + // with react-intl (and perhaps other libraries). So this workaround + // allows us to pass the action to the form by pretending it's a string. + const actionWhileTrickingTypescript = action as unknown as string; + + return ( +
+ {status == 'editing' && ( +
+ + {fields.map((field) => { + const isCustom = 'l' in field; + const label = isCustom + ? field.l + : globalMessages.personFields[field.s](); + + return ( +
+ +
+ ); + })} + +
+ )} + {status == 'submitted' && ( +

+ +

+ )} +
+ ); +}; + +export default EmbeddedJoinForm; diff --git a/src/features/joinForms/l10n/messageIds.ts b/src/features/joinForms/l10n/messageIds.ts index 98b8d63bc5..5e8be1d19c 100644 --- a/src/features/joinForms/l10n/messageIds.ts +++ b/src/features/joinForms/l10n/messageIds.ts @@ -4,8 +4,10 @@ export default makeMessages('feat.joinForms', { defaultTitle: m('Untitled form'), embedding: { copyLink: m('Copy embed URL'), + formSubmitted: m('Form submitted'), linkCopied: m('Embed URL copied.'), openLink: m('Visit now'), + submitButton: m('Submit'), }, formPane: { labels: { diff --git a/src/features/joinForms/rpc/getJoinFormEmbedData.ts b/src/features/joinForms/rpc/getJoinFormEmbedData.ts index 33b1c52a3a..1b7e45cddd 100644 --- a/src/features/joinForms/rpc/getJoinFormEmbedData.ts +++ b/src/features/joinForms/rpc/getJoinFormEmbedData.ts @@ -68,6 +68,8 @@ async function handle(params: Params, apiClient: IApiClient): Promise { }; } }), + formId: formId, + orgId: orgId, token: joinForm.submit_token, }; diff --git a/src/features/joinForms/types.ts b/src/features/joinForms/types.ts index 46c3d2c3c8..0649298d86 100644 --- a/src/features/joinForms/types.ts +++ b/src/features/joinForms/types.ts @@ -54,5 +54,9 @@ type EmbeddedJoinFormDataField = export type EmbeddedJoinFormData = { fields: EmbeddedJoinFormDataField[]; + formId: number; + orgId: number; token: string; }; + +export type EmbeddedJoinFormStatus = 'editing' | 'submitted'; From 092cdcd0ef1490befbbfc9f8769b05387d90ddf2 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Tue, 24 Sep 2024 16:32:08 +0200 Subject: [PATCH 317/369] Implement redirection after submitting a form --- .../joinForms/components/EmbeddedJoinForm.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/features/joinForms/components/EmbeddedJoinForm.tsx b/src/features/joinForms/components/EmbeddedJoinForm.tsx index f187dfb2aa..aaee5ccd2e 100644 --- a/src/features/joinForms/components/EmbeddedJoinForm.tsx +++ b/src/features/joinForms/components/EmbeddedJoinForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC } from 'react'; +import { FC, useEffect } from 'react'; // Type definitions for the new experimental stuff like useFormState in // react-dom are lagging behind the implementation so it's necessary to silence // the TypeScript error about the lack of type definitions here in order to @@ -35,6 +35,19 @@ const EmbeddedJoinForm: FC = ({ encrypted, fields }) => { // allows us to pass the action to the form by pretending it's a string. const actionWhileTrickingTypescript = action as unknown as string; + useEffect(() => { + if (status == 'submitted') { + const url = new URL(location.toString()); + const query = url.searchParams; + const redirectUrl = query.get('redirect'); + + if (redirectUrl) { + const win = window.parent || window; + win.location.href = redirectUrl; + } + } + }, [status]); + return (
{status == 'editing' && ( From 991323cc8ae6f4b8062bedcf81714a92648fef82 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Tue, 24 Sep 2024 17:11:44 +0200 Subject: [PATCH 318/369] Rename css to stylesheet --- src/app/o/[orgId]/embedjoinform/[formData]/page.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/o/[orgId]/embedjoinform/[formData]/page.tsx b/src/app/o/[orgId]/embedjoinform/[formData]/page.tsx index 1719e26cc7..6b132f49e0 100644 --- a/src/app/o/[orgId]/embedjoinform/[formData]/page.tsx +++ b/src/app/o/[orgId]/embedjoinform/[formData]/page.tsx @@ -10,7 +10,7 @@ type PageProps = { orgId: string; }; searchParams: { - css?: string; + stylesheet?: string; }; }; @@ -28,8 +28,8 @@ export default async function Page({ params, searchParams }: PageProps) { return (
- {searchParams.css && ( - + {searchParams.stylesheet && ( + )}
); From 36a315564283f8e2134262b2c24fe6cbb0a611d4 Mon Sep 17 00:00:00 2001 From: ziggi <58265097+ziggabyte@users.noreply.github.com> Date: Wed, 25 Sep 2024 11:48:23 +0200 Subject: [PATCH 319/369] Create template for ZUI issues --- .github/ISSUE_TEMPLATE/zui.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/zui.md diff --git a/.github/ISSUE_TEMPLATE/zui.md b/.github/ISSUE_TEMPLATE/zui.md new file mode 100644 index 0000000000..ebc8d7c439 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/zui.md @@ -0,0 +1,29 @@ +--- +name: ZUI design system +about: Components for the ZUI design system library +title: '' +labels: 'ZUI' +assignees: '' +--- + +## Description + +## Screenshots + +## Figma link + +## Requirements + +- [ ] Requirement 1 + +## Open questions + +## Possible implementations + +For reference, you can look at the already existing ZUI components created on the `undocumented/new-design-system` branch (not ZUI components that exist on `main`, as they are not part of the new design system.) + +## Git + +The main git branch for the work on the new design system is `undocumented/new-design-system`. Unless otherwise instructed, do your work on a new branch branched off from this branch. + +Name your branch `undocumented/zui-name`, ex: `undocumented/zui-button` for a branch where a button component is being made. From ef9fbdf4b52d6323a9c7fec01d3d15e56e0a5132 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 25 Sep 2024 13:13:20 +0200 Subject: [PATCH 320/369] Correct name of label. --- .github/ISSUE_TEMPLATE/zui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/zui.md b/.github/ISSUE_TEMPLATE/zui.md index ebc8d7c439..b0345f637a 100644 --- a/.github/ISSUE_TEMPLATE/zui.md +++ b/.github/ISSUE_TEMPLATE/zui.md @@ -2,7 +2,7 @@ name: ZUI design system about: Components for the ZUI design system library title: '' -labels: 'ZUI' +labels: '🧱 ZUI' assignees: '' --- From b156f41805ba78e45da3d6410db70e11516ce71b Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 25 Sep 2024 20:23:26 +0200 Subject: [PATCH 321/369] Update the ZUI issue template. --- .github/ISSUE_TEMPLATE/zui.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/zui.md b/.github/ISSUE_TEMPLATE/zui.md index b0345f637a..0ff1e44dd3 100644 --- a/.github/ISSUE_TEMPLATE/zui.md +++ b/.github/ISSUE_TEMPLATE/zui.md @@ -1,7 +1,7 @@ --- name: ZUI design system about: Components for the ZUI design system library -title: '' +title: '🧱 ZUI: ' labels: '🧱 ZUI' assignees: '' --- @@ -12,6 +12,8 @@ assignees: '' ## Figma link +You need to be logged into a Figma account to properly view the Figma content. + ## Requirements - [ ] Requirement 1 @@ -26,4 +28,4 @@ For reference, you can look at the already existing ZUI components created on th The main git branch for the work on the new design system is `undocumented/new-design-system`. Unless otherwise instructed, do your work on a new branch branched off from this branch. -Name your branch `undocumented/zui-name`, ex: `undocumented/zui-button` for a branch where a button component is being made. +Name your branch `issue-number/zui-name`, ex: `issue-928/zui-button` for a branch where work is done that is documented in the issue with number 928, where a button component is being made. From 9ea5753671690c2dfcb3a02d21e6c43fd726a772 Mon Sep 17 00:00:00 2001 From: ziggabyte Date: Wed, 25 Sep 2024 21:35:33 +0200 Subject: [PATCH 322/369] Add line about storybook. --- .github/ISSUE_TEMPLATE/zui.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/zui.md b/.github/ISSUE_TEMPLATE/zui.md index 0ff1e44dd3..c7647df7c9 100644 --- a/.github/ISSUE_TEMPLATE/zui.md +++ b/.github/ISSUE_TEMPLATE/zui.md @@ -22,6 +22,8 @@ You need to be logged into a Figma account to properly view the Figma content. ## Possible implementations +Develop this using Storybook. We want all the design system components to be documented through their own Storybook stories. + For reference, you can look at the already existing ZUI components created on the `undocumented/new-design-system` branch (not ZUI components that exist on `main`, as they are not part of the new design system.) ## Git From afc38c8057780d5084fd3396cf29cc10362e8e3b Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 26 Sep 2024 05:32:14 +0200 Subject: [PATCH 323/369] Change introduction to better describe current state of the app --- README.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b9ebd59a40..4560b9843d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,11 @@ -# Zetkin +# Zetkin Web App -This is the new Zetkin front-end application, currently under development. It -will run at app.zetkin.org and replace the current www.zetk.in, call.zetk.in and -organize.zetk.in applications. +This is the current-generation (Gen3) web interface for the Zetkin Platform. It +is gradually replacing the older (Gen2) web apps that can be found elsewhere. -## Technology - -The new Zetkin app is built on [NEXT.js](https://nextjs.org) with TypeScript. +The Zetkin Platform is software for organizing activism, developed by staff and +volunteers at [Zetkin Foundation](https://zetkin.org) and used by organizations +within the international left. ## Contributing Do you want to contribute to this project and become part of a community of people From e0509346ffbcfee84721fbe76249ca44ca7e4459 Mon Sep 17 00:00:00 2001 From: Richard Olsson Date: Thu, 26 Sep 2024 06:00:55 +0200 Subject: [PATCH 324/369] Update instructions to include links to GitHub docs instead of explaining Git --- README.md | 54 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 4560b9843d..7f05f6fa8b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ volunteers at [Zetkin Foundation](https://zetkin.org) and used by organizations within the international left. ## Contributing + Do you want to contribute to this project and become part of a community of people that use their coding skills to help the international left? We try to make the process as easy and transparent as possible. Read all about it in the separate @@ -17,57 +18,64 @@ Also see [TESTING.md](./TESTING.md) for details about automated testing. ## Instructions -### Windows +### Getting the code + +The code is [hosted on GitHub](https://github.com/zetkin/app.zetkin.org) (likely +where you are currently reading this information). If you are unfamiliar with +Git and/or GitHub, here's some recommended reading from the GitHub Docs: + +- [About GitHub and Git](https://docs.github.com/en/get-started/start-your-journey/about-github-and-git) +- [Set up Git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git) + (We recommend either the Git command-line interface or a plugin for your IDE) +- [Contributing to a project](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project) + explains how you can submit your first PR to a project like Zetkin + +You can use any editor or IDE to edit the code, but most of us use [VSCode](https://code.visualstudio.com/). -1. Fork zetkin/app.zetking.org into your personal GitHub account. -1. Install Git for Windows from https://git-scm.com/download/win -2. Generate an SSH key, for example with - Git Gui / Help / Show SSH Key / Generate Key -3. Add your key to GitHub by copying the public part of it to - your GitHub settings at https://github.com/settings/keys -4. Install Visual Studio Code. -5. In Visual Studio Code use Clone Repository and from GitHub, connect your - installation of Visual Studio Code to your GitHub account, and then clone - your fork from GitHub. Note that you will be prompted for the passphrase of - your private key if you chose to use one when you created your SSH Key. +### Running the code (normal) -### Common +The Zetkin web app is a NEXT.js app that runs in the Node.js JavaScript runtime. +If you don't have it already, you must [install Node.js](https://nodejs.org/) +first. -Install all the dependencies using [`yarn` (Classic)](https://classic.yarnpkg.com): +We use [`yarn` (Classic)](https://classic.yarnpkg.com) to manage our code +dependencies. Once you have installed `yarn` you can install all other +dependencies like so: ``` $ yarn install ``` -Then start the devserver: +With dependencies installed, you can start the development server: ``` $ yarn devserver ``` You should now be able to access the app on http://localhost:3000. It will -communicate with the Zetkin API running on our public development server. +communicate with the Zetkin API running on our public development server. See +below for login credentials. -### Docker +### Running the code (Docker) -As **an alternative to the normal development setup**, -you can also run the provided Docker Compose setup. +As **an alternative to the normal development setup**, you can also run the provided +Docker Compose setup. -* Requires Docker Compose v2+ -* Backend development: Run local production (after building, it starts very fast) +- Requires Docker Compose v2+ +- Backend development: Run local production (after building, it starts very fast) and access the organizations directly on http://localhost:3000/organizations/. ``` $ docker compose -f dev.yml --profile static up ``` -* Frontend development: Similar to the normal yarn setup documented here. +- Frontend development: Similar to the normal yarn setup documented here. ``` $ docker compose -f dev.yml --profile dev up ``` -* Linting: You can run lint commands from within a container, too: +- Linting: You can run lint commands from within a container, too: ``` $ # Default: Run .githooks/precommit From 93469bcea7c4dc7c6942dc860d25e69e2dff2f39 Mon Sep 17 00:00:00 2001 From: awarn Date: Fri, 27 Sep 2024 17:13:48 +0200 Subject: [PATCH 325/369] Add tests for privacy policy link environment variable --- .../surveys/submitting-survey.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts index 5283104fc8..da39835273 100644 --- a/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -694,4 +694,40 @@ test.describe('User submitting a survey', () => { expect(await textInput.isVisible()).toBeFalsy(); expect(await checkboxInput.isVisible()).toBeFalsy(); }); + + test('privacy policy link falls back when missing environment variable', async ({ + appUri, + page, + }) => { + delete process.env.ZETKIN_PRIVACY_POLICY_LINK; + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + const policyLink = await page.locator( + '[aria-labelledby="privacy-policy-label"] a' + ); + + expect(await policyLink.getAttribute('href')).toEqual( + 'https://zetkin.org/privacy' + ); + }); + + test('privacy policy link picks up environment variable', async ({ + appUri, + page, + }) => { + process.env.ZETKIN_PRIVACY_POLICY_LINK = 'https://foo.bar'; + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + const policyLink = await page.locator( + '[aria-labelledby="privacy-policy-label"] a' + ); + + expect(await policyLink.getAttribute('href')).toEqual('https://foo.bar'); + }); }); From 6cb39020fa1e377de80b7b840812c71e3a125e2c Mon Sep 17 00:00:00 2001 From: WULCAN <7813515+WULCAN@users.noreply.github.com> Date: Fri, 27 Sep 2024 22:48:49 +0200 Subject: [PATCH 326/369] Extract constant for shorter lines --- .../surveyForm/SurveyOptionsQuestion.tsx | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index 66e93c3c19..9ad1aff372 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -34,10 +34,11 @@ const OptionsQuestion: FC = ({ element }) => { setDropdownValue(event.target.value); }, []); + const question = element.question; return ( - {element.question.response_config.widget_type === 'checkbox' && ( + {question.response_config.widget_type === 'checkbox' && ( = ({ element }) => { > - - {element.question.question} - + {question.question} - {element.question.description && ( + {question.description && ( - {element.question.description} + {question.description} )} - {element.question.options!.map((option: ZetkinSurveyOption) => ( + {question.options!.map((option: ZetkinSurveyOption) => ( } @@ -75,9 +74,8 @@ const OptionsQuestion: FC = ({ element }) => { )} - {(element.question.response_config.widget_type === 'radio' || - typeof element.question.response_config.widget_type === - 'undefined') && ( + {(question.response_config.widget_type === 'radio' || + typeof question.response_config.widget_type === 'undefined') && ( = ({ element }) => { <> - {element.question.question} - {element.question.required && + {question.question} + {question.required && ` (${messages.surveyForm.required()})`} - {element.question.description && ( + {question.description && ( - {element.question.description} + {question.description} )} - {element.question.options!.map((option: ZetkinSurveyOption) => ( + {question.options!.map((option: ZetkinSurveyOption) => ( } + control={} label={option.text} value={option.id} /> @@ -122,7 +120,7 @@ const OptionsQuestion: FC = ({ element }) => { )} - {element.question.response_config.widget_type === 'select' && ( + {question.response_config.widget_type === 'select' && ( = ({ element }) => { <> - {element.question.question} - {element.question.required && + {question.question} + {question.required && ` (${messages.surveyForm.required()})`} - {element.question.description && ( + {question.description && ( - {element.question.description} + {question.description} )} @@ -149,10 +147,10 @@ const OptionsQuestion: FC = ({ element }) => { aria-labelledby={`label-${element.id}`} name={`${element.id}.options`} onChange={handleDropdownChange} - required={element.question.required} + required={question.required} value={dropdownValue} > - {element.question.options!.map((option: ZetkinSurveyOption) => ( + {question.options!.map((option: ZetkinSurveyOption) => ( {option.text} From 0ef85be6a4334a1da9e2635d06b99ec5302d702a Mon Sep 17 00:00:00 2001 From: WULCAN <7813515+WULCAN@users.noreply.github.com> Date: Fri, 27 Sep 2024 22:37:14 +0200 Subject: [PATCH 327/369] Skip a few inputs on ssr Playwright consistently fails submitting-survey.spec on my machine when I run the whole suite. Here I just skip a few inputs until playwright consistently succeeds instead. * Option checkboxes and radiobuttons * Signature input This is certainly not a comprehensive solution, I just fixed the inputs that caused test failures. The placeholder is also particularily lazy, I did not even botter research loading UI in Zetkin. I think we can leave that as a later improvement. For most users, this loading UI will not be visible or quickly flash past. --- .../components/surveyForm/SurveyForm.tsx | 14 +++++-- .../surveyForm/SurveyOptionsQuestion.tsx | 38 +++++++++++-------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index 08a08b030d..097b1cdbc6 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -17,6 +17,7 @@ import SurveyPrivacyPolicy from './SurveyPrivacyPolicy'; import SurveySignature from './SurveySignature'; import SurveySubmitButton from './SurveySubmitButton'; import SurveySuccess from './SurveySuccess'; +import useServerSide from 'core/useServerSide'; import { ZetkinSurveyExtended, ZetkinSurveyFormStatus, @@ -33,6 +34,7 @@ const SurveyForm: FC = ({ survey, user }) => { submit, 'editing' ); + const isServer = useServerSide(); if (!survey) { return null; @@ -52,10 +54,14 @@ const SurveyForm: FC = ({ survey, user }) => { - + {isServer ? ( + '...' + ) : ( + + )} diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx index 9ad1aff372..d3e9b1e725 100644 --- a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -18,6 +18,7 @@ import SurveyOption from './SurveyOption'; import SurveyQuestionDescription from './SurveyQuestionDescription'; import SurveySubheading from './SurveySubheading'; import { useMessages } from 'core/i18n'; +import useServerSide from 'core/useServerSide'; import { ZetkinSurveyOption, ZetkinSurveyOptionsQuestionElement, @@ -33,6 +34,7 @@ const OptionsQuestion: FC = ({ element }) => { const handleDropdownChange = useCallback((event: SelectChangeEvent) => { setDropdownValue(event.target.value); }, []); + const isServer = useServerSide(); const question = element.question; return ( @@ -62,14 +64,16 @@ const OptionsQuestion: FC = ({ element }) => { )} - {question.options!.map((option: ZetkinSurveyOption) => ( - } - label={option.text} - value={option.id} - /> - ))} + {isServer + ? '...' + : question.options!.map((option: ZetkinSurveyOption) => ( + } + label={option.text} + value={option.id} + /> + ))} @@ -108,14 +112,16 @@ const OptionsQuestion: FC = ({ element }) => { - {question.options!.map((option: ZetkinSurveyOption) => ( - } - label={option.text} - value={option.id} - /> - ))} + {isServer + ? '...' + : question.options!.map((option: ZetkinSurveyOption) => ( + } + label={option.text} + value={option.id} + /> + ))} From a2bd8b0e1ff157ad86a49a47a0426af58b2ff9c7 Mon Sep 17 00:00:00 2001 From: WULCAN <7813515+WULCAN@users.noreply.github.com> Date: Fri, 27 Sep 2024 23:27:56 +0200 Subject: [PATCH 328/369] Move the work-around inside SurveySignature When directly in SurveyForm, the placeholder was rendered all the way out on the left side. I opted for a little duplication here instead of indenting everything. Ideally, we'll extract more components instead but this is not so bad. The placeholder is temporary anyway. --- .../surveys/components/surveyForm/SurveyForm.tsx | 14 ++++---------- .../components/surveyForm/SurveySignature.tsx | 10 ++++++++++ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx index 097b1cdbc6..08a08b030d 100644 --- a/src/features/surveys/components/surveyForm/SurveyForm.tsx +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -17,7 +17,6 @@ import SurveyPrivacyPolicy from './SurveyPrivacyPolicy'; import SurveySignature from './SurveySignature'; import SurveySubmitButton from './SurveySubmitButton'; import SurveySuccess from './SurveySuccess'; -import useServerSide from 'core/useServerSide'; import { ZetkinSurveyExtended, ZetkinSurveyFormStatus, @@ -34,7 +33,6 @@ const SurveyForm: FC = ({ survey, user }) => { submit, 'editing' ); - const isServer = useServerSide(); if (!survey) { return null; @@ -54,14 +52,10 @@ const SurveyForm: FC = ({ survey, user }) => { - {isServer ? ( - '...' - ) : ( - - )} + diff --git a/src/features/surveys/components/surveyForm/SurveySignature.tsx b/src/features/surveys/components/surveyForm/SurveySignature.tsx index 7acc638af2..4cb9fc508a 100644 --- a/src/features/surveys/components/surveyForm/SurveySignature.tsx +++ b/src/features/surveys/components/surveyForm/SurveySignature.tsx @@ -12,6 +12,7 @@ import { FC, useCallback, useState } from 'react'; import messageIds from 'features/surveys/l10n/messageIds'; import { Msg } from 'core/i18n'; +import useServerSide from 'core/useServerSide'; import SurveyContainer from './SurveyContainer'; import SurveyOption from './SurveyOption'; import SurveySubheading from './SurveySubheading'; @@ -40,6 +41,15 @@ const SurveySignature: FC = ({ survey, user }) => { [setSignatureType] ); + const isServer = useServerSide(); + if (isServer) { + return ( + + ... + + ); + } + return ( From 5320c88331f72c052d61a7a152a54df68514530d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Heli=20H=C3=A5rd=20K=C3=A4lloff?= Date: Sat, 28 Sep 2024 10:32:20 +0200 Subject: [PATCH 329/369] add person hover card in callers list --- .../callAssignments/components/CallAssignmentCallersList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/callAssignments/components/CallAssignmentCallersList.tsx b/src/features/callAssignments/components/CallAssignmentCallersList.tsx index f7c0d5e3e0..ee6a842aa5 100644 --- a/src/features/callAssignments/components/CallAssignmentCallersList.tsx +++ b/src/features/callAssignments/components/CallAssignmentCallersList.tsx @@ -8,6 +8,7 @@ import { CallAssignmentCaller } from '../apiTypes'; import TagChip from 'features/tags/components/TagManager/components/TagChip'; import { ZetkinTag } from 'utils/types/zetkin'; import ZUIEllipsisMenu from 'zui/ZUIEllipsisMenu'; +import ZUIPersonHoverCard from 'zui/ZUIPersonHoverCard'; import ZUIResponsiveContainer from 'zui/ZUIResponsiveContainer'; import { Msg, useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; @@ -77,7 +78,9 @@ const CallAssignmentCallersList = ({ field: 'id', headerName: ' ', renderCell: (params) => ( - + + + ), sortable: false, }, From a13991926815e529015d9109b993ca3b55e725ed Mon Sep 17 00:00:00 2001 From: KraftKatten Date: Sat, 28 Sep 2024 11:07:09 +0200 Subject: [PATCH 330/369] Issue 2153: Lists fix null pointer exception on value tag removal --- src/features/tags/store.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/features/tags/store.ts b/src/features/tags/store.ts index 6a2534401f..fd0a458ba2 100644 --- a/src/features/tags/store.ts +++ b/src/features/tags/store.ts @@ -112,9 +112,15 @@ const tagsSlice = createSlice({ }, tagUnassigned: (state, action: PayloadAction<[number, number]>) => { const [personId, tagId] = action.payload; - state.tagsByPersonId[personId].items = state.tagsByPersonId[ - personId - ].items.filter((item) => item.id != tagId); + const tagsByPersonId = state.tagsByPersonId[personId]; + + if (!tagsByPersonId) { + return; + } + + tagsByPersonId.items = state.tagsByPersonId[personId].items.filter( + (item) => item.id != tagId + ); }, tagUpdate: (state, action: PayloadAction<[number, string[]]>) => { const [tagId, mutating] = action.payload; From 85dd36372914d9625dfc79c4ab83ecfe56402308 Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 28 Sep 2024 11:15:37 +0200 Subject: [PATCH 331/369] Add CircularProgress skeleton --- .../[orgId]/people/incoming/index.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/pages/organize/[orgId]/people/incoming/index.tsx b/src/pages/organize/[orgId]/people/incoming/index.tsx index bd344f88c1..ea2e5739f0 100644 --- a/src/pages/organize/[orgId]/people/incoming/index.tsx +++ b/src/pages/organize/[orgId]/people/incoming/index.tsx @@ -1,7 +1,14 @@ import { GetServerSideProps } from 'next'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useState } from 'react'; -import { Box, FormControl, InputLabel, MenuItem, Select } from '@mui/material'; +import { + Box, + CircularProgress, + FormControl, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; import uniqBy from 'lodash/uniqBy'; import JoinFormSelect from 'features/joinForms/components/JoinFormSelect'; @@ -87,7 +94,17 @@ const IncomingPage: PageWithLayout = ({ orgId }) => { } + skeleton={ + + + + } > {(submissions) => { const filteredSubmissions = submissions.filter((submission) => { From 206197f91b24f1a4cb4e7ad8b29bc54adc2e1d0b Mon Sep 17 00:00:00 2001 From: Henry Catalini Smith Date: Sat, 28 Sep 2024 11:19:38 +0200 Subject: [PATCH 332/369] Extract prop to a named variable for clarity --- .../[orgId]/people/incoming/index.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/pages/organize/[orgId]/people/incoming/index.tsx b/src/pages/organize/[orgId]/people/incoming/index.tsx index ea2e5739f0..5fe0f7ac12 100644 --- a/src/pages/organize/[orgId]/people/incoming/index.tsx +++ b/src/pages/organize/[orgId]/people/incoming/index.tsx @@ -60,16 +60,19 @@ const IncomingPage: PageWithLayout = ({ orgId }) => { ignoreDataWhileLoading skeleton={} > - {(submissions) => ( - s.form), - 'id' - )} - onFormSelect={(form) => setFilterByForm(form?.id)} - /> - )} + {(submissions) => { + const uniqueForms = uniqBy( + submissions.map((s) => s.form), + 'id' + ); + return ( + setFilterByForm(form?.id)} + /> + ); + }} {/* filter by form submission status */} From c2dda9855be716a98912b659f5f60af080ed437d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Puig?= Date: Sat, 28 Sep 2024 12:18:59 +0200 Subject: [PATCH 333/369] add short data to the milestone date column --- .../JourneyInstancesDataTable/getStaticColumns.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/features/journeys/components/JourneyInstancesDataTable/getStaticColumns.tsx b/src/features/journeys/components/JourneyInstancesDataTable/getStaticColumns.tsx index c3202fc1f9..04093c7816 100644 --- a/src/features/journeys/components/JourneyInstancesDataTable/getStaticColumns.tsx +++ b/src/features/journeys/components/JourneyInstancesDataTable/getStaticColumns.tsx @@ -6,6 +6,7 @@ import { GridFilterItem, GridFilterOperator, } from '@mui/x-data-grid-pro'; +import { FormattedDate } from 'react-intl'; import FilterValueSelect from './FilterValueSelect'; import JourneyInstanceTitle from 'features/journeys/components/JourneyInstanceTitle'; @@ -260,7 +261,16 @@ export const getStaticColumns = ( field: 'nextMilestoneDeadline', renderCell: (params) => params.value ? ( - + <> + +  ( + + ) + ) : null, type: 'date', valueFormatter: (params) => new Date(params.value), From 5e7affdee6c2bd39c69a5531d550e0a499badfc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Puig?= Date: Sat, 28 Sep 2024 12:52:57 +0200 Subject: [PATCH 334/369] ensure events are sorted by start time --- src/features/calendar/components/CalendarDayView/Day/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/calendar/components/CalendarDayView/Day/index.tsx b/src/features/calendar/components/CalendarDayView/Day/index.tsx index 88d73ab5d2..366962b682 100644 --- a/src/features/calendar/components/CalendarDayView/Day/index.tsx +++ b/src/features/calendar/components/CalendarDayView/Day/index.tsx @@ -27,7 +27,7 @@ const Day = ({ date, dayInfo }: { date: Date; dayInfo: DaySummary }) => { gap={1} justifyItems="flex-start" > - {dayInfo.events.map((event, index) => { + {dayInfo.events.sort((a,b) => new Date(a.data.start_time).getTime() - new Date(b.data.start_time).getTime()).map((event, index) => { return ; })} From 9e9ae341a1fef496eeb6199c3adee4eaad96c6f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Puig?= Date: Sat, 28 Sep 2024 12:53:39 +0200 Subject: [PATCH 335/369] format code --- .../components/CalendarDayView/Day/index.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/features/calendar/components/CalendarDayView/Day/index.tsx b/src/features/calendar/components/CalendarDayView/Day/index.tsx index 366962b682..08ca5d5d39 100644 --- a/src/features/calendar/components/CalendarDayView/Day/index.tsx +++ b/src/features/calendar/components/CalendarDayView/Day/index.tsx @@ -27,9 +27,15 @@ const Day = ({ date, dayInfo }: { date: Date; dayInfo: DaySummary }) => { gap={1} justifyItems="flex-start" > - {dayInfo.events.sort((a,b) => new Date(a.data.start_time).getTime() - new Date(b.data.start_time).getTime()).map((event, index) => { - return ; - })} + {dayInfo.events + .sort( + (a, b) => + new Date(a.data.start_time).getTime() - + new Date(b.data.start_time).getTime() + ) + .map((event, index) => { + return ; + })} ); From 372d0be2e5afd7d3aac7e5b9fbca41e7ab879cfc Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Sat, 28 Sep 2024 13:07:56 +0200 Subject: [PATCH 336/369] create isLoading logic in the hook and implement it in component --- .../views/components/ViewDataTable/index.tsx | 175 ++++++++++-------- src/features/views/hooks/useCreateView.ts | 15 +- 2 files changed, 110 insertions(+), 80 deletions(-) diff --git a/src/features/views/components/ViewDataTable/index.tsx b/src/features/views/components/ViewDataTable/index.tsx index a01dcc4a73..96acb37a10 100644 --- a/src/features/views/components/ViewDataTable/index.tsx +++ b/src/features/views/components/ViewDataTable/index.tsx @@ -14,7 +14,7 @@ import { useGridApiRef, } from '@mui/x-data-grid-pro'; import { FunctionComponent, useContext, useState } from 'react'; -import { Link, useTheme } from '@mui/material'; +import { Box, CircularProgress, Link, useTheme } from '@mui/material'; import columnTypes from './columnTypes'; import EmptyView from 'features/views/components/EmptyView'; @@ -163,7 +163,7 @@ const ViewDataTable: FunctionComponent = ({ orgId, view.id ); - const createView = useCreateView(orgId); + const { createView, isLoading } = useCreateView(orgId); const viewGrid = useViewGrid(orgId, view.id); const { updateColumnOrder } = useViewMutations(orgId); @@ -466,85 +466,108 @@ const ViewDataTable: FunctionComponent = ({ return ( <> - - params.id == addedId ? classes.addedRow : '' - } - hideFooter={empty || contentSource == VIEW_CONTENT_SOURCE.DYNAMIC} - localeText={{ - ...theme.components?.MuiDataGrid?.defaultProps?.localeText, - noRowsLabel: messages.empty.notice[contentSource](), - }} - onCellEditStart={(params, event) => { - if (params.reason == GridCellEditStartReasons.printableKeyDown) { - // Don't enter edit mode when the user just presses a printable character. - // Doing so is the default DataGrid behaviour (as in spreadsheets) but it - // means the user will overwrite the original value, which is rarely what - // you want with the precious data that exists in views (when there is no - // undo feature). - event.defaultMuiPrevented = true; + {!isLoading && ( + + params.id == addedId ? classes.addedRow : '' } - }} - onCellKeyDown={(params: GridCellParams, ev) => { - if (!params.isEditable) { - const col = colFromFieldName(params.field, columns); - if (col) { - const handleKeyDown = columnTypes[col.type].handleKeyDown; - if (handleKeyDown) { - handleKeyDown( - viewGrid, - col, - params.row.id, - params.value, - ev, - accessLevel - ); + hideFooter={empty || contentSource == VIEW_CONTENT_SOURCE.DYNAMIC} + localeText={{ + ...theme.components?.MuiDataGrid?.defaultProps?.localeText, + noRowsLabel: messages.empty.notice[contentSource](), + }} + onCellEditStart={(params, event) => { + if (params.reason == GridCellEditStartReasons.printableKeyDown) { + // Don't enter edit mode when the user just presses a printable character. + // Doing so is the default DataGrid behaviour (as in spreadsheets) but it + // means the user will overwrite the original value, which is rarely what + // you want with the precious data that exists in views (when there is no + // undo feature). + event.defaultMuiPrevented = true; + } + }} + onCellKeyDown={( + params: GridCellParams, + ev + ) => { + if (!params.isEditable) { + const col = colFromFieldName(params.field, columns); + if (col) { + const handleKeyDown = columnTypes[col.type].handleKeyDown; + if (handleKeyDown) { + handleKeyDown( + viewGrid, + col, + params.row.id, + params.value, + ev, + accessLevel + ); + } } } - } - }} - onColumnOrderChange={(params) => { - moveColumn(params.column.field, params.targetIndex); - }} - onColumnResize={(params) => { - setColumnWidth(params.colDef.field, params.width); - }} - onRowSelectionModelChange={(model) => setSelection(model as number[])} - pinnedColumns={{ - left: ['id', GRID_CHECKBOX_SELECTION_COL_DEF.field], - }} - processRowUpdate={(after, before) => { - const changedField = Object.keys(after).find( - (key) => after[key] != before[key] - ); - if (changedField) { - const col = colFromFieldName(changedField, columns); - if (col) { - const processRowUpdate = columnTypes[col.type].processRowUpdate; - if (processRowUpdate) { - processRowUpdate(viewGrid, col, after.id, after[changedField]); + }} + onColumnOrderChange={(params) => { + moveColumn(params.column.field, params.targetIndex); + }} + onColumnResize={(params) => { + setColumnWidth(params.colDef.field, params.width); + }} + onRowSelectionModelChange={(model) => setSelection(model as number[])} + pinnedColumns={{ + left: ['id', GRID_CHECKBOX_SELECTION_COL_DEF.field], + }} + processRowUpdate={(after, before) => { + const changedField = Object.keys(after).find( + (key) => after[key] != before[key] + ); + if (changedField) { + const col = colFromFieldName(changedField, columns); + if (col) { + const processRowUpdate = columnTypes[col.type].processRowUpdate; + if (processRowUpdate) { + processRowUpdate( + viewGrid, + col, + after.id, + after[changedField] + ); + } } } - } - return after; - }} - rows={gridRows} - slotProps={componentsProps} - slots={{ - columnMenu: ViewDataTableColumnMenu, - footer: ViewDataTableFooter, - toolbar: ViewDataTableToolbar, - }} - style={{ - border: 'none', - }} - {...modelGridProps} - /> + return after; + }} + rows={gridRows} + slotProps={componentsProps} + slots={{ + columnMenu: ViewDataTableColumnMenu, + footer: ViewDataTableFooter, + toolbar: ViewDataTableToolbar, + }} + style={{ + border: 'none', + }} + {...modelGridProps} + /> + )} + {isLoading && ( + + + + )} {empty && } {columnToRename && ( void { +interface useCreateViewReturn { + createView: (folderId?: number, rows?: number[]) => void; + isLoading: boolean; +} + +export default function useCreateView(orgId: number): useCreateViewReturn { const apiClient = useApiClient(); const router = useRouter(); const dispatch = useAppDispatch(); + const [isLoading, setIsLoading] = useState(false); const createView = async ( folderId = 0, rows: number[] = [] ): Promise => { + setIsLoading(true); dispatch(viewCreate()); const view = await apiClient.rpc(createNew, { folderId, @@ -24,8 +30,9 @@ export default function useCreateView( }); dispatch(viewCreated(view)); router.push(`/organize/${view.organization.id}/people/lists/${view.id}`); + setIsLoading(false); return view; }; - return createView; + return { createView, isLoading }; } From bdf42c995edc845998cc760650b2eea092ce2c51 Mon Sep 17 00:00:00 2001 From: idkirsch Date: Sat, 28 Sep 2024 13:08:17 +0200 Subject: [PATCH 337/369] html space --- src/zui/ZUIDuration/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/zui/ZUIDuration/index.tsx b/src/zui/ZUIDuration/index.tsx index 1dfba4d956..49de759593 100644 --- a/src/zui/ZUIDuration/index.tsx +++ b/src/zui/ZUIDuration/index.tsx @@ -49,12 +49,14 @@ const ZUIDuration: FC = ({ {fields .filter((field) => field.visible) .filter((field) => field.n > 0) - .map((field) => ( - + .map((field, index, array) => ( + + {/* Add a space after the hours field */} + {field.msgId === 'h' && index < array.length - 1 && ' '} ))} From ceb7d2bac18e90b8b27fb7eaab99b56b1c825be6 Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Sat, 28 Sep 2024 13:14:36 +0200 Subject: [PATCH 338/369] implement return in hook --- src/features/views/components/PeopleActionButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/views/components/PeopleActionButton.tsx b/src/features/views/components/PeopleActionButton.tsx index ad0758a111..982de30d8a 100644 --- a/src/features/views/components/PeopleActionButton.tsx +++ b/src/features/views/components/PeopleActionButton.tsx @@ -36,7 +36,7 @@ const PeopleActionButton: FC = ({ const [importDialogOpen, setImportDialogOpen] = useState(false); const [createPersonOpen, setCreatePersonOpen] = useState(false); - const createView = useCreateView(orgId); + const { createView } = useCreateView(orgId); const { createFolder } = useFolder(orgId, folderId); const { createForm } = useCreateJoinForm(orgId); From d49e84ccb24f4cd419b595396bcde4f93375833a Mon Sep 17 00:00:00 2001 From: Zebastian Date: Sat, 28 Sep 2024 14:06:07 +0200 Subject: [PATCH 339/369] Remove margin under email write-only alert --- src/features/emails/components/EmailEditor/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/emails/components/EmailEditor/index.tsx b/src/features/emails/components/EmailEditor/index.tsx index e1654fb118..35c111f2a1 100644 --- a/src/features/emails/components/EmailEditor/index.tsx +++ b/src/features/emails/components/EmailEditor/index.tsx @@ -61,7 +61,7 @@ const EmailEditor: FC = ({ email, onSave }) => { return ( {readOnly && ( - + )} From 63f8558a71e0f5522eecdb53c2b437837669491d Mon Sep 17 00:00:00 2001 From: idkirsch Date: Sat, 28 Sep 2024 14:35:12 +0200 Subject: [PATCH 340/369] fixed it by adding a space behind the letters --- src/zui/ZUIDuration/index.stories.tsx | 2 +- src/zui/ZUIDuration/index.tsx | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/zui/ZUIDuration/index.stories.tsx b/src/zui/ZUIDuration/index.stories.tsx index 2a07ed5103..8ee5bbaafe 100644 --- a/src/zui/ZUIDuration/index.stories.tsx +++ b/src/zui/ZUIDuration/index.stories.tsx @@ -9,7 +9,7 @@ export default { export const Basic: StoryObj = { args: { - seconds: 12345, + seconds: 123456789.5, withDays: true, withHours: true, withMinutes: true, diff --git a/src/zui/ZUIDuration/index.tsx b/src/zui/ZUIDuration/index.tsx index 49de759593..06753a7f43 100644 --- a/src/zui/ZUIDuration/index.tsx +++ b/src/zui/ZUIDuration/index.tsx @@ -54,9 +54,7 @@ const ZUIDuration: FC = ({ - {/* Add a space after the hours field */} - {field.msgId === 'h' && index < array.length - 1 && ' '} + />{' '} ))} From a20330d72e6744bced4f0a9696393d99ba552c2e Mon Sep 17 00:00:00 2001 From: idkirsch Date: Sat, 28 Sep 2024 14:41:26 +0200 Subject: [PATCH 341/369] deleted some variabled that were never used --- src/zui/ZUIDuration/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zui/ZUIDuration/index.tsx b/src/zui/ZUIDuration/index.tsx index 06753a7f43..50db9cdeb5 100644 --- a/src/zui/ZUIDuration/index.tsx +++ b/src/zui/ZUIDuration/index.tsx @@ -49,7 +49,7 @@ const ZUIDuration: FC = ({ {fields .filter((field) => field.visible) .filter((field) => field.n > 0) - .map((field, index, array) => ( + .map((field) => ( Date: Sat, 28 Sep 2024 14:49:33 +0200 Subject: [PATCH 342/369] reverting changes to storybook example --- src/zui/ZUIDuration/index.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zui/ZUIDuration/index.stories.tsx b/src/zui/ZUIDuration/index.stories.tsx index 8ee5bbaafe..2a07ed5103 100644 --- a/src/zui/ZUIDuration/index.stories.tsx +++ b/src/zui/ZUIDuration/index.stories.tsx @@ -9,7 +9,7 @@ export default { export const Basic: StoryObj = { args: { - seconds: 123456789.5, + seconds: 12345, withDays: true, withHours: true, withMinutes: true, From b25d80766a0957f57231c9730ec1e5795fa21146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sat, 28 Sep 2024 14:56:04 +0200 Subject: [PATCH 343/369] Updates Call History Smart Search logic and YML - Rewords the notreached string to be more in line with what the issue states - Changes the logic to not display the minTimes segment when notreached is selected - Removes affected localization strings --- .../components/filters/CallHistory/index.tsx | 41 ++++++++++--------- src/features/smartSearch/l10n/messageIds.ts | 8 ++-- src/locale/da.yml | 4 -- src/locale/de.yml | 3 -- src/locale/en.yml | 4 -- src/locale/nn.yml | 4 -- src/locale/sv.yml | 4 -- 7 files changed, 25 insertions(+), 43 deletions(-) diff --git a/src/features/smartSearch/components/filters/CallHistory/index.tsx b/src/features/smartSearch/components/filters/CallHistory/index.tsx index e42a2f2ecd..1dcf0299ba 100644 --- a/src/features/smartSearch/components/filters/CallHistory/index.tsx +++ b/src/features/smartSearch/components/filters/CallHistory/index.tsx @@ -171,26 +171,27 @@ const CallHistory = ({ ))} ), - minTimes: ( - { - setConfig({ - ...filter.config, - minTimes: +e.target.value, - }); - }} - /> - ), - minTimes: filter.config.minTimes || 1, - }} - /> - ), + minTimes: + filter.config.operator != CALL_OPERATOR.NOTREACHED ? ( + { + setConfig({ + ...filter.config, + minTimes: +e.target.value, + }); + }} + /> + ), + minTimes: filter.config.minTimes || 1, + }} + /> + ) : null, timeFrame: ( ( - '{addRemoveSelect} people who {callSelect} at least {minTimes} in {assignmentSelect} {timeFrame}.' + '{addRemoveSelect} people who {callSelect} {minTimes} in {assignmentSelect} {timeFrame}.' ), minTimes: m<{ minTimes: number }>( '{minTimes, plural, one {once} other {# times}}' @@ -192,7 +192,7 @@ export default makeMessages('feat.smartSearch', { minTimesInput: m<{ input: ReactElement; minTimes: number; - }>('{input} {minTimes, plural, one {time} other {times}}'), + }>('at least {input} {minTimes, plural, one {time} other {times}}'), }, campaignParticipation: { activitySelect: { diff --git a/src/locale/da.yml b/src/locale/da.yml index 343c36c9b2..40e12a27f2 100644 --- a/src/locale/da.yml +++ b/src/locale/da.yml @@ -618,17 +618,13 @@ feat: none: Denne organisation har ingen ringeopgaver endnu callSelect: called: er blevet ringet op - notreached: er blevet forsøgt uden held reached: er nået med succes examples: one: Tilføj personer som vi har nået mindst to gange nogensinde i hvilken som helst opgave til. two: Fjern personer, der er blevet ringet op mindst 1 gang i opgaven 'Aktiver gamle medlemmer' i løbet af de sidste 30 dage. - inputString: '{addRemoveSelect} personer som {callSelect} mindst {minTimes} - i {assignmentSelect} {timeFrame}.' minTimes: '{minTimes} {minTimes, plural, one {gang} other {gange}}' - minTimesInput: '{input} {minTimes, plural, one {gang} other {gange}}' campaignParticipation: activitySelect: activity: type "{activity}" diff --git a/src/locale/de.yml b/src/locale/de.yml index a7089eb82f..7ad3a7195b 100644 --- a/src/locale/de.yml +++ b/src/locale/de.yml @@ -1399,10 +1399,7 @@ feat: für irgendeine Aufgabe zu irgendeinem Zeitpunkt. two: Entferne Personen, die in den letzten 30 Tagen mindestens 1mal angerufen wurden für die Aufgabe "langjährige Mitglieder aktivieren". - inputString: '{addRemoveSelect} Personen, die {callSelect} mindestens {minTimesInput} - {minTimes} in {assignmentSelect} {timeFrame}.' minTimes: '{minTimesInput} {minTimes, plural, one {mal} other {mal}}' - minTimesInput: '{input} {minTimes, plural, one {mal} other {mal}}' campaignParticipation: activitySelect: activity: Typ "{activity}" diff --git a/src/locale/en.yml b/src/locale/en.yml index 1f5714467f..423f499510 100644 --- a/src/locale/en.yml +++ b/src/locale/en.yml @@ -1379,17 +1379,13 @@ feat: none: This organization doesn't have any call assignments yet callSelect: called: have been called - notreached: have been unsuccessfully tried reached: have been successfully reached examples: one: Add people who have been successfully reached at least 2 times in any assignment at any point in time. two: Remove people who have been called at least 1 time in assignment 'Activate old members' during the last 30 days. - inputString: "{addRemoveSelect} people who {callSelect} at least {minTimes} in - {assignmentSelect} {timeFrame}." minTimes: "{minTimes, plural, one {once} other {# times}}" - minTimesInput: "{input} {minTimes, plural, one {time} other {times}}" campaignParticipation: activitySelect: activity: type "{activity}" diff --git a/src/locale/nn.yml b/src/locale/nn.yml index 0d0777d20c..0df80e7d6e 100644 --- a/src/locale/nn.yml +++ b/src/locale/nn.yml @@ -1406,17 +1406,13 @@ feat: none: Denne organisasjonen har ingen ringeoppdrag, ennå! callSelect: called: har blitt ringt - notreached: har blitt forsøkt ringt reached: har blitt nådd examples: one: Legg til folk som har blitt nådd minst 2 ganger i et hvilket som helst ringeoppdrag når som helst. two: Fjern folk som har blitt ringt minst en gang i ringeoppdraget "Valgkamp 2023" de siste 30 dagene. - inputString: '{addRemoveSelect} folk som {callSelect} minst {minTimes} i {assignmentSelect} - {timeFrame}.' minTimes: '{minTimes} {minTimes, plural, one {gang} other {ganger}}' - minTimesInput: '{input} {minTimes, plural, one {gang} other {ganger}}' campaignParticipation: activitySelect: activity: aktiviteten "{activity}" diff --git a/src/locale/sv.yml b/src/locale/sv.yml index 0dec8b3230..888c6e4206 100644 --- a/src/locale/sv.yml +++ b/src/locale/sv.yml @@ -1398,17 +1398,13 @@ feat: none: Den här organisationen har inga ringuppdrag än callSelect: called: har blivit uppringda - notreached: har fått ett samtalsförsök reached: har blivit nådda examples: one: Lägg till personer som blivit nådda åtminstone 2 gånger i något uppdrag närsomhelst. two: Ta bort personer som blivit ringda åtminstone 1 gång i uppdraget 'Aktivera gamla medlemmar' under de senaste 30 dagarna. - inputString: '{addRemoveSelect} personer som {callSelect} minst {minTimes} - i {assignmentSelect} {timeFrame}.' minTimes: '{minTimesInput} {minTimes, plural, one {gång} other {gånger}}' - minTimesInput: '{input} {minTimes, plural, one {gång} other {gånger}}' campaignParticipation: activitySelect: activity: typen "{activity}" From a46fef83775a9217ae399f347cd0ff4d498a89be Mon Sep 17 00:00:00 2001 From: Johan Rende Date: Sat, 28 Sep 2024 15:09:57 +0200 Subject: [PATCH 344/369] issue-2152: Show in week if day is DST start or end --- .../components/CalendarWeekView/DayHeader.tsx | 22 ++++++++++++++++++- .../components/CalendarWeekView/utils.ts | 6 +++++ src/features/calendar/l10n/messageIds.ts | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/features/calendar/components/CalendarWeekView/DayHeader.tsx b/src/features/calendar/components/CalendarWeekView/DayHeader.tsx index a98d158db6..7b3a86e9a2 100644 --- a/src/features/calendar/components/CalendarWeekView/DayHeader.tsx +++ b/src/features/calendar/components/CalendarWeekView/DayHeader.tsx @@ -1,7 +1,12 @@ import { FormattedDate } from 'react-intl'; import { Box, Typography } from '@mui/material'; +import { useMemo } from 'react'; +import dayjs from 'dayjs'; import theme from 'theme'; +import { getDSTOffset } from './utils'; +import { Msg } from 'core/i18n'; +import messageIds from '../../l10n/messageIds'; export interface DayHeaderProps { date: Date; @@ -10,11 +15,18 @@ export interface DayHeaderProps { } const DayHeader = ({ date, focused, onClick }: DayHeaderProps) => { + const dstChangeAmount: number = useMemo( + () => + getDSTOffset(dayjs(date).startOf('day').toDate()) - + getDSTOffset(dayjs(date).endOf('day').toDate()), + [date] + ); + return ( onClick()} sx={{ cursor: 'pointer', @@ -49,6 +61,14 @@ const DayHeader = ({ date, focused, onClick }: DayHeaderProps) => { {/* Empty */} + {dstChangeAmount !== 0 && ( + + + {dstChangeAmount > 0 && } + {dstChangeAmount < 0 && } + + + )} ); }; diff --git a/src/features/calendar/components/CalendarWeekView/utils.ts b/src/features/calendar/components/CalendarWeekView/utils.ts index c3484498bb..512caaa481 100644 --- a/src/features/calendar/components/CalendarWeekView/utils.ts +++ b/src/features/calendar/components/CalendarWeekView/utils.ts @@ -30,3 +30,9 @@ export function scrollToEarliestEvent( weekElement.scrollTo({ behavior: 'smooth', top: heightInPixels }); } + +export function getDSTOffset(date: Date): number { + const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset(); + const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); + return (date.getTimezoneOffset() - Math.max(jan, jul)) / 60; +} diff --git a/src/features/calendar/l10n/messageIds.ts b/src/features/calendar/l10n/messageIds.ts index 20dac78274..212b0f3b67 100644 --- a/src/features/calendar/l10n/messageIds.ts +++ b/src/features/calendar/l10n/messageIds.ts @@ -7,6 +7,8 @@ export default makeMessages('feat.calendar', { shiftEvent: m('Create multiple events that form shifts'), singleEvent: m('Create single event'), }, + dstEnds: m('DST ends'), + dstStarts: m('DST starts'), event: { differentLocations: m<{ numLocations: number }>( '{numLocations} different locations' From 029a5c89e390d21a49795532b8522cc401d48097 Mon Sep 17 00:00:00 2001 From: KraftKatten Date: Sat, 28 Sep 2024 15:23:58 +0200 Subject: [PATCH 345/369] Issue 2149 - ZUIDuration change formatting and properties of duration --- src/zui/ZUIDuration/index.stories.tsx | 9 +- src/zui/ZUIDuration/index.tsx | 119 +++++++++++++++++++++----- 2 files changed, 102 insertions(+), 26 deletions(-) diff --git a/src/zui/ZUIDuration/index.stories.tsx b/src/zui/ZUIDuration/index.stories.tsx index 2a07ed5103..bc476f2026 100644 --- a/src/zui/ZUIDuration/index.stories.tsx +++ b/src/zui/ZUIDuration/index.stories.tsx @@ -9,11 +9,8 @@ export default { export const Basic: StoryObj = { args: { - seconds: 12345, - withDays: true, - withHours: true, - withMinutes: true, - withSeconds: false, - withThousands: false, + seconds: 123456.789, + upperTimeUnit: 'days', + lowerTimeUnit: 'minutes', }, }; diff --git a/src/zui/ZUIDuration/index.tsx b/src/zui/ZUIDuration/index.tsx index 1dfba4d956..df65c25c22 100644 --- a/src/zui/ZUIDuration/index.tsx +++ b/src/zui/ZUIDuration/index.tsx @@ -9,11 +9,16 @@ type Props = { * are supported and negative values will result in no rendered output. */ seconds: number; - withDays?: boolean; - withHours?: boolean; - withMinutes?: boolean; - withSeconds?: boolean; - withThousands?: boolean; + + /** + * The upper range unit of time to display the duration in. + */ + upperTimeUnit?: 'milliseconds' | 'seconds' | 'minutes' | 'hours' | 'days'; + + /** + * The lower range unit of time to display the duration in. + */ + lowerTimeUnit?: 'milliseconds' | 'seconds' | 'minutes' | 'hours' | 'days'; }; type DurField = { @@ -22,26 +27,100 @@ type DurField = { visible: boolean; }; +const timeUnitMap = new Map([ + ['milliseconds', 0], + ['seconds', 1], + ['minutes', 2], + ['hours', 3], + ['days', 4], +] as const); + const ZUIDuration: FC = ({ - withDays = true, - withHours = true, - withThousands = false, - withMinutes = true, - withSeconds = false, + upperTimeUnit = 'days', + lowerTimeUnit = 'minutes', seconds, }) => { - const ms = (seconds * 1000) % 1000; - const s = Math.floor(seconds) % 60; - const m = Math.floor(seconds / 60) % 60; - const h = Math.floor(seconds / 60 / 60) % 24; - const days = Math.floor(seconds / 60 / 60 / 24); + var upper = timeUnitMap.get(upperTimeUnit) ?? 4; + var lower = timeUnitMap.get(lowerTimeUnit) ?? 2; + + if (upper < lower) { + upper = lower; + } + + var timeUnits = [...timeUnitMap.keys()].filter( + (timeUnit) => + (timeUnitMap.get(timeUnit) ?? 0) <= upper && + (timeUnitMap.get(timeUnit) ?? 0) >= lower + ); + + const minutesToSecondsFactor = 60; + const hoursToSecondsFactor = 60 * minutesToSecondsFactor; + const daysToSecondsFactor = 24 * hoursToSecondsFactor; + + const days = timeUnits.includes('days') + ? Math.floor(Math.floor(seconds) / daysToSecondsFactor) + : 0; + + const h = timeUnits.includes('hours') + ? Math.floor( + (Math.floor(seconds) - days * daysToSecondsFactor) / + hoursToSecondsFactor + ) + : 0; + + const m = timeUnits.includes('minutes') + ? Math.floor( + (Math.floor(seconds) - + days * daysToSecondsFactor - + h * hoursToSecondsFactor) / + minutesToSecondsFactor + ) + : 0; + + const s = timeUnits.includes('seconds') + ? Math.floor( + Math.floor(seconds) - + days * daysToSecondsFactor - + h * hoursToSecondsFactor - + m * minutesToSecondsFactor + ) + : 0; + + const ms = Math.round( + (seconds - + days * daysToSecondsFactor - + h * hoursToSecondsFactor - + m * minutesToSecondsFactor - + s) * + 1000 + ); const fields: DurField[] = [ - { msgId: 'days', n: days, visible: withDays }, - { msgId: 'h', n: h, visible: withHours }, - { msgId: 'm', n: m, visible: withMinutes }, - { msgId: 's', n: s, visible: withSeconds }, - { msgId: 'ms', n: ms, visible: withThousands }, + { + msgId: 'days', + n: days, + visible: timeUnits.includes('days'), + }, + { + msgId: 'h', + n: h, + visible: timeUnits.includes('hours'), + }, + { + msgId: 'm', + n: m, + visible: timeUnits.includes('minutes'), + }, + { + msgId: 's', + n: s, + visible: timeUnits.includes('seconds'), + }, + { + msgId: 'ms', + n: ms, + visible: timeUnits.includes('milliseconds'), + }, ]; return ( From d872a29cacf141cd7c113f971c23e26852ab3578 Mon Sep 17 00:00:00 2001 From: KraftKatten Date: Sat, 28 Sep 2024 15:51:06 +0200 Subject: [PATCH 346/369] Issue 2149: Fix order of properties --- src/zui/ZUIDuration/index.stories.tsx | 2 +- src/zui/ZUIDuration/index.tsx | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/zui/ZUIDuration/index.stories.tsx b/src/zui/ZUIDuration/index.stories.tsx index bc476f2026..b248eb3f40 100644 --- a/src/zui/ZUIDuration/index.stories.tsx +++ b/src/zui/ZUIDuration/index.stories.tsx @@ -9,8 +9,8 @@ export default { export const Basic: StoryObj = { args: { + lowerTimeUnit: 'minutes', seconds: 123456.789, upperTimeUnit: 'days', - lowerTimeUnit: 'minutes', }, }; diff --git a/src/zui/ZUIDuration/index.tsx b/src/zui/ZUIDuration/index.tsx index df65c25c22..1f35e5ba92 100644 --- a/src/zui/ZUIDuration/index.tsx +++ b/src/zui/ZUIDuration/index.tsx @@ -4,6 +4,11 @@ import messageIds from 'zui/l10n/messageIds'; import { Msg } from 'core/i18n'; type Props = { + /** + * The lower range unit of time to display the duration in. + */ + lowerTimeUnit?: 'milliseconds' | 'seconds' | 'minutes' | 'hours' | 'days'; + /** * The duration in seconds that should be visualized. Only positive durations * are supported and negative values will result in no rendered output. @@ -14,11 +19,6 @@ type Props = { * The upper range unit of time to display the duration in. */ upperTimeUnit?: 'milliseconds' | 'seconds' | 'minutes' | 'hours' | 'days'; - - /** - * The lower range unit of time to display the duration in. - */ - lowerTimeUnit?: 'milliseconds' | 'seconds' | 'minutes' | 'hours' | 'days'; }; type DurField = { @@ -40,14 +40,14 @@ const ZUIDuration: FC = ({ lowerTimeUnit = 'minutes', seconds, }) => { - var upper = timeUnitMap.get(upperTimeUnit) ?? 4; - var lower = timeUnitMap.get(lowerTimeUnit) ?? 2; + let upper = timeUnitMap.get(upperTimeUnit) ?? 4; + const lower = timeUnitMap.get(lowerTimeUnit) ?? 2; if (upper < lower) { upper = lower; } - var timeUnits = [...timeUnitMap.keys()].filter( + const timeUnits = [...timeUnitMap.keys()].filter( (timeUnit) => (timeUnitMap.get(timeUnit) ?? 0) <= upper && (timeUnitMap.get(timeUnit) ?? 0) >= lower From 1cf2611854ffd6cb62f0003a65a23231b22e176a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9mi=20Puig?= Date: Sat, 28 Sep 2024 16:16:17 +0200 Subject: [PATCH 347/369] add a prop to PotentialDuplicateLists to be able not to show the "exclude" button --- .../components/MergeCandidateList.tsx | 24 +++++++++++-------- .../components/PotentialDuplicatesLists.tsx | 1 + 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/features/duplicates/components/MergeCandidateList.tsx b/src/features/duplicates/components/MergeCandidateList.tsx index fe087fb727..9c62eee9c4 100644 --- a/src/features/duplicates/components/MergeCandidateList.tsx +++ b/src/features/duplicates/components/MergeCandidateList.tsx @@ -19,12 +19,14 @@ interface MergeCandidateListProps { buttonLabel: string; onButtonClick: (person: ZetkinPerson) => void; rows: ZetkinPerson[]; + showActionButton?: boolean; } const MergeCandidateList: FC = ({ buttonLabel, onButtonClick, rows, + showActionButton = true, }) => { const { orgId } = useNumericRouteParams(); @@ -82,16 +84,18 @@ const MergeCandidateList: FC = ({ } /> - + {showActionButton && ( + + )} ); diff --git a/src/features/duplicates/components/PotentialDuplicatesLists.tsx b/src/features/duplicates/components/PotentialDuplicatesLists.tsx index 8bc4847596..5e01d0f164 100644 --- a/src/features/duplicates/components/PotentialDuplicatesLists.tsx +++ b/src/features/duplicates/components/PotentialDuplicatesLists.tsx @@ -28,6 +28,7 @@ const PotentialDuplicatesLists: FC = ({ buttonLabel={messages.modal.notDuplicateButton()} onButtonClick={onDeselect} rows={peopleToMerge} + showActionButton={peopleNotToMerge.length + peopleToMerge.length > 2} /> {peopleToMerge.length > 0 && } {peopleNotToMerge.length > 0 && ( From 3d890b6e5d336339e0483c64eb72b2a048c9ee60 Mon Sep 17 00:00:00 2001 From: Herover Date: Sat, 28 Sep 2024 16:17:46 +0200 Subject: [PATCH 348/369] Fix non-dashed empty results after empty segment in makeSankeySegments Co-authored-by: Idkirsch --- .../smartSearch/components/sankeyDiagram/makeSankeySegments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/smartSearch/components/sankeyDiagram/makeSankeySegments.ts b/src/features/smartSearch/components/sankeyDiagram/makeSankeySegments.ts index feb4d651f7..6fe6febf41 100644 --- a/src/features/smartSearch/components/sankeyDiagram/makeSankeySegments.ts +++ b/src/features/smartSearch/components/sankeyDiagram/makeSankeySegments.ts @@ -76,7 +76,7 @@ export default function makeSankeySegments( kind: SEGMENT_KIND.PSEUDO_ADD, main: null, side: { - style: SEGMENT_STYLE.FILL, + style: change == 0 ? SEGMENT_STYLE.STROKE : SEGMENT_STYLE.FILL, width: change / maxPeople, }, stats, From b2cb3c3def131df0d7ffe0d85c11845146febc79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Heli=20H=C3=A5rd=20K=C3=A4lloff?= Date: Sat, 28 Sep 2024 16:24:19 +0200 Subject: [PATCH 349/369] delete redundant component --- src/zui/ZUICopyToClipboard.tsx | 63 ---------------------------------- 1 file changed, 63 deletions(-) delete mode 100644 src/zui/ZUICopyToClipboard.tsx diff --git a/src/zui/ZUICopyToClipboard.tsx b/src/zui/ZUICopyToClipboard.tsx deleted file mode 100644 index 2940a4555c..0000000000 --- a/src/zui/ZUICopyToClipboard.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import copy from 'copy-to-clipboard'; -import { ButtonBase, Collapse, Fade, Grid, Typography } from '@mui/material'; -import React, { useState } from 'react'; - -import { CopyIcon } from './ZUIInlineCopyToClipBoard'; -import { Msg } from 'core/i18n'; -import messageIds from './l10n/messageIds'; - -const ZUICopyToClipboard: React.FunctionComponent<{ - children: React.ReactNode; - copyText: string | number | boolean; -}> = ({ children, copyText }) => { - const [hover, setHover] = useState(false); - const [copied, setCopied] = useState(false); - - const handleClick = () => { - copy(copyText.toString()); - setCopied(true); - setTimeout(() => setCopied(false), 2500); - }; - - return ( - - setHover(true)} - onMouseLeave={() => setHover(false)} - style={{ cursor: 'pointer' }} - > - - {children} - - - - - - - - - - - - - - - - ); -}; - -export default ZUICopyToClipboard; From df4cad7ff9929d748a2bad59f587cacab5b70b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20J=C3=B6nsson?= Date: Sat, 28 Sep 2024 16:32:14 +0200 Subject: [PATCH 350/369] add option to move person lists in a mobile-friendly dialog --- .../views/components/MoveViewDialog.tsx | 208 ++++++++++++++++++ .../views/components/ViewBrowser/index.tsx | 19 ++ src/features/views/l10n/messageIds.ts | 8 + 3 files changed, 235 insertions(+) create mode 100644 src/features/views/components/MoveViewDialog.tsx diff --git a/src/features/views/components/MoveViewDialog.tsx b/src/features/views/components/MoveViewDialog.tsx new file mode 100644 index 0000000000..40ab224c4f --- /dev/null +++ b/src/features/views/components/MoveViewDialog.tsx @@ -0,0 +1,208 @@ +import { range } from 'lodash'; +import { FunctionComponent } from 'react'; +import { Close, Folder, SubdirectoryArrowRight } from '@mui/icons-material'; +import { + Button, + Dialog, + DialogContent, + DialogTitle, + Icon, + IconButton, + List, + ListItem, + useMediaQuery, +} from '@mui/material'; +import { Box } from '@mui/system'; + +import { useNumericRouteParams } from 'core/hooks'; +import theme from 'theme'; +import ZUIFuture from 'zui/ZUIFuture'; +import { ZetkinViewFolder } from './types'; +import { ViewBrowserItem } from '../hooks/useViewBrowserItems'; +import useViewBrowserMutations from '../hooks/useViewBrowserMutations'; +import useViewTree from '../hooks/useViewTree'; +import { useMessages } from 'core/i18n'; +import messageIds from '../l10n/messageIds'; + +type FolderInsideTreeStructure = { + folder: ZetkinViewFolder; + insideMovedItem: boolean; + nestingLevel: number; +}; + +/** For each subfolder of `id`, add it to the list, followed by its subfolders (and their subfolders, recursively), creating a tree-like structure */ +const sortFoldersByTreeStructureRecursive = ( + id: number | null, + nestingLevel: number, + folderToMove: number | null, + alreadyInsideMovedItem: boolean, + allFolders: ZetkinViewFolder[] +): FolderInsideTreeStructure[] => { + // Find all subfolders and sort them alphabetically + const subfolders = allFolders + .filter((f) => f.parent?.id == id) + .sort((a, b) => a.title.localeCompare(b.title)); + + return subfolders.flatMap((folder) => { + // We cannot move a folder inside itself, or inside one of its children (or their children, recursively) + // This condition makes it so that once we have encountered the folder-to-be-moved, we keep passing `true` all the way down the recursive tree + const insideMovedItem = alreadyInsideMovedItem || folder.id == folderToMove; + return [ + { + folder, + insideMovedItem, + nestingLevel, + }, + ...sortFoldersByTreeStructureRecursive( + folder.id, + nestingLevel + 1, + folderToMove, + insideMovedItem, + allFolders + ), + ]; + }); +}; + +const sortFoldersByTreeStructure = ( + itemToMove: number | null, + allFolders: ZetkinViewFolder[] +): FolderInsideTreeStructure[] => { + return sortFoldersByTreeStructureRecursive( + null, + 0, + itemToMove, + false, + allFolders + ); +}; + +export type MoveViewDialogProps = { + close: () => void; + itemToMove: ViewBrowserItem; +}; +const MoveViewDialog: FunctionComponent = ({ + close, + itemToMove, +}) => { + const messages = useMessages(messageIds); + const { orgId } = useNumericRouteParams(); + + const itemsFuture = useViewTree(orgId); + + const fullScreen = useMediaQuery(theme.breakpoints.down('md')); + const { moveItem } = useViewBrowserMutations(orgId); + + if (itemToMove.type == 'back') { + throw new Error('Should not be possible to move a back button'); + } + + const doMove = (folderId: number | null) => { + moveItem(itemToMove.type, itemToMove.data.id, folderId); + close(); + }; + + return ( + + + + + {messages.moveViewDialog.title({ itemName: itemToMove.title })} + + + + + + + + + + {(data) => { + const treeified = sortFoldersByTreeStructure( + itemToMove.type == 'folder' ? itemToMove.data.id : null, + data.folders + ); + + return ( + <> + + + + {treeified.map( + ({ nestingLevel, folder, insideMovedItem }) => { + return ( + + + + {nestingLevel > 0 && ( + <> + {range(nestingLevel - 1).map((idx) => ( + + ))} + + + )} + + + + + {folder.title} + + + + {folder.id == itemToMove.folderId ? ( + + ) : insideMovedItem ? ( + + ) : ( + + )} + + + + ); + } + )} + + + + ); + }} + + + + + ); +}; + +export default MoveViewDialog; diff --git a/src/features/views/components/ViewBrowser/index.tsx b/src/features/views/components/ViewBrowser/index.tsx index e340e3931b..64940ce597 100644 --- a/src/features/views/components/ViewBrowser/index.tsx +++ b/src/features/views/components/ViewBrowser/index.tsx @@ -28,6 +28,7 @@ import useViewBrowserItems, { ViewBrowserItem, } from 'features/views/hooks/useViewBrowserItems'; import messageIds from 'features/views/l10n/messageIds'; +import MoveViewDialog from '../MoveViewDialog'; interface ViewBrowserProps { basePath: string; @@ -60,6 +61,10 @@ const ViewBrowser: FC = ({ basePath, folderId = null }) => { const itemsFuture = useViewBrowserItems(orgId, folderId); const { deleteFolder, recentlyCreatedFolder } = useFolder(orgId); + const [itemToBeMoved, setItemToBeMoved] = useState( + null + ); + // If a folder was created, go into rename state useEffect(() => { if (gridApiRef.current && recentlyCreatedFolder) { @@ -175,6 +180,14 @@ const ViewBrowser: FC = ({ basePath, folderId = null }) => { }); }, }, + { + id: 'move-item', + label: messages.browser.menu.move(), + onSelect: (e) => { + e.stopPropagation(); + setItemToBeMoved(item); + }, + }, ]} /> ); @@ -242,6 +255,12 @@ const ViewBrowser: FC = ({ basePath, folderId = null }) => { sortingMode="server" sx={{ borderWidth: 0 }} /> + {itemToBeMoved && ( + setItemToBeMoved(null)} + itemToMove={itemToBeMoved} + /> + )} ); }} diff --git a/src/features/views/l10n/messageIds.ts b/src/features/views/l10n/messageIds.ts index 735dcf56dc..485be3f9ed 100644 --- a/src/features/views/l10n/messageIds.ts +++ b/src/features/views/l10n/messageIds.ts @@ -30,6 +30,7 @@ export default makeMessages('feat.views', { }, menu: { delete: m('Delete'), + move: m('Move'), rename: m('Rename'), }, moveToFolder: m<{ folder: ReactElement }>('Move to {folder}'), @@ -338,6 +339,13 @@ export default makeMessages('feat.views', { addPlaceholder: m('Start typing to add person to list'), alreadyInView: m('Already in list'), }, + moveViewDialog: { + alreadyInThisFolder: m('Already in this folder'), + cannotMoveHere: m('Cannot move here'), + moveHere: m('Move'), + moveToRoot: m('Move to root'), + title: m<{ itemName: string }>('Move {itemName}'), + }, newFolderTitle: m('New Folder'), newViewFields: { title: m('New list'), From c90ae2f30d64b3c495f2807f183f755a51218b55 Mon Sep 17 00:00:00 2001 From: Dale Luce Date: Sat, 28 Sep 2024 17:01:01 +0200 Subject: [PATCH 351/369] add strings and logic for edit tag submit --- .../TagManager/components/TagDialog/index.tsx | 10 +++-- src/features/tags/l10n/messageIds.ts | 2 + src/locale/en.yml | 39 ++++++++++++++++++- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/features/tags/components/TagManager/components/TagDialog/index.tsx b/src/features/tags/components/TagManager/components/TagDialog/index.tsx index 81eb659ae2..971e27334a 100644 --- a/src/features/tags/components/TagManager/components/TagDialog/index.tsx +++ b/src/features/tags/components/TagManager/components/TagDialog/index.tsx @@ -72,9 +72,7 @@ const TagDialog: React.FunctionComponent = ({
{ @@ -156,7 +154,11 @@ const TagDialog: React.FunctionComponent = ({ diff --git a/src/features/tags/l10n/messageIds.ts b/src/features/tags/l10n/messageIds.ts index 98dd8855ba..d3f3fba96a 100644 --- a/src/features/tags/l10n/messageIds.ts +++ b/src/features/tags/l10n/messageIds.ts @@ -6,6 +6,7 @@ export default makeMessages('feat.tags', { dialog: { colorErrorText: m('Please enter a valid hex code'), colorLabel: m('Color'), + createAndApplyTagButton: m('Create and apply'), createTagButton: m('Create'), createTitle: m('Create tag'), deleteButtonLabel: m('Delete'), @@ -23,6 +24,7 @@ export default makeMessages('feat.tags', { none: m('Basic (no values)'), text: m('Text values'), }, + updateTagButton: m('Update'), }, manager: { addTag: m('Add tag'), diff --git a/src/locale/en.yml b/src/locale/en.yml index 5756bf47cd..d2d681b1f2 100644 --- a/src/locale/en.yml +++ b/src/locale/en.yml @@ -104,6 +104,7 @@ feat: selectionBar: deselect: Deselect editEvents: Edit events + editParticipants: Manage participants ellipsisMenu: cancel: Cancel cancelWarning: If you do, remember to notify all participants and sign-ups that @@ -710,6 +711,34 @@ feat: remindButtondisabledTooltip: You have to assign a contact person before sending reminders reqParticipantsHelperText: The minimum number of participants required reqParticipantsLabel: Required participants + participantsModal: + affected: + empty: You haven't made any changes yet. Pick an event to move participants + around. + header: Affected people + discardButton: Discard changes + emptyStates: + booked: No one has been booked at this event. You can add participants from the + pool. + pending: There are no additional participants in the pool. You can move + participants from an event to the pool to work with them here. + participants: + buttons: + addBack: Add back + addHere: Add here + move: Move + undo: Undo + headers: + booked: This event + pending: People that can be added + states: + added: Being added to this event + pending: In the pool + removed: Moving away from this event + statusText: "{personCount, plural, =1 {One person} other {# people}} will be + moved around" + submitButton: Execute + title: Manage participants search: Search state: cancelled: Cancelled @@ -1238,6 +1267,10 @@ feat: demote: Demote promote: Promote remove: Remove + urlCard: + linkToPub: Link to public organization + subTitle: Users must connect to the organization before they can access Zetkin + as officials. you: You smartSearch: buttonLabels: @@ -1886,6 +1919,7 @@ feat: dialog: colorErrorText: Please enter a valid hex code colorLabel: Color + createAndApplyTagButton: Create and apply createTagButton: Create createTitle: Create tag deleteButtonLabel: Delete @@ -1901,6 +1935,7 @@ feat: types: none: Basic (no values) text: Text values + updateTagButton: Update manager: addTag: Add tag addValue: Add value for "{tag}" @@ -2485,14 +2520,14 @@ zui: tooltip: edit: Click to edit noEmpty: This cannot be empty + editableImage: + add: Click to add image futures: errorLoading: There was an error loading the data. header: collapseButton: collapse: Collapse expand: Expand - imageSelectDialog: - instructions: Drag and drop an image file, or click to select lists: showMore: Show more... orgScopeSelect: From fce9617d53e48000f0cd66d31640ec006b368935 Mon Sep 17 00:00:00 2001 From: Dale Luce Date: Sat, 28 Sep 2024 17:11:21 +0200 Subject: [PATCH 352/369] fixed mistake --- .../tags/components/TagManager/components/TagDialog/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/features/tags/components/TagManager/components/TagDialog/index.tsx b/src/features/tags/components/TagManager/components/TagDialog/index.tsx index 971e27334a..0915de1434 100644 --- a/src/features/tags/components/TagManager/components/TagDialog/index.tsx +++ b/src/features/tags/components/TagManager/components/TagDialog/index.tsx @@ -72,7 +72,9 @@ const TagDialog: React.FunctionComponent = ({
{ From 23ed64fc53fcafee1ba9a3a6957b9c5552e98167 Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Sat, 28 Sep 2024 17:17:02 +0200 Subject: [PATCH 353/369] add notProcessed message and implement logic in EmailActionButtons component --- .../emails/components/EmailActionButtons.tsx | 42 ++++++++++++------- src/features/emails/l10n/messageIds.ts | 1 + 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/features/emails/components/EmailActionButtons.tsx b/src/features/emails/components/EmailActionButtons.tsx index d98a3c870a..7d3a15d310 100644 --- a/src/features/emails/components/EmailActionButtons.tsx +++ b/src/features/emails/components/EmailActionButtons.tsx @@ -36,21 +36,33 @@ const EmailActionButtons = ({ updateEmail({ published: null })} /> )} {state === EmailState.DRAFT && } - {email.published && state === EmailState.SENT && ( - - - - - ), - }} - /> - - - )} + {email.published && + state === EmailState.SENT && + email.processed !== null && ( + + + + + ), + }} + /> + + + )} + {email.published && + state === EmailState.SENT && + email.processed === null && ( + + + + + + + )} ('Was sent at {datetime}'), willSend: m<{ datetime: ReactElement }>('Will send at {datetime}'), From e74aa9a82fbfe0dab023246dfa37677d64b43a76 Mon Sep 17 00:00:00 2001 From: Dale Luce Date: Sat, 28 Sep 2024 17:18:07 +0200 Subject: [PATCH 354/369] remove unnecessary string --- src/features/tags/l10n/messageIds.ts | 1 - src/locale/en.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/src/features/tags/l10n/messageIds.ts b/src/features/tags/l10n/messageIds.ts index d3f3fba96a..fe3ef69e20 100644 --- a/src/features/tags/l10n/messageIds.ts +++ b/src/features/tags/l10n/messageIds.ts @@ -6,7 +6,6 @@ export default makeMessages('feat.tags', { dialog: { colorErrorText: m('Please enter a valid hex code'), colorLabel: m('Color'), - createAndApplyTagButton: m('Create and apply'), createTagButton: m('Create'), createTitle: m('Create tag'), deleteButtonLabel: m('Delete'), diff --git a/src/locale/en.yml b/src/locale/en.yml index d2d681b1f2..2551f6a6b7 100644 --- a/src/locale/en.yml +++ b/src/locale/en.yml @@ -1919,7 +1919,6 @@ feat: dialog: colorErrorText: Please enter a valid hex code colorLabel: Color - createAndApplyTagButton: Create and apply createTagButton: Create createTitle: Create tag deleteButtonLabel: Delete From 9f00846a6b36c525a3755b64e040c5a776a5cfd2 Mon Sep 17 00:00:00 2001 From: Johan Rende Date: Sat, 28 Sep 2024 17:53:12 +0200 Subject: [PATCH 355/369] issue-2096: Catch exception of note upload fails --- .../[journeyId]/[instanceId]/index.tsx | 9 ++++- src/zui/ZUITimeline/TimelineAddNote.tsx | 37 ++++++++++++------- src/zui/ZUITimeline/index.tsx | 8 +++- src/zui/ZUITimeline/l10n/messageIds.ts | 3 ++ 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/src/pages/organize/[orgId]/journeys/[journeyId]/[instanceId]/index.tsx b/src/pages/organize/[orgId]/journeys/[journeyId]/[instanceId]/index.tsx index 08c829c083..8c3f0c9745 100644 --- a/src/pages/organize/[orgId]/journeys/[journeyId]/[instanceId]/index.tsx +++ b/src/pages/organize/[orgId]/journeys/[journeyId]/[instanceId]/index.tsx @@ -74,6 +74,7 @@ const JourneyDetailsPage: PageWithLayout = () => { const { orgId, instanceId } = useNumericRouteParams(); const messages = useMessages(messageIds); const [isLoading, setIsLoading] = useState(false); + const [showPostRequestError, setShowPostRequestError] = useState(false); const journeyInstanceFuture = useJourneyInstance(orgId, instanceId); const timelineUpdatesFuture = useTimelineUpdates(orgId, instanceId); @@ -116,11 +117,17 @@ const JourneyDetailsPage: PageWithLayout = () => { { + setShowPostRequestError(false); setIsLoading(true); - await addNote(note); + try { + await addNote(note); + } catch (e) { + setShowPostRequestError(true); + } setIsLoading(false); }} onEditNote={editNote} + showPostRequestError={showPostRequestError} updates={updates} /> )} diff --git a/src/zui/ZUITimeline/TimelineAddNote.tsx b/src/zui/ZUITimeline/TimelineAddNote.tsx index c16cf8c23f..0034253fdc 100644 --- a/src/zui/ZUITimeline/TimelineAddNote.tsx +++ b/src/zui/ZUITimeline/TimelineAddNote.tsx @@ -1,6 +1,7 @@ -import { Box, Collapse } from '@mui/material'; -import React, { useEffect, useState } from 'react'; +import { Box, Collapse, Typography } from '@mui/material'; +import React, { FormEvent, useEffect, useState } from 'react'; +import theme from 'theme'; import { useMessages } from 'core/i18n'; import { ZetkinNoteBody } from 'utils/types/zetkin'; import ZUISubmitCancelButtons from '../ZUISubmitCancelButtons'; @@ -12,11 +13,13 @@ import messageIds from './l10n/messageIds'; import { useNumericRouteParams } from 'core/hooks'; interface AddNoteProps { + showPostRequestError?: boolean; disabled?: boolean; onSubmit: (note: ZetkinNoteBody) => void; } const TimelineAddNote: React.FunctionComponent = ({ + showPostRequestError, disabled, onSubmit, }) => { @@ -33,7 +36,7 @@ const TimelineAddNote: React.FunctionComponent = ({ } = useFileUploads(orgId); useEffect(() => { - if (!disabled) { + if (!disabled && !showPostRequestError) { onCancel(); } }, [disabled]); @@ -47,18 +50,17 @@ const TimelineAddNote: React.FunctionComponent = ({ (fileUpload) => fileUpload.state == FileUploadState.UPLOADING ); + async function onSubmitHandler(evt: FormEvent) { + evt.preventDefault(); + if (note?.text) { + onSubmit({ + ...note, + file_ids: fileUploads.map((fileUpload) => fileUpload.apiData!.id), + }); + } + } return ( - { - evt.preventDefault(); - if (note?.text) { - onSubmit({ - ...note, - file_ids: fileUploads.map((fileUpload) => fileUpload.apiData!.id), - }); - } - }} - > + = ({ onClickAttach={() => openFilePicker()} placeholder={messages.addNotePlaceholder()} /> + {showPostRequestError && ( + + + {messages.fileUploadErrorMessage()} + + + )} 0}> void; onEditNote: (note: Pick) => void; @@ -29,6 +30,7 @@ export interface ZUITimelineProps { } const ZUITimeline: React.FunctionComponent = ({ + showPostRequestError, disabled, onAddNote, onEditNote, @@ -46,7 +48,11 @@ const ZUITimeline: React.FunctionComponent = ({ - + {/* Filter timeline select */} diff --git a/src/zui/ZUITimeline/l10n/messageIds.ts b/src/zui/ZUITimeline/l10n/messageIds.ts index 15825cdf89..f4a59f8c4f 100644 --- a/src/zui/ZUITimeline/l10n/messageIds.ts +++ b/src/zui/ZUITimeline/l10n/messageIds.ts @@ -12,6 +12,9 @@ export default makeMessages('zui.timeline', { }, }, expand: m('show full timeline'), + fileUploadErrorMessage: m( + "Unable to add note. Possible reason is that it's too long." + ), filter: { byType: { all: m('All'), From dfe273e5d1f9916eeb22762eadd827703c4d8dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sun, 29 Sep 2024 10:12:32 +0200 Subject: [PATCH 356/369] Revision based on review - Fixes the filtering bug caused by the PR - Removes the number string from the result when applying the filter --- .../CallHistory/DisplayCallHistory.tsx | 14 ++++++----- .../components/filters/CallHistory/index.tsx | 25 +++++++++++++------ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/features/smartSearch/components/filters/CallHistory/DisplayCallHistory.tsx b/src/features/smartSearch/components/filters/CallHistory/DisplayCallHistory.tsx index 8eb7f76885..f68e21895b 100644 --- a/src/features/smartSearch/components/filters/CallHistory/DisplayCallHistory.tsx +++ b/src/features/smartSearch/components/filters/CallHistory/DisplayCallHistory.tsx @@ -6,6 +6,7 @@ import { Msg } from 'core/i18n'; import UnderlinedMsg from '../../UnderlinedMsg'; import { useNumericRouteParams } from 'core/hooks'; import { + CALL_OPERATOR, CallHistoryFilterConfig, OPERATION, SmartSearchFilterWithId, @@ -43,12 +44,13 @@ const DisplayCallHistory = ({ ), callSelect: , - minTimes: ( - - ), + minTimes: + operator == CALL_OPERATOR.NOTREACHED ? null : ( + + ), timeFrame: , }} /> diff --git a/src/features/smartSearch/components/filters/CallHistory/index.tsx b/src/features/smartSearch/components/filters/CallHistory/index.tsx index 1dcf0299ba..0a53a455fe 100644 --- a/src/features/smartSearch/components/filters/CallHistory/index.tsx +++ b/src/features/smartSearch/components/filters/CallHistory/index.tsx @@ -156,12 +156,21 @@ const CallHistory = ({ ), callSelect: ( - setConfig({ - ...filter.config, - operator: e.target.value as CALL_OPERATOR, - }) - } + onChange={(e) => { + const callOperator = e.target.value as CALL_OPERATOR; + if (callOperator == CALL_OPERATOR.NOTREACHED) { + setConfig({ + ...filter.config, + minTimes: undefined, + operator: callOperator, + }); + } else { + setConfig({ + ...filter.config, + operator: callOperator, + }); + } + }} value={filter.config.operator} > {Object.values(CALL_OPERATOR).map((o) => ( @@ -172,7 +181,7 @@ const CallHistory = ({ ), minTimes: - filter.config.operator != CALL_OPERATOR.NOTREACHED ? ( + filter.config.operator == CALL_OPERATOR.NOTREACHED ? null : ( - ) : null, + ), timeFrame: ( Date: Sun, 29 Sep 2024 10:46:56 +0200 Subject: [PATCH 357/369] move isLoading logic inside ViewDataTableToolbar button --- .../ViewDataTable/ViewDataTableToolbar.tsx | 29 ++- .../views/components/ViewDataTable/index.tsx | 175 ++++++++---------- 2 files changed, 96 insertions(+), 108 deletions(-) diff --git a/src/features/views/components/ViewDataTable/ViewDataTableToolbar.tsx b/src/features/views/components/ViewDataTable/ViewDataTableToolbar.tsx index 0b4879f046..bfc6cb66f8 100644 --- a/src/features/views/components/ViewDataTable/ViewDataTableToolbar.tsx +++ b/src/features/views/components/ViewDataTable/ViewDataTableToolbar.tsx @@ -1,6 +1,6 @@ import { useContext } from 'react'; import { Add, Launch, RemoveCircleOutline } from '@mui/icons-material'; -import { Box, Button, Slide, Tooltip } from '@mui/material'; +import { Box, Button, CircularProgress, Slide, Tooltip } from '@mui/material'; import { DataGridProProps, GridColDef, @@ -18,6 +18,7 @@ export interface ViewDataTableToolbarProps { disableConfigure?: boolean; disabled: boolean; gridColumns: GridColDef[]; + isLoading: boolean; isSmartSearch: boolean; onColumnCreate: () => void; onRowsRemove: () => void; @@ -34,6 +35,7 @@ const ViewDataTableToolbar: React.FunctionComponent< disableConfigure, disabled, gridColumns, + isLoading, isSmartSearch, onColumnCreate, onRowsRemove, @@ -55,16 +57,23 @@ const ViewDataTableToolbar: React.FunctionComponent< }; return ( - - + + )} + {isLoading && ( + - + )} diff --git a/src/features/views/components/ViewDataTable/index.tsx b/src/features/views/components/ViewDataTable/index.tsx index 96acb37a10..ed9a5eb040 100644 --- a/src/features/views/components/ViewDataTable/index.tsx +++ b/src/features/views/components/ViewDataTable/index.tsx @@ -14,7 +14,7 @@ import { useGridApiRef, } from '@mui/x-data-grid-pro'; import { FunctionComponent, useContext, useState } from 'react'; -import { Box, CircularProgress, Link, useTheme } from '@mui/material'; +import { Link, useTheme } from '@mui/material'; import columnTypes from './columnTypes'; import EmptyView from 'features/views/components/EmptyView'; @@ -445,6 +445,7 @@ const ViewDataTable: FunctionComponent = ({ disableConfigure, disabled: waiting, gridColumns, + isLoading, isSmartSearch: !!view.content_query, onColumnCreate, onRowsRemove, @@ -466,108 +467,86 @@ const ViewDataTable: FunctionComponent = ({ return ( <> - {!isLoading && ( - - params.id == addedId ? classes.addedRow : '' + + params.id == addedId ? classes.addedRow : '' + } + hideFooter={empty || contentSource == VIEW_CONTENT_SOURCE.DYNAMIC} + localeText={{ + ...theme.components?.MuiDataGrid?.defaultProps?.localeText, + noRowsLabel: messages.empty.notice[contentSource](), + }} + onCellEditStart={(params, event) => { + if (params.reason == GridCellEditStartReasons.printableKeyDown) { + // Don't enter edit mode when the user just presses a printable character. + // Doing so is the default DataGrid behaviour (as in spreadsheets) but it + // means the user will overwrite the original value, which is rarely what + // you want with the precious data that exists in views (when there is no + // undo feature). + event.defaultMuiPrevented = true; } - hideFooter={empty || contentSource == VIEW_CONTENT_SOURCE.DYNAMIC} - localeText={{ - ...theme.components?.MuiDataGrid?.defaultProps?.localeText, - noRowsLabel: messages.empty.notice[contentSource](), - }} - onCellEditStart={(params, event) => { - if (params.reason == GridCellEditStartReasons.printableKeyDown) { - // Don't enter edit mode when the user just presses a printable character. - // Doing so is the default DataGrid behaviour (as in spreadsheets) but it - // means the user will overwrite the original value, which is rarely what - // you want with the precious data that exists in views (when there is no - // undo feature). - event.defaultMuiPrevented = true; - } - }} - onCellKeyDown={( - params: GridCellParams, - ev - ) => { - if (!params.isEditable) { - const col = colFromFieldName(params.field, columns); - if (col) { - const handleKeyDown = columnTypes[col.type].handleKeyDown; - if (handleKeyDown) { - handleKeyDown( - viewGrid, - col, - params.row.id, - params.value, - ev, - accessLevel - ); - } + }} + onCellKeyDown={(params: GridCellParams, ev) => { + if (!params.isEditable) { + const col = colFromFieldName(params.field, columns); + if (col) { + const handleKeyDown = columnTypes[col.type].handleKeyDown; + if (handleKeyDown) { + handleKeyDown( + viewGrid, + col, + params.row.id, + params.value, + ev, + accessLevel + ); } } - }} - onColumnOrderChange={(params) => { - moveColumn(params.column.field, params.targetIndex); - }} - onColumnResize={(params) => { - setColumnWidth(params.colDef.field, params.width); - }} - onRowSelectionModelChange={(model) => setSelection(model as number[])} - pinnedColumns={{ - left: ['id', GRID_CHECKBOX_SELECTION_COL_DEF.field], - }} - processRowUpdate={(after, before) => { - const changedField = Object.keys(after).find( - (key) => after[key] != before[key] - ); - if (changedField) { - const col = colFromFieldName(changedField, columns); - if (col) { - const processRowUpdate = columnTypes[col.type].processRowUpdate; - if (processRowUpdate) { - processRowUpdate( - viewGrid, - col, - after.id, - after[changedField] - ); - } + } + }} + onColumnOrderChange={(params) => { + moveColumn(params.column.field, params.targetIndex); + }} + onColumnResize={(params) => { + setColumnWidth(params.colDef.field, params.width); + }} + onRowSelectionModelChange={(model) => setSelection(model as number[])} + pinnedColumns={{ + left: ['id', GRID_CHECKBOX_SELECTION_COL_DEF.field], + }} + processRowUpdate={(after, before) => { + const changedField = Object.keys(after).find( + (key) => after[key] != before[key] + ); + if (changedField) { + const col = colFromFieldName(changedField, columns); + if (col) { + const processRowUpdate = columnTypes[col.type].processRowUpdate; + if (processRowUpdate) { + processRowUpdate(viewGrid, col, after.id, after[changedField]); } } - return after; - }} - rows={gridRows} - slotProps={componentsProps} - slots={{ - columnMenu: ViewDataTableColumnMenu, - footer: ViewDataTableFooter, - toolbar: ViewDataTableToolbar, - }} - style={{ - border: 'none', - }} - {...modelGridProps} - /> - )} - {isLoading && ( - - - - )} + } + return after; + }} + rows={gridRows} + slotProps={componentsProps} + slots={{ + columnMenu: ViewDataTableColumnMenu, + footer: ViewDataTableFooter, + toolbar: ViewDataTableToolbar, + }} + style={{ + border: 'none', + }} + {...modelGridProps} + /> + {empty && } {columnToRename && ( Date: Sun, 29 Sep 2024 11:00:10 +0200 Subject: [PATCH 358/369] use event.id as key --- .../calendar/components/CalendarDayView/Day/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/calendar/components/CalendarDayView/Day/index.tsx b/src/features/calendar/components/CalendarDayView/Day/index.tsx index 88d73ab5d2..8948660739 100644 --- a/src/features/calendar/components/CalendarDayView/Day/index.tsx +++ b/src/features/calendar/components/CalendarDayView/Day/index.tsx @@ -27,8 +27,8 @@ const Day = ({ date, dayInfo }: { date: Date; dayInfo: DaySummary }) => { gap={1} justifyItems="flex-start" > - {dayInfo.events.map((event, index) => { - return ; + {dayInfo.events.map((event) => { + return ; })} From afbbac9c20e72b1cd52114bd61bde05ede976b68 Mon Sep 17 00:00:00 2001 From: Johan Rende Date: Sun, 29 Sep 2024 11:39:15 +0200 Subject: [PATCH 359/369] issue-2152: Add DST labels for each calendar view --- .../components/CalendarDayView/Day/index.tsx | 26 ++++++++++++++++--- .../components/CalendarMonthView/Day.tsx | 20 ++++++++++++++ .../components/CalendarWeekView/DayHeader.tsx | 2 +- .../components/CalendarWeekView/index.tsx | 15 +++++++++-- .../components/CalendarWeekView/utils.ts | 6 ----- src/features/calendar/components/utils.ts | 6 +++++ src/features/calendar/l10n/messageIds.ts | 4 +-- 7 files changed, 65 insertions(+), 14 deletions(-) diff --git a/src/features/calendar/components/CalendarDayView/Day/index.tsx b/src/features/calendar/components/CalendarDayView/Day/index.tsx index 88d73ab5d2..3d0b8d9960 100644 --- a/src/features/calendar/components/CalendarDayView/Day/index.tsx +++ b/src/features/calendar/components/CalendarDayView/Day/index.tsx @@ -1,10 +1,22 @@ -import { Box } from '@mui/material'; +import { Box, Typography } from '@mui/material'; +import { useMemo } from 'react'; +import dayjs from 'dayjs'; import DateLabel from './DateLabel'; -import { DaySummary } from '../../utils'; +import { DaySummary, getDSTOffset } from '../../utils'; import Event from './Event'; +import messageIds from 'features/calendar/l10n/messageIds'; +import theme from 'theme'; +import { Msg } from 'core/i18n'; const Day = ({ date, dayInfo }: { date: Date; dayInfo: DaySummary }) => { + const dstOffset = useMemo( + () => + getDSTOffset(dayjs(date).startOf('day').toDate()) - + getDSTOffset(dayjs(date).endOf('day').toDate()), + [date] + ); + return ( { backgroundColor: '#eeeeee', }} > - + + {dstOffset !== 0 && ( + + + {dstOffset > 0 && } + {dstOffset < 0 && } + + + )} {/* Remaining space for list of events */} { const isToday = dayjs(date).isSame(new Date(), 'day'); + const dstOffset = useMemo( + () => + getDSTOffset(dayjs(date).startOf('day').toDate()) - + getDSTOffset(dayjs(date).endOf('day').toDate()), + [date] + ); + let textColor = theme.palette.text.secondary; if (isToday) { textColor = theme.palette.primary.main; @@ -56,6 +67,15 @@ const Day = ({ + {dstOffset !== 0 && ( + + + 0 ? messageIds.dstStarts : messageIds.dstEnds} + /> + + + )} {clusters.map((cluster, index) => { return ( { return focusWeekStartDay.day(weekday + 1).toDate(); }); + const dstChangeAmount: number = useMemo( + () => + getDSTOffset(dayjs(focusWeekStartDay).startOf('day').toDate()) - + getDSTOffset( + dayjs(focusWeekStartDay.add(6, 'days')).endOf('day').toDate() + ), + [focusWeekStartDay] + ); + const eventsByDate = useWeekCalendarEvents({ campaignId: campId, dates: dayDates, @@ -77,10 +87,12 @@ const CalendarWeekView = ({ focusDate, onClickDay }: CalendarWeekViewProps) => { <> {/* Headers across the top */} {/* Empty */} @@ -103,7 +115,6 @@ const CalendarWeekView = ({ focusDate, onClickDay }: CalendarWeekViewProps) => { gridTemplateColumns={`${HOUR_COLUMN_WIDTH} repeat(7, 1fr)`} gridTemplateRows={'1fr'} height="100%" - marginTop={2} overflow="auto" > {/* Hours column */} diff --git a/src/features/calendar/components/CalendarWeekView/utils.ts b/src/features/calendar/components/CalendarWeekView/utils.ts index 512caaa481..c3484498bb 100644 --- a/src/features/calendar/components/CalendarWeekView/utils.ts +++ b/src/features/calendar/components/CalendarWeekView/utils.ts @@ -30,9 +30,3 @@ export function scrollToEarliestEvent( weekElement.scrollTo({ behavior: 'smooth', top: heightInPixels }); } - -export function getDSTOffset(date: Date): number { - const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset(); - const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); - return (date.getTimezoneOffset() - Math.max(jan, jul)) / 60; -} diff --git a/src/features/calendar/components/utils.ts b/src/features/calendar/components/utils.ts index 3e0f2e97f7..df6088922b 100644 --- a/src/features/calendar/components/utils.ts +++ b/src/features/calendar/components/utils.ts @@ -278,3 +278,9 @@ export const getActivitiesByDay = ( return dateHashmap; }; + +export function getDSTOffset(date: Date): number { + const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset(); + const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); + return (date.getTimezoneOffset() - Math.max(jan, jul)) / 60; +} diff --git a/src/features/calendar/l10n/messageIds.ts b/src/features/calendar/l10n/messageIds.ts index 212b0f3b67..698b5b5876 100644 --- a/src/features/calendar/l10n/messageIds.ts +++ b/src/features/calendar/l10n/messageIds.ts @@ -7,8 +7,8 @@ export default makeMessages('feat.calendar', { shiftEvent: m('Create multiple events that form shifts'), singleEvent: m('Create single event'), }, - dstEnds: m('DST ends'), - dstStarts: m('DST starts'), + dstEnds: m('Winter time'), + dstStarts: m('Summer time'), event: { differentLocations: m<{ numLocations: number }>( '{numLocations} different locations' From 3c4734dca4cc5666538e53404180c2e95ff14a3a Mon Sep 17 00:00:00 2001 From: Rebe R <36491300+rebecarubio@users.noreply.github.com> Date: Sun, 29 Sep 2024 12:21:41 +0200 Subject: [PATCH 360/369] add loading logic inside the button --- .../ViewDataTable/ViewDataTableToolbar.tsx | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/features/views/components/ViewDataTable/ViewDataTableToolbar.tsx b/src/features/views/components/ViewDataTable/ViewDataTableToolbar.tsx index bfc6cb66f8..d2585a38b4 100644 --- a/src/features/views/components/ViewDataTable/ViewDataTableToolbar.tsx +++ b/src/features/views/components/ViewDataTable/ViewDataTableToolbar.tsx @@ -57,23 +57,16 @@ const ViewDataTableToolbar: React.FunctionComponent< }; return ( - {!isLoading && ( - - - - )} - {isLoading && ( - - )} + From 6bdcc9de49d9ff4ef6d5801b4755ae4eb5c1f9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20J=C3=B6nsson?= Date: Sun, 29 Sep 2024 12:21:49 +0200 Subject: [PATCH 361/369] redesign the MoveViewDialog to be navigatable instead of showing all folders --- .../views/components/MoveViewDialog.tsx | 340 ++++++++++-------- src/features/views/l10n/messageIds.ts | 8 +- 2 files changed, 199 insertions(+), 149 deletions(-) diff --git a/src/features/views/components/MoveViewDialog.tsx b/src/features/views/components/MoveViewDialog.tsx index 40ab224c4f..e36e2a56eb 100644 --- a/src/features/views/components/MoveViewDialog.tsx +++ b/src/features/views/components/MoveViewDialog.tsx @@ -1,79 +1,111 @@ -import { range } from 'lodash'; -import { FunctionComponent } from 'react'; -import { Close, Folder, SubdirectoryArrowRight } from '@mui/icons-material'; +import { FunctionComponent, useState } from 'react'; +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import { + Breadcrumbs, Button, Dialog, + DialogActions, DialogContent, DialogTitle, - Icon, - IconButton, + Link, List, ListItem, useMediaQuery, } from '@mui/material'; import { Box } from '@mui/system'; +import { useTheme } from '@mui/styles'; import { useNumericRouteParams } from 'core/hooks'; -import theme from 'theme'; import ZUIFuture from 'zui/ZUIFuture'; import { ZetkinViewFolder } from './types'; -import { ViewBrowserItem } from '../hooks/useViewBrowserItems'; +import useViewBrowserItems, { + ViewBrowserFolderItem, + ViewBrowserItem, + ViewBrowserViewItem, +} from '../hooks/useViewBrowserItems'; import useViewBrowserMutations from '../hooks/useViewBrowserMutations'; import useViewTree from '../hooks/useViewTree'; import { useMessages } from 'core/i18n'; import messageIds from '../l10n/messageIds'; +import BrowserItemIcon from './ViewBrowser/BrowserItemIcon'; +import { ViewTreeData } from 'pages/api/views/tree'; -type FolderInsideTreeStructure = { - folder: ZetkinViewFolder; - insideMovedItem: boolean; - nestingLevel: number; +const folderById = (id: number | null, viewTree: ViewTreeData) => { + if (id == null) { + return null; + } + return viewTree.folders.find((f) => f.id == id) ?? null; }; -/** For each subfolder of `id`, add it to the list, followed by its subfolders (and their subfolders, recursively), creating a tree-like structure */ -const sortFoldersByTreeStructureRecursive = ( - id: number | null, - nestingLevel: number, - folderToMove: number | null, - alreadyInsideMovedItem: boolean, - allFolders: ZetkinViewFolder[] -): FolderInsideTreeStructure[] => { - // Find all subfolders and sort them alphabetically - const subfolders = allFolders - .filter((f) => f.parent?.id == id) - .sort((a, b) => a.title.localeCompare(b.title)); - - return subfolders.flatMap((folder) => { - // We cannot move a folder inside itself, or inside one of its children (or their children, recursively) - // This condition makes it so that once we have encountered the folder-to-be-moved, we keep passing `true` all the way down the recursive tree - const insideMovedItem = alreadyInsideMovedItem || folder.id == folderToMove; - return [ - { - folder, - insideMovedItem, - nestingLevel, - }, - ...sortFoldersByTreeStructureRecursive( - folder.id, - nestingLevel + 1, - folderToMove, - insideMovedItem, - allFolders - ), - ]; - }); +const getAllParentFolderIds = ( + folderId: number | null, + viewTree: ViewTreeData +) => { + let parentFolderIds: number[] = []; + + while (folderId != null) { + parentFolderIds = [folderId, ...parentFolderIds]; + + const folder = folderById(folderId, viewTree); + folderId = folder?.parent?.id ?? null; + } + return parentFolderIds; }; -const sortFoldersByTreeStructure = ( - itemToMove: number | null, - allFolders: ZetkinViewFolder[] -): FolderInsideTreeStructure[] => { - return sortFoldersByTreeStructureRecursive( - null, - 0, - itemToMove, - false, - allFolders +const MoveItemBreadcrumbs = ({ + onClickFolder, + orgId, + viewedFolder, +}: { + onClickFolder: (folderId: number | null) => void; + orgId: number; + viewedFolder: number | null; +}) => { + const messages = useMessages(messageIds); + const viewTreeFuture = useViewTree(orgId); + + return ( + + {(viewTree) => { + const folders = getAllParentFolderIds(viewedFolder, viewTree) + .map((id) => folderById(id, viewTree)) + .filter((folder): folder is ZetkinViewFolder => !!folder); + + // Add the root folder to the beginning + const breadcrumbItems = [ + { + id: null, + title: messages.browserLayout.title(), + }, + ...folders, + ]; + return ( + } + > + {breadcrumbItems.map(({ title, id }) => + id == viewedFolder ? ( + {title} + ) : ( + onClickFolder(id)} + sx={{ cursor: 'pointer' }} + underline="hover" + > + {title} + + ) + )} + + ); + }} + ); }; @@ -85,13 +117,16 @@ const MoveViewDialog: FunctionComponent = ({ close, itemToMove, }) => { - const messages = useMessages(messageIds); - const { orgId } = useNumericRouteParams(); + const [viewedFolder, setViewedFolder] = useState(itemToMove.folderId); - const itemsFuture = useViewTree(orgId); + const { orgId } = useNumericRouteParams(); + const itemsFuture = useViewBrowserItems(orgId, viewedFolder); + const viewTreeFuture = useViewTree(orgId); + const { moveItem } = useViewBrowserMutations(orgId); + const messages = useMessages(messageIds); + const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('md')); - const { moveItem } = useViewBrowserMutations(orgId); if (itemToMove.type == 'back') { throw new Error('Should not be possible to move a back button'); @@ -109,98 +144,115 @@ const MoveViewDialog: FunctionComponent = ({ maxWidth={'sm'} onClose={close} open={true} + scroll="paper" > - - - - {messages.moveViewDialog.title({ itemName: itemToMove.title })} - - - - - - - - - - {(data) => { - const treeified = sortFoldersByTreeStructure( - itemToMove.type == 'folder' ? itemToMove.data.id : null, - data.folders - ); + + + + + + {(data) => { + const relevantItems = data.filter( + // This explicit typing should not be necessary, but it is. Don't know why. + (item): item is ViewBrowserFolderItem | ViewBrowserViewItem => + item.type != 'back' + ); + if (relevantItems.length == 0) { return ( - <> - - - - {treeified.map( - ({ nestingLevel, folder, insideMovedItem }) => { - return ( - - - - {nestingLevel > 0 && ( - <> - {range(nestingLevel - 1).map((idx) => ( - - ))} - - - )} - - - - - {folder.title} - - - - {folder.id == itemToMove.folderId ? ( - - ) : insideMovedItem ? ( - - ) : ( - - )} - - - - ); - } - )} - - - + + + {messages.moveViewDialog.emptyFolder()} + + ); - }} - - + } + return ( + + {relevantItems.map((item) => { + const { + data: { id }, + title, + type, + } = item; + + return ( + setViewedFolder(id) : undefined + } + sx={ + type == 'folder' + ? { cursor: 'pointer' } + : { + color: theme.palette.onSurface.disabled, + } + } + > + + + + + + + {type == 'folder' ? ( + + {title} + + ) : ( + title + )} + + + + ); + })} + + ); + }} + + + {(viewTree) => { + const tryingToMoveFolderIntoItself = + itemToMove.type == 'folder' && + getAllParentFolderIds(viewedFolder, viewTree).some( + (id) => id == itemToMove.data.id + ); + return ( + + + + + ); + }} + ); }; diff --git a/src/features/views/l10n/messageIds.ts b/src/features/views/l10n/messageIds.ts index 485be3f9ed..3f09d80a61 100644 --- a/src/features/views/l10n/messageIds.ts +++ b/src/features/views/l10n/messageIds.ts @@ -340,11 +340,9 @@ export default makeMessages('feat.views', { alreadyInView: m('Already in list'), }, moveViewDialog: { - alreadyInThisFolder: m('Already in this folder'), - cannotMoveHere: m('Cannot move here'), - moveHere: m('Move'), - moveToRoot: m('Move to root'), - title: m<{ itemName: string }>('Move {itemName}'), + cancel: m('Cancel'), + emptyFolder: m('Empty folder'), + moveHere: m('Move here'), }, newFolderTitle: m('New Folder'), newViewFields: { From a58835b3e9e967380ba1d6ab60711e18619186ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20J=C3=B6nsson?= Date: Sun, 29 Sep 2024 13:10:00 +0200 Subject: [PATCH 362/369] set a max-height on the MoveViewDialog --- src/features/views/components/MoveViewDialog.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/features/views/components/MoveViewDialog.tsx b/src/features/views/components/MoveViewDialog.tsx index e36e2a56eb..c89b326d10 100644 --- a/src/features/views/components/MoveViewDialog.tsx +++ b/src/features/views/components/MoveViewDialog.tsx @@ -144,6 +144,12 @@ const MoveViewDialog: FunctionComponent = ({ maxWidth={'sm'} onClose={close} open={true} + PaperProps={{ + sx: { + height: '80vh', + maxHeight: '600px', + }, + }} scroll="paper" > @@ -154,7 +160,7 @@ const MoveViewDialog: FunctionComponent = ({ /> - + {(data) => { const relevantItems = data.filter( From d860e2600de759753f75b0e16780fba54fa32c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattias=20Ringstr=C3=B6m?= Date: Sun, 29 Sep 2024 13:16:20 +0200 Subject: [PATCH 363/369] Updated Favicon --- public/logo-zetkin.png | Bin 6860 -> 12890 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/logo-zetkin.png b/public/logo-zetkin.png index cf1b1402086f7f726a77f2575411d0d5c2c18572..c6ea0b0db6924669e4eb8071f7e546624e142cc8 100644 GIT binary patch literal 12890 zcmeHtX*`v0xb90thES+vk(q>uu#9DDna4&nALU+;&w{GxRmqI)4JEp>|eM{_I&XDRD^z5q|FXyh>-^Ip$E6!ve~@ zEDrY1e=wIW-)3U=ICUuKw&e#}O%HKruJhtt{zANEcRWcGDFb1}tO-;3TI;)(y{yLjSop|F;&edZty7?qq zzHMRHJgdog_b8RM!d@{!F~*ZQ_d}%{O71t`6T0ZpFzGR-DV8cumq_Oy<8yB8vbh24 z`oJXqwu<#x$4=S+j(bt~147ystud8F)$!~~RVh2e&B2dUVqe9+{CbM(J#UM!J!{i3 z!SgCg3@w8OLN)kLVNx4IDjO!JbP5EE-*E`)q@t)@GIdRI}UHF8zH znf%UBZ&mA8*ezp`0QuXSa#V%MH)_8Wj|7tzdCJ6X{iv&MeG#1AM?dl+-Fb81@lW)W z4qU%*$rnNB52Jr5khu7x2yz0!sh=|qh@T(w4={78ZCozxp5a$@xnrzOdDQa4+tajR z7SA50)Ym^8&-!Y3G>`9O7T?*a6xu^i?#!HislZ}rU!P07b4OMEfIyP;;jnv$EH6B} z7azQbR50I7i}xM&{S~6pQOte5Wk59c`AAFEtJD*tm7Zy7F}#=6a57=nqI8|85R0*x zw0Pd{;y9Jpwelq2j~4F_`RLLJljbTJ`Bx+H8=vwk2GkKGvgIPqHh!=c&neV;{q+cK z3?&RDiYvcRX=8l6!5cxAGFBs$U#>_rdxxCHXi8ZjR{c+2N?Qo+C*1jT3PILCl}BXg zY;rey|B0ouPea}oT-NG39I^0%)t+}Bf;^bi(T-S&AD0{09j>sYC(c!x@jr=_);3O4 zSz|<0iCBIzHSPsxv;1sf!e%;r`}j$+M8xaoJs-KZsE`vMe?D7DOCyuUSvLcnmBo;P zIc&rsff`}5gY5!6LZsuh?n;fAk|&LCx(O+Z!pAovSHjY`YQNDS2-{Z&9FrWWxaEk% z!V6f{LZW~ki|iBylB7t&RW^9X``7He=NS%??Dtc%?#E;vj+Q;WO@Rm}YDer&RxUgi z>d5@fQJ9ae$fRov>;0J-{EXh5(?zm2pGB$b{f6iT+SvV<_96AreLAyqa_xP; zY7HxxDBc!)H!;pRx(oYbc^o(LI=*JoxB#8QXf1W8hN)7c#K8@koblQ*-Nn}l`w*hfu_-xypG&nxy40f+T@67v8znv#^q{zA*vc(?)v%SOyvh{D@1>oI2!C-rh*{1ryOvL zQBzmXrC*{5uo;}7fd~Ea)26jIKM7!IMki`^GNWbSRZFi_q9+C@rcWV4iL>Qa#`k26 zDG($PE0{f3@tSwNDimVC5C7wNEdv2T?x+di4(qos6=VD5kw+1M0b++>gisxU)r}eh zId=uQn}TC*33A2E2tpO@7lD-{nOF?c5WJ{IB^=iGAe0f#&;I0?`=Gn`WF!wg6s<0H+;ioF)Cdx^_$-Yc)BH(! zC!sMPQRI05Yh}|&OKW~y`#YQs>Gw+&m}l+Z*@qZDpU`oekZZ5C9WlaF+ZCY)WGc-P zu|Hv#ltMk47fp{K#H1Td;VOQ#aJYRDeOpQgVKvcxr19+&2^Ew}&T97X2q8XhHS-8@ zj*$xXW!KlOPa$HW(sLiKD!dW>MOVFd{7_v1vLej^vs>3OAn!3zSb3hNg`pB#1yIx| zBMaal6`w~O|2$8;h)8k!hJLcXX5-AFVm8$lTu+-t3y*Vi7b+}Po2Z2zG;?vuGPgO5TvMkY zn$M5a**Z3p%#4jA=;-Kz$`Tibs^*6LM|!D`SdA73BdQvj@D!)Q4P6`#$Dwc|?;w}r zOmwiOrsiz7fr?pwf%VQmDoEA^VMMsx<9nHiq@>YYU;gi{+E>!9DZRPoqEb>_i(7wa zo;Ihhtl&Kc{?Yhe?xj+?v;DgxB5i5u`u+R&MNXa4HOn#Wynntep8h@hZmzGbN~ z{9z!_Ae3y>$YjLJml?{zn=bIWCaFR1W=x!vQ`noGTQ8c&JL6>BOI$j6Vec;32CqgP zUZxiNLY9~evt8)lzORq{~rfq22nUR0FoTJUq zW?*0-4(pc_5fM=mymh0nE+kz$s;7=XU_5?SH`>bQQNJy>>QXY2r2FglZ#LLU7&DH< zi);Dx$!K-9SFc&P)NiiOwJQx5xHjbPw=!c?_w)ByD0koQ4wXV!W!+CERl6VhMBx`} zBLo$@2ZRKyA1e!sJytV&VsesPP_g^p8oq7#gw>;q@nZc;3z2q}PcSH)|$gN?p4J9giU~8WgIrKL?7N@l-ft zb@h7-m2=|VlG-2qtZQ~R=Vk{hiu(#}y|AP|^WQVK7OD(5si}Ud!&iC=ZAaiMT_vvF zJ+;BNx+U8``B@M7^;=i24c}UAm8gp$`L=9t{Emu4iz+J*7Yc1gY0iHuE%h3yOuLko;HMImkWFJ$S6@cI5H+COGmtdKdWF-qCG~^ zaph-mM;yufA_QIERNJe&o-Sh6m8MK8+eI%WY(23p$KE$I^j7(MJ>ihiCagDb>o!w_ z9HB;(O$-f_;3#ZzFXkJcJ9nFoo_=yUU}8DI)WRaGsOYqeThE0j$7L^$H$CwizBx_3 zQ!nAxmv?D)q^^#9)LF*ueJA9gv8gDXE8@xK`+mQ`$laY!v zrXYxNWOQ`q%*+f?@xW4qj(F!CYI@UbV}WiX?JMN2^vdO?ykw+x1BF5ZBEK z7J!lu?$b@k@OwIB)DhCvBwF0M#Jyj<+lU{>q_{+Eddgk+?Ahqd9>8|kMZy1u5?LPvWKFvHdn#M=35TYyy1Kf{`+ElhHzuR+UR~Ge;(Mxi zXa(Zc*u=zdv1aYpPim_0(G7Ei)&YaXfB9DG?gweP5j?`f!^45aXPx!<#%e0GjT6%6 zXG{|^c-apapE@ydrCSogaZ)1N3oi_mJ0G&}p{SO)=ssG18^v%32Zx|q30sR2m*;>p zuGk0WW`#D^3O9ZlUb&J+7B1oxMTm*kgq_uIMfP_y^b>N9d%k^_TOP^C#MEzQZk_(= zg^=i(GbSP;BIgvg(+?Yn=DCnKENa@cqN_}8ib}c(5=4k1)0pmAs-c(}1XOUx`cbIW2HL?QDs!^0UoX{p9xOy(sxv-G;{NeKwc%NH&@%+QT}zoey_ zgdns5r16E6;!bv zKkj~t=DIl0UbdFunp>-|Of;=5EAiPrK0ZOU9Z-O?FPD2({`{vEKmJ5E2M$tsM99zv z{l-N=w2<{N8Zp~itJY|Ve%PF-sHpV3ygaEy1;Yv-Cp6lJD*d{-cb91V=D(Tw&Gsz& z_|&ffan2jBECA{dki+nExMr&-@z$T8MvxS&7)(CaZ#GLm0qa~H3@{+V%lm$%9Em*s zmo$5G1A#sTJAl-mhZ+1&{K$G0iy+xWMTXSW)GZwy<{=@qByN=g4jH#BSSyqzyduH| zlzr)Wb+sZuj9zSPY@=uBK@3)ANr}tgO`#DiR6|Q^@2Y!G*7F}fey{=^fV|w=P}pb4 z@-GEL-Yv{@rfLJ%aC6JKm}imP(_;$Mr%__dbGW+V$M|^Evu8=`>j908joCodLIz}s zqZ$-QOTC|UFaTYgyl;l`-nMVPO^s#qQ;0*IhzU7T;L1$$xx4#ap;Y8tE-xToKqz6F z!aTsNVEDEeNwqW1?WUTV56Bm$Svfdz+zTo+4U{+14jnoKh|H`ijMU3tadw_t?YHem z3k2jt49Vnb@YbK+GaZVlu)r7+#A}^ZQD4bSIzcztgB+DsF2CZMO`PCvy$H*vyp(gUMBBRo_gq>29hU_dg zFlD5!TLF5@I5sh8sH?;4>X8L1+V~ZyKsj1Zxh^sgaB*jxNx;H%n-M!&K%um>G=4^I zk)QA$fm#Obn<$W43kW_qKoQX(4vVG*a)@+1c6B-hLL-5EHmMD*~tnRs1YW zJ`u3m9WeLf?3e_OPm6SAecww5YNYSvsZ*xF-q2Te8->3edvJVZk+64pY-|jQ(P<(Z zVj(ImZCVq!3U7Z16?&&*Z=0!syXYv)_Zxs#Z?=ih4(u)LMH`#BRkFPARM85P1&|Ai ze7#p^11F=*b(@!7LGqIUyY1Ka*-<0U^at73pj`Lv?riC4EkEkZzwF);DKgua|B5V( zP*TB0=_?);5);dR&-gJz@0HuHiBFcLZf5tlhLK1nQKa4#4eLs{f(|icfOw*&XLo_|H`m9X-u>Zud;WWQf6}?T($_~-OIZias7~9 zrG&kZ$%%=XrLhJe-SUp2`k-Rmda{xMghWrC)NYHDG5I~!X5eweCLlTPtVhmCvz&D_ zC7*-#R9OyCY-wvV)7K|~$brN0L+c&z$$!)RCA<%@<5>V(;C~wc5C4xB@Zb9Jy)F%L zjg^&E7~<-3*|knMxTu7Ld_b1T$w?7e*|nbU!0>#m&5ewV!otH%uUwhPE>8tPSng@v z*wmC`l&T3+Tqy@QqM%cKCLg$x1@$uv;(PllFpyWkscOm!v$BMNn=SOP0}??R8`2?3 zl-E&5BD8*Ba%#$L;2$T;>VP-w5;l2kq8Ei#R7Nab(@Rtv5;nUN@!fi|*`QsTd=R81 zl#dq>*?(HQA?uvq3APh5!d)t}H+e>N~<)E*4{=|UYjcMSF!usq~ zL`0Kf>F(#xx-~n0zQ-vCSB7kog}S}B9e{FxGNcm#|DKh)KJ?Gefz&sS%_rsMdISI|;N( zfIPM=aY@ch4EY9lkg$F;{J3m8z>TV;1;r92qzuquLj%R-du7)K9I*PT=r};**)SLn zEYL;iJS4I^LO`dd!q=IROEG55HhgASWp>)a7{DOIS!@SqzWM1VjVysFs$NE`ZXGO=nNf0w5=0LqOr#SXh#w z0+K3?2xwGR(dpf8nrqEwh=8>B5A}bkIaLB=pF8O39Lv%i zvM|LoR6Wb$t50fo{#?$tsxTHcFX#aQ7PM>i5JWHpG>F!72M!zvd-~M;+O<>ZND%{d z64h(M?oFlT4~H`xsnY9q=Dh~#N7E7syO(^Y+s)AW3GN33C?Gd4jF)Aui0#W zVIsgnvq$`GIuhmegD~T+kI(t^nrXGcOE3bAgQ+=V!z8P2%%d9w$~6UoimGia6#z2F zwk`x6q0(=zX%;c0FhW9_Wk-Tip~Oksh3L?q1({was2gL4eJ}#YkifACWz6pj!%lIw zNhZm&YX0n#qmt*3SX~FAhE(69yv_~=g4;-K4e&HqthQkoiPQ(~!e3@;wEcd3c?RX- zA08cE?KQWh-uWVdqSxlyVj&GzfU0#RUJ^s2D(`)4^iuWukH-vqQjpi(FzNiGyIfDT z&RqR$2om29=$PB`k4841bsbb&8XFt`#ZKT^@I+S8h^XDZi%8fTP$V*5!&Y_MzaEo? z&ymq`ue3Jkh<#wd1|C#X_%T!gfc({KmYn*hRlM81uqFd==@p4GDbj*MxCxTs*sc@4 zLut))W^A#t0DTWgVF=r=XemV141~}s)CN%ri4G_R>GgD~ECqA3K%09YOs`28i6 zg**iKKQ~n6Z~nK)o#PP^!NIu}1L}kPT2gphrNv_P+tz0~kx)AvvgHBj(+cxj2R!m? z6)SfEIh>J7mB6IDt_qB=E0jCrqes$hq!DH~xSV%oq58Kz7)Ax}5AK06BrGmY8nZnJ zRi_(HH6J{VIM~CZ@l+Nc#u`G+0K&l{cm?a_koC-ixS5^JEj){9bUy1foCj%+npqjC zrnG2v?YAZ50|Ny{ghB;xZa*@6SiZ=T(P8%pWjM#fP-|Ed3@K^%DxUEpK+=9YAlIjRGO1g zxM_L38CC3aPxqP{8(lxgNLB(xV?AX2rYdEwQd?KI2Y}8Et9{?B>V`-DrD9M$GE%^a zAUrNwSi~r=kM5K49Q=xB<*US#Q!0F?-N4y_e6qdI4RX1-@7QiFTCe{lZivtu$|VI9 z5?0CGS`a#CFpU!8H7mwXxRhWmt2xj5R}$NRAoNuFxx4TBpZ(;`=`r}$2%MGE80sRM z#=YJGP9U)*SKBzOZ%#%5angm_w+gO=8!!)m<>v)&f6(PVsN+{!`J%=^PyxdU%t}C3 zW>G}o$8x|5cqcP&nz?eBqiLExd~o@abdE#XpI4R@a*8?&4 z_7wB!a5my0TJTQpGAU=F)+-3-b2oLXVGrS$BD=b}O6(i<@9ZoR%0t$5gh)FLbqBeX z^T2oo0c`PMq40+gaNKU?kEEWi>Q!c)};vLX@(7#Yp1tl~lEvYqv8idDJhW- z{xjgb3Gr5fCmUl_LIxL)p*ajgBzmW71_ZnX1)GNY=HNXUm3t1EX6j?=uHN?zQ&Xn90MD#)XrAB~Sen&b-V#m&H~ zdf{y4oln%GYRs!{=VF3_KS(T&@}V2A;LNUKAK17SK=>I!H%=PIrx|`4(;#u<=U0?)lK&Ddfh0%? zoxPory?jXiIFdv9=&d59Q74#kpBj_W6|ClcR4W1Ce+Ct5@y6jkfYH%ifuieU=Rv@e zA=iqf&YaO}esd5NtmDm3=i=N8P!AGCUbM#mbUFw0901oKINzDcYN4}V-#DU$Mmy>x zxsb4v%SG7s*3i(1B{@>lA=ix|&)^*^0O_$LWBZ=`%jRJ7_i7xmzJd)ixT(m6BBw<4 z(jBPpg*WEEaVQ0rXoerP->KmZdIG`>ECm@<8v>PgfMN_`9>*?bnFg5CZ4|UH;GEbC zbyl}I%dpaau>hEk?O#(W{XICT8hMtb+HlTd9eEO=pjJf%1T>TaS59LZr>8m+3t>ww zw@qe)!Pw~p9^ad3z};SbNbp*=|Kjj=h5*`o zJ1*-fjoFu30sWy>aKV4?br?fn*pxUnA4E+iFnz(ejRgn67@U~Zg`vy)_A?lQtMiw6 z7}tON_|XvPi*B>3q|L2)8FwzZNikNWUJJtDMh-(-M#lb?13D;0hfagET|JuNIlwP6 z_^8ulhXd5s27RBBj|yvo6;Qh|bsPfi6FxHO90jsH3X9R!)9d}mskP+VfD~#%(j4SG z9nt8`+4$juu(GlZv_!QpTrdRYW(wv9-_1LpitHOAE2wZT0~o|-9~pP zsgcw5Jly0vXtvfGOJ;WV43GvVu~iy*%KN5V)UIh{|7TZmrSzr#Ne@bm>Tt-_dYOWB5h^{F3g0iIdJv*zUwL z#H&GxMZ0_n{=+u`I;0gOCZM-QOHY3WLm}`C`X7IdY?dYPS`E(wSV_aLSK1X8g~i0$ z@Zm&F3WRv*hdlGU20v@1KVKa|Y)QkdhDc`W6K{ji3RD|hMLtUL7>o~ro&sxyZWcp2 z;PfEw&tUlMyxHJURM7Um^>*x*G^7I-d@(Q(8{No$7d{eBh7*L?X~Hu^z6Zfq60q#h zElGI@JY4OuoZJFv1*Wew9912Y4Jb0x2(3k@4CkH06a5}hI0zyK* zu9uj{PS<;(8_9t6U&sRpjwRhbM0db`kgd^x3Tc@^ol9sWz$*_doyZ)4FjqYWFzgCI zlHr&{1!~v{d%hmrAys(>E=7y-kr3D;uqf)imf<3na3+A5wQrjYiZsAy(3nNt6SQg@ zQPPRRLiHiV;v^j(0Eq`5AnbWe%*Q2({je?7Cm@|cIhX(e*3~-4qV|A662o)Cn-)HD z0Ww{=Ih%`8=x(DEB2rSu&{4{D*O+*!7DwYA&7bXMoT_v2aJQF{qISBQgC5C`iQpkpcR z-gnCL*fmwqrr^Y5?mu`CM>G}ai)HPZZoZm6)Yy? z7x+4}s6Y8@vQ)3bDXVGsdYBpuGf|GYf1gNEtSG5!FMe9CL8f;0sio0`OqEz3;sD zC?Co&w+%Lf3_F7nn1OvfQGacu)*9+WFLcDAU+DstPL54YWyFgYH@_Ijqfe-UPQ?}0 zZszQ+781y#@9x6;!qIbCdPn2dnQ|X$Gt^>)0__B#hCb@Xz~e1=PDeDWL10;edNaL1 zJ6#kF-BRSvN2r4`ezT@EzuOhiYa(R15bsWnBjoJN*Ih7tMQPy?2;~#BAS&I_UYgRa zGUc5WllkGA?|A;gIViuHB+hIewFkndPJPD5)1mj7&b0%^!Ls6HyVtQ@T145u|8vM4mkT8Xv)3QdOoe9&mxOor8i?qM zzmC!4eE2{44ekFCg8nz@_PAV?8viHn_*1w!Mj9BZHflkx-4ngFc%e(^!FzaUPflho z!FJ@jb$O`;yg!N8eO|7esmoOqA&p;qwvzwm0?a+yn1I>siVa2hT`LnC{(j4`Ng;Eu z(Qn8Fry!uQeuf3gMRpMG31{=b+MGhW1%=$kZGJ)~yi!?aV`WT&?GnW^{(ir?3*UG} zx@@zd1Jc16flr3(!p&4VK|#bPA%MR zEI=-FeUdmjnQID*$<$2nSl6jT2SINgv*TvRCSlX-{n>S9$#SGyWd`d*e?PksS)yw! zAhCZR?3=f)v3axi#hqcHgjMGHLjBtMgkf#iub^3yFC!tFidr>-vw0&rjS#7T-jYrC zCXH8LjxbQ1<{{c9RG%C@jCiu^|+-E;*ARaBu3@~HO$}|-@ zlJDCzTf>~cGdLT)lArc1ea8}Nqu)RTR)JJpDs5|;;*03}`_Sy;$4Jcg!xk4}#a&fh zL+eZ*z_&=_K_af|mulJJQga(^vp0LxBz>zXf=0m=T*C)#IKAB)R$WR$&fc++o1DZH ztNwWr*9US!@B!hgL^k0P;rE_d1wFc*x02Hae$2uLK_b>&eopdJwx+R66!Goe@nR8` zvmaz0!nSQq&3s4?ndI7yh=wCbsQy!wnC3kks0TF!7iiSQm|oGoXI1S(ntm$y7L2lB=&sVg~v*?2r(Ulh_|xH<3ztA|Wu$&H&dx z`vC_Rct;2O$RB+D17A*Lhd52XP(w5lf}VX5Lobzo{f|rK(wbd$+MBA0zm{|;)}zo% z={sg!juE^&6<*M+>EXe3=mhq9$EWUo%eJMjFSP2<=OcUL8U!?AK0-{|Z$t>{1P*)0 zUn49IUidpeXT*N;hq2r3g2gZap=|U@eEGQCmhS!2mWci|dZV835?_<{m=XF<^{=`F zf_Ysl$K_Pw4)ReOQphEsm-4Lz$v(Imei>N~Sb5jLN!R2w3I1R*Bx1wO5WW-iszwEK zn%Zt6B2y*#$!Q!KWi1&}lt|vv{nxNVZT}LxY5}uVY`5tuV$i*)t@GiWIVokQv5Sb{R63$i8MU zL~3k<#=iXK_xpT)f82N9=e&FGJNJCgJ@0(adtqjx&%$_t5dZ*|dw1_xQ2Tvq8$3rx zy-r~Tg{d6_;;vl~05Ea>w}D1xpBw|gg(vs!Xj_H;T+clToat)p5;m+MzFb!>j9&Yi}qicYe?F{nb53>QN~1{?i<#b8rWNZLvPUe zYq9%lsYU~-cRA*#XeC#|JT)HDCjMkQ-i_MDZ`=-YI&amIy2rVAivDVfTAc@w`N=ZggN_xdUKdI9WTr3OOzX zhp=dg-~<&_7%qcSOdZ=~So6M&cSIGjG|Va9W|DLXKF|l0bt}6GAkf)8U=CCTow%`; za>oG)G+buQn`XpeK~+G1{;9F7JA0zQN0jXFs!|v|JDH6iE`&P@l6}jZ>fKZ9KzZ2E zed;XQLsQAa+8Ff9{>LCc-)Y;{_VlL$wgQ>Fy_>Ht5>typvLuE5$BXp>ycYBjb`lp& zh48IXx|Nf1fUnRTS0ZI<+Zq6x0sF#974)NhtJO=3-de5Kr8|O4|^~w{TJUDNVbT zIuty_VLJb~?`xZk<^g&AQFuQkZk~(L(wLQyuLSaw^K3EcLN-j~;&q)^2=7G%!cseh zH#NJtG|RzNIoUgU+OCs1gVDoVG&mtapYfiWyoW5fy4uLb&8sfT450@pUM*PsvC&(* z?|LMgFo))(a z_`tqNTSj4N1PDpT)&u2Br-i??dej*g;NU5S&%e4e4|npYuhPNEyK}QWbzEerCuhF7 zXx~#ql1U|EK~v0fI&cgt;4|^t-0B)g>%)QW3KVgckq_@#?|^`x6?2bIUOzBlO8 z%rJ7ayem5xS)4$1@G1V( z?FBjRFR8cC7Cigodu}p~WNTC1Xal<%N!Glhu-F_9WqwD-K)PqP6pdwtpfvexYZoyhUS}=`rQyJ> z8RM`K`29gJQw=FG-A1~e6y}Z&OJ#SO5cO39vBe*2>-9VbjJ8N+v62ZUkz?Qe#-A7N z)x4*SVj+mv#c3XLdj)~z9tTcOQ5iG`S5VfW4m@wYAw(#k(-Mw$7mHI3h37XidB9r5 zr5;hn9VnJwZ+)&I;in_-U|;|3Y-N!m4#XA(85?PF!M|;o8|-Pad=7!!~wujn<~lz#~O|^W&bzC$%vT*6u`9Dyo#x;e@moF9cWDM=xEKW#;r_ zLk97uz6l-9P$}-QN&iS=@nMfy_E1*=WIA{2mRd;PYgeWfbt1sggCQse#V&0jHrmE%##_bpR zGmsp{yV4Gl)8V`kE*u{K9+Q2LOF4dfl@+%a>Lk20LLxwEOZazNKe>X zh=DOI?cqmNWWgp;d^Ce2x0w)|;<1+n12^5RUm%jT;h+tJYfA<|+vU8k3xYX^T7h&C zN3vQDy6%gzR$@R1KFNlK2XXXBs_)QLf*{NdQV~A2S_AXMSR=}EszVBF zVt(&p1{YqXvo<;;v7Bv%$gPwo6AD$`7Siph-7?zQ;y_g<#b=`|tt6MSUTpb&yB8Z$ z)zTe4c*58nw1 z#@9~H+DWQ_N)}Aj5g&KXwOy?f29pn{2oN|OvMr2-z#ipD$pHbP*sXR)B}Xi zC;z;@QjOPvk}s_YQehSE@N17~wd5{;SaG<2Ot`$%i|=-SXQYc0;`ef!;|_1?ZelE0 z&4=i4AkYneTF2-=9?ADhhwQ z6eXZAEn)RC?A8HAxk3$8`c8jS;0h}3-u`x;Sl%B~n@d}1-Bdfojx#?Vw!1Xxb}@gy z)mT{*R62&fPU{i?i<+}LOdF89NPKr80`5xkD|zOCe0xh9C#7`bcDw8VT40S1Y*J?a z`IcN4nhOCo;i>GXP}9XpA=xwF<5axUM3-T?qC~4O^JDHnaoDV<2s^^yRr$YNhbC(Y zA|%J+WAIVUxGgeimVMZbV63X>y)g zJ{!by4OG|W0}9}RBQy=vJ!knL zFOZ9%E?&$30|{iKW;(~VOjbk>+_~q$L%gjSk;WXLQaQRc6{AqEPnoC5k4`mbp8t%E zhXRKGcHfIj5V>^&SWW)#m}Ad|^?hTvmmHnOL42QbG!OR_<=sGkl49_2Txs`=YSL(s zG;=@K4_xJy#wBP%kFd>gR8uQ3>{j}qX6UN&d^@Voc@_KKH@CGaDd4eN;S>`}M<2SITx+5c7cMX5enQ_Wx0+`Ht@71mU z`y(vs_%NMsO?i5eTP(Di1o_7DQJtvyxVEcFp>4!7$P&`h@Mo5;3PzL(*`TiQa*rU-WEEvuABGsk!{aZ%6_{_pfVK(Lu!8MavYb({Hd&6_=kXFddg&nwj?3%1g)QpIDKH(Uy?o#k(zGfgi6Vde)Je*LV_9ed8O< zcF62mZ4PpWZ}&Ow% z-Z>mYC$IFMq{(QBvH!+TX}5I!{{>R2ajAGpQ4Pzj%YjHNJg55q&b$_#>>k;>FnGG> z-dMbG7P5wH!Y;FjXVDh8MDKaYHGljR`!1}EL>(}M3ZZ+?&@leT^eDQh5QI2+#r0=m zKI%a&ljNg#K=XK7#2z{o6vM8s?L0gX{g_EBsld{z7bW6+s6)f^xwPGc@gS5t|8R{) zB=~1YqBJM@o1u>vJx^!J62X1)7Us$6BD+@6=|(oR`B@<&KzKd_z7|ba3h8-Tq)N=S zt+9*VGk)%*V}4-$0yTr$>oPhU;}H2iWvR)PBma0UKB|t9xUevIR@#gGAgRj{c_WZs z*R>dSa!26h5_d5j`N=iHFNE|Ll0=hoUr^x6+xb6aVXiFl1W+C)7@`4jJ$hOqK6mPozo`*cIvU~>G7;JVC zKN{~SKYV)F?62$vSxaYotW<+dvs$lWYLMO9Wza5tM+0B@7iG_|AWOGx=ph#$06loc z;7^%#Tx^0_)VGTP#?)bxV6zdcn@^kXgMS(%R<|D|zbp?w_Hq#Kpq^eXw!Ow}ky7|m zC7>?opssf&HC4EsOy)95NYLPZt#aaIN!3B?8@vv;;O?NJf(YE|D+8azZX zAU^b{GtayLIq#Ht+V-g9RQPs~EZ{VJhBf3K|5K#XdjF0?*Si!EF}m%BO~E!O#m?yx zfesb%_j^Vsx*4>dO7gc_xK*g&#k6KU+Lz?*!$0H0ic>=&yBn0YSEqPSHhtZskgVA8 zLi(AP3?(g$b180;zDbWgN8fmo`;A8-J zn!~q%x(9?u?4~zS0-5^6&S7c#rJ7uAw@~-G7w^gs9qr5sjZJ8E8=E6&0i z=+BB9jI&2(J}QxRX{2TZjgb!Wd~l|iY@f?Ag4;YmoJJ8e>nnqYM@qL|XqX+6XV64o z%XPpC)*ACEu7I=Q!S{?-xfk@xZCAT==?CnNt-7a23&f~7zYR-+&`(f|jJ^V4sPWQX zQK;_QpL_$+C(h5)-MQ{*ac6L()W&=eM68f1eaO2Q8?L=K%soylLj=U08-bK?8uEh) zwB7M4E1ErlfrXBELEEIq?)cxw?Pfkr?vInT#ej2t{kqP{s=2q<_gIbk*^rnAHoM+l z?$~d4nz?@p%g_??oi2XEN%Wzg>wMkdtG`P128C~~Dks{ZvYy7>2S;wS2peJr3#zt3 zl}x{l{X@B-KKb_gq=j?PO*^Ea8WEcgHnIy^i?lj7Ixppsc|9{9AH5AbYxeaDs9wG*H?|GTy*hsI%wt@vEy(Q5{BYP<&nQ-6 z4s9rM%sC5b5^eC=+HC`RijImHZG3*PPUUAla2pamP5sSg3*9JafUm{GScqbSkU8&7D?es&liY%jr+EP zzxQkEUi@BmeSKfukV(%CXZlL#n3-x#u zpJ&_q!PT&$u%XqxFw>gw-AVp zEEd~_2=K#hbp!0IVYP^cWK-VLCBB{|TS`FBk2|Qzbw7a*tGPTHR}!B`nw@P8N*C2f zX;(C@fd>0oy~=jB4n)(d)F%BWh|4JSLGCLn%#%WF?dJlC7sO99Gr9cLq$2QD+cU5| zsG+jZ|}f;qmzO6R8`&2 zGwjn=lcXo)RzG+cLmG`RdErpF^y|4(^yhxde)4Ky#bHbB=OF{gzU?;_gDR2)LMHmJ z>&ff`fw`l>ltFag=YQ2OC;Wkjj76VQx%K3D`$-tf&`*OSXRLKw$Q=4PnCk1vq}@6W z-^nyP%6)T0@}SF&JFngeNsP})Lkq|qc1WlD4}8JznPmIc?2tqPSLu|3Syc0r^XAjY zo820>z^>fPd%eeu=R47C-2H9hx1G_}Hteljf>qZeU{O&Ti8iTLb#VXs6ZbAGI9mxq zMxLDHU!d;IYTs^}5|VuMmSXX6JV@sW==HU%7uN6_V_N9)10y>MOd1!}v)4Jbc!4Y@{xZj11x)eU&)+B2OplJ&y->H{eB z7i_li^x5mrm00P2DZ{oHXJk6>1>D#h<1(%KsJZEx#db%fT$byMls_{=ir zQcCYl)dsS^s*35%h0Lpm&&v;+yVq-vJNj)YCHo7Nb&Kdd%fU;1vj@s@hV3C2GFO$< zVNrRg=MEHYGj3DOy}sPAS$54xhd41329xx&yoqlO4MfU?w0`s$udNIV;XQ8zh6>@6 z*=rIjiRFj!`wKHB+T$CZP1pP8Q?~Cu)={1OdhKn!HisQW!w+*kwp&Z?Qf*!@`aFY5 zXSRg?+bTys40dRCDH1thWQ)8OOU<*Q_og6-HZ2SkQe}Uc@b`WuKL%<+*RWcG@`aU$ zlaXLyws5Ao#k>!0n6I=rs&YPQ=B8V}@6mG!_ZPK^-nXv#1&UchF)sN2cD-$oA!`(xB4Vi~ckXMZDXJDAT#m zZmv>77ju33Htrl)=x(X@1TlvrM)kG~sSMPT(?8EwwD*lu@nTh-{*szcZlRYT3+FHn}%DSHckEqv}xAC_$?g$wbal+Q_{V&AQ( zNOM4kubVo7O~hXL&ylVaETyF z)svd3MZ@q?OSdl$fyAqLbbg(H$XoNWCCA3~d}Y#{A&-6PEj0nKcN+7qol~W(!+TW) z=xR}IIg{%m_Fz&%B$q{ZvzW@(848-S$(#W})heEf#AZNkk)o-lVBPK0M~AZcKw;EC ziixtpTol87A$o*2QzxutR?K`^W<^oVh@L)aGuS`t znOt}=;yW-A351_pirPBd)IVP$H<|83PUpArBA}BC+PPa>q0}l+a7uQ9eZHmu>;yYC z@1tJ=l}yY^52cL7?y;iS>qZ1KF_$r4c_7qEmu_!Xa)#G<2>z5wVS80F(SW-*`+evP ze8L(&!i!_RwwooYhe%B2>U97%X!XVxYp4*M=_)+Ft%Z}4=2~J2mFYr@+ftg{&W`T* zXNgmbQ$m5W&m(o!mfFp!Y)Wdk%`~$AJe4v@4HKwkBi(#Q3=~{N6>ol&6`+bA7DNxn z)ff8frdo>>#_|#$<4^Z8dFiQKDwVB?wE$%7V>G=N+CxuHCqM3N-yukBa3T^dSdJ<3 sMu1vJ`X*SPY(iL1db(nfv305`+N@*FWkzPD{+6+ZZ-gb@s58iN8kpKVy From 4a962b9631135c209a1096b14f14174ad0f89d65 Mon Sep 17 00:00:00 2001 From: Johan Rende Date: Sun, 29 Sep 2024 13:21:00 +0200 Subject: [PATCH 364/369] Cleanup dst detection after code review comments --- .../components/CalendarDayView/Day/index.tsx | 21 +++++++++---------- .../components/CalendarMonthView/Day.tsx | 18 +++++++--------- .../components/CalendarWeekView/DayHeader.tsx | 20 +++++++++--------- .../components/CalendarWeekView/index.tsx | 13 +++++------- src/features/calendar/components/utils.ts | 19 +++++++++++++---- 5 files changed, 48 insertions(+), 43 deletions(-) diff --git a/src/features/calendar/components/CalendarDayView/Day/index.tsx b/src/features/calendar/components/CalendarDayView/Day/index.tsx index e096c44d21..b2ca3ba253 100644 --- a/src/features/calendar/components/CalendarDayView/Day/index.tsx +++ b/src/features/calendar/components/CalendarDayView/Day/index.tsx @@ -3,20 +3,14 @@ import { useMemo } from 'react'; import dayjs from 'dayjs'; import DateLabel from './DateLabel'; -import { DaySummary, getDSTOffset } from '../../utils'; +import { DaySummary, getDstChangeAtDate } from '../../utils'; import Event from './Event'; import messageIds from 'features/calendar/l10n/messageIds'; import theme from 'theme'; import { Msg } from 'core/i18n'; const Day = ({ date, dayInfo }: { date: Date; dayInfo: DaySummary }) => { - const dstOffset = useMemo( - () => - getDSTOffset(dayjs(date).startOf('day').toDate()) - - getDSTOffset(dayjs(date).endOf('day').toDate()), - [date] - ); - + const dstChange = useMemo(() => getDstChangeAtDate(dayjs(date)), [date]); return ( { > - {dstOffset !== 0 && ( + {dstChange !== undefined && ( - {dstOffset > 0 && } - {dstOffset < 0 && } + )} diff --git a/src/features/calendar/components/CalendarMonthView/Day.tsx b/src/features/calendar/components/CalendarMonthView/Day.tsx index f10f813210..8dc387e7eb 100644 --- a/src/features/calendar/components/CalendarMonthView/Day.tsx +++ b/src/features/calendar/components/CalendarMonthView/Day.tsx @@ -7,7 +7,7 @@ import messageIds from '../../l10n/messageIds'; import theme from 'theme'; import { AnyClusteredEvent } from 'features/calendar/utils/clusterEventsForWeekCalender'; import EventCluster from '../EventCluster'; -import { getDSTOffset } from '../utils'; +import { getDstChangeAtDate } from '../utils'; import { Msg } from 'core/i18n'; type DayProps = { @@ -26,13 +26,7 @@ const Day = ({ onClick, }: DayProps) => { const isToday = dayjs(date).isSame(new Date(), 'day'); - - const dstOffset = useMemo( - () => - getDSTOffset(dayjs(date).startOf('day').toDate()) - - getDSTOffset(dayjs(date).endOf('day').toDate()), - [date] - ); + const dstChange = useMemo(() => getDstChangeAtDate(dayjs(date)), [date]); let textColor = theme.palette.text.secondary; if (isToday) { @@ -67,11 +61,15 @@ const Day = ({ - {dstOffset !== 0 && ( + {dstChange !== undefined && ( 0 ? messageIds.dstStarts : messageIds.dstEnds} + id={ + dstChange === 'summertime' + ? messageIds.dstStarts + : messageIds.dstEnds + } /> diff --git a/src/features/calendar/components/CalendarWeekView/DayHeader.tsx b/src/features/calendar/components/CalendarWeekView/DayHeader.tsx index 0b3cb1e87e..760db2468d 100644 --- a/src/features/calendar/components/CalendarWeekView/DayHeader.tsx +++ b/src/features/calendar/components/CalendarWeekView/DayHeader.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import dayjs from 'dayjs'; import theme from 'theme'; -import { getDSTOffset } from '../utils'; +import { getDstChangeAtDate } from '../utils'; import { Msg } from 'core/i18n'; import messageIds from '../../l10n/messageIds'; @@ -15,12 +15,7 @@ export interface DayHeaderProps { } const DayHeader = ({ date, focused, onClick }: DayHeaderProps) => { - const dstChangeAmount: number = useMemo( - () => - getDSTOffset(dayjs(date).startOf('day').toDate()) - - getDSTOffset(dayjs(date).endOf('day').toDate()), - [date] - ); + const dstChange = useMemo(() => getDstChangeAtDate(dayjs(date)), [date]); return ( { {/* Empty */} - {dstChangeAmount !== 0 && ( + {dstChange !== undefined && ( - {dstChangeAmount > 0 && } - {dstChangeAmount < 0 && } + )} diff --git a/src/features/calendar/components/CalendarWeekView/index.tsx b/src/features/calendar/components/CalendarWeekView/index.tsx index 2ebd62f559..5c131cad59 100644 --- a/src/features/calendar/components/CalendarWeekView/index.tsx +++ b/src/features/calendar/components/CalendarWeekView/index.tsx @@ -24,7 +24,7 @@ import messageIds from 'features/calendar/l10n/messageIds'; import { Msg } from 'core/i18n'; import range from 'utils/range'; import { scrollToEarliestEvent } from './utils'; -import { getDSTOffset } from '../utils'; +import { getDstChangeAtDate } from '../utils'; import useCreateEvent from 'features/events/hooks/useCreateEvent'; import { useNumericRouteParams } from 'core/hooks'; import useWeekCalendarEvents from 'features/calendar/hooks/useWeekCalendarEvents'; @@ -57,13 +57,10 @@ const CalendarWeekView = ({ focusDate, onClickDay }: CalendarWeekViewProps) => { return focusWeekStartDay.day(weekday + 1).toDate(); }); - const dstChangeAmount: number = useMemo( + const dstChange = useMemo( () => - getDSTOffset(dayjs(focusWeekStartDay).startOf('day').toDate()) - - getDSTOffset( - dayjs(focusWeekStartDay.add(6, 'days')).endOf('day').toDate() - ), - [focusWeekStartDay] + dayDates.map((d) => dayjs(d)).find((date) => getDstChangeAtDate(date)), + [dayDates] ); const eventsByDate = useWeekCalendarEvents({ @@ -92,7 +89,7 @@ const CalendarWeekView = ({ focusDate, onClickDay }: CalendarWeekViewProps) => { display="grid" gridTemplateColumns={`${HOUR_COLUMN_WIDTH} repeat(7, 1fr)`} gridTemplateRows={'1fr'} - marginBottom={dstChangeAmount === 0 ? 2 : 0} + marginBottom={dstChange === undefined ? 2 : 0} > {/* Empty */} diff --git a/src/features/calendar/components/utils.ts b/src/features/calendar/components/utils.ts index df6088922b..e9d2dac5cf 100644 --- a/src/features/calendar/components/utils.ts +++ b/src/features/calendar/components/utils.ts @@ -279,8 +279,19 @@ export const getActivitiesByDay = ( return dateHashmap; }; -export function getDSTOffset(date: Date): number { - const jan = new Date(date.getFullYear(), 0, 1).getTimezoneOffset(); - const jul = new Date(date.getFullYear(), 6, 1).getTimezoneOffset(); - return (date.getTimezoneOffset() - Math.max(jan, jul)) / 60; +export type DSTChange = 'summertime' | 'wintertime'; +export function getDstChangeAtDate(date: dayjs.Dayjs): DSTChange | undefined { + const change = + getTimezoneAtDate(date.startOf('day')) - + getTimezoneAtDate(date.endOf('day')); + if (change === 0) { + return undefined; + } + return change > 0 ? 'summertime' : 'wintertime'; +} + +export function getTimezoneAtDate(date: dayjs.Dayjs): number { + const jan = new Date(date.get('year'), 0, 1).getTimezoneOffset(); + const jul = new Date(date.get('year'), 6, 1).getTimezoneOffset(); + return (date.utcOffset() - Math.max(jan, jul)) / 60; } From 490da4dac03290f23f820c71ef3e23dafd958775 Mon Sep 17 00:00:00 2001 From: Johan Rende Date: Sun, 29 Sep 2024 14:42:29 +0200 Subject: [PATCH 365/369] Moved error handling to ZUITimeline --- .../journeys/[journeyId]/[instanceId]/index.tsx | 9 +-------- src/zui/ZUITimeline/index.tsx | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/pages/organize/[orgId]/journeys/[journeyId]/[instanceId]/index.tsx b/src/pages/organize/[orgId]/journeys/[journeyId]/[instanceId]/index.tsx index 8c3f0c9745..08c829c083 100644 --- a/src/pages/organize/[orgId]/journeys/[journeyId]/[instanceId]/index.tsx +++ b/src/pages/organize/[orgId]/journeys/[journeyId]/[instanceId]/index.tsx @@ -74,7 +74,6 @@ const JourneyDetailsPage: PageWithLayout = () => { const { orgId, instanceId } = useNumericRouteParams(); const messages = useMessages(messageIds); const [isLoading, setIsLoading] = useState(false); - const [showPostRequestError, setShowPostRequestError] = useState(false); const journeyInstanceFuture = useJourneyInstance(orgId, instanceId); const timelineUpdatesFuture = useTimelineUpdates(orgId, instanceId); @@ -117,17 +116,11 @@ const JourneyDetailsPage: PageWithLayout = () => { { - setShowPostRequestError(false); setIsLoading(true); - try { - await addNote(note); - } catch (e) { - setShowPostRequestError(true); - } + await addNote(note); setIsLoading(false); }} onEditNote={editNote} - showPostRequestError={showPostRequestError} updates={updates} /> )} diff --git a/src/zui/ZUITimeline/index.tsx b/src/zui/ZUITimeline/index.tsx index c2173a2573..0c42963b3e 100644 --- a/src/zui/ZUITimeline/index.tsx +++ b/src/zui/ZUITimeline/index.tsx @@ -1,5 +1,5 @@ import { Alert } from '@mui/material'; -import React from 'react'; +import React, { useState } from 'react'; import { Box, CardActionArea, @@ -22,7 +22,6 @@ import { ZetkinNote, ZetkinNoteBody } from 'utils/types/zetkin'; import messageIds from './l10n/messageIds'; export interface ZUITimelineProps { - showPostRequestError?: boolean; disabled?: boolean; onAddNote: (note: ZetkinNoteBody) => void; onEditNote: (note: Pick) => void; @@ -30,7 +29,6 @@ export interface ZUITimelineProps { } const ZUITimeline: React.FunctionComponent = ({ - showPostRequestError, disabled, onAddNote, onEditNote, @@ -44,13 +42,23 @@ const ZUITimeline: React.FunctionComponent = ({ typeFilterOptions, } = useFilterUpdates(updates); + const [showPostRequestError, setShowPostRequestError] = useState(false); + return ( { + setShowPostRequestError(false); + try { + // Await is needed here to catch the exception if the request fails + await onAddNote(note); + } catch (e) { + setShowPostRequestError(true); + } + }} showPostRequestError={showPostRequestError} /> From db15e119de32617cb2e49cdc4f1ada030d866231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20J=C3=B6nsson?= Date: Sun, 29 Sep 2024 19:31:15 +0200 Subject: [PATCH 366/369] improve type predicate by making it more explicit and remove unnecessary export --- src/features/views/components/MoveViewDialog.tsx | 8 +++----- src/features/views/hooks/useViewBrowserItems.ts | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/features/views/components/MoveViewDialog.tsx b/src/features/views/components/MoveViewDialog.tsx index c89b326d10..c53d7afb6f 100644 --- a/src/features/views/components/MoveViewDialog.tsx +++ b/src/features/views/components/MoveViewDialog.tsx @@ -19,9 +19,8 @@ import { useNumericRouteParams } from 'core/hooks'; import ZUIFuture from 'zui/ZUIFuture'; import { ZetkinViewFolder } from './types'; import useViewBrowserItems, { - ViewBrowserFolderItem, + ViewBrowserBackItem, ViewBrowserItem, - ViewBrowserViewItem, } from '../hooks/useViewBrowserItems'; import useViewBrowserMutations from '../hooks/useViewBrowserMutations'; import useViewTree from '../hooks/useViewTree'; @@ -109,7 +108,7 @@ const MoveItemBreadcrumbs = ({ ); }; -export type MoveViewDialogProps = { +type MoveViewDialogProps = { close: () => void; itemToMove: ViewBrowserItem; }; @@ -164,8 +163,7 @@ const MoveViewDialog: FunctionComponent = ({ {(data) => { const relevantItems = data.filter( - // This explicit typing should not be necessary, but it is. Don't know why. - (item): item is ViewBrowserFolderItem | ViewBrowserViewItem => + (item): item is Exclude => item.type != 'back' ); if (relevantItems.length == 0) { diff --git a/src/features/views/hooks/useViewBrowserItems.ts b/src/features/views/hooks/useViewBrowserItems.ts index ebde90f123..56bba5376b 100644 --- a/src/features/views/hooks/useViewBrowserItems.ts +++ b/src/features/views/hooks/useViewBrowserItems.ts @@ -20,7 +20,7 @@ export interface ViewBrowserViewItem { folderId: number | null; } -type ViewBrowserBackItem = { +export type ViewBrowserBackItem = { folderId: number | null; id: string; title: string | null; From ee2f7cf2cd4217ab66e7d0ad1b58509a84550a12 Mon Sep 17 00:00:00 2001 From: Herover Date: Sun, 29 Sep 2024 22:02:14 +0200 Subject: [PATCH 367/369] Add test for adding nothing to empty sankey diagram --- .../sankeyDiagram/makeSankeySegments.spec.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/src/features/smartSearch/components/sankeyDiagram/makeSankeySegments.spec.ts b/src/features/smartSearch/components/sankeyDiagram/makeSankeySegments.spec.ts index e1fd122743..d750a8e9d6 100644 --- a/src/features/smartSearch/components/sankeyDiagram/makeSankeySegments.spec.ts +++ b/src/features/smartSearch/components/sankeyDiagram/makeSankeySegments.spec.ts @@ -537,4 +537,82 @@ describe('makeSankeySegments()', () => { }, ]); }); + + it('handles adding nothing to empty stream', () => { + const result = makeSankeySegments([ + { + change: 0, + filter: { + config: { + fields: { + first_name: 'aaaaaaaaaaa', + }, + organizations: [1], + }, + op: OPERATION.ADD, + type: FILTER_TYPE.PERSON_DATA, + }, + matches: 0, + result: 0, + }, + { + change: 100, + filter: { + config: { + fields: { + first_name: 'a', + }, + organizations: [1], + }, + op: OPERATION.ADD, + type: FILTER_TYPE.PERSON_DATA, + }, + matches: 100, + result: 100, + }, + ]); + + expect(result).toEqual([ + { + kind: SEGMENT_KIND.EMPTY, + }, + { + kind: SEGMENT_KIND.PSEUDO_ADD, + main: null, + side: { + style: SEGMENT_STYLE.STROKE, + width: 0, + }, + stats: { + change: 0, + input: 0, + matches: 0, + output: 0, + }, + }, + { + kind: SEGMENT_KIND.ADD, + main: { + style: SEGMENT_STYLE.FILL, + width: 0.05, + }, + side: { + style: SEGMENT_STYLE.FILL, + width: 0.95, + }, + stats: { + change: 100, + input: 0, + matches: 100, + output: 100, + }, + }, + { + kind: SEGMENT_KIND.EXIT, + output: 100, + style: SEGMENT_STYLE.FILL, + width: 1, + }, + ]); + }); }); From ad8ff9751089766022c2912bc65064b141ec40c2 Mon Sep 17 00:00:00 2001 From: Johan Rende Date: Mon, 30 Sep 2024 10:35:29 +0200 Subject: [PATCH 368/369] Changed message phrasing --- src/zui/ZUITimeline/l10n/messageIds.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/zui/ZUITimeline/l10n/messageIds.ts b/src/zui/ZUITimeline/l10n/messageIds.ts index f4a59f8c4f..9cabdb6d03 100644 --- a/src/zui/ZUITimeline/l10n/messageIds.ts +++ b/src/zui/ZUITimeline/l10n/messageIds.ts @@ -12,9 +12,7 @@ export default makeMessages('zui.timeline', { }, }, expand: m('show full timeline'), - fileUploadErrorMessage: m( - "Unable to add note. Possible reason is that it's too long." - ), + fileUploadErrorMessage: m('Unable to add note. It might be too long'), filter: { byType: { all: m('All'), From fe67c895b08f9e769a057dd65c1e452414f923e9 Mon Sep 17 00:00:00 2001 From: a-jaxell Date: Thu, 3 Oct 2024 19:53:38 +0200 Subject: [PATCH 369/369] Fix typo --- .../[orgId]/projects/[campId]/emails/[emailId]/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/index.tsx b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/index.tsx index 2eb8ef546f..23eedd13ac 100644 --- a/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/index.tsx +++ b/src/pages/organize/[orgId]/projects/[campId]/emails/[emailId]/index.tsx @@ -72,7 +72,7 @@ const EmailPage: PageWithLayout = () => {