From 75ba7dc3d2d0d540a5c03764dbdc2de6d803828a Mon Sep 17 00:00:00 2001 From: Antoine Hurard <50317271+AntoineRelief@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:33:27 -1000 Subject: [PATCH 1/2] feat: Add new surveyjs widgets to manage files AB#138121 --- libs/shared/jest.config.ts | 4 + libs/shared/src/i18n/en.json | 28 ++ libs/shared/src/i18n/fr.json | 28 ++ libs/shared/src/i18n/test.json | 28 ++ libs/shared/src/i18n/uk.json | 28 ++ .../files-management-question.component.html | 96 +++++ .../files-management-question.component.scss | 3 + ...iles-management-question.component.spec.ts | 180 +++++++++ .../files-management-question.component.ts | 153 ++++++++ .../files-upload-question.component.html | 112 ++++++ .../files-upload-question.component.scss | 9 + .../files-upload-question.component.spec.ts | 351 ++++++++++++++++++ .../files-upload-question.component.ts | 349 +++++++++++++++++ .../form-builder/form-builder.component.ts | 22 +- .../src/lib/services/file/file.service.ts | 2 + libs/shared/src/lib/style/survey.scss | 7 + .../file-item-actions.component.ts | 4 +- .../components/files-management.spec.ts | 158 ++++++++ .../lib/survey/components/files-management.ts | 149 ++++++++ .../survey/components/files-upload.spec.ts | 181 +++++++++ .../src/lib/survey/components/files-upload.ts | 181 +++++++++ .../utils/files-widgets.util.spec.ts | 332 +++++++++++++++++ .../components/utils/files-widgets.util.ts | 316 ++++++++++++++++ libs/shared/src/lib/survey/init.ts | 6 + .../lib/survey/widgets/file-widget.spec.ts | 2 + .../src/lib/survey/widgets/file-widget.ts | 3 + 26 files changed, 2728 insertions(+), 4 deletions(-) create mode 100644 libs/shared/src/lib/components/files-management-question/files-management-question.component.html create mode 100644 libs/shared/src/lib/components/files-management-question/files-management-question.component.scss create mode 100644 libs/shared/src/lib/components/files-management-question/files-management-question.component.spec.ts create mode 100644 libs/shared/src/lib/components/files-management-question/files-management-question.component.ts create mode 100644 libs/shared/src/lib/components/files-upload-question/files-upload-question.component.html create mode 100644 libs/shared/src/lib/components/files-upload-question/files-upload-question.component.scss create mode 100644 libs/shared/src/lib/components/files-upload-question/files-upload-question.component.spec.ts create mode 100644 libs/shared/src/lib/components/files-upload-question/files-upload-question.component.ts create mode 100644 libs/shared/src/lib/survey/components/files-management.spec.ts create mode 100644 libs/shared/src/lib/survey/components/files-management.ts create mode 100644 libs/shared/src/lib/survey/components/files-upload.spec.ts create mode 100644 libs/shared/src/lib/survey/components/files-upload.ts create mode 100644 libs/shared/src/lib/survey/components/utils/files-widgets.util.spec.ts create mode 100644 libs/shared/src/lib/survey/components/utils/files-widgets.util.ts diff --git a/libs/shared/jest.config.ts b/libs/shared/jest.config.ts index e4e194ad7b..44358a049a 100644 --- a/libs/shared/jest.config.ts +++ b/libs/shared/jest.config.ts @@ -47,6 +47,8 @@ export default { '/src/lib/components/record-history/record-history-table/*.spec.ts', '/src/lib/components/record-history/record-history-cards/*.spec.ts', '/src/lib/components/field-history-question/*.spec.ts', + '/src/lib/components/files-upload-question/*.spec.ts', + '/src/lib/components/files-management-question/*.spec.ts', '/src/lib/components/form/form.component.spec.ts', '/src/lib/components/widgets/common/tab-actions/*.spec.ts', '/src/lib/components/widgets/common/tab-actions/read-only-fields-modal/*.spec.ts', @@ -74,6 +76,8 @@ export default { '/src/lib/survey/components/resource.spec.ts', '/src/lib/survey/components/resources.spec.ts', '/src/lib/survey/components/field-history.spec.ts', + '/src/lib/survey/components/files-upload.spec.ts', + '/src/lib/survey/components/files-management.spec.ts', '/src/lib/survey/components/utils/*.spec.ts', '/src/lib/survey/widgets/*.spec.ts', '/src/lib/survey/widgets/utils/*.spec.ts', diff --git a/libs/shared/src/i18n/en.json b/libs/shared/src/i18n/en.json index d774906d0f..1fd5561163 100644 --- a/libs/shared/src/i18n/en.json +++ b/libs/shared/src/i18n/en.json @@ -1095,6 +1095,34 @@ "title": "Email preview", "to": "To" }, + "filesManagement": { + "empty": "No files yet.", + "field": "Document type", + "fileName": "File name", + "language": "Language", + "noLanguage": "No language", + "status": "Status", + "statusActive": "Active", + "statusOutdated": "Outdated", + "statusPending": "To upload" + }, + "filesUpload": { + "addFiles": "Add files", + "dropFilesHere": "Drop files here", + "empty": "No files to upload yet.", + "errors": { + "invalidType": "\"{{name}}\" is not an accepted file type.", + "maxSizeExceeded": "\"{{name}}\" exceeds the maximum allowed size.", + "maximumAllowedFiles": "Maximum number of files allowed: {{number}}", + "noTargetField": "Select a file question before adding files.", + "singleFileOnly": "This question only accepts a single file.", + "uploadFailed": "The files could not be added, please try again." + }, + "field": "Document type", + "fileName": "File name", + "language": "Language", + "targetField": "Document type" + }, "form": { "aggregation": { "delete": { diff --git a/libs/shared/src/i18n/fr.json b/libs/shared/src/i18n/fr.json index a02cb8892e..0aa04b9963 100644 --- a/libs/shared/src/i18n/fr.json +++ b/libs/shared/src/i18n/fr.json @@ -1095,6 +1095,34 @@ "title": "Prévisualisation de mail", "to": "À" }, + "filesManagement": { + "empty": "Aucun fichier pour le moment.", + "field": "Type de document", + "fileName": "Nom du fichier", + "language": "Langue", + "noLanguage": "Sans langue", + "status": "Statut", + "statusActive": "Actif", + "statusOutdated": "Obsolète", + "statusPending": "À téléverser" + }, + "filesUpload": { + "addFiles": "Ajouter des fichiers", + "dropFilesHere": "Déposez les fichiers ici", + "empty": "Aucun fichier à téléverser pour le moment.", + "errors": { + "invalidType": "« {{name}} » n'est pas un type de fichier accepté.", + "maxSizeExceeded": "« {{name}} » dépasse la taille maximale autorisée.", + "maximumAllowedFiles": "Nombre maximum de fichiers autorisé : {{number}}", + "noTargetField": "Sélectionnez une question de type fichier avant d'ajouter des fichiers.", + "singleFileOnly": "Cette question n'accepte qu'un seul fichier.", + "uploadFailed": "Les fichiers n'ont pas pu être ajoutés, veuillez réessayer." + }, + "field": "Type de document", + "fileName": "Nom du fichier", + "language": "Langue", + "targetField": "Type de document" + }, "form": { "aggregation": { "delete": { diff --git a/libs/shared/src/i18n/test.json b/libs/shared/src/i18n/test.json index 952169a46c..354384472c 100644 --- a/libs/shared/src/i18n/test.json +++ b/libs/shared/src/i18n/test.json @@ -1095,6 +1095,34 @@ "title": "******", "to": "******" }, + "filesManagement": { + "empty": "******", + "field": "******", + "fileName": "******", + "language": "******", + "noLanguage": "******", + "status": "******", + "statusActive": "******", + "statusOutdated": "******", + "statusPending": "******" + }, + "filesUpload": { + "addFiles": "******", + "dropFilesHere": "******", + "empty": "******", + "errors": { + "invalidType": "****** {{name}} ******", + "maxSizeExceeded": "****** {{name}} ******", + "maximumAllowedFiles": "****** {{number}}", + "noTargetField": "******", + "singleFileOnly": "******", + "uploadFailed": "******" + }, + "field": "******", + "fileName": "******", + "language": "******", + "targetField": "******" + }, "form": { "aggregation": { "delete": { diff --git a/libs/shared/src/i18n/uk.json b/libs/shared/src/i18n/uk.json index 469fafc440..bf15517010 100644 --- a/libs/shared/src/i18n/uk.json +++ b/libs/shared/src/i18n/uk.json @@ -1095,6 +1095,34 @@ "title": "Попередній перегляд електронного листа", "to": "Кому" }, + "filesManagement": { + "empty": "Поки що немає файлів.", + "field": "Тип документа", + "fileName": "Назва файлу", + "language": "Мова", + "noLanguage": "Без мови", + "status": "Статус", + "statusActive": "Активний", + "statusOutdated": "Застарілий", + "statusPending": "До завантаження" + }, + "filesUpload": { + "addFiles": "Додати файли", + "dropFilesHere": "Перетягніть файли сюди", + "empty": "Поки що немає файлів для завантаження.", + "errors": { + "invalidType": "«{{name}}» не є дозволеним типом файлу.", + "maxSizeExceeded": "«{{name}}» перевищує максимально дозволений розмір.", + "maximumAllowedFiles": "Максимальна кількість файлів: {{number}}", + "noTargetField": "Виберіть питання типу «файл», перш ніж додавати файли.", + "singleFileOnly": "Це питання приймає лише один файл.", + "uploadFailed": "Не вдалося додати файли, спробуйте ще раз." + }, + "field": "Тип документа", + "fileName": "Назва файлу", + "language": "Мова", + "targetField": "Тип документа" + }, "form": { "aggregation": { "delete": { diff --git a/libs/shared/src/lib/components/files-management-question/files-management-question.component.html b/libs/shared/src/lib/components/files-management-question/files-management-question.component.html new file mode 100644 index 0000000000..b93a912abb --- /dev/null +++ b/libs/shared/src/lib/components/files-management-question/files-management-question.component.html @@ -0,0 +1,96 @@ + + + + + + {{ dataItem.file.name }} + + + + + + + + {{ dataItem.languageLabel }} + + + + {{ 'components.filesManagement.noLanguage' | translate }} + + + + + + + + + {{ 'components.filesManagement.statusPending' | translate }} + + + + {{ 'components.filesManagement.statusOutdated' | translate }} + + + + {{ 'components.filesManagement.statusActive' | translate }} + + + + + + + + + + + + + diff --git a/libs/shared/src/lib/components/files-management-question/files-management-question.component.scss b/libs/shared/src/lib/components/files-management-question/files-management-question.component.scss new file mode 100644 index 0000000000..5d4e87f30f --- /dev/null +++ b/libs/shared/src/lib/components/files-management-question/files-management-question.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/shared/src/lib/components/files-management-question/files-management-question.component.spec.ts b/libs/shared/src/lib/components/files-management-question/files-management-question.component.spec.ts new file mode 100644 index 0000000000..72886fae9a --- /dev/null +++ b/libs/shared/src/lib/components/files-management-question/files-management-question.component.spec.ts @@ -0,0 +1,180 @@ +import { TranslateService } from '@ngx-translate/core'; +import { SurveyModel } from 'survey-core'; +import { QuestionFile } from '../../survey/types'; +import { FilesManagementQuestionComponent } from './files-management-question.component'; + +describe('FilesManagementQuestionComponent', () => { + let survey: SurveyModel; + let component: FilesManagementQuestionComponent; + + /** Files of the attachments question */ + const pending = { name: 'pending.pdf', content: 'data:x', lang: 'fr' }; + const active = { name: 'active.png', content: 'file-id' }; + const outdated = { + name: 'outdated.docx', + content: { driveId: 'drive', itemId: 'item' }, + outdated: true, + outdatedAt: '2026-01-01', + }; + + beforeEach(() => { + survey = new SurveyModel({ + elements: [ + { type: 'file', name: 'attachments', title: 'Attachments' }, + { type: 'file', name: 'cover', valueName: 'cover_image' }, + ], + }); + survey.setValue('attachments', [pending, active, outdated]); + // Stored under the question data field + survey.setValue('cover_image', [ + { name: 'cover.png', content: 'cover-id' }, + ]); + component = new FilesManagementQuestionComponent({ + instant: (key: string) => key, + currentLang: 'en', + defaultLang: 'en', + } as unknown as TranslateService); + component.survey = survey; + }); + + /** + * Gets the attachments question. + * + * @returns Attachments question + */ + const attachments = (): QuestionFile => + survey.getQuestionByName('attachments') as QuestionFile; + + it('lists every file of the survey with its document type, language and status', () => { + component.refresh(); + + expect( + component.rows.map((row) => [ + row.file.name, + row.fieldTitle, + row.languageLabel, + row.status, + row.icon, + ]) + ).toEqual([ + ['pending.pdf', 'Attachments', 'French', 'pending', 'k-i-file-pdf'], + ['active.png', 'Attachments', '', 'active', 'k-i-file-image'], + ['outdated.docx', 'Attachments', '', 'outdated', 'k-i-file-word'], + ['cover.png', 'cover', '', 'active', 'k-i-file-image'], + ]); + expect(component.rows.map((row) => row.outdated)).toEqual([ + false, + false, + true, + false, + ]); + }); + + it('only offers the outdated actions on stored files of questions allowing them', () => { + component.refresh(); + expect(component.rows.map((row) => row.canOutdate)).toEqual([ + false, + false, + false, + false, + ]); + + attachments().allowOutdatedFiles = true; + component.refresh(); + expect( + component.rows.map((row) => [row.canOutdate, row.permanentRemoval]) + ).toEqual([ + [false, false], + [true, true], + [true, true], + [false, false], + ]); + }); + + it('follows the per-field permission to remove stored files', () => { + attachments().canDeleteFiles = false; + component.refresh(); + + // Files not saved yet can always be removed + expect(component.rows.map((row) => row.canRemove)).toEqual([ + true, + false, + false, + true, + ]); + }); + + it('offers no action in the form builder or in read-only surveys', () => { + attachments().allowOutdatedFiles = true; + survey.setDesignMode(true); + component.refresh(); + expect( + component.rows.every((row) => !row.canRemove && !row.canOutdate) + ).toBe(true); + + survey.setDesignMode(false); + survey.mode = 'display'; + component.refresh(); + expect( + component.rows.every((row) => !row.canRemove && !row.canOutdate) + ).toBe(true); + }); + + it('marks a file as outdated and back as active, replacing the question value', () => { + attachments().allowOutdatedFiles = true; + component.refresh(); + const before = survey.getValue('attachments'); + + component.rows[1].toggleOutdated(); + const after = survey.getValue('attachments'); + expect(after).not.toBe(before); + expect(after[1]).toEqual({ + ...active, + outdated: true, + outdatedAt: expect.any(String), + }); + + component.refresh(); + component.rows[2].toggleOutdated(); + expect(survey.getValue('attachments')[2]).toEqual({ + name: outdated.name, + content: outdated.content, + }); + }); + + it('removes a file through the file question itself', () => { + const doRemoveFile = jest + .spyOn(attachments(), 'doRemoveFile') + .mockImplementation(() => undefined); + component.refresh(); + + component.rows[0].removeFile(); + + expect(doRemoveFile).toHaveBeenCalledWith(pending); + }); + + it('sorts the rows and keeps the sort across refreshes', () => { + component.refresh(); + component.onSortChange([{ field: 'file.name', dir: 'asc' }]); + expect(component.rows.map((row) => row.file.name)).toEqual([ + 'active.png', + 'cover.png', + 'outdated.docx', + 'pending.pdf', + ]); + + survey.setValue('cover_image', []); + component.refresh(); + expect(component.rows.map((row) => row.file.name)).toEqual([ + 'active.png', + 'outdated.docx', + 'pending.pdf', + ]); + }); + + it('lists nothing without a survey', () => { + component.survey = undefined; + component.refresh(); + expect(component.rows).toEqual([]); + }); +}); diff --git a/libs/shared/src/lib/components/files-management-question/files-management-question.component.ts b/libs/shared/src/lib/components/files-management-question/files-management-question.component.ts new file mode 100644 index 0000000000..40324a9ce0 --- /dev/null +++ b/libs/shared/src/lib/components/files-management-question/files-management-question.component.ts @@ -0,0 +1,153 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { orderBy, SortDescriptor } from '@progress/kendo-data-query'; +import { omit } from 'lodash'; +import { SurveyModel } from 'survey-core'; +import { File } from '../../services/file/file.service'; +import { + getFileIcon, + isOutdatedFile, + isStoredFile, +} from '../../services/file/file.utils'; +import { FileItemActionsComponent } from '../../survey/components/file-item-actions/public-api'; +import { FilesManagementQuestion } from '../../survey/components/files-management'; +import { getFileQuestions } from '../../survey/components/utils/files-widgets.util'; +import { QuestionFile } from '../../survey/types'; +import { getLanguageName } from '../../utils/languages'; +import { EmptyModule } from '../ui/empty/empty.module'; + +/** A single row of the files management table. */ +export interface FileManagementRow { + /** Underlying file, as stored in the file question's value */ + file: File; + /** Kendo icon class matching the file extension */ + icon: string; + /** Display name of the file question */ + fieldTitle: string; + /** Display name of the row's language, empty when the file has none */ + languageLabel: string; + /** Whether the file was marked as outdated */ + outdated: boolean; + /** Status of the file: to upload on save, outdated, or active */ + status: 'pending' | 'outdated' | 'active'; + /** Whether the file can be marked as outdated / active */ + canOutdate: boolean; + /** Whether the file can be removed */ + canRemove: boolean; + /** Whether the removal is permanent ( file question allowing outdated files ) */ + permanentRemoval: boolean; + /** Marks the file as outdated, or as active if already outdated */ + toggleOutdated: () => void; + /** Removes the file from its question */ + removeFile: () => void; +} + +/** + * Rendered inside a Files management SurveyJS question: lists every file held + * by the survey's file questions, with the same actions as the file + * questions ( download, mark as outdated / active, remove ). + */ +@Component({ + standalone: true, + selector: 'shared-files-management-question', + templateUrl: './files-management-question.component.html', + styleUrls: ['./files-management-question.component.scss'], + imports: [ + CommonModule, + TranslateModule, + GridModule, + EmptyModule, + FileItemActionsComponent, + ], +}) +export class FilesManagementQuestionComponent { + /** Survey owning the question */ + @Input() survey?: SurveyModel; + /** The Files management question instance being rendered */ + @Input() question?: FilesManagementQuestion; + + /** Rows of the table, sorted */ + public rows: FileManagementRow[] = []; + /** Current sort of the table */ + public sort: SortDescriptor[] = []; + + /** + * Rendered inside a Files management SurveyJS question. + * + * @param translate Angular translation service + */ + constructor(private translate: TranslateService) {} + + /** + * Rebuilds the rows from every file question of the survey. Called by the + * SurveyJS question after every render and whenever a file question value + * changes. + */ + refresh(): void { + const rows: FileManagementRow[] = []; + for (const question of getFileQuestions(this.survey) as QuestionFile[]) { + const value: File[] = Array.isArray(question.value) ? question.value : []; + const readOnly = !!this.survey?.isDesignMode || question.isReadOnly; + const fieldTitle = question.title || question.valueName || question.name; + for (const file of value) { + if (!file) { + continue; + } + const stored = isStoredFile(file); + const outdated = isOutdatedFile(file); + const permanentRemoval = !!question.allowOutdatedFiles && stored; + rows.push({ + file, + icon: getFileIcon(file.name), + fieldTitle, + languageLabel: file.lang + ? getLanguageName(file.lang, this.translate) + : '', + outdated, + status: !stored ? 'pending' : outdated ? 'outdated' : 'active', + canOutdate: !readOnly && permanentRemoval, + // Removing a stored file is a per-field role permission. Files not + // saved yet can always be removed. + canRemove: + !readOnly && (!stored || question.canDeleteFiles !== false), + permanentRemoval, + toggleOutdated: () => this.toggleOutdated(question, file), + removeFile: () => question.doRemoveFile(file), + }); + } + } + this.rows = orderBy(rows, this.sort); + } + + /** + * Sorts the rows. + * + * @param sort New sort descriptors + */ + onSortChange(sort: SortDescriptor[]): void { + this.sort = sort; + this.rows = orderBy(this.rows, this.sort); + } + + /** + * Marks a file as outdated, or back as active when it already is. The + * question value is replaced ( not mutated ) so SurveyJS detects the + * change, like the native file question does. + * + * @param question File question holding the file + * @param file File to update + */ + private toggleOutdated(question: QuestionFile, file: File): void { + const value: File[] = Array.isArray(question.value) ? question.value : []; + const index = value.indexOf(file); + if (index < 0) { + return; + } + const updated: File = isOutdatedFile(file) + ? (omit(file, ['outdated', 'outdatedAt']) as File) + : { ...file, outdated: true, outdatedAt: new Date().toISOString() }; + question.value = value.map((item, i) => (i === index ? updated : item)); + } +} diff --git a/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.html b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.html new file mode 100644 index 0000000000..0c40d35982 --- /dev/null +++ b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.html @@ -0,0 +1,112 @@ +
+
+
+ + +
+
+ + +
+
+ + + + + + +

