Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const HowWeVoteHelps = React.lazy(() => import(/* webpackChunkName: 'HowWeVoteHe
const Intro = React.lazy(() => import(/* webpackChunkName: 'Intro' */ './js/pages/Intro/Intro'));
const IntroNetwork = React.lazy(() => import(/* webpackChunkName: 'IntroNetwork' */ './js/pages/Intro/IntroNetwork'));
const Location = React.lazy(() => import(/* webpackChunkName: 'Location' */ './js/pages/Settings/Location'));
const ManageMyCandidates = React.lazy(() => import(/* webpackChunkName: 'ManageMyCandidates' */ './js/pages/More/ManageMyCandidates'));
const ManageMyCandidatesLanding = React.lazy(() => import(/* webpackChunkName: 'ManageMyCandidatesLanding' */ './js/pages/ManageMyCandidates/ManageMyCandidatesLanding'));
const Measure = React.lazy(() => import(/* webpackChunkName: 'Measure' */ './js/pages/Ballot/Measure'));
const News = React.lazy(() => import(/* webpackChunkName: 'News' */ './js/pages/Activity/News'));
const Office = React.lazy(() => import(/* webpackChunkName: 'Office' */ './js/pages/Ballot/Office'));
Expand Down Expand Up @@ -581,7 +581,8 @@ class App extends Component {
<Route path="/more/faq" component={FAQ} />
<Route path="/more/howwevotehelps" component={HowWeVoteHelps} />
<Route path="/more/jump" component={SignInJumpProcess} />
<Route path="/managecandidates" component={ManageMyCandidates} />
<Route path="/managecandidates/:subtab" exact component={ManageMyCandidatesLanding} />
<Route path="/managecandidates" exact component={ManageMyCandidatesLanding} />
<Route path="/more/myballot" component={WeVoteBallotEmbed} />
<Route path="/more/network/friends" component={Friends} />
<Route path="/more/network/key/:invitation_secret_key/ignore" component={FriendInvitationByEmailVerifyProcess} />
Expand Down
27 changes: 19 additions & 8 deletions src/js/components/More/EditInvitationModal.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import styled, { createGlobalStyle } from 'styled-components';
import { ContentCopy as CopyIcon } from '@mui/icons-material';
Expand All @@ -8,13 +8,22 @@ import ModalDisplayTemplateA from '../Widgets/ModalDisplayTemplateA';
const EditInvitationModal = ({
isOpen,
onClose,
draftInvite,
setDraftInvite,
initialInvite,
invitationBody,
onSave,
notify,
selectedPoliticianId,
}) => {
const [draftInvite, setDraftInvite] = useState(invitationBody);
const [initialInvite, setInitialInvite] = useState(invitationBody);

// Reset draft when invitationBody or showEdit changes
useEffect(() => {
if (isOpen) {
setDraftInvite(invitationBody);
setInitialInvite(invitationBody);
}
}, [invitationBody, isOpen]);

const handleCopyInviteBody = async () => {
try {
await navigator.clipboard.writeText(
Expand All @@ -26,6 +35,10 @@ const EditInvitationModal = ({
}
};

const handleSave = () => {
onSave(draftInvite);
};

if (!isOpen) return null;

const dialogTitleJSX = (
Expand Down Expand Up @@ -59,7 +72,7 @@ const EditInvitationModal = ({
<EditCloseButton type="button" onClick={onClose}>Close</EditCloseButton>
<PrimarySaveBtn
type="button"
onClick={onSave}
onClick={handleSave}
disabled={draftInvite.trim() === initialInvite.trim()}
>
Save invitation
Expand Down Expand Up @@ -88,9 +101,7 @@ const EditInvitationModal = ({
EditInvitationModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
draftInvite: PropTypes.string.isRequired,
setDraftInvite: PropTypes.func.isRequired,
initialInvite: PropTypes.string.isRequired,
invitationBody: PropTypes.string.isRequired,
onSave: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
selectedPoliticianId: PropTypes.string.isRequired,
Expand Down
106 changes: 89 additions & 17 deletions src/js/components/More/PasteListModal.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import styled, { createGlobalStyle } from 'styled-components';
import { WarningAmber as WarningIcon } from '@mui/icons-material';
Expand All @@ -7,16 +7,96 @@ import ModalDisplayTemplateA from '../Widgets/ModalDisplayTemplateA';

const escapeHTML = (s) => s.replace(/[&<>]/g, (element) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[element]));

const emailRE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;

function parsePastedList (text) {
const lines = text.split(/\r?\n/);
const rows = [];
const errors = [];

lines.forEach((raw, idx) => {
const line = raw.trim();
if (!line) return;

const parts = line.split(',').map((s) => s.trim()).filter(Boolean);
const emailCount = (line.match(emailRE) || []).length;

if (parts.length > 3) {
errors.push({ line: idx, reason: 'Too many commas' });
return;
}
if (emailCount > 1) {
errors.push({ line: idx, reason: 'Two emails on one line (missing line break?)' });
return;
}
// Must have at least "Name, Email"
if (parts.length < 2) {
errors.push({ line: idx, reason: 'Missing email (format: Name, email[, phone])' });
return;
}
// Validate email
const emailPart = parts[1].replace(/[<>]/g, '');
if (!emailRE.test(emailPart)) {
errors.push({ line: idx, reason: 'Invalid email' });
return;
}

rows.push({
name: parts[0] || '',
email: emailPart || '',
phone: parts[2] || '',
});
});

return { rows, errors };
}

function buildMirrorHTML (text, errors) {
const errSet = new Set(errors.map((e) => e.line));
return text.split(/\r?\n/).map((line, i) => {
const isError = errSet.has(i) && line.trim().length > 0;
const safe = escapeHTML(line);
return isError ? `<span class="err">${safe}</span>` : safe;
}).join('\n');
}

export default function PasteListModal ({
isOpen,
onClose,
pasteText,
onPasteTextChange,
mirrorHTML,
pasteErrors,
onImport,
prospectiveCount,
}) {
const [pasteText, setPasteText] = useState('');
const [pasteErrors, setPasteErrors] = useState([]);
const [mirrorHTML, setMirrorHTML] = useState('');

const onPasteTextChange = (e) => {
const { value } = e.target;
setPasteText(value);
const { errors } = parsePastedList(value);
setPasteErrors(errors);
setMirrorHTML(buildMirrorHTML(value, errors));
};

const prospectiveCount = useMemo(() => parsePastedList(pasteText).rows.length, [pasteText]);

const handlePasteImport = () => {
const { rows, errors } = parsePastedList(pasteText);
if (errors.length) {
setPasteErrors(errors);
setMirrorHTML(buildMirrorHTML(pasteText, errors));
return; // block import until fixed
}
onImport(rows);
};

const handleClose = () => {
// Reset state on close
setPasteText('');
setPasteErrors([]);
setMirrorHTML('');
onClose();
};

const dialogTitleJSX = (
<HeaderRow>
<Title>Paste voters</Title>
Expand Down Expand Up @@ -93,10 +173,10 @@ John Dough, [email protected], (213)-123-4567`}
</EditAreaWrapper>

<Footer style={{ justifyContent: 'space-between' }}>
<PlainButton type="button" onClick={onClose}>Cancel</PlainButton>
<PlainButton type="button" onClick={handleClose}>Cancel</PlainButton>
<PrimaryButton
type="button"
onClick={onImport}
onClick={handlePasteImport}
disabled={!pasteText.trim()}
>
{prospectiveCount ?
Expand All @@ -113,7 +193,7 @@ John Dough, [email protected], (213)-123-4567`}
<SoftenCorners />
<ModalDisplayTemplateA
show={isOpen}
toggleModal={onClose}
toggleModal={handleClose}
externalUniqueId="pasteListModal"
dialogTitleJSX={dialogTitleJSX}
tallMode={false}
Expand All @@ -126,15 +206,7 @@ John Dough, [email protected], (213)-123-4567`}
PasteListModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
pasteText: PropTypes.string.isRequired,
onPasteTextChange: PropTypes.func.isRequired,
mirrorHTML: PropTypes.string.isRequired,
pasteErrors: PropTypes.arrayOf(PropTypes.shape({
line: PropTypes.number.isRequired,
reason: PropTypes.string.isRequired,
})).isRequired,
onImport: PropTypes.func.isRequired,
prospectiveCount: PropTypes.number.isRequired,
};

// Global Styles
Expand Down
102 changes: 91 additions & 11 deletions src/js/components/More/UploadCSVModal.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import styled, { createGlobalStyle } from 'styled-components';
import { FileDownloadOutlined as DownloadIcon, CheckCircle as CheckIcon } from '@mui/icons-material';
Expand All @@ -8,15 +8,88 @@ import ModalDisplayTemplateA from '../Widgets/ModalDisplayTemplateA';
export default function UploadCSVModal ({
isOpen,
onClose,
onDownloadSample,
onSelectFile,
allColumnsOK,
onImport,
notify,
}) {
const fileInputRef = useRef(null);
const [allColumnsOK, setAllColumnsOK] = useState(false);

const handleDownloadSample = () => {
const csv =
'Name,Email,Mobile,Address\n' +
'John Smith,[email protected],"(123) 456-7890","123 State St, Anytown, CA 94117"\n';
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wevote_sample.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};

const handleSelectCSV = () => fileInputRef.current?.click();

const handleCSVSelected = async (e) => {
const file = e.target.files?.[0];
if (!file) return;

if (!/\.csv$/i.test(file.name)) {
notify('Please choose a .csv file.', false);
e.target.value = '';
return;
}

const text = await file.text();
const lines = text.replace(/\r/g, '').split('\n').filter((l) => l.trim().length > 0);
const [headerLine, ...dataLines] = lines;
const headers = headerLine.split(',').map((h) => h.trim().toLowerCase());

const nameIdx = headers.indexOf('name');
const emailIdx = headers.indexOf('email');
const mobileIdx = headers.indexOf('mobile');
const phoneIdx = headers.indexOf('phone');
const phoneCol = mobileIdx > -1 ? mobileIdx : phoneIdx;
const addressIdx = headers.indexOf('address');

const ok = nameIdx > -1 && emailIdx > -1 && phoneCol > -1 && addressIdx > -1;

const rows = dataLines.map((line) => {
const cols = line.split(',').map((supporter) => supporter.trim());
return {
name: nameIdx > -1 ? cols[nameIdx] : '',
email: emailIdx > -1 ? cols[emailIdx] : '',
phone: phoneCol > -1 ? cols[phoneCol] : '',
address: addressIdx > -1 ? cols[addressIdx] : '',
};
}).filter((r) => r.name || r.email || r.phone || r.address);

if (ok && rows.length > 0) {
onImport(rows);
setAllColumnsOK(false);
} else {
setAllColumnsOK(ok);
if (!ok) {
notify('We could not find all required columns (Name, Email, Mobile/Phone, Address). Please adjust and re-upload.', false);
} else if (rows.length === 0) {
notify('No rows found in this CSV.', false);
}
}

e.target.value = '';
};

const handleClose = () => {
setAllColumnsOK(false);
onClose();
};

const dialogTitleJSX = (
<HeaderRow>
<Title>Upload CSV file</Title>
<HeaderDivider />
<HeaderLink type="button" onClick={onDownloadSample}>
<HeaderLink type="button" onClick={handleDownloadSample}>
<DownloadIcon fontSize="small" />
<span>Download sample file</span>
</HeaderLink>
Expand Down Expand Up @@ -60,9 +133,17 @@ export default function UploadCSVModal ({
</Grid>

<Footer>
<CancelButton type="button" onClick={onClose}>Cancel</CancelButton>
<SelectButton type="button" onClick={onSelectFile}>Select file</SelectButton>
<CancelButton type="button" onClick={handleClose}>Cancel</CancelButton>
<SelectButton type="button" onClick={handleSelectCSV}>Select file</SelectButton>
</Footer>

<input
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this element is missing an accessible name or label. That makes it hard for people using screen readers or voice control to use the control.

ref={fileInputRef}
type="file"
accept=".csv"
style={{ display: 'none' }}
onChange={handleCSVSelected}
/>
</div>
);

Expand All @@ -73,7 +154,7 @@ export default function UploadCSVModal ({
<SoftenCorners />
<ModalDisplayTemplateA
show={isOpen}
toggleModal={onClose}
toggleModal={handleClose}
externalUniqueId="uploadCSVModal"
dialogTitleJSX={dialogTitleJSX}
tallMode={false}
Expand All @@ -86,9 +167,8 @@ export default function UploadCSVModal ({
UploadCSVModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onDownloadSample: PropTypes.func.isRequired,
onSelectFile: PropTypes.func.isRequired,
allColumnsOK: PropTypes.bool.isRequired,
onImport: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};

// Global Styles
Expand Down
Loading