diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json
index 73c4acd3b5d..19d86727bc3 100644
--- a/client/i18n/locales/english/translations.json
+++ b/client/i18n/locales/english/translations.json
@@ -682,6 +682,7 @@
"http-url": "An unsecure (http) URL cannot be used.",
"own-work-url": "Remember to submit your own work.",
"publicly-visible-url": "Remember to submit a publicly visible app URL.",
+ "ms-learn-link": "Please use a valid Microsoft Learn trophy link.",
"path-url": "You probably want to submit the root path i.e. https://example.com, not https://example.com/path"
},
"certification": {
diff --git a/client/src/components/formHelpers/form-fields.tsx b/client/src/components/formHelpers/form-fields.tsx
index 0ab9eeb9931..dade529c657 100644
--- a/client/src/components/formHelpers/form-fields.tsx
+++ b/client/src/components/formHelpers/form-fields.tsx
@@ -16,13 +16,15 @@ import {
composeValidators,
fCCValidator,
httpValidator,
- pathValidator
+ pathValidator,
+ microsoftValidator
} from './form-validators';
export type FormOptions = {
ignored?: string[];
isEditorLinkAllowed?: boolean;
isLocalLinkAllowed?: boolean;
+ isMicrosoftLearnLink?: boolean;
required?: string[];
types?: { [key: string]: string };
placeholders?: { [key: string]: string };
@@ -42,7 +44,8 @@ function FormFields(props: FormFieldsProps): JSX.Element {
required = [],
types = {},
isEditorLinkAllowed = false,
- isLocalLinkAllowed = false
+ isLocalLinkAllowed = false,
+ isMicrosoftLearnLink = false
} = options;
const nullOrWarning = (
@@ -71,6 +74,7 @@ function FormFields(props: FormFieldsProps): JSX.Element {
if (!isLocalLinkAllowed) {
validators.push(localhostValidator);
}
+ if (isMicrosoftLearnLink) validators.push(microsoftValidator);
const validationWarning = composeValidators(...validators)(value);
const message: string = (error ||
validationError ||
diff --git a/client/src/components/formHelpers/form-validators.test.tsx b/client/src/components/formHelpers/form-validators.test.tsx
new file mode 100644
index 00000000000..3e2807b4f42
--- /dev/null
+++ b/client/src/components/formHelpers/form-validators.test.tsx
@@ -0,0 +1,63 @@
+import { isMicrosoftLearnLink } from './form-validators';
+
+const baseUrl =
+ 'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy';
+describe('form-validators', () => {
+ describe('isMicrosoftLearnLink', () => {
+ it('should reject links to domains other than learn.microsoft.com', () => {
+ {
+ expect(isMicrosoftLearnLink('https://lean.microsoft.com')).toBe(false);
+ expect(isMicrosoftLearnLink('https://learn.microsft.com')).toBe(false);
+ }
+ });
+
+ it('should reject links without a sharingId', () => {
+ expect(isMicrosoftLearnLink(`${baseUrl}?username=moT01`)).toBe(false);
+
+ expect(isMicrosoftLearnLink(`${baseUrl}?username=moT01&sharingId=`)).toBe(
+ false
+ );
+ });
+
+ it('should reject links without a username', () => {
+ expect(isMicrosoftLearnLink(`${baseUrl}?sharingId=Whatever`)).toBe(false);
+ expect(isMicrosoftLearnLink(`${baseUrl}?sharingId=123&username=`)).toBe(
+ false
+ );
+ });
+
+ it('should reject links without the /training/achievements/ subpath', () => {
+ expect(
+ isMicrosoftLearnLink(
+ 'https://learn.microsoft.com/en-us/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8'
+ )
+ ).toBe(false);
+ });
+
+ it('should reject links with the wrong trophy subpath', () => {
+ // missing .trophy
+ expect(
+ isMicrosoftLearnLink(
+ 'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1?username=moT01&sharingId=E2EF453C1F9208B8'
+ )
+ ).toBe(false);
+ // no number
+ expect(
+ isMicrosoftLearnLink(
+ 'https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-a.trophy?username=moT01&sharingId=E2EF453C1F9208B8'
+ )
+ ).toBe(false);
+ });
+
+ it.each(['en-us', 'fr-fr', 'lang-country'])(
+ 'should accept links with the %s locale',
+ locale => {
+ expect(
+ isMicrosoftLearnLink(
+ `https://learn.microsoft.com/${locale}/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8`
+ )
+ ).toBe(true);
+ }
+ );
+ });
+});
diff --git a/client/src/components/formHelpers/form-validators.tsx b/client/src/components/formHelpers/form-validators.tsx
index cab0ac9dc63..66e299f9506 100644
--- a/client/src/components/formHelpers/form-validators.tsx
+++ b/client/src/components/formHelpers/form-validators.tsx
@@ -19,6 +19,27 @@ function isPathRoot(urlString: string): boolean {
type Validator = (value: string) => React.ReactElement | null;
+// example link: https://learn.microsoft.com/en-us/training/achievements/learn.wwl.get-started-c-sharp-part-1.trophy?username=moT01&sharingId=E2EF453C1F9208B8
+export const isMicrosoftLearnLink = (value: string): boolean => {
+ let url;
+ try {
+ url = new URL(value);
+ } catch {
+ return false;
+ }
+
+ const correctDomain = url.hostname === 'learn.microsoft.com';
+ const correctPath = !!url.pathname.match(
+ /^\/[^/]+\/training\/achievements\/learn\.wwl\.get-started-c-sharp-part-\d\.trophy$/
+ );
+ const hasSharingId = !!url.searchParams.get('sharingId');
+ const hasUsername = !!url.searchParams.get('username');
+ return correctDomain && correctPath && hasSharingId && hasUsername;
+};
+
+export const microsoftValidator: Validator = value =>
+ !isMicrosoftLearnLink(value) ? validation.ms-learn-link : null;
+
export const editorValidator: Validator = value =>
editorRegex.test(value) ? validation.editor-url : null;
@@ -36,11 +57,10 @@ export const httpValidator: Validator = value =>
export const pathValidator: Validator = value =>
isPathRoot(value) ? validation.path-url : null;
-export function composeValidators(...validators: (Validator | null)[]) {
+export function composeValidators(...validators: Validator[]) {
return (value: string): ReturnType | null =>
validators.reduce(
- (error: ReturnType, validator) =>
- error ?? (validator ? validator(value) : null),
+ (error: ReturnType, validator) => error ?? validator(value),
null
);
}
diff --git a/client/src/components/formHelpers/form.tsx b/client/src/components/formHelpers/form.tsx
index dc5ecd0c894..ad98380acde 100644
--- a/client/src/components/formHelpers/form.tsx
+++ b/client/src/components/formHelpers/form.tsx
@@ -8,11 +8,12 @@ import {
editorValidator,
composeValidators,
fCCValidator,
- httpValidator
+ httpValidator,
+ microsoftValidator
} from './form-validators';
import FormFields, { FormOptions } from './form-fields';
-type URLValues = {
+type FormValues = {
[key: string]: string;
};
@@ -22,7 +23,7 @@ type ValidationError = {
};
export type ValidatedValues = {
- values: URLValues;
+ values: FormValues;
errors: ValidationError[];
invalidValues: (JSX.Element | null)[];
};
@@ -31,45 +32,55 @@ const normalizeOptions = {
stripWWW: false
};
-function formatUrlValues(
- values: URLValues,
+function validateFormValues(
+ formValues: FormValues,
options: FormOptions
): ValidatedValues {
- const { isEditorLinkAllowed, isLocalLinkAllowed, types } = options;
+ const {
+ isEditorLinkAllowed,
+ isLocalLinkAllowed,
+ isMicrosoftLearnLink,
+ types
+ } = options;
const validatedValues: ValidatedValues = {
values: {},
errors: [],
invalidValues: []
};
- const urlValues = Object.keys(values).reduce((result, key: string) => {
- // NOTE: pathValidator is not used here, because it is only used as a
- // suggestion - should not prevent form submission
- const validators = [fCCValidator, httpValidator];
- const isSolutionLink = key !== 'githubLink';
- if (isSolutionLink && !isEditorLinkAllowed) {
- validators.push(editorValidator);
- }
- if (!isLocalLinkAllowed) {
- validators.push(localhostValidator);
- }
-
- let value: string = values[key];
- const nullOrWarning = composeValidators(...validators)(value);
- if (nullOrWarning) {
- validatedValues.invalidValues.push(nullOrWarning);
- }
- if (value && types && types[key] === 'url') {
- try {
- value = normalizeUrl(value, normalizeOptions);
- } catch (err: unknown) {
- validatedValues.errors.push({
- error: err as { message?: string },
- value
- });
+ const urlValues = Object.entries(formValues).reduce(
+ (result, [key, value]) => {
+ // NOTE: pathValidator is not used here, because it is only used as a
+ // suggestion - should not prevent form submission
+ const validators = [fCCValidator, httpValidator];
+ const isSolutionLink = key !== 'githubLink';
+ if (isSolutionLink && !isEditorLinkAllowed) {
+ validators.push(editorValidator);
}
- }
- return { ...result, [key]: value };
- }, {});
+ if (!isLocalLinkAllowed) {
+ validators.push(localhostValidator);
+ }
+ if (isMicrosoftLearnLink) {
+ validators.push(microsoftValidator);
+ }
+
+ const nullOrWarning = composeValidators(...validators)(value);
+ if (nullOrWarning) {
+ validatedValues.invalidValues.push(nullOrWarning);
+ }
+ if (value && types && types[key] === 'url') {
+ try {
+ value = normalizeUrl(value, normalizeOptions);
+ } catch (err: unknown) {
+ validatedValues.errors.push({
+ error: err as { message?: string },
+ value
+ });
+ }
+ }
+ return { ...result, [key]: value };
+ },
+ {}
+ );
validatedValues.values = urlValues;
return validatedValues;
}
@@ -81,7 +92,7 @@ export type StrictSolutionFormProps = {
id: string;
initialValues?: Record;
options: FormOptions;
- submit: (values: ValidatedValues, ...args: unknown[]) => void;
+ submit: (values: ValidatedValues) => void;
};
export const StrictSolutionForm = ({
@@ -96,8 +107,8 @@ export const StrictSolutionForm = ({
return (