+ {{ errorMessage }} +

+ + + + + + + {{ dataItem.file.name }} + + + + + + + + {{ dataItem.languageLabel }} + + + + {{ 'components.filesManagement.noLanguage' | translate }} + + + + + + + + + + + + + + +
diff --git a/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.scss b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.scss new file mode 100644 index 0000000000..db36a885f0 --- /dev/null +++ b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.scss @@ -0,0 +1,9 @@ +:host { + display: block; +} + +.files-upload-question { + kendo-dropdownlist { + width: 100%; + } +} diff --git a/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.spec.ts b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.spec.ts new file mode 100644 index 0000000000..1c197e9ed4 --- /dev/null +++ b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.spec.ts @@ -0,0 +1,351 @@ +import { ChangeDetectorRef } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { FileInfo, SelectEvent } from '@progress/kendo-angular-upload'; +import { QuestionFileModel, SurveyModel } from 'survey-core'; +import { FilesUploadQuestion } from '../../survey/components/files-upload'; +import { FilesUploadQuestionComponent } from './files-upload-question.component'; + +describe('FilesUploadQuestionComponent', () => { + let survey: SurveyModel; + let uploadHandler: jest.Mock; + let component: FilesUploadQuestionComponent; + let cdr: { detectChanges: jest.Mock }; + + /** Stored file of the attachments question */ + const stored = { name: 'stored.png', type: 'image/png', content: 'file-id' }; + + /** + * Builds a fake files upload question exposing its properties. + * + * @param properties Question properties + * @returns Fake question + */ + const createQuestion = ( + properties: Record = {} + ): FilesUploadQuestion => + ({ + isReadOnly: false, + getPropertyValue: (name: string) => properties[name], + } as unknown as FilesUploadQuestion); + + /** + * Builds a Kendo select event. + * + * @param files Picked files + * @param validationErrors Kendo validation errors of every file + * @returns Select event + */ + const createSelectEvent = ( + files: File[], + validationErrors?: string[] + ): SelectEvent => + ({ + files: files.map( + (file, index): FileInfo => ({ + name: file.name, + rawFile: file, + uid: `${index}`, + validationErrors, + }) + ), + preventDefault: jest.fn(), + } as unknown as SelectEvent); + + /** + * Builds a native file. + * + * @param name File name + * @param type Mime type + * @returns File + */ + const createFile = (name: string, type = 'image/png'): File => + new File(['x'], name, { type }); + + beforeEach(() => { + survey = new SurveyModel({ + title: { default: 'Files', fr: 'Fichiers' }, + elements: [ + { + type: 'file', + name: 'attachments', + title: 'Attachments', + allowMultiple: true, + acceptedTypes: 'image/*', + }, + { type: 'file', name: 'cover', title: 'Cover', acceptedTypes: '.png' }, + ], + }); + // App-registered property, not part of the bare survey model + survey + .getQuestionByName('attachments') + .setPropertyValue('allowedFileNumber', 2); + survey.setValue('attachments', [stored]); + // Same contract as FormBuilderService.onUploadFiles: files are read and + // handed back to the callback + uploadHandler = jest.fn((_, options) => + options.callback( + 'success', + options.files.map((file: File) => ({ + file, + content: `data:${file.name}`, + })) + ) + ); + survey.onUploadFiles.add(uploadHandler); + cdr = { detectChanges: jest.fn() }; + component = new FilesUploadQuestionComponent( + { + instant: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + currentLang: 'en', + defaultLang: 'en', + } as unknown as TranslateService, + cdr as unknown as ChangeDetectorRef, + { availableLanguages: ['en', 'fr'] } + ); + component.survey = survey; + component.question = createQuestion({ + targetField: 'cover', + defaultLanguage: 'fr', + }); + component.ngOnInit(); + }); + + afterEach(() => { + component.ngOnDestroy(); + }); + + it('preselects the configured document type and language, and reads the target restrictions', () => { + component.refresh(); + + expect(component.targetFieldChoices).toEqual([ + { value: 'attachments', text: 'Attachments' }, + { value: 'cover', text: 'Cover' }, + ]); + expect(component.languageChoices.map((c) => c.value)).toEqual(['en', 'fr']); + expect(component.targetFieldControl.value).toBe('cover'); + expect(component.languageControl.value).toBe('fr'); + expect(component.multiple).toBe(false); + expect(component.accept).toBe('.png'); + expect(component.restrictions).toEqual({ allowedExtensions: ['png'] }); + expect(component.disabled).toBe(false); + }); + + it('falls back to the first choices when the configured ones do not exist', () => { + component.question = createQuestion({ + targetField: 'removed', + defaultLanguage: 'de', + }); + component.refresh(); + + expect(component.targetFieldControl.value).toBe('attachments'); + expect(component.languageControl.value).toBe('en'); + expect(component.multiple).toBe(true); + }); + + it('keeps the user selections across refreshes', () => { + component.refresh(); + component.targetFieldControl.setValue('attachments'); + component.languageControl.setValue('en'); + + component.refresh(); + expect(component.targetFieldControl.value).toBe('attachments'); + expect(component.languageControl.value).toBe('en'); + }); + + it('is disabled in the form builder and in read-only surveys', () => { + survey.setDesignMode(true); + component.refresh(); + expect(component.disabled).toBe(true); + expect(component.targetFieldControl.disabled).toBe(true); + + survey.setDesignMode(false); + survey.mode = 'display'; + component.refresh(); + expect(component.disabled).toBe(true); + + survey.mode = 'edit'; + component.refresh(); + expect(component.disabled).toBe(false); + expect(component.targetFieldControl.disabled).toBe(false); + }); + + it('adds picked files to the target question, tagged with the language', () => { + component.refresh(); + component.targetFieldControl.setValue('attachments'); + component.errorMessage = 'previous error'; + const event = createSelectEvent([createFile('new.png')]); + + component.onSelect(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(uploadHandler).toHaveBeenCalledTimes(1); + expect(uploadHandler.mock.calls[0][1].name).toBe('attachments'); + expect(survey.getValue('attachments')).toEqual([ + stored, + { + name: 'new.png', + type: 'image/png', + content: 'data:new.png', + lang: 'fr', + }, + ]); + expect(component.errorMessage).toBe(''); + expect(cdr.detectChanges).toHaveBeenCalled(); + }); + + it('rejects a whole pick exceeding the allowed number of files, keeping the existing ones', () => { + component.refresh(); + component.targetFieldControl.setValue('attachments'); + + component.onSelect( + createSelectEvent([createFile('a.png'), createFile('b.png')]) + ); + + expect(uploadHandler).not.toHaveBeenCalled(); + expect(survey.getValue('attachments')).toEqual([stored]); + expect(component.errorMessage).toBe( + 'components.filesUpload.errors.maximumAllowedFiles:{"number":2}' + ); + }); + + it('rejects a second file on a single-file question', () => { + component.refresh(); + survey.setValue('cover', [stored]); + + component.onSelect(createSelectEvent([createFile('a.png')])); + + expect(uploadHandler).not.toHaveBeenCalled(); + expect(component.errorMessage).toBe( + 'components.filesUpload.errors.singleFileOnly' + ); + }); + + it('rejects files flagged by the Kendo restrictions, naming the file', () => { + component.refresh(); + + component.onSelect( + createSelectEvent([createFile('a.gif')], ['invalidFileExtension']) + ); + expect(component.errorMessage).toBe( + 'components.filesUpload.errors.invalidType:{"name":"a.gif"}' + ); + + component.onSelect( + createSelectEvent([createFile('big.png')], ['invalidMaxFileSize']) + ); + expect(component.errorMessage).toBe( + 'components.filesUpload.errors.maxSizeExceeded:{"name":"big.png"}' + ); + expect(uploadHandler).not.toHaveBeenCalled(); + }); + + it('rejects mime types the target question does not accept', () => { + component.refresh(); + component.targetFieldControl.setValue('attachments'); + survey.setValue('attachments', []); + + component.onSelect( + createSelectEvent([createFile('notes.txt', 'text/plain')]) + ); + + expect(uploadHandler).not.toHaveBeenCalled(); + expect(component.errorMessage).toBe( + 'components.filesUpload.errors.invalidType:{"name":"notes.txt"}' + ); + }); + + it('asks for a document type when none is selected', () => { + component.refresh(); + component.targetFieldControl.setValue(null); + + component.onSelect(createSelectEvent([createFile('a.png')])); + + expect(component.errorMessage).toBe( + 'components.filesUpload.errors.noTargetField' + ); + }); + + it('reports a failed file reading', () => { + survey.onUploadFiles.remove(uploadHandler); + survey.onUploadFiles.add((_, options) => options.callback('error')); + component.refresh(); + + component.onSelect(createSelectEvent([createFile('a.png')])); + + expect(survey.getValue('cover')).toBeUndefined(); + expect(component.errorMessage).toBe( + 'components.filesUpload.errors.uploadFailed' + ); + }); + + it('clears the error when the document type changes', () => { + component.refresh(); + component.errorMessage = 'error'; + + component.targetFieldControl.setValue('attachments'); + + expect(component.errorMessage).toBe(''); + expect(component.multiple).toBe(true); + }); + + it('ignores picks while disabled', () => { + survey.setDesignMode(true); + component.refresh(); + + component.onSelect(createSelectEvent([createFile('a.png')])); + + expect(uploadHandler).not.toHaveBeenCalled(); + expect(component.errorMessage).toBe(''); + }); + + it('lists every file of the survey waiting to be uploaded, with its document type', () => { + survey.setValue('attachments', [ + stored, + { name: 'b.png', content: 'data:b', lang: 'fr' }, + ]); + survey.setValue('cover', [{ name: 'a.png', content: 'data:a' }]); + component.refresh(); + + expect( + component.rows.map((row) => [ + row.file.name, + row.fieldTitle, + row.languageLabel, + row.canRemove, + ]) + ).toEqual([ + ['b.png', 'Attachments', 'French', true], + ['a.png', 'Cover', '', true], + ]); + expect(component.rows[0].icon).toBe('k-i-file-image'); + + component.onSortChange([{ field: 'file.name', dir: 'asc' }]); + expect(component.rows.map((row) => row.file.name)).toEqual([ + 'a.png', + 'b.png', + ]); + }); + + it('removes a file to upload through the file question itself', () => { + const file = { name: 'a.png', content: 'data:a' }; + survey.setValue('cover', [file]); + const cover = survey.getQuestionByName('cover') as QuestionFileModel; + const doRemoveFile = jest + .spyOn(cover, 'doRemoveFile') + .mockImplementation(() => undefined); + component.refresh(); + + component.rows.find((row) => row.file.name === 'a.png')?.removeFile(); + + expect(doRemoveFile).toHaveBeenCalledWith(file); + }); + + it('cannot remove files to upload in read-only surveys', () => { + survey.setValue('cover', [{ name: 'a.png', content: 'data:a' }]); + survey.mode = 'display'; + component.refresh(); + + expect(component.rows[0].canRemove).toBe(false); + }); +}); diff --git a/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.ts b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.ts new file mode 100644 index 0000000000..73faaf2000 --- /dev/null +++ b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.ts @@ -0,0 +1,349 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectorRef, + Component, + Inject, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { + FileInfo, + FileRestrictions, + SelectEvent, + UploadsModule, +} from '@progress/kendo-angular-upload'; +import { orderBy, SortDescriptor } from '@progress/kendo-data-query'; +import { Subject, takeUntil } from 'rxjs'; +import { QuestionFileModel, SurveyModel } from 'survey-core'; +import { File as StoredFile } from '../../services/file/file.service'; +import { getFileIcon, isStoredFile } from '../../services/file/file.utils'; +import { FileItemActionsComponent } from '../../survey/components/file-item-actions/public-api'; +import { FilesUploadQuestion } from '../../survey/components/files-upload'; +import { + checkFileRestrictions, + getFileQuestionChoices, + getFileQuestions, + getKendoFileRestrictions, + getLanguageChoices, + WidgetChoice, +} from '../../survey/components/utils/files-widgets.util'; +import { QuestionFile } from '../../survey/types'; +import { getLanguageName } from '../../utils/languages'; +import { EmptyModule } from '../ui/empty/empty.module'; + +/** Translation keys of the validation errors raised by the Kendo file select. */ +const KENDO_VALIDATION_MESSAGES: Record = { + invalidFileExtension: 'components.filesUpload.errors.invalidType', + invalidMaxFileSize: 'components.filesUpload.errors.maxSizeExceeded', +}; + +/** A file of the survey waiting to be uploaded when the record is saved. */ +export interface PendingFileRow { + /** Underlying file, as held in the file question's value */ + file: StoredFile; + /** Kendo icon class matching the file extension */ + icon: string; + /** Display name of the file question */ + fieldTitle: string; + /** Display name of the row's language, empty when the file has none */ + languageLabel: string; + /** Whether the file can be removed */ + canRemove: boolean; + /** Removes the file from its question */ + removeFile: () => void; +} + +/** + * Rendered inside a Files upload SurveyJS question: lets the user pick one of + * the survey's file questions, tag files with a language and add them to it + * without navigating to that question, and lists every file of the survey + * waiting to be uploaded. Files are uploaded when the record is saved, like + * files picked in the file questions themselves. + */ +@Component({ + standalone: true, + selector: 'shared-files-upload-question', + templateUrl: './files-upload-question.component.html', + styleUrls: ['./files-upload-question.component.scss'], + imports: [ + CommonModule, + ReactiveFormsModule, + TranslateModule, + DropDownsModule, + LabelModule, + UploadsModule, + GridModule, + EmptyModule, + FileItemActionsComponent, + ], +}) +export class FilesUploadQuestionComponent implements OnInit, OnDestroy { + /** Survey owning the question */ + @Input() survey?: SurveyModel; + /** The Files upload question instance being rendered */ + @Input() question?: FilesUploadQuestion; + + /** Choices of the target file question dropdown */ + public targetFieldChoices: WidgetChoice[] = []; + /** Choices of the language dropdown ( languages of the form ) */ + public languageChoices: WidgetChoice[] = []; + /** Selected target file question */ + public targetFieldControl = new FormControl(null); + /** Selected language, applied to the files added from now on */ + public languageControl = new FormControl(null); + /** Kendo restrictions of the target file question */ + public restrictions: FileRestrictions = {}; + /** Native file input filter of the target file question */ + public accept = ''; + /** Whether the target file question accepts several files */ + public multiple = true; + /** Whether the question cannot be used ( form builder, read-only survey ) */ + public disabled = false; + /** Last validation error, kept until files are successfully added */ + public errorMessage = ''; + /** Files of the survey waiting to be uploaded, sorted */ + public rows: PendingFileRow[] = []; + /** Current sort of the table */ + public sort: SortDescriptor[] = []; + + /** Emits when the component is destroyed */ + private destroy$ = new Subject(); + + /** + * Rendered inside a Files upload SurveyJS question. + * + * @param translate Angular translation service + * @param cdr Change detector of the component + * @param environment Injected environment ( available languages ) + */ + constructor( + private translate: TranslateService, + private cdr: ChangeDetectorRef, + @Inject('environment') private environment: any + ) {} + + ngOnInit(): void { + this.targetFieldControl.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe(() => { + this.updateTargetRestrictions(); + this.errorMessage = ''; + }); + } + + /** + * (Re)computes the dropdown choices, the selections, the restrictions and + * the files to upload from the survey / question. Called by the SurveyJS + * question after every render and whenever a file question value changes. + */ + refresh(): void { + this.targetFieldChoices = getFileQuestionChoices(this.survey); + this.languageChoices = getLanguageChoices( + this.survey, + this.environment?.availableLanguages ?? [], + this.translate + ); + this.targetFieldControl.setValue( + this.pickChoice( + this.targetFieldChoices, + this.targetFieldControl.value ?? + (this.question?.getPropertyValue('targetField') as string) + ), + { emitEvent: false } + ); + this.languageControl.setValue( + this.pickChoice( + this.languageChoices, + this.languageControl.value ?? + (this.question?.getPropertyValue('defaultLanguage') as string) + ), + { emitEvent: false } + ); + this.disabled = + !!this.survey?.isDesignMode || + !!this.survey?.isDisplayMode || + !!this.question?.isReadOnly; + [this.targetFieldControl, this.languageControl].forEach((control) => + this.disabled + ? control.disable({ emitEvent: false }) + : control.enable({ emitEvent: false }) + ); + this.updateTargetRestrictions(); + this.computeRows(); + } + + /** + * Adds the files picked in the file select to the target file question, + * tagged with the selected language, unless they violate its restrictions. + * Files are read through the same `survey.uploadFiles` flow as the native + * file question, so the temporary files storage / save pipeline picks them + * up unchanged. A rejected selection only leaves an error message: files + * already added stay untouched. + * + * @param event Kendo select event + */ + onSelect(event: SelectEvent): void { + // The file select is only a picker: the files it selects are held by the + // target file question, and listed by this question along with the other + // files of the survey + event.preventDefault(); + if (this.disabled || !this.survey) { + return; + } + const target = this.targetQuestion; + if (!target) { + this.errorMessage = this.translate.instant( + 'components.filesUpload.errors.noTargetField' + ); + return; + } + const invalid = event.files.find((file) => file.validationErrors?.length); + if (invalid) { + this.errorMessage = this.validationMessage(invalid); + return; + } + const files = event.files + .map((file) => file.rawFile) + .filter((file): file is File => !!file); + const existingCount = Array.isArray(target.value) ? target.value.length : 0; + const error = checkFileRestrictions(target, existingCount, files); + if (error) { + this.errorMessage = this.translate.instant(error.key, error.params); + return; + } + const language = this.languageControl.value || undefined; + this.survey.uploadFiles( + target, + target.name, + files, + (status: string, data?: { file: File; content: unknown }[]) => { + if (status !== 'success') { + this.errorMessage = this.translate.instant( + 'components.filesUpload.errors.uploadFailed' + ); + this.cdr.detectChanges(); + return; + } + const additions: StoredFile[] = (data || []).map((result) => ({ + name: result.file.name, + type: result.file.type, + content: result.content as StoredFile['content'], + lang: language, + })); + const current: StoredFile[] = Array.isArray(target.value) + ? target.value + : []; + target.value = current.concat(additions); + this.errorMessage = ''; + this.cdr.detectChanges(); + } + ); + } + + /** + * Sorts the rows. + * + * @param sort New sort descriptors + */ + onSortChange(sort: SortDescriptor[]): void { + this.sort = sort; + this.rows = orderBy(this.rows, this.sort); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + /** + * Currently selected target file question, if any. + * + * @returns The file question matching the current dropdown selection + */ + private get targetQuestion(): QuestionFileModel | undefined { + const name = this.targetFieldControl.value; + if (!this.survey || !name) { + return undefined; + } + const question = this.survey.getQuestionByName(name); + return question instanceof QuestionFileModel ? question : undefined; + } + + /** + * Resolves the translated validation error raised by the Kendo file select + * on a picked file. + * + * @param file Picked file + * @returns Error message + */ + private validationMessage(file: FileInfo): string { + const error = file.validationErrors?.[0] || ''; + return this.translate.instant(KENDO_VALIDATION_MESSAGES[error] || error, { + name: file.name, + }); + } + + /** + * Keeps a selection when it is one of the choices, otherwise falls back to + * the first choice. + * + * @param choices Available choices + * @param value Wanted selection + * @returns Selection to apply + */ + private pickChoice( + choices: WidgetChoice[], + value: string | null | undefined + ): string | null { + return choices.some((choice) => choice.value === value) + ? (value as string) + : choices[0]?.value ?? null; + } + + /** + * Reads the target file question restrictions applied to the file select. + */ + private updateTargetRestrictions(): void { + const target = this.targetQuestion; + this.restrictions = target ? getKendoFileRestrictions(target) : {}; + this.accept = (target?.getPropertyValue('acceptedTypes') as string) || ''; + this.multiple = !target || !!target.allowMultiple; + } + + /** + * Rebuilds the rows from every file question of the survey, keeping the + * files not stored yet: the ones uploaded when the record is saved. + */ + private computeRows(): void { + const rows: PendingFileRow[] = []; + for (const question of getFileQuestions(this.survey) as QuestionFile[]) { + const value: StoredFile[] = Array.isArray(question.value) + ? question.value + : []; + const readOnly = this.disabled || question.isReadOnly; + const fieldTitle = question.title || question.valueName || question.name; + for (const file of value) { + if (!file || isStoredFile(file)) { + continue; + } + rows.push({ + file, + icon: getFileIcon(file.name), + fieldTitle, + languageLabel: file.lang + ? getLanguageName(file.lang, this.translate) + : '', + canRemove: !readOnly, + removeFile: () => question.doRemoveFile(file), + }); + } + } + this.rows = orderBy(rows, this.sort); + } +} diff --git a/libs/shared/src/lib/components/form-builder/form-builder.component.ts b/libs/shared/src/lib/components/form-builder/form-builder.component.ts index d6e0c863f5..b07f591de8 100644 --- a/libs/shared/src/lib/components/form-builder/form-builder.component.ts +++ b/libs/shared/src/lib/components/form-builder/form-builder.component.ts @@ -41,6 +41,14 @@ import { FIELD_HISTORY_ALLOWED_PROPERTIES, FIELD_HISTORY_QUESTION_TYPE, } from '../../survey/components/field-history'; +import { + FILES_MANAGEMENT_ALLOWED_PROPERTIES, + FILES_MANAGEMENT_QUESTION_TYPE, +} from '../../survey/components/files-management'; +import { + FILES_UPLOAD_ALLOWED_PROPERTIES, + FILES_UPLOAD_QUESTION_TYPE, +} from '../../survey/components/files-upload'; /** * Array containing the different types of questions. @@ -346,15 +354,23 @@ export class FormBuilderComponent options.allowEdit = true; } ); + // Questions displaying / placing data of other questions never store a + // value of their own: no "required" toggle, restricted property grid + const displayOnlyQuestions: Record = { + [FIELD_HISTORY_QUESTION_TYPE]: FIELD_HISTORY_ALLOWED_PROPERTIES, + [FILES_UPLOAD_QUESTION_TYPE]: FILES_UPLOAD_ALLOWED_PROPERTIES, + [FILES_MANAGEMENT_QUESTION_TYPE]: FILES_MANAGEMENT_ALLOWED_PROPERTIES, + }; this.surveyCreator.onElementAllowOperations.add((_, options) => { - if (options.obj?.getType() === FIELD_HISTORY_QUESTION_TYPE) { + if (displayOnlyQuestions[options.obj?.getType()]) { options.allowChangeRequired = false; } }); this.surveyCreator.onShowingProperty.add((_, options) => { + const allowedProperties = displayOnlyQuestions[options.obj?.getType()]; if ( - options.obj?.getType() === FIELD_HISTORY_QUESTION_TYPE && - !FIELD_HISTORY_ALLOWED_PROPERTIES.includes(options.property.name) + allowedProperties && + !allowedProperties.includes(options.property.name) ) { options.canShow = false; } diff --git a/libs/shared/src/lib/services/file/file.service.ts b/libs/shared/src/lib/services/file/file.service.ts index 11704c8840..4f5a04096e 100644 --- a/libs/shared/src/lib/services/file/file.service.ts +++ b/libs/shared/src/lib/services/file/file.service.ts @@ -40,6 +40,8 @@ export interface File { outdated?: boolean; /** ISO date of the last time the file was marked as outdated */ outdatedAt?: string; + /** Language the file was uploaded for ( tagged by the Files upload question ). */ + lang?: string; } /** diff --git a/libs/shared/src/lib/style/survey.scss b/libs/shared/src/lib/style/survey.scss index 62e8c71de1..ee71933c4a 100644 --- a/libs/shared/src/lib/style/survey.scss +++ b/libs/shared/src/lib/style/survey.scss @@ -71,6 +71,13 @@ } ::ng-deep .sd-matrixdropdown.sd-table, +// File questions hidden by a Files management question: they keep storing +// the files uploaded through Files upload questions, so they are hidden from +// the DOM only ( SurveyJS visibility would clear their value on completion ) +::ng-deep .file-question--hidden { + display: none !important; +} + ::ng-deep .sd-table.sd-matrixdynamic { padding: 0.5rem 0 1rem 0; } diff --git a/libs/shared/src/lib/survey/components/file-item-actions/file-item-actions.component.ts b/libs/shared/src/lib/survey/components/file-item-actions/file-item-actions.component.ts index 6980711881..1fe8996b38 100644 --- a/libs/shared/src/lib/survey/components/file-item-actions/file-item-actions.component.ts +++ b/libs/shared/src/lib/survey/components/file-item-actions/file-item-actions.component.ts @@ -20,7 +20,7 @@ import { File, FileService } from '../../../services/file/file.service'; imports: [NgIf, ButtonModule, IconModule, TooltipModule, TranslateModule], template: ` { + let appended: ComponentRef[]; + let domService: Pick< + DomService, + 'appendComponentToBody' | 'removeComponentFromBody' + >; + + beforeAll(() => { + domService = { + appendComponentToBody: jest.fn( + (_: unknown, parent: HTMLElement): ComponentRef => { + const nativeElement = document.createElement( + 'shared-files-management-question' + ); + parent.appendChild(nativeElement); + const ref = { + instance: { refresh: jest.fn() }, + location: { nativeElement }, + changeDetectorRef: { detectChanges: jest.fn() }, + } as unknown as ComponentRef; + appended.push(ref); + return ref; + } + ) as any, + removeComponentFromBody: jest.fn((ref: ComponentRef) => + ref.location.nativeElement.remove() + ), + }; + const injector = { + get: (token: unknown): unknown => { + if (token === DomService) return domService; + throw new Error('Unexpected injection token'); + }, + } as Injector; + init(injector, ComponentCollection.Instance); + }); + + beforeEach(() => { + appended = []; + jest.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + /** + * Builds a survey holding a file question and a files management question. + * + * @param hideFileQuestions Whether the file questions must be hidden + * @returns Survey model + */ + const createSurvey = (hideFileQuestions: boolean): SurveyModel => + new SurveyModel({ + elements: [ + { type: 'file', name: 'attachments' }, + { + type: FILES_MANAGEMENT_QUESTION_TYPE, + name: 'management', + hideFileQuestions, + }, + ], + }); + + it('hides options related to a stored value in the form builder', () => { + [ + 'valueName', + 'defaultValue', + 'isRequired', + 'readOnly', + 'validators', + ].forEach((property) => + expect(FILES_MANAGEMENT_ALLOWED_PROPERTIES).not.toContain(property) + ); + ['title', 'description', 'tooltip', 'hideFileQuestions'].forEach( + (property) => + expect(FILES_MANAGEMENT_ALLOWED_PROPERTIES).toContain(property) + ); + }); + + it('registers a question whose hide option drives the file questions visibility', () => { + const survey = createSurvey(true); + const question = survey.getQuestionByName( + 'management' + ) as FilesManagementQuestion; + + expect(question.getType()).toBe(FILES_MANAGEMENT_QUESTION_TYPE); + expect(question.hideNumber).toBe(true); + expect(survey.data).toEqual({}); + expect(shouldHideFileQuestions(survey)).toBe(true); + expect(shouldHideFileQuestions(createSurvey(false))).toBe(false); + + // Never in the form builder + survey.setDesignMode(true); + expect(shouldHideFileQuestions(survey)).toBe(false); + }); + + it('renders the component, hides the rendered file questions and follows file changes', () => { + const survey = createSurvey(true); + const question = survey.getQuestionByName( + 'management' + ) as FilesManagementQuestion; + const attachments = survey.getQuestionByName('attachments'); + const row = document.createElement('div'); + row.className = 'sd-row'; + row.innerHTML = `
`; + document.body.appendChild(row); + const element = document.createElement('div'); + element.innerHTML = '
'; + document.body.appendChild(element); + const customQuestion = ComponentCollection.Instance.getCustomQuestionByName( + FILES_MANAGEMENT_QUESTION_TYPE + ) as any; + + customQuestion.json.onAfterRender(question, element); + + const ref = appended[0]; + expect(ref.instance.survey).toBe(survey); + expect(ref.instance.question).toBe(question); + expect(ref.instance.refresh).toHaveBeenCalledTimes(1); + expect(row.firstElementChild?.classList).toContain( + FILE_QUESTION_HIDDEN_CLASS + ); + + survey.setValue('attachments', [{ name: 'a.png', content: 'data:x' }]); + expect(ref.instance.refresh).toHaveBeenCalledTimes(2); + + // Hiding the management question by logic shows the file questions again + question.visible = false; + expect(row.firstElementChild?.classList).not.toContain( + FILE_QUESTION_HIDDEN_CLASS + ); + + survey.dispose(); + expect(domService.removeComponentFromBody).toHaveBeenCalledWith(ref); + }); +}); diff --git a/libs/shared/src/lib/survey/components/files-management.ts b/libs/shared/src/lib/survey/components/files-management.ts new file mode 100644 index 0000000000..aa917413bb --- /dev/null +++ b/libs/shared/src/lib/survey/components/files-management.ts @@ -0,0 +1,149 @@ +import { ComponentRef, Injector } from '@angular/core'; +import { + ComponentCollection, + Question as SurveyQuestion, + Serializer, + SvgRegistry, + SurveyModel, + ValueChangedEvent, +} from 'survey-core'; +import { FilesManagementQuestionComponent } from '../../components/files-management-question/files-management-question.component'; +import { DomService } from '../../services/dom/dom.service'; +import { registerCustomPropertyHelp } from '../localization'; +import { + FILE_QUESTION_TYPE, + FILES_MANAGEMENT_QUESTION_TYPE, + HIDE_FILE_QUESTIONS_PROPERTY, + registerFileQuestionsVisibility, + syncFileQuestionsVisibility, +} from './utils/files-widgets.util'; +import { + addGridTeardown, + destroyGrid, + registerGridForCleanup, +} from './utils/grid-cleanup'; + +export { FILES_MANAGEMENT_QUESTION_TYPE }; + +/** + * Properties shown in the form builder for a Files management question. Same + * rationale as the Files upload question: it never stores a value of its own. + */ +export const FILES_MANAGEMENT_ALLOWED_PROPERTIES = [ + 'name', + 'title', + 'description', + HIDE_FILE_QUESTIONS_PROPERTY, + 'visible', + 'visibleIf', + 'tooltip', + 'page', + 'titleLocation', + 'descriptionLocation', + 'startWithNewLine', + 'indent', + 'width', + 'minWidth', + 'maxWidth', +]; + +/** Files management custom question properties used during rendering. */ +export interface FilesManagementQuestion extends SurveyQuestion { + /** Hide the survey's file questions, files being managed here */ + hideFileQuestions?: boolean; + /** Rendered Angular component */ + filesManagementComponentRef?: ComponentRef; +} + +/** + * Registers the Files management SurveyJS question: a front-end only question + * listing every file held by the survey's file questions, with the same + * download / outdated / remove actions as the file questions. It can also + * hide the survey's file questions, which then keep storing files and + * defining their restrictions while the Files upload and Files management + * questions handle the user interactions. + * + * @param injector Parent Angular injector + * @param componentCollection SurveyJS custom component collection + */ +export const init = ( + injector: Injector, + componentCollection: ComponentCollection +): void => { + const domService = injector.get(DomService); + + SvgRegistry.registerIconFromSvg( + FILES_MANAGEMENT_QUESTION_TYPE, + '' + ); + + const component = { + name: FILES_MANAGEMENT_QUESTION_TYPE, + title: 'Files management', + iconName: `icon-${FILES_MANAGEMENT_QUESTION_TYPE}`, + category: 'Custom Questions', + questionJSON: { + name: FILES_MANAGEMENT_QUESTION_TYPE, + type: 'html', + }, + onInit: (): void => { + Serializer.addProperty(FILES_MANAGEMENT_QUESTION_TYPE, { + name: `${HIDE_FILE_QUESTIONS_PROPERTY}:boolean`, + category: 'general', + displayName: 'Hide file questions', + default: false, + visibleIndex: 3, + }); + registerCustomPropertyHelp( + HIDE_FILE_QUESTIONS_PROPERTY, + 'Hide the file questions of the form when filling it, so files are only uploaded through Files upload questions and managed through this table. File questions keep storing the files and defining their restrictions ( accepted types, maximum size, number of files ), and stay visible in the form builder.' + ); + }, + onLoaded: (question: FilesManagementQuestion): void => { + question.isRequired = false; + question.hideNumber = true; + }, + onAfterRender: ( + question: FilesManagementQuestion, + element: HTMLElement + ): void => { + const content = + element.querySelector('.sd-question__content') ?? element; + const survey = question.survey as SurveyModel | undefined; + destroyGrid(survey, question.filesManagementComponentRef, domService); + const componentRef: ComponentRef = + domService.appendComponentToBody( + FilesManagementQuestionComponent, + content + ); + question.filesManagementComponentRef = componentRef; + componentRef.instance.survey = survey; + componentRef.instance.question = question; + componentRef.instance.refresh(); + componentRef.changeDetectorRef.detectChanges(); + + // Keep the table in sync with the files of the survey + const valueChangedHandler = ( + _: SurveyModel, + options: ValueChangedEvent + ): void => { + if (options.question?.getType() === FILE_QUESTION_TYPE) { + componentRef.instance.refresh(); + componentRef.changeDetectorRef.detectChanges(); + } + }; + survey?.onValueChanged.add(valueChangedHandler); + + // Hide the file questions when the question is configured so + registerFileQuestionsVisibility(survey, element.ownerDocument); + syncFileQuestionsVisibility(survey, element.ownerDocument); + + addGridTeardown(componentRef, () => { + survey?.onValueChanged.remove(valueChangedHandler); + question.filesManagementComponentRef = undefined; + }); + registerGridForCleanup(survey, componentRef, domService); + }, + }; + componentCollection.add(component); +}; diff --git a/libs/shared/src/lib/survey/components/files-upload.spec.ts b/libs/shared/src/lib/survey/components/files-upload.spec.ts new file mode 100644 index 0000000000..246ec99dd4 --- /dev/null +++ b/libs/shared/src/lib/survey/components/files-upload.spec.ts @@ -0,0 +1,181 @@ +import { ComponentRef, Injector } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { ComponentCollection, Serializer, SurveyModel } from 'survey-core'; +import { DomService } from '../../services/dom/dom.service'; +import { + FILES_UPLOAD_ALLOWED_PROPERTIES, + FILES_UPLOAD_QUESTION_TYPE, + FilesUploadQuestion, + init, +} from './files-upload'; + +/** Instance surface of the rendered component used by the question. */ +interface FakeInstance { + survey?: SurveyModel; + question?: FilesUploadQuestion; + refresh: jest.Mock; +} + +describe('files upload question', () => { + let appended: ComponentRef[]; + let domService: Pick< + DomService, + 'appendComponentToBody' | 'removeComponentFromBody' + >; + + beforeAll(() => { + domService = { + appendComponentToBody: jest.fn( + (_: unknown, parent: HTMLElement): ComponentRef => { + const nativeElement = document.createElement( + 'shared-files-upload-question' + ); + parent.appendChild(nativeElement); + const ref = { + instance: { refresh: jest.fn() }, + location: { nativeElement }, + changeDetectorRef: { detectChanges: jest.fn() }, + } as unknown as ComponentRef; + appended.push(ref); + return ref; + } + ) as any, + removeComponentFromBody: jest.fn((ref: ComponentRef) => + ref.location.nativeElement.remove() + ), + }; + const injector = { + get: (token: unknown): unknown => { + if (token === DomService) return domService; + if (token === TranslateService) + return { instant: (key: string) => key, currentLang: 'en' }; + if (token === 'environment') + return { availableLanguages: ['en', 'fr'] }; + throw new Error('Unexpected injection token'); + }, + } as Injector; + init(injector, ComponentCollection.Instance); + }); + + beforeEach(() => { + appended = []; + jest.clearAllMocks(); + }); + + /** + * Builds a survey holding a file question and a files upload question. + * + * @returns Survey model + */ + const createSurvey = (): SurveyModel => + new SurveyModel({ + title: { default: 'Files', fr: 'Fichiers' }, + elements: [ + { type: 'file', name: 'attachments', title: 'Attachments' }, + { type: 'file', name: 'cover' }, + { + type: FILES_UPLOAD_QUESTION_TYPE, + name: 'upload', + targetField: 'cover', + defaultLanguage: 'fr', + }, + ], + }); + + it('hides options related to a stored value in the form builder', () => { + [ + 'valueName', + 'defaultValue', + 'isRequired', + 'requiredIf', + 'readOnly', + 'validators', + ].forEach((property) => + expect(FILES_UPLOAD_ALLOWED_PROPERTIES).not.toContain(property) + ); + [ + 'title', + 'description', + 'tooltip', + 'targetField', + 'defaultLanguage', + ].forEach((property) => + expect(FILES_UPLOAD_ALLOWED_PROPERTIES).toContain(property) + ); + }); + + it('registers a question keeping its own properties in the structure', () => { + const survey = createSurvey(); + const question = survey.getQuestionByName('upload') as FilesUploadQuestion; + + expect(question.getType()).toBe(FILES_UPLOAD_QUESTION_TYPE); + expect(question.getPropertyValue('targetField')).toBe('cover'); + expect(question.getPropertyValue('defaultLanguage')).toBe('fr'); + expect(question.hideNumber).toBe(true); + expect(question.isRequired).toBe(false); + // Never part of the survey data + expect(survey.data).toEqual({}); + }); + + it('offers the file questions and the form languages in the property grid', () => { + const survey = createSurvey(); + const question = survey.getQuestionByName('upload') as FilesUploadQuestion; + const choicesOf = (name: string) => { + const callback = jest.fn(); + Serializer.findProperty(FILES_UPLOAD_QUESTION_TYPE, name).getChoices( + question, + callback + ); + return callback.mock.calls[0][0]; + }; + + expect(choicesOf('targetField')).toEqual([ + { value: 'attachments', text: 'Attachments' }, + { value: 'cover', text: 'cover' }, + ]); + expect(choicesOf('defaultLanguage').map((c: any) => c.value)).toEqual([ + 'en', + 'fr', + ]); + }); + + it('renders the component inside the question and refreshes it on file changes', () => { + const survey = createSurvey(); + const question = survey.getQuestionByName('upload') as FilesUploadQuestion; + const element = document.createElement('div'); + element.innerHTML = '
'; + const customQuestion = ComponentCollection.Instance.getCustomQuestionByName( + FILES_UPLOAD_QUESTION_TYPE + ) as any; + + customQuestion.json.onAfterRender(question, element); + + expect(appended).toHaveLength(1); + const ref = appended[0]; + expect( + element.querySelector('.sd-question__content')?.firstElementChild + ).toBe(ref.location.nativeElement); + expect(ref.instance.survey).toBe(survey); + expect(ref.instance.question).toBe(question); + expect(ref.instance.refresh).toHaveBeenCalledTimes(1); + + survey.setValue('cover', [{ name: 'a.png', content: 'data:x' }]); + expect(ref.instance.refresh).toHaveBeenCalledTimes(2); + survey.setValue('unrelated', 'value'); + expect(ref.instance.refresh).toHaveBeenCalledTimes(2); + + // Re-rendering replaces the previous component + customQuestion.json.onAfterRender(question, element); + expect(domService.removeComponentFromBody).toHaveBeenCalledWith(ref); + expect(appended).toHaveLength(2); + survey.setValue('cover', []); + expect(ref.instance.refresh).toHaveBeenCalledTimes(2); + expect(appended[1].instance.refresh).toHaveBeenCalledTimes(2); + + // Disposing the survey destroys the component + survey.dispose(); + expect(domService.removeComponentFromBody).toHaveBeenCalledWith( + appended[1] + ); + }); +}); diff --git a/libs/shared/src/lib/survey/components/files-upload.ts b/libs/shared/src/lib/survey/components/files-upload.ts new file mode 100644 index 0000000000..588ee4ec47 --- /dev/null +++ b/libs/shared/src/lib/survey/components/files-upload.ts @@ -0,0 +1,181 @@ +import { ComponentRef, Injector } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { + ComponentCollection, + Question as SurveyQuestion, + Serializer, + SvgRegistry, + SurveyModel, + ValueChangedEvent, +} from 'survey-core'; +import { FilesUploadQuestionComponent } from '../../components/files-upload-question/files-upload-question.component'; +import { DomService } from '../../services/dom/dom.service'; +import { registerCustomPropertyHelp } from '../localization'; +import { + FILE_QUESTION_TYPE, + FILES_UPLOAD_QUESTION_TYPE, + getFileQuestionChoices, + getLanguageChoices, + WidgetChoice, +} from './utils/files-widgets.util'; +import { + addGridTeardown, + destroyGrid, + registerGridForCleanup, +} from './utils/grid-cleanup'; + +export { FILES_UPLOAD_QUESTION_TYPE }; + +/** + * Properties shown in the form builder for a Files upload question. The + * question never stores a value of its own ( the files it uploads end up in + * the target file question instead ), so validation / data related options + * are hidden, like for the Field history question. + */ +export const FILES_UPLOAD_ALLOWED_PROPERTIES = [ + 'name', + 'title', + 'description', + 'targetField', + 'defaultLanguage', + 'visible', + 'visibleIf', + 'tooltip', + 'page', + 'titleLocation', + 'descriptionLocation', + 'startWithNewLine', + 'indent', + 'width', + 'minWidth', + 'maxWidth', +]; + +/** Files upload custom question properties used during rendering. */ +export interface FilesUploadQuestion extends SurveyQuestion { + /** Name of the file question selected by default */ + targetField?: string; + /** Language selected by default */ + defaultLanguage?: string; + /** Rendered Angular component */ + filesUploadComponentRef?: ComponentRef; +} + +/** + * Registers the Files upload SurveyJS question: a front-end only question + * letting the user pick one of the survey's file questions and add files to + * it, tagged with a language, without navigating to that question, while + * listing every file of the survey waiting to be uploaded. Its `targetField` + * / `defaultLanguage` properties are part of the form structure and only + * preselect the dropdowns: the files end up in the target file question's + * value and are uploaded when the record is saved, like any other file. + * + * @param injector Parent Angular injector + * @param componentCollection SurveyJS custom component collection + */ +export const init = ( + injector: Injector, + componentCollection: ComponentCollection +): void => { + const domService = injector.get(DomService); + const translate = injector.get(TranslateService); + const availableLanguages: string[] = + injector.get('environment')?.availableLanguages ?? []; + + SvgRegistry.registerIconFromSvg( + FILES_UPLOAD_QUESTION_TYPE, + '' + ); + + const component = { + name: FILES_UPLOAD_QUESTION_TYPE, + title: 'Files upload', + iconName: `icon-${FILES_UPLOAD_QUESTION_TYPE}`, + category: 'Custom Questions', + questionJSON: { + name: FILES_UPLOAD_QUESTION_TYPE, + type: 'html', + }, + onInit: (): void => { + Serializer.addProperty(FILES_UPLOAD_QUESTION_TYPE, { + name: 'targetField', + category: 'general', + displayName: 'Document type', + visibleIndex: 3, + choices: ( + question: FilesUploadQuestion, + choicesCallback: (choices: WidgetChoice[]) => void + ) => { + choicesCallback( + getFileQuestionChoices(question.survey as SurveyModel | undefined) + ); + }, + }); + registerCustomPropertyHelp( + 'targetField', + 'Document type ( file question ) selected by default. Added files are placed in it and follow its restrictions ( accepted types, maximum size, number of files ). Users can pick another document type of the form when filling it.' + ); + Serializer.addProperty(FILES_UPLOAD_QUESTION_TYPE, { + name: 'defaultLanguage', + category: 'general', + displayName: 'Default language', + visibleIndex: 4, + choices: ( + question: FilesUploadQuestion, + choicesCallback: (choices: WidgetChoice[]) => void + ) => { + choicesCallback( + getLanguageChoices( + question.survey as SurveyModel | undefined, + availableLanguages, + translate + ) + ); + }, + }); + registerCustomPropertyHelp( + 'defaultLanguage', + 'Language selected by default when the form opens. Available languages are the system language and the languages of the form translations that the system supports, like the language switch of the public forms.' + ); + }, + onLoaded: (question: FilesUploadQuestion): void => { + question.isRequired = false; + question.hideNumber = true; + }, + onAfterRender: ( + question: FilesUploadQuestion, + element: HTMLElement + ): void => { + const content = + element.querySelector('.sd-question__content') ?? element; + const survey = question.survey as SurveyModel | undefined; + destroyGrid(survey, question.filesUploadComponentRef, domService); + const componentRef: ComponentRef = + domService.appendComponentToBody(FilesUploadQuestionComponent, content); + question.filesUploadComponentRef = componentRef; + componentRef.instance.survey = survey; + componentRef.instance.question = question; + componentRef.instance.refresh(); + componentRef.changeDetectorRef.detectChanges(); + + // Keep the list of files to upload in sync with the files of the survey + const valueChangedHandler = ( + _: SurveyModel, + options: ValueChangedEvent + ): void => { + if (options.question?.getType() === FILE_QUESTION_TYPE) { + componentRef.instance.refresh(); + componentRef.changeDetectorRef.detectChanges(); + } + }; + survey?.onValueChanged.add(valueChangedHandler); + + addGridTeardown(componentRef, () => { + survey?.onValueChanged.remove(valueChangedHandler); + question.filesUploadComponentRef = undefined; + }); + registerGridForCleanup(survey, componentRef, domService); + }, + }; + componentCollection.add(component); +}; diff --git a/libs/shared/src/lib/survey/components/utils/files-widgets.util.spec.ts b/libs/shared/src/lib/survey/components/utils/files-widgets.util.spec.ts new file mode 100644 index 0000000000..ad39bc6bbb --- /dev/null +++ b/libs/shared/src/lib/survey/components/utils/files-widgets.util.spec.ts @@ -0,0 +1,332 @@ +import { TranslateService } from '@ngx-translate/core'; +import { Question as SurveyQuestion, SurveyModel } from 'survey-core'; +import { + applyFileQuestionVisibility, + checkFileRestrictions, + FILE_QUESTION_HIDDEN_CLASS, + getFileQuestionChoices, + getFileQuestions, + getKendoFileRestrictions, + getLanguageChoices, + getSurveyLanguages, + registerFileQuestionsVisibility, + shouldHideFileQuestions, + syncFileQuestionsVisibility, +} from './files-widgets.util'; + +/** Translate service surface used to display language names */ +const translate = { currentLang: 'en', defaultLang: 'en' } as TranslateService; + +/** + * Builds a survey holding two file questions and a text question. + * + * @returns Survey model + */ +const createSurvey = (): SurveyModel => { + const survey = new SurveyModel({ + elements: [ + { + type: 'file', + name: 'attachments', + title: 'Attachments', + allowMultiple: true, + acceptedTypes: '.pdf,.png', + maxSize: 1000, + }, + { type: 'file', name: 'cover', valueName: 'cover_image' }, + { type: 'text', name: 'comment', title: 'Comment' }, + ], + }); + // App-registered property, not part of the bare survey model + survey + .getQuestionByName('attachments') + .setPropertyValue('allowedFileNumber', 2); + return survey; +}; + +/** + * Builds a fake files management question. + * + * @param hideFileQuestions Value of its hideFileQuestions property + * @param isVisible Whether the question is visible + * @returns Fake question + */ +const createManagementQuestion = ( + hideFileQuestions: boolean, + isVisible = true +): SurveyQuestion => + ({ + getType: () => 'filesmanagement', + isVisible, + getPropertyValue: (name: string) => + name === 'hideFileQuestions' ? hideFileQuestions : undefined, + } as unknown as SurveyQuestion); + +/** + * Builds a fake survey. + * + * @param questions Questions of the survey + * @param isDesignMode Whether the survey is edited in the form builder + * @returns Fake survey + */ +const createFakeSurvey = ( + questions: SurveyQuestion[], + isDesignMode = false +): SurveyModel => + ({ + isDesignMode, + getAllQuestions: () => questions, + onVisibleChanged: { add: jest.fn() }, + } as unknown as SurveyModel); + +/** + * Builds a native file. + * + * @param name File name + * @param type Mime type + * @param size Size in bytes + * @returns File + */ +const createFile = (name: string, type: string, size = 10): File => + new File([new Array(size + 1).join('x')], name, { type }); + +/** + * Renders a SurveyJS-like row holding a question, in the document. + * + * @param id Question id + * @returns Question root element, column wrapper and row + */ +const renderRow = (id: string) => { + const row = document.createElement('div'); + row.className = 'sd-row sd-clearfix sd-page__row'; + row.innerHTML = `
`; + document.body.appendChild(row); + const root = row.querySelector('.sd-question') as HTMLElement; + return { row, wrapper: root.parentElement as HTMLElement, root }; +}; + +describe('files widgets utils', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('file questions', () => { + it('lists the file questions of the survey, in order', () => { + const survey = createSurvey(); + expect(getFileQuestions(survey).map((q) => q.name)).toEqual([ + 'attachments', + 'cover', + ]); + expect(getFileQuestions(undefined)).toEqual([]); + }); + + it('builds choices from the question title, which defaults to its name', () => { + expect(getFileQuestionChoices(createSurvey())).toEqual([ + { value: 'attachments', text: 'Attachments' }, + { value: 'cover', text: 'cover' }, + ]); + }); + }); + + describe('languages', () => { + it('combines the system default language with the form translations available in the system', () => { + const survey = new SurveyModel({ + title: { default: 'Hello', fr: 'Bonjour', ua: 'Привіт', de: 'Hallo' }, + }); + // 'ua' is SurveyJS's code for Ukrainian, 'de' is not available + expect(getSurveyLanguages(survey, ['en', 'fr', 'uk'])).toEqual([ + 'en', + 'fr', + 'uk', + ]); + }); + + it('offers only the default language for a form without translations', () => { + expect(getSurveyLanguages(createSurvey(), ['en', 'fr'])).toEqual(['en']); + expect(getSurveyLanguages(undefined, ['en', 'fr'])).toEqual(['en']); + expect(getSurveyLanguages(createSurvey(), [])).toEqual([]); + }); + + it('displays language names', () => { + const survey = new SurveyModel({ + title: { default: 'Hello', fr: 'Bonjour' }, + }); + const choices = getLanguageChoices(survey, ['en', 'fr'], translate); + expect(choices.map((choice) => choice.value)).toEqual(['en', 'fr']); + expect(choices[1].text).toBe('French'); + }); + }); + + describe('restrictions', () => { + it('maps extensions and maximum size to Kendo restrictions', () => { + const [attachments, cover] = getFileQuestions(createSurvey()); + expect(getKendoFileRestrictions(attachments)).toEqual({ + allowedExtensions: ['pdf', 'png'], + maxFileSize: 1000, + }); + expect(getKendoFileRestrictions(cover)).toEqual({}); + }); + + it('leaves mime types out of Kendo restrictions, which cannot express them', () => { + const [attachments] = getFileQuestions(createSurvey()); + attachments.setPropertyValue('acceptedTypes', '.pdf,image/*'); + expect(getKendoFileRestrictions(attachments)).toEqual({ + maxFileSize: 1000, + }); + }); + + it('rejects a second file on a single-file question', () => { + const [, cover] = getFileQuestions(createSurvey()); + expect( + checkFileRestrictions(cover, 1, [createFile('a.png', 'image/png')]) + ).toEqual({ key: 'components.filesUpload.errors.singleFileOnly' }); + expect( + checkFileRestrictions(cover, 0, [createFile('a.png', 'image/png')]) + ).toBeNull(); + }); + + it('rejects files exceeding the allowed number, counting existing ones', () => { + const [attachments] = getFileQuestions(createSurvey()); + const files = [ + createFile('a.pdf', 'application/pdf'), + createFile('b.pdf', 'application/pdf'), + ]; + expect(checkFileRestrictions(attachments, 1, files)).toEqual({ + key: 'components.filesUpload.errors.maximumAllowedFiles', + params: { number: 2 }, + }); + expect(checkFileRestrictions(attachments, 0, files)).toBeNull(); + expect(checkFileRestrictions(attachments, 5, [])).toBeNull(); + }); + + it('checks mime types and wildcards itself when Kendo cannot', () => { + const [attachments] = getFileQuestions(createSurvey()); + attachments.setPropertyValue('acceptedTypes', 'image/*,application/pdf'); + expect( + checkFileRestrictions(attachments, 0, [ + createFile('a.png', 'image/png'), + createFile('notes.txt', 'text/plain'), + ]) + ).toEqual({ + key: 'components.filesUpload.errors.invalidType', + params: { name: 'notes.txt' }, + }); + expect( + checkFileRestrictions(attachments, 0, [ + createFile('a.png', 'image/png'), + createFile('b.pdf', 'application/pdf'), + ]) + ).toBeNull(); + }); + + it('leaves extension checks to Kendo', () => { + const [attachments] = getFileQuestions(createSurvey()); + expect( + checkFileRestrictions(attachments, 0, [ + createFile('notes.txt', 'text/plain'), + ]) + ).toBeNull(); + }); + }); + + describe('file questions visibility', () => { + it('hides file questions when a visible files management question asks for it, outside the form builder', () => { + expect( + shouldHideFileQuestions( + createFakeSurvey([createManagementQuestion(true)]) + ) + ).toBe(true); + expect( + shouldHideFileQuestions( + createFakeSurvey([createManagementQuestion(false)]) + ) + ).toBe(false); + expect( + shouldHideFileQuestions( + createFakeSurvey([createManagementQuestion(true, false)]) + ) + ).toBe(false); + expect( + shouldHideFileQuestions( + createFakeSurvey([createManagementQuestion(true)], true) + ) + ).toBe(false); + expect(shouldHideFileQuestions(createFakeSurvey([]))).toBe(false); + expect(shouldHideFileQuestions(undefined)).toBe(false); + }); + + it('hides the whole row column of a rendered file question, from any element inside it', () => { + const { wrapper, root } = renderRow('sq_1'); + const survey = createFakeSurvey([createManagementQuestion(true)]); + + applyFileQuestionVisibility( + survey, + root.querySelector('.sd-question__content') as HTMLElement + ); + expect(wrapper.classList).toContain(FILE_QUESTION_HIDDEN_CLASS); + expect(root.classList).not.toContain(FILE_QUESTION_HIDDEN_CLASS); + + applyFileQuestionVisibility( + createFakeSurvey([createManagementQuestion(false)]), + root + ); + expect(wrapper.classList).not.toContain(FILE_QUESTION_HIDDEN_CLASS); + }); + + it('hides the question itself when it is not rendered in a row', () => { + const root = document.createElement('div'); + root.className = 'sd-question'; + document.body.appendChild(root); + + applyFileQuestionVisibility( + createFakeSurvey([createManagementQuestion(true)]), + root + ); + expect(root.classList).toContain(FILE_QUESTION_HIDDEN_CLASS); + }); + + it('syncs every rendered file question of the survey', () => { + const first = renderRow('sq_1'); + const second = renderRow('sq_2'); + const fileQuestion = (id: string) => + ({ getType: () => 'file', id } as unknown as SurveyQuestion); + const survey = createFakeSurvey([ + createManagementQuestion(true), + fileQuestion('sq_1'), + fileQuestion('sq_2'), + fileQuestion('sq_not_rendered'), + ]); + + syncFileQuestionsVisibility(survey, document); + expect(first.wrapper.classList).toContain(FILE_QUESTION_HIDDEN_CLASS); + expect(second.wrapper.classList).toContain(FILE_QUESTION_HIDDEN_CLASS); + }); + + it('re-syncs when a files management question is shown or hidden, registering once per survey', () => { + const { wrapper } = renderRow('sq_1'); + const management = createManagementQuestion(true); + const survey = createFakeSurvey([ + management, + { getType: () => 'file', id: 'sq_1' } as unknown as SurveyQuestion, + ]); + const add = survey.onVisibleChanged.add as jest.Mock; + + registerFileQuestionsVisibility(survey, document); + registerFileQuestionsVisibility(survey, document); + expect(add).toHaveBeenCalledTimes(1); + + const handler = add.mock.calls[0][0]; + handler(survey, { question: management }); + expect(wrapper.classList).toContain(FILE_QUESTION_HIDDEN_CLASS); + + (management as any).isVisible = false; + handler(survey, { question: management }); + expect(wrapper.classList).not.toContain(FILE_QUESTION_HIDDEN_CLASS); + + // Other questions do not trigger a sync + (management as any).isVisible = true; + handler(survey, { question: { getType: () => 'text' } }); + expect(wrapper.classList).not.toContain(FILE_QUESTION_HIDDEN_CLASS); + }); + }); +}); diff --git a/libs/shared/src/lib/survey/components/utils/files-widgets.util.ts b/libs/shared/src/lib/survey/components/utils/files-widgets.util.ts new file mode 100644 index 0000000000..45ab5d2ee2 --- /dev/null +++ b/libs/shared/src/lib/survey/components/utils/files-widgets.util.ts @@ -0,0 +1,316 @@ +import { TranslateService } from '@ngx-translate/core'; +import { FileRestrictions } from '@progress/kendo-angular-upload'; +import { Question as SurveyQuestion, SurveyModel } from 'survey-core'; +import { getLanguageName, toI18nLocale } from '../../../utils/languages'; + +/** SurveyJS question type name of a native file upload question. */ +export const FILE_QUESTION_TYPE = 'file'; +/** SurveyJS type used by the files upload question. */ +export const FILES_UPLOAD_QUESTION_TYPE = 'filesupload'; +/** SurveyJS type used by the files management question. */ +export const FILES_MANAGEMENT_QUESTION_TYPE = 'filesmanagement'; +/** Property of the files management question hiding the survey's file questions. */ +export const HIDE_FILE_QUESTIONS_PROPERTY = 'hideFileQuestions'; +/** Class toggled on hidden file questions ( see survey.scss ). */ +export const FILE_QUESTION_HIDDEN_CLASS = 'file-question--hidden'; +/** Key under which the file questions visibility handler is tracked on a survey. */ +const VISIBILITY_HANDLER_KEY = '__fileQuestionsVisibilityHandler'; + +/** Generic dropdown choice. */ +export interface WidgetChoice { + value: string; + text: string; +} + +/** + * Returns every file-type question of the survey, in survey order. + * + * @param survey Survey to inspect + * @returns File questions found in the survey ( empty array if none / no survey ) + */ +export const getFileQuestions = ( + survey: SurveyModel | undefined +): SurveyQuestion[] => + (survey?.getAllQuestions() ?? []).filter( + (candidate) => candidate.getType() === FILE_QUESTION_TYPE + ); + +/** + * Builds the dropdown choices used to pick a target file question. + * + * @param survey Survey to inspect + * @returns Choices, one per file-type question + */ +export const getFileQuestionChoices = ( + survey: SurveyModel | undefined +): WidgetChoice[] => + getFileQuestions(survey).map((question) => ({ + value: question.name, + text: question.title || question.valueName || question.name, + })); + +/** + * Languages of a form, computed on the front-end the same way the public + * forms application restricts its language switch: the system's default + * language, plus the locales used by the form's translations ( Translation + * tab of the form builder ) that are available in the system. + * + * @param survey Survey to inspect + * @param availableLanguages Languages available in the system ( environment ), default one first + * @returns Language codes ( i18n / Angular convention ), default one first + */ +export const getSurveyLanguages = ( + survey: SurveyModel | undefined, + availableLanguages: string[] +): string[] => { + const usedLocales = survey?.getUsedLocales?.() ?? []; + const formLanguages = usedLocales + .map((locale) => toI18nLocale(locale)) + .filter((language) => availableLanguages.includes(language)); + return Array.from( + new Set([...availableLanguages.slice(0, 1), ...formLanguages]) + ); +}; + +/** + * Builds the language choices for the files upload question. + * + * @param survey Survey to inspect + * @param availableLanguages Languages available in the system ( environment ), default one first + * @param translate Angular translation service, to display the language names + * @returns Language choices + */ +export const getLanguageChoices = ( + survey: SurveyModel | undefined, + availableLanguages: string[], + translate: TranslateService +): WidgetChoice[] => + getSurveyLanguages(survey, availableLanguages).map((code) => ({ + value: code, + text: getLanguageName(code, translate), + })); + +/** + * Splits a file question's `acceptedTypes` ( same format as the native + * `accept` HTML attribute, e.g. `.png,.jpg,image/*` ) into normalized tokens. + * + * @param acceptedTypes Comma separated list of extensions / mime types / wildcards + * @returns Lower-cased, trimmed, non-empty tokens + */ +const parseAcceptedTypes = (acceptedTypes: string | undefined): string[] => + (acceptedTypes || '') + .split(',') + .map((token) => token.trim().toLowerCase()) + .filter((token) => !!token); + +/** + * Checks whether a file's mime type / extension is covered by a file + * question's `acceptedTypes`. + * + * @param file File being checked + * @param tokens Accepted types tokens ( see {@link parseAcceptedTypes} ) + * @returns True when the file matches at least one entry ( or no restriction ) + */ +const matchesAcceptedTypes = (file: File, tokens: string[]): boolean => { + if (!tokens.length) { + return true; + } + const fileName = file.name.toLowerCase(); + const fileType = (file.type || '').toLowerCase(); + return tokens.some((token) => { + if (token.startsWith('.')) { + return fileName.endsWith(token); + } + if (token.endsWith('/*')) { + return fileType.startsWith(token.slice(0, -1)); + } + return fileType === token; + }); +}; + +/** + * Builds the Kendo file select restrictions of a file question, so invalid + * files are flagged inline by the file list itself: + * - `maxFileSize` from `maxSize`, + * - `allowedExtensions` from `acceptedTypes`, only when it holds extensions + * only ( Kendo cannot express mime types / wildcards, which are then checked + * by {@link checkFileRestrictions} instead ). + * + * @param question Target file question + * @returns Kendo restrictions + */ +export const getKendoFileRestrictions = ( + question: SurveyQuestion +): FileRestrictions => { + const tokens = parseAcceptedTypes( + question.getPropertyValue('acceptedTypes') as string | undefined + ); + const extensions = tokens + .filter((token) => token.startsWith('.')) + .map((token) => token.slice(1)); + const maxSize = Number(question.getPropertyValue('maxSize') || 0); + return { + ...(extensions.length && extensions.length === tokens.length + ? { allowedExtensions: extensions } + : {}), + ...(maxSize > 0 ? { maxFileSize: maxSize } : {}), + }; +}; + +/** A restriction violated when staging files for upload. */ +export interface FileRestrictionError { + /** Translation key describing the violation. */ + key: string; + /** Translation parameters ( e.g. the offending file name, the allowed count ). */ + params?: Record; +} + +/** + * Validates files about to be staged against a target file question's + * restrictions not covered by the Kendo file select ones: the number of + * files ( `allowMultiple` / `allowedFileNumber`, read the same way + * `FormBuilderService.checkFileUploadValidity` does ) and the accepted types + * when they cannot be expressed as Kendo restrictions. + * + * @param question Target file question + * @param existingCount Number of files already held by the question ( stored + already staged ) + * @param incoming Files about to be added + * @returns The first violated restriction, or null when the files are valid + */ +export const checkFileRestrictions = ( + question: SurveyQuestion, + existingCount: number, + incoming: File[] +): FileRestrictionError | null => { + if (!incoming.length) { + return null; + } + const allowMultiple = !!question.getPropertyValue('allowMultiple'); + const allowedFileNumber = Number( + question.getPropertyValue('allowedFileNumber') || 0 + ); + if (!allowMultiple && existingCount + incoming.length > 1) { + return { key: 'components.filesUpload.errors.singleFileOnly' }; + } + if ( + allowMultiple && + allowedFileNumber > 0 && + existingCount + incoming.length > allowedFileNumber + ) { + return { + key: 'components.filesUpload.errors.maximumAllowedFiles', + params: { number: allowedFileNumber }, + }; + } + const tokens = parseAcceptedTypes( + question.getPropertyValue('acceptedTypes') as string | undefined + ); + if (!getKendoFileRestrictions(question).allowedExtensions) { + const invalid = incoming.find( + (file) => !matchesAcceptedTypes(file, tokens) + ); + if (invalid) { + return { + key: 'components.filesUpload.errors.invalidType', + params: { name: invalid.name }, + }; + } + } + return null; +}; + +/** + * Whether the survey's file questions must be hidden: a visible files + * management question asks for it. Never in the form builder, where the file + * questions must stay reachable to configure their restrictions. + * + * @param survey Survey to inspect + * @returns True when file questions must be hidden + */ +export const shouldHideFileQuestions = ( + survey: SurveyModel | undefined +): boolean => + !!survey && + !survey.isDesignMode && + survey + .getAllQuestions() + .some( + (question) => + question.getType() === FILES_MANAGEMENT_QUESTION_TYPE && + question.isVisible && + !!question.getPropertyValue(HIDE_FILE_QUESTIONS_PROPERTY) + ); + +/** + * Hides / shows a rendered file question according to the survey. The + * question is hidden through the DOM only: SurveyJS visibility would clear + * its value on completion and skip its validation, while hidden file + * questions must keep storing the files placed by the files upload question. + * + * @param survey Survey owning the file question + * @param element Rendered question element ( or any element inside it ) + */ +export const applyFileQuestionVisibility = ( + survey: SurveyModel | undefined, + element: HTMLElement +): void => { + const hidden = shouldHideFileQuestions(survey); + const root = element.closest('.sd-question') ?? element; + // SurveyJS wraps each question of a row in a flex column: hide it as well so + // the hidden question does not leave an empty column behind + const wrapper = + root.parentElement?.parentElement?.classList.contains('sd-row') && + root.parentElement + ? root.parentElement + : root; + wrapper.classList.toggle(FILE_QUESTION_HIDDEN_CLASS, hidden); +}; + +/** + * Applies the current file questions visibility to every rendered file + * question of the survey. + * + * @param survey Survey owning the file questions + * @param document Document the survey is rendered in + */ +export const syncFileQuestionsVisibility = ( + survey: SurveyModel | undefined, + document: Document +): void => { + if (!survey) { + return; + } + getFileQuestions(survey).forEach((question) => { + const element = document.getElementById(question.id); + if (element) { + applyFileQuestionVisibility(survey, element); + } + }); +}; + +/** + * Keeps the file questions visibility in sync when a files management + * question is shown / hidden by logic ( once per survey ). Newly rendered + * file questions are handled by the file widget itself. + * + * @param survey Survey owning the file questions + * @param document Document the survey is rendered in + */ +export const registerFileQuestionsVisibility = ( + survey: SurveyModel | undefined, + document: Document +): void => { + if (!survey) { + return; + } + const host = survey as SurveyModel & Record; + if (host[VISIBILITY_HANDLER_KEY]) { + return; + } + host[VISIBILITY_HANDLER_KEY] = true; + survey.onVisibleChanged.add((_, options) => { + if (options.question.getType() === FILES_MANAGEMENT_QUESTION_TYPE) { + syncFileQuestionsVisibility(survey, document); + } + }); +}; diff --git a/libs/shared/src/lib/survey/init.ts b/libs/shared/src/lib/survey/init.ts index f77da06ccb..d430f5b24d 100644 --- a/libs/shared/src/lib/survey/init.ts +++ b/libs/shared/src/lib/survey/init.ts @@ -11,6 +11,8 @@ import { ReferenceDataService } from '../services/reference-data/reference-data. import addCustomFunctions from '../utils/custom-functions'; import * as EditorComponent from './components/editor'; import * as FieldHistoryComponent from './components/field-history'; +import * as FilesManagementComponent from './components/files-management'; +import * as FilesUploadComponent from './components/files-upload'; import * as GeospatialComponent from './components/geospatial'; import * as OwnerComponent from './components/owner'; import * as ResourceComponent from './components/resource'; @@ -58,6 +60,8 @@ const CUSTOM_COMPONENTS = [ 'geospatial', 'editor', 'field-history', + 'filesupload', + 'filesmanagement', ]; /** @@ -146,6 +150,8 @@ export const initCustomSurvey = ( GeospatialComponent.init(domService, ComponentCollection.Instance); EditorComponent.init(injector, ComponentCollection.Instance); FieldHistoryComponent.init(injector, ComponentCollection.Instance); + FilesUploadComponent.init(injector, ComponentCollection.Instance); + FilesManagementComponent.init(injector, ComponentCollection.Instance); } // load global properties diff --git a/libs/shared/src/lib/survey/widgets/file-widget.spec.ts b/libs/shared/src/lib/survey/widgets/file-widget.spec.ts index ad01d295d9..be1a44cdab 100644 --- a/libs/shared/src/lib/survey/widgets/file-widget.spec.ts +++ b/libs/shared/src/lib/survey/widgets/file-widget.spec.ts @@ -96,6 +96,8 @@ describe('file widget', () => { mode, onValueChanged: { add: jest.fn(), remove: jest.fn() }, runExpression: jest.fn(), + // No files management question hiding the file questions + getAllQuestions: () => [], }, isReadOnly: mode === 'display', allowMultiple: false, diff --git a/libs/shared/src/lib/survey/widgets/file-widget.ts b/libs/shared/src/lib/survey/widgets/file-widget.ts index 53cf0d6abf..ad98c49bfe 100644 --- a/libs/shared/src/lib/survey/widgets/file-widget.ts +++ b/libs/shared/src/lib/survey/widgets/file-widget.ts @@ -17,6 +17,7 @@ import { FileItemActionsComponent } from '../components/file-item-actions/public import { FileQuestionActionsComponent } from '../components/file-question-actions/public-api'; import { isOutdatedFile, isStoredFile } from '../../services/file/file.utils'; import { TranslateService } from '@ngx-translate/core'; +import { applyFileQuestionVisibility } from '../components/utils/files-widgets.util'; /** * Set document properties based on value expressions @@ -588,6 +589,8 @@ export const init = ( // preview loading). filePreviewQuestion.__pdfPreviewObserver?.disconnect(); const sync = (): void => { + // Hidden when a Files management question of the survey asks for it + applyFileQuestionVisibility(survey, htmlElement); updatePdfPreview(question, htmlElement); updateFileItems( filePreviewQuestion, From 47ccfbc1b14ed3484c15686f72df700c9454dae0 Mon Sep 17 00:00:00 2001 From: Antoine Hurard Date: Mon, 12 Oct 2026 23:04:25 +1300 Subject: [PATCH 2/2] Add a way to limit languages per files-upload --- .../files-upload-question.component.html | 3 +- .../files-upload-question.component.spec.ts | 37 ++++++++++ .../files-upload-question.component.ts | 37 ++++++++-- .../survey/components/files-upload.spec.ts | 71 ++++++++++++++++--- .../src/lib/survey/components/files-upload.ts | 43 +++++++++-- .../utils/files-widgets.util.spec.ts | 23 ++++++ .../components/utils/files-widgets.util.ts | 31 +++++++- 7 files changed, 219 insertions(+), 26 deletions(-) diff --git a/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.html b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.html index 0c40d35982..de0a000a9c 100644 --- a/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.html +++ b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.html @@ -14,7 +14,8 @@ [formControl]="targetFieldControl" > -
+ +
{ expect(component.languageControl.value).toBe('en'); }); + it('offers only the allowed languages, imposing a single one', () => { + component.question = createQuestion({ + targetField: 'cover', + allowedLanguages: ['fr'], + }); + component.refresh(); + expect(component.languageChoices.map((c) => c.value)).toEqual(['fr']); + expect(component.languageControl.value).toBe('fr'); + expect(component.fixedLanguage).toBe(true); + expect(component.languageControl.disabled).toBe(true); + + // Several allowed languages: the user picks one of them + component.question = createQuestion({ + targetField: 'cover', + allowedLanguages: ['fr', 'en'], + }); + component.refresh(); + expect(component.languageChoices.map((c) => c.value)).toEqual(['en', 'fr']); + expect(component.fixedLanguage).toBe(false); + expect(component.languageControl.disabled).toBe(false); + }); + + it('ignores an allowed languages restriction matching none of the form languages', () => { + component.question = createQuestion({ + targetField: 'cover', + allowedLanguages: ['de'], + defaultLanguage: 'fr', + }); + component.refresh(); + expect(component.languageChoices.map((c) => c.value)).toEqual(['en', 'fr']); + expect(component.languageControl.value).toBe('fr'); + expect(component.fixedLanguage).toBe(false); + expect(component.languageControl.disabled).toBe(false); + }); + it('is disabled in the form builder and in read-only surveys', () => { survey.setDesignMode(true); component.refresh(); expect(component.disabled).toBe(true); expect(component.targetFieldControl.disabled).toBe(true); + expect(component.languageControl.disabled).toBe(true); survey.setDesignMode(false); survey.mode = 'display'; @@ -168,6 +204,7 @@ describe('FilesUploadQuestionComponent', () => { component.refresh(); expect(component.disabled).toBe(false); expect(component.targetFieldControl.disabled).toBe(false); + expect(component.languageControl.disabled).toBe(false); }); it('adds picked files to the target question, tagged with the language', () => { diff --git a/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.ts b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.ts index 73faaf2000..cf0affbce1 100644 --- a/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.ts +++ b/libs/shared/src/lib/components/files-upload-question/files-upload-question.component.ts @@ -26,6 +26,7 @@ import { getFileIcon, isStoredFile } from '../../services/file/file.utils'; import { FileItemActionsComponent } from '../../survey/components/file-item-actions/public-api'; import { FilesUploadQuestion } from '../../survey/components/files-upload'; import { + ALLOWED_LANGUAGES_PROPERTY, checkFileRestrictions, getFileQuestionChoices, getFileQuestions, @@ -91,8 +92,10 @@ export class FilesUploadQuestionComponent implements OnInit, OnDestroy { /** Choices of the target file question dropdown */ public targetFieldChoices: WidgetChoice[] = []; - /** Choices of the language dropdown ( languages of the form ) */ + /** Choices of the language dropdown ( languages of the form allowed by the question ) */ public languageChoices: WidgetChoice[] = []; + /** Whether the language is imposed by the question: a single language is offered */ + public fixedLanguage = false; /** Selected target file question */ public targetFieldControl = new FormControl(null); /** Selected language, applied to the files added from now on */ @@ -147,8 +150,12 @@ export class FilesUploadQuestionComponent implements OnInit, OnDestroy { this.languageChoices = getLanguageChoices( this.survey, this.environment?.availableLanguages ?? [], - this.translate + this.translate, + this.question?.getPropertyValue(ALLOWED_LANGUAGES_PROPERTY) as + | string[] + | undefined ); + this.fixedLanguage = this.languageChoices.length === 1; this.targetFieldControl.setValue( this.pickChoice( this.targetFieldChoices, @@ -169,10 +176,11 @@ export class FilesUploadQuestionComponent implements OnInit, OnDestroy { !!this.survey?.isDesignMode || !!this.survey?.isDisplayMode || !!this.question?.isReadOnly; - [this.targetFieldControl, this.languageControl].forEach((control) => - this.disabled - ? control.disable({ emitEvent: false }) - : control.enable({ emitEvent: false }) + this.toggleControl(this.targetFieldControl, !this.disabled); + // An imposed language leaves nothing to pick ( dropdown hidden ) + this.toggleControl( + this.languageControl, + !this.disabled && !this.fixedLanguage ); this.updateTargetRestrictions(); this.computeRows(); @@ -306,6 +314,23 @@ export class FilesUploadQuestionComponent implements OnInit, OnDestroy { : choices[0]?.value ?? null; } + /** + * Enables / disables a dropdown control without notifying its subscribers. + * + * @param control Control to update + * @param enabled Whether the control must be enabled + */ + private toggleControl( + control: FormControl, + enabled: boolean + ): void { + if (enabled) { + control.enable({ emitEvent: false }); + } else { + control.disable({ emitEvent: false }); + } + } + /** * Reads the target file question restrictions applied to the file select. */ diff --git a/libs/shared/src/lib/survey/components/files-upload.spec.ts b/libs/shared/src/lib/survey/components/files-upload.spec.ts index 246ec99dd4..bce4fddb61 100644 --- a/libs/shared/src/lib/survey/components/files-upload.spec.ts +++ b/libs/shared/src/lib/survey/components/files-upload.spec.ts @@ -98,12 +98,29 @@ describe('files upload question', () => { 'description', 'tooltip', 'targetField', + 'allowedLanguages', 'defaultLanguage', ].forEach((property) => expect(FILES_UPLOAD_ALLOWED_PROPERTIES).toContain(property) ); }); + /** + * Resolves the property grid choices of a question property. + * + * @param question Question being edited + * @param name Property name + * @returns Choices offered by the property + */ + const choicesOf = (question: FilesUploadQuestion, name: string): any[] => { + const callback = jest.fn(); + Serializer.findProperty(FILES_UPLOAD_QUESTION_TYPE, name).getChoices( + question, + callback + ); + return callback.mock.calls[0][0]; + }; + it('registers a question keeping its own properties in the structure', () => { const survey = createSurvey(); const question = survey.getQuestionByName('upload') as FilesUploadQuestion; @@ -120,23 +137,55 @@ describe('files upload question', () => { it('offers the file questions and the form languages in the property grid', () => { const survey = createSurvey(); const question = survey.getQuestionByName('upload') as FilesUploadQuestion; - const choicesOf = (name: string) => { - const callback = jest.fn(); - Serializer.findProperty(FILES_UPLOAD_QUESTION_TYPE, name).getChoices( - question, - callback - ); - return callback.mock.calls[0][0]; - }; - expect(choicesOf('targetField')).toEqual([ + expect(choicesOf(question, 'targetField')).toEqual([ { value: 'attachments', text: 'Attachments' }, { value: 'cover', text: 'cover' }, ]); - expect(choicesOf('defaultLanguage').map((c: any) => c.value)).toEqual([ - 'en', + expect( + choicesOf(question, 'allowedLanguages').map((c: any) => c.value) + ).toEqual(['en', 'fr']); + expect( + choicesOf(question, 'defaultLanguage').map((c: any) => c.value) + ).toEqual(['en', 'fr']); + }); + + it('restricts the default language to the allowed languages, kept in the structure', () => { + const survey = new SurveyModel({ + title: { default: 'Files', fr: 'Fichiers' }, + elements: [ + { type: 'file', name: 'cover' }, + { + type: FILES_UPLOAD_QUESTION_TYPE, + name: 'upload', + allowedLanguages: ['fr'], + }, + ], + }); + const question = survey.getQuestionByName('upload') as FilesUploadQuestion; + + // SurveyJS arrays carry extra properties: compare their items + expect([...question.getPropertyValue('allowedLanguages')]).toEqual(['fr']); + expect(survey.toJSON().pages[0].elements[1].allowedLanguages).toEqual([ 'fr', ]); + // Multi-select in the property grid, refreshing the default language + expect( + Serializer.findProperty(FILES_UPLOAD_QUESTION_TYPE, 'allowedLanguages') + .type + ).toBe('multiplevalues'); + expect( + Serializer.findProperty( + FILES_UPLOAD_QUESTION_TYPE, + 'allowedLanguages' + ).getDependedProperties() + ).toContain('defaultLanguage'); + expect( + choicesOf(question, 'allowedLanguages').map((c: any) => c.value) + ).toEqual(['en', 'fr']); + expect( + choicesOf(question, 'defaultLanguage').map((c: any) => c.value) + ).toEqual(['fr']); }); it('renders the component inside the question and refreshes it on file changes', () => { diff --git a/libs/shared/src/lib/survey/components/files-upload.ts b/libs/shared/src/lib/survey/components/files-upload.ts index 588ee4ec47..32ce8d2d69 100644 --- a/libs/shared/src/lib/survey/components/files-upload.ts +++ b/libs/shared/src/lib/survey/components/files-upload.ts @@ -12,6 +12,7 @@ import { FilesUploadQuestionComponent } from '../../components/files-upload-ques import { DomService } from '../../services/dom/dom.service'; import { registerCustomPropertyHelp } from '../localization'; import { + ALLOWED_LANGUAGES_PROPERTY, FILE_QUESTION_TYPE, FILES_UPLOAD_QUESTION_TYPE, getFileQuestionChoices, @@ -37,6 +38,7 @@ export const FILES_UPLOAD_ALLOWED_PROPERTIES = [ 'title', 'description', 'targetField', + ALLOWED_LANGUAGES_PROPERTY, 'defaultLanguage', 'visible', 'visibleIf', @@ -55,6 +57,8 @@ export const FILES_UPLOAD_ALLOWED_PROPERTIES = [ export interface FilesUploadQuestion extends SurveyQuestion { /** Name of the file question selected by default */ targetField?: string; + /** Languages the user can tag files with, empty for every language of the form */ + allowedLanguages?: string[]; /** Language selected by default */ defaultLanguage?: string; /** Rendered Angular component */ @@ -66,9 +70,10 @@ export interface FilesUploadQuestion extends SurveyQuestion { * letting the user pick one of the survey's file questions and add files to * it, tagged with a language, without navigating to that question, while * listing every file of the survey waiting to be uploaded. Its `targetField` - * / `defaultLanguage` properties are part of the form structure and only - * preselect the dropdowns: the files end up in the target file question's - * value and are uploaded when the record is saved, like any other file. + * / `allowedLanguages` / `defaultLanguage` properties are part of the form + * structure ( so each form of a resource configures its own ) and only drive + * the dropdowns: the files end up in the target file question's value and + * are uploaded when the record is saved, like any other file. * * @param injector Parent Angular injector * @param componentCollection SurveyJS custom component collection @@ -116,9 +121,9 @@ export const init = ( 'Document type ( file question ) selected by default. Added files are placed in it and follow its restrictions ( accepted types, maximum size, number of files ). Users can pick another document type of the form when filling it.' ); Serializer.addProperty(FILES_UPLOAD_QUESTION_TYPE, { - name: 'defaultLanguage', + name: `${ALLOWED_LANGUAGES_PROPERTY}:multiplevalues`, category: 'general', - displayName: 'Default language', + displayName: 'Allowed languages', visibleIndex: 4, choices: ( question: FilesUploadQuestion, @@ -133,9 +138,35 @@ export const init = ( ); }, }); + registerCustomPropertyHelp( + ALLOWED_LANGUAGES_PROPERTY, + 'Languages the user can tag files with, among the languages of the form: the system language and the languages of the form translations that the system supports. Leave empty to offer all of them. With a single allowed language, every file gets that language and the language cannot be changed.' + ); + Serializer.addProperty(FILES_UPLOAD_QUESTION_TYPE, { + name: 'defaultLanguage', + category: 'general', + displayName: 'Default language', + visibleIndex: 5, + dependsOn: [ALLOWED_LANGUAGES_PROPERTY], + choices: ( + question: FilesUploadQuestion, + choicesCallback: (choices: WidgetChoice[]) => void + ) => { + choicesCallback( + getLanguageChoices( + question.survey as SurveyModel | undefined, + availableLanguages, + translate, + question.getPropertyValue(ALLOWED_LANGUAGES_PROPERTY) as + | string[] + | undefined + ) + ); + }, + }); registerCustomPropertyHelp( 'defaultLanguage', - 'Language selected by default when the form opens. Available languages are the system language and the languages of the form translations that the system supports, like the language switch of the public forms.' + 'Language selected by default when the form opens, among the allowed languages.' ); }, onLoaded: (question: FilesUploadQuestion): void => { diff --git a/libs/shared/src/lib/survey/components/utils/files-widgets.util.spec.ts b/libs/shared/src/lib/survey/components/utils/files-widgets.util.spec.ts index ad39bc6bbb..05ac11cbe9 100644 --- a/libs/shared/src/lib/survey/components/utils/files-widgets.util.spec.ts +++ b/libs/shared/src/lib/survey/components/utils/files-widgets.util.spec.ts @@ -9,6 +9,7 @@ import { getKendoFileRestrictions, getLanguageChoices, getSurveyLanguages, + restrictLanguages, registerFileQuestionsVisibility, shouldHideFileQuestions, syncFileQuestionsVisibility, @@ -155,6 +156,28 @@ describe('files widgets utils', () => { expect(choices.map((choice) => choice.value)).toEqual(['en', 'fr']); expect(choices[1].text).toBe('French'); }); + + it('restricts the languages to the allowed ones, in form order', () => { + expect(restrictLanguages(['en', 'fr', 'uk'], ['uk', 'fr'])).toEqual([ + 'fr', + 'uk', + ]); + const survey = new SurveyModel({ + title: { default: 'Hello', fr: 'Bonjour' }, + }); + expect( + getLanguageChoices(survey, ['en', 'fr'], translate, ['fr']).map( + (choice) => choice.value + ) + ).toEqual(['fr']); + }); + + it('keeps every language without restriction, or when the restriction matches none of them', () => { + expect(restrictLanguages(['en', 'fr'], [])).toEqual(['en', 'fr']); + expect(restrictLanguages(['en', 'fr'], undefined)).toEqual(['en', 'fr']); + // Translation removed from the form since the restriction was set + expect(restrictLanguages(['en', 'fr'], ['de'])).toEqual(['en', 'fr']); + }); }); describe('restrictions', () => { diff --git a/libs/shared/src/lib/survey/components/utils/files-widgets.util.ts b/libs/shared/src/lib/survey/components/utils/files-widgets.util.ts index 45ab5d2ee2..e06390b40b 100644 --- a/libs/shared/src/lib/survey/components/utils/files-widgets.util.ts +++ b/libs/shared/src/lib/survey/components/utils/files-widgets.util.ts @@ -11,6 +11,8 @@ export const FILES_UPLOAD_QUESTION_TYPE = 'filesupload'; export const FILES_MANAGEMENT_QUESTION_TYPE = 'filesmanagement'; /** Property of the files management question hiding the survey's file questions. */ export const HIDE_FILE_QUESTIONS_PROPERTY = 'hideFileQuestions'; +/** Property of the files upload question restricting the languages offered. */ +export const ALLOWED_LANGUAGES_PROPERTY = 'allowedLanguages'; /** Class toggled on hidden file questions ( see survey.scss ). */ export const FILE_QUESTION_HIDDEN_CLASS = 'file-question--hidden'; /** Key under which the file questions visibility handler is tracked on a survey. */ @@ -72,20 +74,45 @@ export const getSurveyLanguages = ( ); }; +/** + * Restricts a form's languages to the ones a files upload question allows + * ( its "Allowed languages" ). No restriction, or a restriction matching none + * of the form's languages ( e.g. the translations were removed from the form + * since ), keeps every language of the form. + * + * @param languages Languages of the form ( see {@link getSurveyLanguages} ) + * @param allowedLanguages Allowed language codes, empty / undefined for no restriction + * @returns Languages to offer, in form order + */ +export const restrictLanguages = ( + languages: string[], + allowedLanguages: string[] | undefined +): string[] => { + const restricted = languages.filter((language) => + (allowedLanguages ?? []).includes(language) + ); + return restricted.length ? restricted : languages; +}; + /** * Builds the language choices for the files upload question. * * @param survey Survey to inspect * @param availableLanguages Languages available in the system ( environment ), default one first * @param translate Angular translation service, to display the language names + * @param allowedLanguages Languages allowed by the question, empty for every language of the form * @returns Language choices */ export const getLanguageChoices = ( survey: SurveyModel | undefined, availableLanguages: string[], - translate: TranslateService + translate: TranslateService, + allowedLanguages: string[] = [] ): WidgetChoice[] => - getSurveyLanguages(survey, availableLanguages).map((code) => ({ + restrictLanguages( + getSurveyLanguages(survey, availableLanguages), + allowedLanguages + ).map((code) => ({ value: code, text: getLanguageName(code, translate), }));