diff --git a/src/GovUk.Education.ExploreEducationStatistics.Admin.Tests/Services/DataArchiveValidationServiceTests.cs b/src/GovUk.Education.ExploreEducationStatistics.Admin.Tests/Services/DataArchiveValidationServiceTests.cs index 27fca5a4bca..11c6f987170 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Admin.Tests/Services/DataArchiveValidationServiceTests.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Admin.Tests/Services/DataArchiveValidationServiceTests.cs @@ -238,8 +238,8 @@ public async Task ValidateBulkDataArchiveFiles_IndexFileMissing_ReturnsValidatio .AssertBadRequestWithValidationErrors([ new ErrorViewModel { - Code = ValidationMessages.BulkDataZipMustContainDatasetNamesCsv.Code, - Message = ValidationMessages.BulkDataZipMustContainDatasetNamesCsv.Message, + Code = ValidationMessages.BulkDataZipMustContainDataSetNamesCsv.Code, + Message = ValidationMessages.BulkDataZipMustContainDataSetNamesCsv.Message, } ]); } @@ -267,8 +267,8 @@ public async Task ValidateBulkDataArchiveFiles_IndexFileHasIncorrectHeaders_Retu .AssertBadRequestWithValidationErrors([ new ErrorViewModel { - Code = ValidationMessages.DatasetNamesCsvIncorrectHeaders.Code, - Message = ValidationMessages.DatasetNamesCsvIncorrectHeaders.Message, + Code = ValidationMessages.DataSetNamesCsvIncorrectHeaders.Code, + Message = ValidationMessages.DataSetNamesCsvIncorrectHeaders.Message, }, ]); } @@ -319,7 +319,7 @@ public async Task ValidateBulkDataArchiveFiles_DuplicateDataSetTitlesAndFileName .AssertLeft() .AssertBadRequestWithValidationErrors([ ValidationMessages.GenerateErrorDataSetTitleShouldBeUnique("Duplicate title"), - ValidationMessages.GenerateErrorDatasetNamesCsvFilenamesShouldBeUnique("one"), + ValidationMessages.GenerateErrorDataSetNamesCsvFilenamesShouldBeUnique("one"), ]); } } @@ -342,7 +342,7 @@ public async Task ValidateBulkDataArchiveFiles_Fail_DataSetNamesCsvFilesnamesSho result .AssertLeft() .AssertBadRequestWithValidationErrors([ - ValidationMessages.GenerateErrorDatasetNamesCsvFilenamesShouldNotEndDotCsv("one.csv") + ValidationMessages.GenerateErrorDataSetNamesCsvFilenamesShouldNotEndDotCsv("one.csv") ]); } } diff --git a/src/GovUk.Education.ExploreEducationStatistics.Admin/Controllers/Api/ReleasesController.cs b/src/GovUk.Education.ExploreEducationStatistics.Admin/Controllers/Api/ReleasesController.cs index 331b6667e50..87440621bcb 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Admin/Controllers/Api/ReleasesController.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Admin/Controllers/Api/ReleasesController.cs @@ -107,7 +107,9 @@ public async Task>> ReorderDataFiles(Guid releas [RequestFormLimits(ValueLengthLimit = int.MaxValue, MultipartBodyLengthLimit = int.MaxValue)] public async Task> UploadDataSet(Guid releaseVersionId, [FromQuery(Name = "replacingFileId")] Guid? replacingFileId, - [FromQuery(Name = "title")] string title, + [FromQuery(Name = "title")] + [MaxLength(120)] + string title, IFormFile file, IFormFile metaFile) { @@ -125,7 +127,9 @@ public async Task> UploadDataSet(Guid releaseVersionI [RequestFormLimits(ValueLengthLimit = int.MaxValue, MultipartBodyLengthLimit = int.MaxValue)] public async Task> UploadDataSetAsZip(Guid releaseVersionId, [FromQuery(Name = "replacingFileId")] Guid? replacingFileId, - [FromQuery(Name = "title")] string title, + [FromQuery(Name = "title")] + [MaxLength(120)] + string title, IFormFile zipFile) { return await _releaseDataFileService diff --git a/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/DataBlockRequests.cs b/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/DataBlockRequests.cs index 947b9e36324..d81e49514cd 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/DataBlockRequests.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/DataBlockRequests.cs @@ -1,9 +1,9 @@ #nullable enable -using System.Collections.Generic; using FluentValidation; using GovUk.Education.ExploreEducationStatistics.Common.Model.Chart; using GovUk.Education.ExploreEducationStatistics.Common.Model.Data; using GovUk.Education.ExploreEducationStatistics.Common.Requests; +using System.Collections.Generic; namespace GovUk.Education.ExploreEducationStatistics.Admin.Requests; @@ -17,7 +17,7 @@ public record DataBlockCreateRequest public FullTableQueryRequest Query { get; init; } = null!; - public List Charts { get; init; } = new(); + public List Charts { get; init; } = []; public TableBuilderConfiguration Table { get; init; } = null!; @@ -26,7 +26,8 @@ public class Validator : AbstractValidator public Validator() { RuleFor(request => request.Heading) - .NotEmpty(); + .NotEmpty() + .MaximumLength(120); RuleFor(request => request.Name) .NotEmpty(); @@ -47,7 +48,7 @@ public record DataBlockUpdateRequest public FullTableQueryRequest Query { get; init; } = null!; - public List Charts { get; init; } = new(); + public List Charts { get; init; } = []; public TableBuilderConfiguration Table { get; init; } = null!; @@ -56,7 +57,8 @@ public class Validator : AbstractValidator public Validator() { RuleFor(request => request.Heading) - .NotEmpty(); + .NotEmpty() + .MaximumLength(120); RuleFor(request => request.Name) .NotEmpty(); diff --git a/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/DataGuidanceUpdateRequest.cs b/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/DataGuidanceUpdateRequest.cs index 1183e3dd119..e854959c8ee 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/DataGuidanceUpdateRequest.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/DataGuidanceUpdateRequest.cs @@ -1,24 +1,45 @@ -#nullable enable +#nullable enable +using FluentValidation; using System; using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; namespace GovUk.Education.ExploreEducationStatistics.Admin.Requests; public record DataGuidanceUpdateRequest { - [Required] public string Content { get; init; } = string.Empty; - [MinLength(1)] - public List DataSets { get; init; } = new(); + public List DataSets { get; init; } = []; + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Content) + .NotEmpty(); + + RuleFor(request => request.DataSets) + .NotEmpty(); + } + } } public record DataGuidanceDataSetUpdateRequest { - [Required] public Guid FileId { get; init; } - [Required] public string Content { get; init; } = string.Empty; + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.FileId) + .NotEmpty(); + + RuleFor(request => request.Content) + .NotEmpty() + .MaximumLength(250); + } + } } diff --git a/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/FeaturedTableRequests.cs b/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/FeaturedTableRequests.cs index eb61edee5a1..43933bc1241 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/FeaturedTableRequests.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/FeaturedTableRequests.cs @@ -1,26 +1,49 @@ #nullable enable +using FluentValidation; using System; -using System.ComponentModel.DataAnnotations; namespace GovUk.Education.ExploreEducationStatistics.Admin.Requests; public record FeaturedTableCreateRequest { - [Required] public string Name { get; init; } = string.Empty; - [Required] public string Description { get; set; } = string.Empty; public Guid DataBlockId { get; set; } + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Name) + .NotEmpty() + .MaximumLength(120); + + RuleFor(request => request.Description) + .NotEmpty() + .MaximumLength(200); + } + } } public record FeaturedTableUpdateRequest { - [Required] public string Name { get; init; } = string.Empty; - [Required] public string Description { get; set; } = string.Empty; + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Name) + .NotEmpty() + .MaximumLength(120); + + RuleFor(request => request.Description) + .NotEmpty() + .MaximumLength(200); + } + } } diff --git a/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/ReleaseFileRequests.cs b/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/ReleaseFileRequests.cs index f406c0fd134..4f8c11a184c 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/ReleaseFileRequests.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Admin/Requests/ReleaseFileRequests.cs @@ -1,5 +1,5 @@ #nullable enable -using System.ComponentModel.DataAnnotations; +using FluentValidation; using Microsoft.AspNetCore.Http; namespace GovUk.Education.ExploreEducationStatistics.Admin.Requests; @@ -9,26 +9,62 @@ public record ReleaseDataFileUpdateRequest public string? Title { get; set; } public string? Summary { get; set; } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .MaximumLength(120); + } + } } public record ReleaseAncillaryFileUploadRequest { - [Required] public string Title { get; set; } = string.Empty; - [Required] public string Summary { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; - [Required] public IFormFile File { get; set; } = null!; + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .NotEmpty() + .MaximumLength(120); + + RuleFor(request => request.Summary) + .NotEmpty() + .MaximumLength(250); + + RuleFor(request => request.File) + .NotEmpty(); + } + } } public record ReleaseAncillaryFileUpdateRequest { - [Required] public string Title { get; set; } = string.Empty; - [Required] public string Summary { get; set; } = string.Empty; public IFormFile? File { get; set; } + + public class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .NotEmpty() + .MaximumLength(120); + + RuleFor(request => request.Summary) + .NotEmpty() + .MaximumLength(250); + } + } } diff --git a/src/GovUk.Education.ExploreEducationStatistics.Admin/Services/DataArchiveValidationService.cs b/src/GovUk.Education.ExploreEducationStatistics.Admin/Services/DataArchiveValidationService.cs index 71153aea010..ae3b51bcc35 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Admin/Services/DataArchiveValidationService.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Admin/Services/DataArchiveValidationService.cs @@ -103,8 +103,8 @@ public async Task>> ValidateBulkDa { return Common.Validators.ValidationUtils.ValidationResult(new ErrorViewModel { - Code = ValidationMessages.BulkDataZipMustContainDatasetNamesCsv.Code, - Message = ValidationMessages.BulkDataZipMustContainDatasetNamesCsv.Message, + Code = ValidationMessages.BulkDataZipMustContainDataSetNamesCsv.Code, + Message = ValidationMessages.BulkDataZipMustContainDataSetNamesCsv.Message, }); } @@ -120,8 +120,8 @@ public async Task>> ValidateBulkDa { return Common.Validators.ValidationUtils.ValidationResult(new ErrorViewModel { - Code = ValidationMessages.DatasetNamesCsvIncorrectHeaders.Code, - Message = ValidationMessages.DatasetNamesCsvIncorrectHeaders.Message, + Code = ValidationMessages.DataSetNamesCsvIncorrectHeaders.Code, + Message = ValidationMessages.DataSetNamesCsvIncorrectHeaders.Message, }); } @@ -134,24 +134,29 @@ public async Task>> ValidateBulkDa rows = await CsvUtils.GetCsvRows(dataSetNamesStream); } + var errors = new List(); + var dataSetNamesCsvEntries = new List<(string BaseFilename, string Title)>(); foreach (var row in rows) { var filename = row[fileNameIndex]; var datasetName = row[datasetNameIndex].Trim(); + if (datasetName.Length > 120) + { + errors.Add(ValidationMessages.GenerateErrorDataSetTitleTooLong(datasetName)); + } + dataSetNamesCsvEntries.Add((BaseFilename: filename, Title: datasetName)); } - var errors = new List(); - dataSetNamesCsvEntries .Select(entry => entry.BaseFilename) .Where(baseFilename => baseFilename.EndsWith(".csv")) .ToList() .ForEach(baseFilename => { - errors.Add(ValidationMessages.GenerateErrorDatasetNamesCsvFilenamesShouldNotEndDotCsv(baseFilename)); + errors.Add(ValidationMessages.GenerateErrorDataSetNamesCsvFilenamesShouldNotEndDotCsv(baseFilename)); }); // Check for duplicate data set titles - because the bulk zip itself may contain duplicates! @@ -175,7 +180,7 @@ public async Task>> ValidateBulkDa .ForEach(duplicateFilename => { errors.Add(ValidationMessages - .GenerateErrorDatasetNamesCsvFilenamesShouldBeUnique(duplicateFilename)); + .GenerateErrorDataSetNamesCsvFilenamesShouldBeUnique(duplicateFilename)); }); if (errors.Count > 0) diff --git a/src/GovUk.Education.ExploreEducationStatistics.Admin/Validators/ValidationMessages.cs b/src/GovUk.Education.ExploreEducationStatistics.Admin/Validators/ValidationMessages.cs index 46f26173e4b..9354e84ff7a 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Admin/Validators/ValidationMessages.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Admin/Validators/ValidationMessages.cs @@ -106,27 +106,27 @@ public static ErrorViewModel GenerateErrorMustBeCsvFile(string fullFilename) }; } - public static readonly LocalizableMessage BulkDataZipMustContainDatasetNamesCsv = new( - Code: nameof(BulkDataZipMustContainDatasetNamesCsv), + public static readonly LocalizableMessage BulkDataZipMustContainDataSetNamesCsv = new( + Code: nameof(BulkDataZipMustContainDataSetNamesCsv), Message: "For bulk imports, the ZIP must include dataset_names.csv" ); - public static readonly LocalizableMessage DatasetNamesCsvReaderException = new( - Code: nameof(DatasetNamesCsvReaderException), + public static readonly LocalizableMessage DataSetNamesCsvReaderException = new( + Code: nameof(DataSetNamesCsvReaderException), Message: "Failed to read dataset_names.csv. Exception: {0}" ); - public static ErrorViewModel GenerateErrorDatasetNamesCsvReaderException(string exception) + public static ErrorViewModel GenerateErrorDataSetNamesCsvReaderException(string exception) { return new ErrorViewModel { - Code = DatasetNamesCsvReaderException.Code, - Message = string.Format(DatasetNamesCsvReaderException.Message, exception), + Code = DataSetNamesCsvReaderException.Code, + Message = string.Format(DataSetNamesCsvReaderException.Message, exception), }; } - public static readonly LocalizableMessage DatasetNamesCsvIncorrectHeaders = new( - Code: nameof(DatasetNamesCsvIncorrectHeaders), + public static readonly LocalizableMessage DataSetNamesCsvIncorrectHeaders = new( + Code: nameof(DataSetNamesCsvIncorrectHeaders), Message: "dataset_names.csv has incorrect headers. It should have 'file_name' and 'dataset_name' only." ); @@ -158,22 +158,22 @@ public static ErrorViewModel GenerateErrorDataSetTitleShouldBeUnique(string dupl }; } - public static readonly LocalizableMessage DatasetNamesCsvFilenamesShouldBeUnique = new( - Code: nameof(DatasetNamesCsvFilenamesShouldBeUnique), + public static readonly LocalizableMessage DataSetNamesCsvFilenamesShouldBeUnique = new( + Code: nameof(DataSetNamesCsvFilenamesShouldBeUnique), Message: "In dataset_names.csv, all filenames should be unique. Duplicate filename: '{0}'." ); - public static ErrorViewModel GenerateErrorDatasetNamesCsvFilenamesShouldBeUnique(string duplicate) + public static ErrorViewModel GenerateErrorDataSetNamesCsvFilenamesShouldBeUnique(string duplicate) { return new ErrorViewModel { - Code = DatasetNamesCsvFilenamesShouldBeUnique.Code, - Message = string.Format(DatasetNamesCsvFilenamesShouldBeUnique.Message, duplicate), + Code = DataSetNamesCsvFilenamesShouldBeUnique.Code, + Message = string.Format(DataSetNamesCsvFilenamesShouldBeUnique.Message, duplicate), }; } - public static readonly LocalizableMessage DatasetNamesCsvFilenamesShouldNotEndDotCsv = new( - Code: nameof(DatasetNamesCsvFilenamesShouldNotEndDotCsv), + public static readonly LocalizableMessage DataSetNamesCsvFilenamesShouldNotEndDotCsv = new( + Code: nameof(DataSetNamesCsvFilenamesShouldNotEndDotCsv), Message: "Inside dataset_names.csv, file_name cell entries should not end in '.csv' i.e. should be 'filename' not 'filename.csv'. Filename found with extension: '{0}'." ); @@ -205,12 +205,26 @@ public static ErrorViewModel GenerateErrorDataReplacementAlreadyInProgress() }; } - public static ErrorViewModel GenerateErrorDatasetNamesCsvFilenamesShouldNotEndDotCsv(string filename) + public static readonly LocalizableMessage DataSetTitleTooLong = new( + Code: nameof(DataSetTitleTooLong), + Message: "Subject title '{0}' must be 120 characters or less" + ); + + public static ErrorViewModel GenerateErrorDataSetTitleTooLong(string title) + { + return new ErrorViewModel + { + Code = DataSetTitleTooLong.Code, + Message = string.Format(DataSetTitleTooLong.Message, title), + }; + } + + public static ErrorViewModel GenerateErrorDataSetNamesCsvFilenamesShouldNotEndDotCsv(string filename) { return new ErrorViewModel { - Code = DatasetNamesCsvFilenamesShouldNotEndDotCsv.Code, - Message = string.Format(DatasetNamesCsvFilenamesShouldNotEndDotCsv.Message, filename), + Code = DataSetNamesCsvFilenamesShouldNotEndDotCsv.Code, + Message = string.Format(DataSetNamesCsvFilenamesShouldNotEndDotCsv.Message, filename), }; } diff --git a/src/explore-education-statistics-admin/src/pages/release/data/ReleaseDataFilePage.tsx b/src/explore-education-statistics-admin/src/pages/release/data/ReleaseDataFilePage.tsx index 1fbbd215c72..f189c26ab73 100644 --- a/src/explore-education-statistics-admin/src/pages/release/data/ReleaseDataFilePage.tsx +++ b/src/explore-education-statistics-admin/src/pages/release/data/ReleaseDataFilePage.tsx @@ -55,6 +55,8 @@ export default function ReleaseDataFilePage({ ); }; + const titleMaxLength = 120; + return ( <> ({ - title: Yup.string().required('Enter a title'), + title: Yup.string() + .required('Enter a title') + .max( + titleMaxLength, + `Subject title must be ${titleMaxLength} characters or less`, + ), })} >
@@ -84,6 +91,7 @@ export default function ReleaseDataFilePage({ className="govuk-!-width-two-thirds" label="Title" name="title" + maxLength={titleMaxLength} /> diff --git a/src/explore-education-statistics-admin/src/pages/release/data/components/AncillaryFileForm.tsx b/src/explore-education-statistics-admin/src/pages/release/data/components/AncillaryFileForm.tsx index e47434e8d4a..f8b58a3595a 100644 --- a/src/explore-education-statistics-admin/src/pages/release/data/components/AncillaryFileForm.tsx +++ b/src/explore-education-statistics-admin/src/pages/release/data/components/AncillaryFileForm.tsx @@ -21,6 +21,8 @@ export interface AncillaryFileFormValues { const formId = 'ancillaryFileForm'; const MAX_FILE_SIZE = 2147483647; // 2GB +const titleMaxLength = 120; +const summaryMaxLength = 250; const errorMappings = [ mapFieldErrors({ @@ -76,8 +78,17 @@ export default function AncillaryFileForm({ f => f.title.toUpperCase() !== value.toUpperCase(), ); }, - }), - summary: Yup.string().required('Enter a summary'), + }) + .max( + titleMaxLength, + `Title must be ${titleMaxLength} characters or less`, + ), + summary: Yup.string() + .required('Enter a summary') + .max( + summaryMaxLength, + `Summary must be ${summaryMaxLength} characters or less`, + ), file: Yup.file() .minSize(0, 'Choose a file that is not empty') .maxSize(MAX_FILE_SIZE, 'Choose a file that is under 2GB') @@ -117,6 +128,7 @@ export default function AncillaryFileForm({ disabled={formState.isSubmitting} label="Title" name="title" + maxLength={titleMaxLength} /> @@ -124,6 +136,7 @@ export default function AncillaryFileForm({ disabled={formState.isSubmitting} label="Summary" name="summary" + maxLength={summaryMaxLength} /> diff --git a/src/explore-education-statistics-admin/src/pages/release/data/components/DataFileUploadForm.tsx b/src/explore-education-statistics-admin/src/pages/release/data/components/DataFileUploadForm.tsx index 59df8a9c1d6..ac728bd6ba2 100644 --- a/src/explore-education-statistics-admin/src/pages/release/data/components/DataFileUploadForm.tsx +++ b/src/explore-education-statistics-admin/src/pages/release/data/components/DataFileUploadForm.tsx @@ -29,6 +29,7 @@ export interface DataFileUploadFormValues { } const MAX_FILENAME_SIZE = 150; +const titleMaxLength = 120; const subjectErrorMappings = [ mapFieldErrors({ @@ -71,18 +72,19 @@ function baseErrorMappings( ...fileErrorMappings, ZipFilenameMustEndDotZip: 'ZipFilenameMustEndDotZip', MustBeZipFile: 'MustBeZipFile', - BulkDataZipMustContainDatasetNamesCsv: - 'BulkDataZipMustContainDatasetNamesCsv', - DatasetNamesCsvReaderException: 'DatasetNamesCsvReaderException', - DatasetNamesCsvIncorrectHeaders: 'DatasetNamesCsvIncorrectHeaders', - DatasetNamesCsvFilenamesShouldNotEndDotCsv: - 'DatasetNamesCsvFilenamesShouldNotEndDotCsv', - DatasetNamesCsvFilenamesShouldBeUnique: - 'DatasetNamesCsvFilenamesShouldBeUnique', + BulkDataZipMustContainDataSetNamesCsv: + 'BulkDataZipMustContainDataSetNamesCsv', + DataSetNamesCsvReaderException: 'DataSetNamesCsvReaderException', + DataSetNamesCsvIncorrectHeaders: 'DataSetNamesCsvIncorrectHeaders', + DataSetNamesCsvFilenamesShouldNotEndDotCsv: + 'DataSetNamesCsvFilenamesShouldNotEndDotCsv', + DataSetNamesCsvFilenamesShouldBeUnique: + 'DataSetNamesCsvFilenamesShouldBeUnique', FileNotFoundInZip: 'FileNotFoundInZip', ZipContainsUnusedFiles: 'ZipContainsUnusedFiles', DataReplacementAlreadyInProgress: 'Data replacement already in progress', + DataSetTitleTooLong: 'DataSetTitleTooLong', }, }), ]; @@ -179,21 +181,27 @@ export default function DataFileUploadForm({ is: (uploadType: FileType) => uploadType === 'csv' || uploadType === 'zip', then: s => - s.required('Enter a subject title').test({ - name: 'unique', - message: 'Enter a unique subject title', - test(value: string) { - if (!value) { - return true; - } + s + .required('Enter a subject title') + .test({ + name: 'unique', + message: 'Enter a unique subject title', + test(value: string) { + if (!value) { + return true; + } - return ( - dataFiles?.find( - f => f.title.toUpperCase() === value.toUpperCase(), - ) === undefined - ); - }, - }), + return ( + dataFiles?.find( + f => f.title.toUpperCase() === value.toUpperCase(), + ) === undefined + ); + }, + }) + .max( + titleMaxLength, + `Subject title must be ${titleMaxLength} characters or less`, + ), }), }); } @@ -233,6 +241,7 @@ export default function DataFileUploadForm({ name="subjectTitle" label="Subject title" className="govuk-!-width-two-thirds" + maxLength={titleMaxLength} /> )} diff --git a/src/explore-education-statistics-admin/src/pages/release/data/components/ReleaseDataGuidanceSection.tsx b/src/explore-education-statistics-admin/src/pages/release/data/components/ReleaseDataGuidanceSection.tsx index efcc4dc3dae..80146f9349c 100644 --- a/src/explore-education-statistics-admin/src/pages/release/data/components/ReleaseDataGuidanceSection.tsx +++ b/src/explore-education-statistics-admin/src/pages/release/data/components/ReleaseDataGuidanceSection.tsx @@ -44,6 +44,7 @@ interface Props { canUpdateRelease: boolean; } +const contentMaxLength = 250; const formId = 'dataGuidanceForm'; const ReleaseDataGuidanceSection = ({ releaseId, canUpdateRelease }: Props) => { @@ -114,16 +115,21 @@ const ReleaseDataGuidanceSection = ({ releaseId, canUpdateRelease }: Props) => { dataSets: Yup.array().of( Yup.object({ id: Yup.string(), - content: Yup.string().required(params => { - const [, index] = toPath(params.path); - const dataSet = dataGuidance?.dataSets[Number(index)]; - - if (!dataSet) { - return null; - } - - return `Enter file guidance content for ${dataSet.name}`; - }), + content: Yup.string() + .required(params => { + const [, index] = toPath(params.path); + const dataSet = dataGuidance?.dataSets[Number(index)]; + + if (!dataSet) { + return null; + } + + return `Enter file guidance content for ${dataSet.name}`; + }) + .max( + contentMaxLength, + `File guidance content must be ${contentMaxLength} characters or less`, + ), }), ), })} @@ -179,6 +185,7 @@ const ReleaseDataGuidanceSection = ({ releaseId, canUpdateRelease }: Props) => { label="File guidance content" name={`dataSets.${index}.content`} rows={3} + maxLength={contentMaxLength} /> ) : ( ; +const titleMaxLength = 120; +const descriptionMaxLength = 200; const formId = 'dataBlockDetailsForm'; interface Props { @@ -56,15 +58,32 @@ const DataBlockDetailsForm = ({ const validationSchema = useMemo>(() => { return Yup.object({ name: Yup.string().required('Enter a data block name'), - heading: Yup.string().required('Enter a table title'), + heading: Yup.string() + .required('Enter a table title') + .max( + titleMaxLength, + `Table title must be ${titleMaxLength} characters or less`, + ), source: Yup.string(), highlightName: Yup.string().when('isHighlight', { is: true, - then: s => s.required('Enter a featured table name'), + then: s => + s + .required('Enter a featured table name') + .max( + titleMaxLength, + `Featured table name must be ${titleMaxLength} characters or less`, + ), }), highlightDescription: Yup.string().when('isHighlight', { is: true, - then: s => s.required('Enter a featured table description'), + then: s => + s + .required('Enter a featured table description') + .max( + descriptionMaxLength, + `Featured table description must be ${descriptionMaxLength} characters or less`, + ), }), isHighlight: Yup.boolean(), }); @@ -100,6 +119,7 @@ const DataBlockDetailsForm = ({ onBlur={() => { onTitleChange?.(getValues('heading')); }} + maxLength={titleMaxLength} /> @@ -125,12 +145,14 @@ const DataBlockDetailsForm = ({ label="Featured table name" hint="We will show this name to table builder users as a featured table" className="govuk-!-width-two-thirds" + maxLength={titleMaxLength} /> name="highlightDescription" label="Featured table description" hint="Describe the contents of this featured table to table builder users" className="govuk-!-width-two-thirds" + maxLength={descriptionMaxLength} /> } diff --git a/src/explore-education-statistics-common/src/components/form/FormFieldTextArea.tsx b/src/explore-education-statistics-common/src/components/form/FormFieldTextArea.tsx index 246ba3837e1..72369488537 100644 --- a/src/explore-education-statistics-common/src/components/form/FormFieldTextArea.tsx +++ b/src/explore-education-statistics-common/src/components/form/FormFieldTextArea.tsx @@ -4,15 +4,35 @@ import FormField, { import FormTextArea from '@common/components/form/FormTextArea'; import { FormTextAreaProps } from '@common/components/form/FormBaseTextArea'; import React from 'react'; -import { FieldValues } from 'react-hook-form'; +import { FieldValues, useWatch } from 'react-hook-form'; +import FormCharacterCount from '@common/components/form/FormCharacterCount'; +import FormGroup from './FormGroup'; type Props = FormFieldComponentProps< FormTextAreaProps, TFormValues >; -export default function FormFieldTextArea( - props: Props, -) { +export default function FormFieldTextArea({ + maxLength, + ...props +}: Props) { + const watchedValue = useWatch({ name: props.name }); + + if (!!maxLength && maxLength > 0) { + return ( +
+ + + + +
+ ); + } + return ; } diff --git a/src/explore-education-statistics-common/src/components/form/FormFieldTextInput.tsx b/src/explore-education-statistics-common/src/components/form/FormFieldTextInput.tsx index abc58788276..90aba48798d 100644 --- a/src/explore-education-statistics-common/src/components/form/FormFieldTextInput.tsx +++ b/src/explore-education-statistics-common/src/components/form/FormFieldTextInput.tsx @@ -5,15 +5,35 @@ import FormTextInput, { FormTextInputProps, } from '@common/components/form/FormTextInput'; import React from 'react'; -import { FieldValues } from 'react-hook-form'; +import { FieldValues, useWatch } from 'react-hook-form'; +import FormCharacterCount from '@common/components/form/FormCharacterCount'; +import FormGroup from './FormGroup'; type Props = FormFieldComponentProps< FormTextInputProps, TFormValues >; -export default function FormFieldTextInput( - props: Props, -) { +export default function FormFieldTextInput({ + maxLength, + ...props +}: Props) { + const watchedValue = useWatch({ name: props.name }); + + if (!!maxLength && maxLength > 0) { + return ( +
+ + + + +
+ ); + } + return ; } diff --git a/src/explore-education-statistics-common/src/components/form/FormTextArea.tsx b/src/explore-education-statistics-common/src/components/form/FormTextArea.tsx index 6aa385bf9bf..2ebf6b27e5f 100644 --- a/src/explore-education-statistics-common/src/components/form/FormTextArea.tsx +++ b/src/explore-education-statistics-common/src/components/form/FormTextArea.tsx @@ -1,36 +1,8 @@ -import FormGroup from '@common/components/form/FormGroup'; -import FormCharacterCount from '@common/components/form/FormCharacterCount'; import FormBaseTextArea, { FormTextAreaProps, } from '@common/components/form/FormBaseTextArea'; import React from 'react'; -import { useWatch } from 'react-hook-form'; -export default function FormTextArea({ - id, - maxLength, - name, - ...props -}: FormTextAreaProps) { - const value = useWatch({ name }); - - if (!!maxLength && maxLength > 0) { - return ( -
- - - - -
- ); - } - - return ( - - ); +export default function FormTextArea(props: FormTextAreaProps) { + return ; } diff --git a/src/explore-education-statistics-common/src/components/form/FormTextInput.tsx b/src/explore-education-statistics-common/src/components/form/FormTextInput.tsx index 81e8bc82b37..0108fa1f1e0 100644 --- a/src/explore-education-statistics-common/src/components/form/FormTextInput.tsx +++ b/src/explore-education-statistics-common/src/components/form/FormTextInput.tsx @@ -1,8 +1,6 @@ import FormBaseInput, { FormBaseInputProps, } from '@common/components/form/FormBaseInput'; -import FormCharacterCount from '@common/components/form/FormCharacterCount'; -import FormGroup from '@common/components/form/FormGroup'; import React from 'react'; export interface FormTextInputProps extends FormBaseInputProps { @@ -13,24 +11,8 @@ export interface FormTextInputProps extends FormBaseInputProps { export default function FormTextInput({ id, - maxLength, value, ...props }: FormTextInputProps) { - if (!!maxLength && maxLength > 0) { - return ( -
- - - - -
- ); - } return ; } diff --git a/src/explore-education-statistics-common/src/components/form/__tests__/FormFieldTextArea.test.tsx b/src/explore-education-statistics-common/src/components/form/__tests__/FormFieldTextArea.test.tsx new file mode 100644 index 00000000000..733ba544071 --- /dev/null +++ b/src/explore-education-statistics-common/src/components/form/__tests__/FormFieldTextArea.test.tsx @@ -0,0 +1,136 @@ +import FormFieldTextArea from '@common/components/form/FormFieldTextArea'; +import FormProvider from '@common/components/form/FormProvider'; +import { render, screen } from '@testing-library/react'; +import noop from 'lodash/noop'; +import React from 'react'; + +describe('FormFieldTextArea', () => { + describe('maxLength', () => { + test('shows a character count message when `maxLength` is above 0', () => { + render( + + + , + ); + + expect( + screen.getByText('You have 10 characters remaining'), + ).toBeInTheDocument(); + }); + + test('aria-describedby contains the character count message id when `maxLength` is above 0', () => { + render( + + + , + ); + + const ariaDescribedBy = screen + .getByLabelText('Test input') + .getAttribute('aria-describedby'); + + expect( + screen.getByText('You have 10 characters remaining'), + ).toHaveAttribute('id', 'test-input-info'); + expect(ariaDescribedBy).toContain('test-input-info'); + }); + + test('does not show a character count message when `maxLength` is below 0', () => { + render( + + + , + ); + + expect( + screen.queryByText(/You have .+ characters remaining/), + ).not.toBeInTheDocument(); + }); + + test('does not show a character count message when `maxLength` is 0', () => { + render( + + + , + ); + + expect( + screen.queryByText(/You have .+ characters remaining/), + ).not.toBeInTheDocument(); + }); + + test('shows correct character count message when difference to `maxLength` is 1', () => { + render( + + + , + ); + + expect( + screen.getByText('You have 1 character remaining'), + ).toBeInTheDocument(); + }); + + test('shows correct character count message when difference to `maxLength` is 0', () => { + render( + + + , + ); + + expect( + screen.getByText('You have 0 characters remaining'), + ).toBeInTheDocument(); + }); + + test('shows correct character count message when difference to `maxLength` is -1', () => { + render( + + + , + ); + + expect( + screen.getByText('You have 1 character too many'), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/explore-education-statistics-common/src/components/form/__tests__/FormTextArea.test.tsx b/src/explore-education-statistics-common/src/components/form/__tests__/FormTextArea.test.tsx index 9b86fd200ab..138eb0a23d7 100644 --- a/src/explore-education-statistics-common/src/components/form/__tests__/FormTextArea.test.tsx +++ b/src/explore-education-statistics-common/src/components/form/__tests__/FormTextArea.test.tsx @@ -1,5 +1,4 @@ import FormTextArea from '@common/components/form/FormTextArea'; -import FormProvider from '@common/components/form/FormProvider'; import { render, screen } from '@testing-library/react'; import noop from 'lodash/noop'; import React from 'react'; @@ -7,9 +6,7 @@ import React from 'react'; describe('FormTextArea', () => { test('renders correctly with required props', () => { const { container } = render( - - - , + , ); expect(screen.getByLabelText('Test input')).toBeDefined(); @@ -18,14 +15,12 @@ describe('FormTextArea', () => { test('renders correctly with hint', () => { const { container } = render( - - - , + , ); const hint = screen.getByText('Fill me in'); @@ -36,15 +31,13 @@ describe('FormTextArea', () => { test('renders correctly with error', () => { const { container } = render( - - - , + , ); const error = screen.getByText('Field is required'); @@ -55,15 +48,13 @@ describe('FormTextArea', () => { test('aria-describedby is equal to the hint id', () => { render( - - - , + , ); expect(screen.getByText('Fill me in')).toHaveAttribute( @@ -78,15 +69,13 @@ describe('FormTextArea', () => { test('aria-describedby is equal to the error id', () => { render( - - - , + , ); expect(screen.getByText('Field is required')).toHaveAttribute( @@ -101,15 +90,13 @@ describe('FormTextArea', () => { test('aria-describedby contains both hint and error ids', () => { render( - - - , + , ); expect(screen.getByText('Fill me in')).toHaveAttribute( @@ -128,131 +115,4 @@ describe('FormTextArea', () => { expect(ariaDescribedBy).toContain('test-input-error'); expect(ariaDescribedBy).toContain('test-input-hint'); }); - - test('shows a character count message when `maxLength` is above 0', () => { - render( - - - , - ); - - expect( - screen.getByText('You have 10 characters remaining'), - ).toBeInTheDocument(); - }); - - test('aria-describedby contains the character count message id when `maxLength` is above 0', () => { - render( - - - , - ); - - const ariaDescribedBy = screen - .getByLabelText('Test input') - .getAttribute('aria-describedby'); - - expect( - screen.getByText('You have 10 characters remaining'), - ).toHaveAttribute('id', 'test-input-info'); - expect(ariaDescribedBy).toContain('test-input-info'); - }); - - test('does not show a character count message when `maxLength` is below 0', () => { - render( - - - , - ); - - expect( - screen.queryByText(/You have .+ characters remaining/), - ).not.toBeInTheDocument(); - }); - - test('does not show a character count message when `maxLength` is 0', () => { - render( - - - , - ); - - expect( - screen.queryByText(/You have .+ characters remaining/), - ).not.toBeInTheDocument(); - }); - - test('shows correct character count message when difference to `maxLength` is 1', () => { - render( - - - , - ); - - expect( - screen.getByText('You have 1 character remaining'), - ).toBeInTheDocument(); - }); - - test('shows correct character count message when difference to `maxLength` is 0', () => { - render( - - - , - ); - - expect( - screen.getByText('You have 0 characters remaining'), - ).toBeInTheDocument(); - }); - - test('shows correct character count message when difference to `maxLength` is -1', () => { - render( - - - , - ); - - expect( - screen.getByText('You have 1 character too many'), - ).toBeInTheDocument(); - }); });