Skip to content

Commit

Permalink
feat(invite): allow invitations to course by email
Browse files Browse the repository at this point in the history
  • Loading branch information
bivanalhar authored and cysjonathan committed Nov 26, 2024
1 parent a1b55d9 commit 379cfc0
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FC } from 'react';
import { FC, useState } from 'react';
import {
Control,
UseFieldArrayAppend,
Expand All @@ -13,6 +13,12 @@ import {
IndividualInvites,
} from 'types/course/userInvitations';

import { parseInvitationInput } from 'course/user-invitations/operations';
import { InvitationEntry } from 'course/user-invitations/types';
import ErrorText from 'lib/components/core/ErrorText';
import TextField from 'lib/components/core/fields/TextField';
import useTranslation from 'lib/hooks/useTranslation';

import IndividualInvitation from './IndividualInvitation';

interface Props extends WrappedComponentProps {
Expand All @@ -35,17 +41,37 @@ const translations = defineMessages({
id: 'course.userInvitations.IndividualInvitations.invite',
defaultMessage: 'Invite All Users',
},
nameEmailInput: {
id: 'course.userInvitations.IndividualInvitations.nameEmailInput',
defaultMessage:
"John Doe '<[email protected]'>; \"Doe, Jane\" '<[email protected]'>; ...",
},
addRowsByEmail: {
id: 'course.userInvitations.IndividualInvitations.addRowsByEmail',
defaultMessage: 'Add Rows by Email',
},
malformedEmail: {
id: 'course.userInvitations.IndividualInvitations.malformedEmail',
defaultMessage:
'{n, plural, one {This email is } other {These emails are }} wrongly formatted: {emails}',
},
});

const IndividualInvitations: FC<Props> = (props) => {
const { isLoading, permissions, fieldsConfig, intl } = props;
const { append, fields } = fieldsConfig;
const { append, remove, fields } = fieldsConfig;

const appendNewRow = (): void => {
const lastRow = fields[fields.length - 1];
const { t } = useTranslation();

const [nameEmailInput, setNameEmailInput] = useState('');

const appendRow = (
lastRow: IndividualInvite,
entry?: InvitationEntry,
): void => {
append({
name: '',
email: '',
name: entry?.name ?? '',
email: entry?.email ?? '',
role: lastRow.role,
phantom: lastRow.phantom,
...(permissions.canManagePersonalTimes && {
Expand All @@ -54,8 +80,62 @@ const IndividualInvitations: FC<Props> = (props) => {
});
};

const appendNewRow = (): void => {
const lastRow = fields[fields.length - 1];
appendRow(lastRow, undefined);
};

const appendInputs = (results: InvitationEntry[]): void => {
const lastRow = fields[fields.length - 1];

for (let idx = fields.length - 1; idx >= 0; idx--) {
const { name, email } = fields[idx];
if (!name && !email) remove(idx);
}

results.forEach((entry) => appendRow(lastRow, entry));
};

const parsedInput = parseInvitationInput(nameEmailInput);

return (
<>
<div className="flex items-center gap-3">
<TextField
className="w-full"
hiddenLabel
multiline
name="nameEmailInvitation"
onChange={(e): void => setNameEmailInput(e.target.value)}
placeholder={t(translations.nameEmailInput)}
size="small"
value={nameEmailInput}
variant="filled"
/>
<Button
className="whitespace-nowrap"
color="primary"
onClick={(): void => {
appendInputs(parsedInput.results);
setNameEmailInput(parsedInput.errors.join('; '));
}}
variant="outlined"
>
{t(translations.addRowsByEmail)}
</Button>
</div>

{parsedInput.errors.length > 0 && (
<div className="mt-1">
<ErrorText
errors={t(translations.malformedEmail, {
n: parsedInput.errors.length,
emails: parsedInput.errors.join(', '),
})}
/>
</div>
)}

{fields.map(
(field, index): JSX.Element => (
<IndividualInvitation
Expand Down
40 changes: 40 additions & 0 deletions client/app/bundles/course/user-invitations/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import CourseAPI from 'api/course';

import { actions } from './store';
import { InvitationEntry } from './types';

/**
* Prepares and maps answer value in the react-hook-form into server side format.
Expand Down Expand Up @@ -129,3 +130,42 @@ export function toggleRegistrationCode(shouldEnable: boolean): Operation {
);
});
}

const splitNameAndEmailRegex =
/^(?:"\s*([^"]+?)\s*"|\s*([^"<]+?))?\s*<\s*([^>\s]+?)\s*>$|^\s*([^"<\s]+@[^\s,;<>]+)\s*$/;
const formattedEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export const splitEntries = (input: string): string[] => {
return input.split(/\s*[;,\n\u200B]\s*(?=(?:[^"]*"[^"]*")*[^"]*$)/);
};

const processInvitationEntry = (
entry: string,
errors: string[],
results: InvitationEntry[],
): void => {
if (!entry) return;
const match = splitNameAndEmailRegex.exec(entry);
if (match) {
const email = match[3] || match[4];
const name = match[1] || match[2] || email;
if (formattedEmailRegex.test(email)) {
results.push({ name, email });
return;
}
}
errors.push(entry);
};

export const parseInvitationInput = (
input: string,
): { results: InvitationEntry[]; errors: string[] } => {
const results: InvitationEntry[] = [];
const errors: string[] = [];

const entries = splitEntries(input);

entries.forEach((entry) => processInvitationEntry(entry, errors, results));

return { results, errors };
};
5 changes: 5 additions & 0 deletions client/app/bundles/course/user-invitations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,8 @@ export interface InvitationsState {
manageCourseUsersData: ManageCourseUsersSharedData;
courseRegistrationKey: string;
}

export interface InvitationEntry {
name: string;
email: string;
}

0 comments on commit 379cfc0

Please sign in to comment.