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..4850c54cdf --- /dev/null +++ b/integrationTesting/mockData/orgs/KPD/surveys/MembershipSurvey/index.ts @@ -0,0 +1,89 @@ +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: 2, + 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, + }, + { + hidden: false, + id: 3, + question: { + description: '', + options: [ + { + id: 1, + text: 'Flyers', + }, + { + id: 2, + text: 'Phone banking', + }, + ], + question: 'How do you want to help?', + required: false, + response_config: { + widget_type: 'checkbox', + }, + response_type: RESPONSE_TYPE.OPTIONS, + }, + 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: 'allow_anonymous', + 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..5283104fc8 --- /dev/null +++ b/integrationTesting/tests/organize/surveys/submitting-survey.spec.ts @@ -0,0 +1,697 @@ +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'; +import { + ELEMENT_TYPE, + RESPONSE_TYPE, + ZetkinSurveyApiSubmission, +} from 'utils/types/zetkin'; + +test.describe('User submitting a survey', () => { + const apiPostPath = `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`; + + test.beforeEach(async ({ login, moxy }) => { + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + KPDMembershipSurvey + ); + + login(RosaLuxemburgUser, [ + { + organization: KPD, + profile: { + id: RosaLuxemburg.id, + name: RosaLuxemburg.first_name + ' ' + RosaLuxemburg.last_name, + }, + role: null, + }, + ]); + }); + + test.afterEach(({ moxy }) => { + moxy.teardown(); + }); + + test('submits text input', async ({ appUri, moxy, page }) => { + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + 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([ + page.waitForResponse((res) => res.request().method() == 'POST'), + await page.click('data-testid=Survey-submit'), + ]); + + const log = moxy.log(`/v1${apiPostPath}`); + expect(log.length).toBe(1); + + const data = log[0].data as ZetkinSurveyApiSubmission; + expect(data).toEqual({ + responses: [ + { + question_id: KPDMembershipSurvey.elements[1].id, + response: 'Topple capitalism', + }, + ], + signature: null, + }); + }); + + test('required text input blocks submission when empty', async ({ + appUri, + moxy, + page, + }) => { + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + { + ...KPDMembershipSurvey, + elements: [ + { + hidden: false, + id: 2, + question: { + description: '', + question: 'What would you like to do?', + required: true, + response_config: { + multiline: true, + }, + response_type: RESPONSE_TYPE.TEXT, + }, + type: ELEMENT_TYPE.QUESTION, + }, + ], + } + ); + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, + 'post', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + const requiredTextInput = await page.locator('[name="2.text"]'); + await requiredTextInput.waitFor({ state: 'visible' }); + + await page.click('input[name="sig"][value="anonymous"]'); + await page.click('data-testid=Survey-acceptTerms'); + await page.click('data-testid=Survey-submit'); + + const valueMissing = await requiredTextInput.evaluate( + (element: HTMLTextAreaElement) => element.validity.valueMissing + ); + expect(valueMissing).toBe(true); + }); + + test('submits radio input', async ({ appUri, moxy, page }) => { + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + { + ...KPDMembershipSurvey, + elements: [ + { + hidden: false, + id: 1, + question: { + description: '', + options: [ + { + id: 1, + text: 'Yes', + }, + { + id: 2, + 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, + }, + ], + } + ); + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, + 'post', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + await page.click('input[name="1.options"]'); + + 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'), + await page.click('data-testid=Survey-submit'), + ]); + + const log = moxy.log(`/v1${apiPostPath}`); + expect(log.length).toBe(1); + + const data = log[0].data as ZetkinSurveyApiSubmission; + expect(data).toEqual({ + responses: [ + { + options: [1], + question_id: KPDMembershipSurvey.elements[0].id, + }, + ], + signature: null, + }); + }); + + test('required radio input blocks submission when empty', async ({ + appUri, + moxy, + page, + }) => { + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + { + ...KPDMembershipSurvey, + elements: [ + { + hidden: false, + id: 1, + question: { + description: '', + options: [ + { + id: 1, + text: 'Yes', + }, + { + id: 2, + text: 'No', + }, + ], + question: 'Do you want to be active?', + required: true, + response_config: { + widget_type: 'radio', + }, + response_type: RESPONSE_TYPE.OPTIONS, + }, + type: ELEMENT_TYPE.QUESTION, + }, + ], + } + ); + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, + 'post', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + const requiredRadioInput = await page.locator( + '[name="1.options"] >> nth=0' + ); + await requiredRadioInput.waitFor({ state: 'visible' }); + + await page.click('input[name="sig"][value="anonymous"]'); + await page.click('data-testid=Survey-acceptTerms'); + await page.click('data-testid=Survey-submit'); + + const valueMissing = await requiredRadioInput.evaluate( + (element: HTMLInputElement) => element.validity.valueMissing + ); + expect(valueMissing).toBe(true); + }); + + test('submits checkbox input', async ({ appUri, moxy, page }) => { + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + { + ...KPDMembershipSurvey, + elements: [ + { + hidden: false, + id: 3, + question: { + description: '', + options: [ + { + id: 1, + text: 'Flyers', + }, + { + id: 2, + text: 'Phone banking', + }, + ], + question: 'How do you want to help?', + required: false, + response_config: { + widget_type: 'checkbox', + }, + response_type: RESPONSE_TYPE.OPTIONS, + }, + type: ELEMENT_TYPE.QUESTION, + }, + ], + } + ); + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, + 'post', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + await page.click('input[name="3.options"][value="1"]'); + await page.click('input[name="3.options"][value="2"]'); + + 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'), + await page.click('data-testid=Survey-submit'), + ]); + + const log = moxy.log(`/v1${apiPostPath}`); + expect(log.length).toBe(1); + + const data = log[0].data as ZetkinSurveyApiSubmission; + expect(data).toEqual({ + responses: [ + { + options: [1, 2], + question_id: KPDMembershipSurvey.elements[2].id, + }, + ], + signature: null, + }); + }); + + test('submits select input', async ({ appUri, moxy, page }) => { + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + { + ...KPDMembershipSurvey, + elements: [ + { + hidden: false, + id: 3, + question: { + description: '', + options: [ + { + id: 1, + text: 'Yes', + }, + { + id: 2, + text: 'No', + }, + ], + question: 'Is this a select box?', + required: false, + response_config: { + widget_type: 'select', + }, + response_type: RESPONSE_TYPE.OPTIONS, + }, + type: ELEMENT_TYPE.QUESTION, + }, + ], + } + ); + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, + 'post', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + const selectInput = await page.locator( + '[id="mui-component-select-3.options"]' + ); + const yes = await page.locator('[role="option"][data-value="1"]'); + + await selectInput.waitFor({ state: 'visible' }); + await selectInput.click(); + await yes.waitFor({ state: 'visible' }); + await yes.click(); + + 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'), + await page.click('data-testid=Survey-submit'), + ]); + + const log = moxy.log(`/v1${apiPostPath}`); + expect(log.length).toBe(1); + + const data = log[0].data as ZetkinSurveyApiSubmission; + expect(data).toEqual({ + responses: [ + { + options: [1], + question_id: 3, + }, + ], + signature: null, + }); + }); + + test('required select input blocks submission when empty', async ({ + appUri, + moxy, + page, + }) => { + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + { + ...KPDMembershipSurvey, + elements: [ + { + hidden: false, + id: 3, + question: { + description: '', + options: [ + { + id: 1, + text: 'Yes', + }, + { + id: 2, + text: 'No', + }, + ], + question: 'Is this a select box?', + required: true, + response_config: { + widget_type: 'select', + }, + response_type: RESPONSE_TYPE.OPTIONS, + }, + type: ELEMENT_TYPE.QUESTION, + }, + ], + } + ); + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + const selectInput = await page.locator( + '[id="mui-component-select-3.options"]' + ); + const hiddenInput = await page.locator('input[name="3.options"]'); + + await selectInput.waitFor({ state: 'visible' }); + await page.click('input[name="sig"][value="anonymous"]'); + await page.click('data-testid=Survey-acceptTerms'); + await page.click('data-testid=Survey-submit'); + + const valueMissing = await hiddenInput.evaluate( + (element: HTMLSelectElement) => element.validity.valueMissing + ); + expect(valueMissing).toBe(true); + }); + + test('submits untouched "select" widget as []', async ({ + appUri, + moxy, + page, + }) => { + // Include a select-widget element in the survey + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + { + ...KPDMembershipSurvey, + elements: [ + { + hidden: false, + id: 3, + question: { + description: '', + options: [ + { + id: 1, + text: 'Yes', + }, + { + id: 2, + text: 'No', + }, + ], + question: 'Is this a select box?', + required: false, + response_config: { + widget_type: 'select', + }, + response_type: RESPONSE_TYPE.OPTIONS, + }, + type: ELEMENT_TYPE.QUESTION, + }, + ], + } + ); + + // Respond when survey is submitted + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}/submissions`, + 'post', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + // Navigate to survey and submit without touching the select widget (or any) + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + 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'), + await page.click('data-testid=Survey-submit'), + ]); + + const log = moxy.log(`/v1${apiPostPath}`); + expect(log.length).toBe(1); + + const data = log[0].data as ZetkinSurveyApiSubmission; + expect(data).toEqual({ + responses: [ + { + options: [], + question_id: 3, + }, + ], + signature: null, + }); + }); + + 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', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + await page.click('input[name="1.options"]'); + 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'); + await page.fill('input[name="sig.last_name"]', 'User'); + await page.click('data-testid=Survey-acceptTerms'); + await Promise.all([ + page.waitForResponse((res) => res.request().method() == 'POST'), + await page.click('data-testid=Survey-submit'), + ]); + + const log = moxy.log(`/v1${apiPostPath}`); + expect(log.length).toBe(1); + + const data = log[0].data as ZetkinSurveyApiSubmission; + expect(data).toMatchObject({ + signature: { + email: 'testuser@example.org', + first_name: 'Test', + last_name: 'User', + }, + }); + }); + + 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', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + await page.click('input[name="1.options"][value="1"]'); + 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([ + page.waitForResponse((res) => res.request().method() == 'POST'), + await page.click('data-testid=Survey-submit'), + ]); + + const log = moxy.log(`/v1${apiPostPath}`); + expect(log.length).toBe(1); + + const data = log[0].data as ZetkinSurveyApiSubmission; + expect(data).toMatchObject({ + signature: 'user', + }); + }); + + 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', + { + timestamp: '1857-05-07T13:37:00.000Z', + } + ); + + 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"]'); + await page.click('data-testid=Survey-acceptTerms'); + await Promise.all([ + page.waitForResponse((res) => res.request().method() == 'POST'), + await page.click('data-testid=Survey-submit'), + ]); + + const log = moxy.log(`/v1${apiPostPath}`); + expect(log.length).toBe(1); + + const data = log[0].data as ZetkinSurveyApiSubmission; + expect(data).toMatchObject({ + signature: null, + }); + }); + + 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"]'); + await page.click('data-testid=Survey-acceptTerms'); + + await Promise.all([ + page.waitForResponse((res) => res.request().method() == 'POST'), + await page.click('data-testid=Survey-submit'), + ]); + + await expect(page.locator('data-testid=Survey-error')).toBeVisible(); + await expect( + page.locator('input[name="1.options"][value="1"]') + ).toBeChecked(); + await expect(page.locator('[name="2.text"]')).toHaveValue( + 'Topple capitalism' + ); + await expect( + page.locator('input[name="sig"][value="anonymous"]') + ).toBeChecked(); + await expect(page.locator('data-testid=Survey-acceptTerms')).toBeChecked(); + }); + + test("doesn't render hidden elements", async ({ appUri, moxy, page }) => { + moxy.setZetkinApiMock( + `/orgs/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}`, + 'get', + { + ...KPDMembershipSurvey, + elements: KPDMembershipSurvey.elements.map((element) => ({ + ...element, + hidden: true, + })), + } + ); + + await page.goto( + `${appUri}/o/${KPDMembershipSurvey.organization.id}/surveys/${KPDMembershipSurvey.id}` + ); + + const radioInput = await page.locator('input[name="1.options"]'); + const textInput = await page.locator('[name="2.text"]'); + const checkboxInput = await page.locator('input[name="3.options"]'); + + expect(await radioInput.isVisible()).toBeFalsy(); + expect(await textInput.isVisible()).toBeFalsy(); + expect(await checkboxInput.isVisible()).toBeFalsy(); + }); +}); diff --git a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx index 028667ca73..34387f2368 100644 --- a/src/app/o/[orgId]/surveys/[surveyId]/page.tsx +++ b/src/app/o/[orgId]/surveys/[surveyId]/page.tsx @@ -1,12 +1,58 @@ -import { redirect } from 'next/navigation'; +'use server'; -export default function Page({ +import { headers } from 'next/headers'; +import { Metadata } from 'next'; +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 = { + params: { + orgId: string; + surveyId: string; + }; +}; + +export async function generateMetadata({ params, -}: { - params: { orgId: string; surveyId: string }; -}) { +}: PageProps): Promise { const { orgId, surveyId } = params; - redirect( - `http://${process.env.ZETKIN_API_DOMAIN}/o/${orgId}/surveys/${surveyId}` + const apiClient = new BackendApiClient({}); + const survey = await apiClient.get( + `/api/orgs/${orgId}/surveys/${surveyId}` ); + return { + description: survey.info_text, + openGraph: { + description: survey.info_text, + title: survey.title, + }, + title: survey.title, + }; } + +// @ts-expect-error https://nextjs.org/docs/app/building-your-application/configuring/typescript#async-server-component-typescript-error +const Page: FC = async ({ params }): Promise => { + const headersList = headers(); + const headersEntries = headersList.entries(); + const headersObject = Object.fromEntries(headersEntries); + const apiClient = new BackendApiClient(headersObject); + + let user: ZetkinUser | null; + try { + user = await apiClient.get('/api/users/me'); + } catch (e) { + user = null; + } + + const { orgId, surveyId } = params; + 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 new file mode 100644 index 0000000000..ac1110c914 --- /dev/null +++ b/src/features/surveys/actions/submit.ts @@ -0,0 +1,36 @@ +'use server'; + +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'; + +export async function submit( + prevState: ZetkinSurveyFormStatus, + formData: FormData +): Promise { + const headersList = headers(); + const headersEntries = headersList.entries(); + const headersObject = Object.fromEntries(headersEntries); + const apiClient = new BackendApiClient(headersObject); + + let user: ZetkinUser | null; + try { + user = await apiClient.get('/api/users/me'); + } catch (e) { + user = null; + } + + const { orgId, surveyId } = Object.fromEntries([...formData.entries()]); + const submission = prepareSurveyApiSubmission(formData, !!user); + try { + await apiClient.post( + `/api/orgs/${orgId}/surveys/${surveyId}/submissions`, + submission + ); + } catch (e) { + return 'error'; + } + return 'submitted'; +} diff --git a/src/features/surveys/components/SurveyURLCard.tsx b/src/features/surveys/components/SurveyURLCard.tsx index 22426d7fec..dd52be360d 100644 --- a/src/features/surveys/components/SurveyURLCard.tsx +++ b/src/features/surveys/components/SurveyURLCard.tsx @@ -1,11 +1,12 @@ import { OpenInNew } from '@mui/icons-material'; +import { useMemo } from 'react'; import { Box, Link, useTheme } from '@mui/material'; -import { useEnv } from 'core/hooks'; import ZUICard from 'zui/ZUICard'; 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,9 +15,16 @@ interface SurveyURLCardProps { } const SurveyURLCard = ({ isOpen, orgId, surveyId }: SurveyURLCardProps) => { + const survey = useSurvey(parseInt(orgId), parseInt(surveyId)); const messages = useMessages(messageIds); const theme = useTheme(); - const env = useEnv(); + const surveyUrl = useMemo( + () => + survey.data + ? `${location.protocol}//${location.host}/o/${survey.data.organization.id}/surveys/${surveyId}` + : '', + [survey.data, surveyId] + ); return ( { } > - - {`${env.vars.ZETKIN_APP_DOMAIN}/o/${orgId}/surveys/${surveyId}`} + + {surveyUrl} = ({ children, ...boxProps }, ref) => { + return ( + + + {children} + + + ); +}; + +export default forwardRef(SurveyContainer); diff --git a/src/features/surveys/components/surveyForm/SurveyElements.tsx b/src/features/surveys/components/surveyForm/SurveyElements.tsx new file mode 100644 index 0000000000..5aeb32dbbe --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyElements.tsx @@ -0,0 +1,34 @@ +import { Box } from '@mui/system'; +import { FC } from 'react'; + +import SurveyQuestion from './SurveyQuestion'; +import SurveyTextBlock from './SurveyTextBlock'; +import { + ZetkinSurveyExtended, + ZetkinSurveyTextElement, +} from 'utils/types/zetkin'; + +export type SurveyElementsProps = { + survey: ZetkinSurveyExtended; +}; + +const SurveyElements: FC = ({ survey }) => { + return ( + + {survey.elements + .filter((element) => element.hidden !== true) + .map((element) => ( + + {element.type === 'question' && ( + + )} + {element.type === 'text' && ( + + )} + + ))} + + ); +}; + +export default SurveyElements; diff --git a/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx b/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx new file mode 100644 index 0000000000..8864355de7 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyErrorMessage.tsx @@ -0,0 +1,32 @@ +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'; + +const SurveyErrorMessage: FC = () => { + const element = useRef(null); + const theme = useTheme(); + useEffect(() => { + if (element.current) { + element.current.scrollIntoView({ behavior: 'smooth' }); + } + }, []); + return ( + + + + + + ); +}; + +export default SurveyErrorMessage; diff --git a/src/features/surveys/components/surveyForm/SurveyForm.tsx b/src/features/surveys/components/surveyForm/SurveyForm.tsx new file mode 100644 index 0000000000..08a08b030d --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyForm.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { Box } from '@mui/material'; +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 { 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, + ZetkinUser, +} from 'utils/types/zetkin'; + +export type SurveyFormProps = { + survey: ZetkinSurveyExtended; + user: ZetkinUser | null; +}; + +const SurveyForm: FC = ({ survey, user }) => { + const [status, action] = useFormState( + submit, + 'editing' + ); + + if (!survey) { + return null; + } + + return ( + + + {(status === 'editing' || status === 'error') && ( +
+ + + + + + + + +
+ )} + {status === 'submitted' && } +
+ ); +}; + +export default SurveyForm; diff --git a/src/features/surveys/components/surveyForm/SurveyHeading.tsx b/src/features/surveys/components/surveyForm/SurveyHeading.tsx new file mode 100644 index 0000000000..b520c1de0a --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyHeading.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Box, Typography } from '@mui/material'; + +import SurveyContainer from './SurveyContainer'; +import SurveyErrorMessage from './SurveyErrorMessage'; +import ZUIAvatar from 'zui/ZUIAvatar'; +import { + ZetkinSurveyExtended, + ZetkinSurveyFormStatus, +} from 'utils/types/zetkin'; + +export type SurveyHeadingProps = { + status: ZetkinSurveyFormStatus; + survey: ZetkinSurveyExtended; +}; + +const SurveyHeading: FC = ({ status, survey }) => { + const searchParams = useSearchParams(); + const hideOrganization = searchParams?.get('hideOrganization'); + return ( + + {hideOrganization !== 'true' && ( + + + + {survey.organization.title} + + + )} + + + + {survey.title} + + {survey.info_text && ( + + {survey.info_text} + + )} + + + {status === 'error' && } + + ); +}; + +export default SurveyHeading; diff --git a/src/features/surveys/components/surveyForm/SurveyOption.tsx b/src/features/surveys/components/surveyForm/SurveyOption.tsx new file mode 100644 index 0000000000..d9edaa7899 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyOption.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import { FormControlLabel, FormControlLabelProps } from '@mui/material'; + +export type SurveyOptionProps = FormControlLabelProps; + +const SurveyOption: FC = ({ ...formControlLabelProps }) => { + return ( + + ); +}; + +export default SurveyOption; diff --git a/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx new file mode 100644 index 0000000000..66e93c3c19 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyOptionsQuestion.tsx @@ -0,0 +1,169 @@ +import { + Box, + Checkbox, + FormControl, + FormGroup, + FormLabel, + MenuItem, + Radio, + RadioGroup, + Select, + 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, +} from 'utils/types/zetkin'; + +export type OptionsQuestionProps = { + element: ZetkinSurveyOptionsQuestionElement; +}; + +const OptionsQuestion: FC = ({ element }) => { + const messages = useMessages(messageIds); + const [dropdownValue, setDropdownValue] = useState(''); + const handleDropdownChange = useCallback((event: SelectChangeEvent) => { + setDropdownValue(event.target.value); + }, []); + + return ( + + + {element.question.response_config.widget_type === 'checkbox' && ( + + + + + + {element.question.question} + + + {element.question.description && ( + + {element.question.description} + + )} + + + {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.question} + {element.question.required && + ` (${messages.surveyForm.required()})`} + + + + {element.question.description && ( + + {element.question.description} + + )} + + + + {element.question.options!.map((option: ZetkinSurveyOption) => ( + } + label={option.text} + value={option.id} + /> + ))} + + + + )} + {element.question.response_config.widget_type === 'select' && ( + + + + + + <> + {element.question.question} + {element.question.required && + ` (${messages.surveyForm.required()})`} + + + + {element.question.description && ( + + {element.question.description} + + )} + + + + + )} + + + ); +}; + +export default OptionsQuestion; diff --git a/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx new file mode 100644 index 0000000000..5222051536 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyPrivacyPolicy.tsx @@ -0,0 +1,63 @@ +import { FC } from 'react'; +import { + Box, + Checkbox, + FormControl, + FormGroup, + FormLabel, + 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 = { + survey: ZetkinSurveyExtended; +}; + +const SurveyPrivacyPolicy: FC = ({ survey }) => { + const messages = useMessages(messageIds); + return ( + + + + + + + + + + } + data-testid="Survey-acceptTerms" + label={} + name="privacy.approval" + /> + + + + + + + + + + + + + ); +}; + +export default SurveyPrivacyPolicy; diff --git a/src/features/surveys/components/surveyForm/SurveyQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyQuestion.tsx new file mode 100644 index 0000000000..894e7f653b --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyQuestion.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; + +import SurveyOptionsQuestion from './SurveyOptionsQuestion'; +import SurveyTextQuestion from './SurveyTextQuestion'; +import { + ZetkinSurveyOptionsQuestionElement, + ZetkinSurveyQuestionElement, + ZetkinSurveyTextQuestionElement, +} from 'utils/types/zetkin'; + +export type SurveyQuestionProps = { + element: ZetkinSurveyQuestionElement; +}; + +const SurveyQuestion: FC = ({ element }) => { + return ( + <> + {element.question.response_type === 'text' && ( + + )} + {element.question.response_type === 'options' && ( + + )} + + ); +}; + +export default SurveyQuestion; 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/SurveySignature.tsx b/src/features/surveys/components/surveyForm/SurveySignature.tsx new file mode 100644 index 0000000000..7acc638af2 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveySignature.tsx @@ -0,0 +1,137 @@ +import { + Box, + FormControl, + FormLabel, + Radio, + RadioGroup, + TextField, + Typography, + 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, + ZetkinUser, +} from 'utils/types/zetkin'; + +export type SurveySignatureProps = { + survey: ZetkinSurveyExtended; + user: ZetkinUser | null; +}; + +const SurveySignature: FC = ({ survey, user }) => { + const theme = useTheme(); + + const [signatureType, setSignatureType] = useState< + ZetkinSurveySignatureType | undefined + >(undefined); + + const handleRadioChange = useCallback( + (value: ZetkinSurveySignatureType) => { + setSignatureType(value); + }, + [setSignatureType] + ); + + return ( + + + + handleRadioChange(e.target.value as ZetkinSurveySignatureType) + } + > + + + + + + + + + {user && ( + } + label={ + + + + } + 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" + /> + )} + + + + + + ); +}; + +export default SurveySignature; diff --git a/src/features/surveys/components/surveyForm/SurveySubheading.tsx b/src/features/surveys/components/surveyForm/SurveySubheading.tsx new file mode 100644 index 0000000000..1130188995 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveySubheading.tsx @@ -0,0 +1,25 @@ +import { Typography } from '@mui/material'; +import { ElementType, FC, ReactElement } from 'react'; + +export type SurveySubheadingProps = { + children: ReactElement | string; + component?: ElementType; +}; + +const SurveySubheading: FC = ({ + children, + component = 'span', +}) => { + return ( + + {children} + + ); +}; + +export default SurveySubheading; diff --git a/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx b/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx new file mode 100644 index 0000000000..0f2a5aacbd --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveySubmitButton.tsx @@ -0,0 +1,26 @@ +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'; + +const SurveySubmitButton: FC = () => { + const messages = useMessages(messageIds); + + return ( + + + + ); +}; + +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..e80bf6b49a --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveySuccess.tsx @@ -0,0 +1,31 @@ +'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 { 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..cf74da8c51 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyTextBlock.tsx @@ -0,0 +1,23 @@ +import { FC } from 'react'; +import { Typography } from '@mui/material'; + +import SurveyContainer from './SurveyContainer'; +import SurveySubheading from './SurveySubheading'; +import { ZetkinSurveyTextElement } from 'utils/types/zetkin'; + +export type SurveyTextBlockProps = { + element: ZetkinSurveyTextElement; +}; + +const SurveyTextBlock: FC = ({ element }) => { + return ( + + + {element.text_block.header} + + {element.text_block.content} + + ); +}; + +export default SurveyTextBlock; diff --git a/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx new file mode 100644 index 0000000000..dc62e50f73 --- /dev/null +++ b/src/features/surveys/components/surveyForm/SurveyTextQuestion.tsx @@ -0,0 +1,53 @@ +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'; + +export type SurveyOptionsQuestionProps = { + element: ZetkinSurveyTextQuestionElement; +}; + +const SurveyOptionsQuestion: FC = ({ element }) => { + const messages = useMessages(messageIds); + return ( + + + + + + + <> + {element.question.question} + {element.question.required && + ` (${messages.surveyForm.required()})`} + + + + {element.question.description && ( + + {element.question.description} + + )} + + + + + + ); +}; + +export default SurveyOptionsQuestion; diff --git a/src/features/surveys/l10n/messageIds.ts b/src/features/surveys/l10n/messageIds.ts index 9b0c7c7f38..054c19c136 100644 --- a/src/features/surveys/l10n/messageIds.ts +++ b/src/features/surveys/l10n/messageIds.ts @@ -168,6 +168,49 @@ export default makeMessages('feat.surveys', { suggestedPeople: m('Suggested people'), unlink: m('Unlink'), }, + surveyForm: { + accept: m('I accept the terms stated below'), + error: m( + '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'), + sign: { + anonymous: m('Sign anonymously'), + nameAndEmail: m('Sign with name and e-mail'), + }, + submit: m('Submit'), + 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'), + }, + }, + surveyFormSubmitted: { + text: m<{ title: string }>( + 'Your responses to “{title}” have been submitted.' + ), + title: m('Survey Submitted'), + }, + surveySignature: { + email: { + email: m('Email'), + firstName: m('First name'), + lastName: m('Last name'), + }, + title: m('Choose how to sign'), + type: { + anonymous: m('Sign anonymously'), + email: m('Sign with name and email'), + user: m<{ email: string; person: string }>( + 'Sign as {person} with email {email}' + ), + }, + }, tabs: { overview: m('Overview'), questions: m('Questions'), @@ -175,7 +218,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/features/surveys/utils/prepareSurveyApiSubmission.spec.ts b/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts new file mode 100644 index 0000000000..8393cc9e1a --- /dev/null +++ b/src/features/surveys/utils/prepareSurveyApiSubmission.spec.ts @@ -0,0 +1,84 @@ +import prepareSurveyApiSubmission from './prepareSurveyApiSubmission'; + +describe('prepareSurveyApiSubmission()', () => { + let formData: FormData; + + beforeEach(() => { + formData = new FormData(); + }); + + it('formats a text response', () => { + formData.set('123.text', 'Lorem ipsum dolor sit amet'); + const submission = prepareSurveyApiSubmission(formData); + expect(submission.responses).toMatchObject([ + { + question_id: 123, + response: 'Lorem ipsum dolor sit amet', + }, + ]); + }); + + it('formats a radio button response', () => { + formData.set('123.options', '456'); + const submission = prepareSurveyApiSubmission(formData); + expect(submission.responses).toMatchObject([ + { + options: [456], + question_id: 123, + }, + ]); + }); + + it('formats a checkbox response', () => { + formData.set('123.options', '456'); + formData.append('123.options', '789'); + const submission = prepareSurveyApiSubmission(formData); + expect(submission.responses).toMatchObject([ + { + options: [456, 789], + question_id: 123, + }, + ]); + }); + + it('formats a select widget response', () => { + formData.set('123.options', '234'); + const submission = prepareSurveyApiSubmission(formData); + expect(submission.responses).toMatchObject([ + { + options: [234], + question_id: 123, + }, + ]); + }); + + it('formats empty select response', () => { + formData.set('123.options', ''); + const submission = prepareSurveyApiSubmission(formData); + expect(submission.responses).toMatchObject([ + { + options: [], + question_id: 123, + }, + ]); + }); + + it('signs as the logged-in account when a logged-in user requests to sign as themself', () => { + formData.set('sig', 'user'); + const submission = prepareSurveyApiSubmission(formData, true); + expect(submission.signature).toEqual('user'); + }); + + it('signs with custom contact details when a name and email are given', () => { + formData.set('sig', 'email'); + formData.set('sig.email', 'testuser@example.org'); + formData.set('sig.first_name', 'test'); + formData.set('sig.last_name', 'user'); + const submission = prepareSurveyApiSubmission(formData); + expect(submission.signature).toMatchObject({ + email: 'testuser@example.org', + first_name: 'test', + last_name: 'user', + }); + }); +}); diff --git a/src/features/surveys/utils/prepareSurveyApiSubmission.ts b/src/features/surveys/utils/prepareSurveyApiSubmission.ts new file mode 100644 index 0000000000..fa6f57800c --- /dev/null +++ b/src/features/surveys/utils/prepareSurveyApiSubmission.ts @@ -0,0 +1,68 @@ +import uniq from 'lodash/uniq'; + +import { + ZetkinSurveyApiSubmission, + ZetkinSurveyQuestionResponse, + ZetkinSurveySignaturePayload, +} from 'utils/types/zetkin'; + +export default function prepareSurveyApiSubmission( + formData: FormData, + isLoggedIn?: boolean +): ZetkinSurveyApiSubmission { + const responses: ZetkinSurveyQuestionResponse[] = []; + const keys = uniq([...formData.keys()]); + + for (const name of keys) { + if (!name.match(/^[0-9]+\.(options|text)$/)) { + continue; + } + + const value = formData.getAll(name); + const fields = name.split('.'); + const [id, type] = fields; + + if (type == 'text') { + responses.push({ + question_id: parseInt(id), + response: value[0] as string, + }); + } + + if (type === 'options' && typeof value === 'string') { + responses.push({ + options: value == '' ? [] : [parseInt(value, 10)], + question_id: parseInt(id, 10), + }); + } + + if (type === 'options' && Array.isArray(value)) { + responses.push({ + options: value + .filter((o) => o !== '') + .map((o) => parseInt(o.toString(), 10)), + question_id: parseInt(id, 10), + }); + } + } + + let signature: ZetkinSurveySignaturePayload = null; + + const sig = formData.get('sig') as string | null; + if (sig === 'user' && isLoggedIn) { + signature = 'user'; + } + + if (sig == 'email') { + signature = { + email: formData.get('sig.email') as string, + first_name: formData.get('sig.first_name') as string, + last_name: formData.get('sig.last_name') as string, + }; + } + + return { + responses, + signature, + }; +} diff --git a/src/utils/locale.spec.ts b/src/utils/locale.spec.ts new file mode 100644 index 0000000000..8c3d3d4214 --- /dev/null +++ b/src/utils/locale.spec.ts @@ -0,0 +1,30 @@ +import { NextApiRequest } from 'next'; + +import { getBrowserLanguage } from './locale'; + +describe('getBrowserLanguage', () => { + it('returns the preferred language of the user if available', () => { + const request: Partial = { + headers: { + 'accept-language': 'sv', + }, + }; + const language = getBrowserLanguage(request as NextApiRequest); + expect(language).toEqual('sv'); + }); + + it('returns a good default language if the user preference cannot be fulfilled', () => { + const request: Partial = { + headers: { + 'accept-language': 'pt-BR', + }, + }; + const language = getBrowserLanguage(request as NextApiRequest); + expect(language).toEqual('en'); + }); + + it('treats string input as an accept language header', () => { + const language = getBrowserLanguage('sv;q=0.9,ar;q=0.8'); + expect(language).toEqual('sv'); + }); +}); diff --git a/src/utils/types/zetkin.ts b/src/utils/types/zetkin.ts index 98f916fe61..c94c5093be 100644 --- a/src/utils/types/zetkin.ts +++ b/src/utils/types/zetkin.ts @@ -284,6 +284,17 @@ export type ZetkinSurveyElement = | ZetkinSurveyTextElement | ZetkinSurveyQuestionElement; +export type ZetkinSurveyFormStatus = + | 'editing' + | 'invalid' + | 'error' + | 'submitted'; + +export type ZetkinSurveyApiSubmission = { + responses: ZetkinSurveyQuestionResponse[]; + signature: ZetkinSurveySignaturePayload; +}; + export enum RESPONSE_TYPE { OPTIONS = 'options', TEXT = 'text', @@ -324,7 +335,7 @@ export interface ZetkinSurveyOption { text: string; } -type ZetkinSurveyQuestionResponse = +export type ZetkinSurveyQuestionResponse = | { question_id: number; response: string; @@ -334,6 +345,17 @@ type ZetkinSurveyQuestionResponse = question_id: number; }; +export type ZetkinSurveySignatureType = 'email' | 'user' | 'anonymous'; + +export type ZetkinSurveySignaturePayload = + | null + | 'user' + | { + email: string; + first_name: string; + last_name: string; + }; + export interface ZetkinSurveySubmission { id: number; organization: { diff --git a/yarn.lock b/yarn.lock index 4b35244565..a7c6a4bccf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6007,9 +6007,9 @@ caniuse-lite@^1.0.30001280: 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== caniuse-lite@^1.0.30001629: version "1.0.30001637"