diff --git a/babel.config.js b/babel.config.js index 9000dd62d..abb8ff7f5 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,19 +1,6 @@ module.exports = { "presets": [ - [ - "@babel/preset-env", - { - "useBuiltIns": "usage", - "corejs": "3.8", - "targets": { - "edge": "17", - "firefox": "60", - "chrome": "67", - "safari": "11.1", - "ie": "11" - } - } - ], + "@babel/preset-env", "@babel/preset-typescript", "@babel/preset-react" ], diff --git a/lib/GADS.pm b/lib/GADS.pm index 54c4f8fa4..ed6f0c47e 100644 --- a/lib/GADS.pm +++ b/lib/GADS.pm @@ -1727,6 +1727,54 @@ post '/file/:id?' => require_login sub { } }; +put '/api/file/:id' => require_login sub { + my $id = route_parameters->get('id'); + my $is_new = param('is_new'); + + my $newname = param('filename') + or error __"Filename is required"; + + $filecheck->check_name($upload); + + if ($is_new) + { + my $file = schema->resultset('Fileval')->find_with_permission($id, logged_in_user, new_file_only => 1) + or error __x"File ID {id} cannot be found", id => $id; + + $file->single_rset->update({ name => $newname }); + + content_type 'application/json'; + + return encode_json({ + id => $file->single_id, + name => $file->single_name, + is_ok => 1, + }); + } + else + { + my $file = schema->resultset('Fileval')->find_with_permission($id, logged_in_user, + rename_existing => 1) + or error __x"File ID {id} cannot be found", id => $id; + + my $newFile = rset('Fileval')->create({ + name => $newname, + mimetype => $file->single_mimetype, + content => $file->single_content, + is_independent => 0, + edit_user_id => logged_in_user->id, + }); + + content_type 'application/json'; + + return encode_json({ + id => $newFile->id, + name => $newFile->name, + is_ok => 1, + }); + } +}; + # Use api route to ensure errors are returned as JSON post '/api/file/?' => require_login sub { @@ -1745,10 +1793,17 @@ post '/api/file/?' => require_login sub { if (my $upload = upload('file')) { - my $mimetype = $filecheck->check_file($upload); # Borks on invalid file type + my $mimetype = $filecheck->check_file($upload, check_name => 0); # Borks on invalid file type + my $filename = $upload->filename; + + # Remove any invalid characters from the new name - this will possibly be changed to an error going forward + # Due to dragging allowing (almost) any character it is decided that this would be best so users can input what + # they want, and the text be stripped on rename server-side + $filename =~ s/[^a-zA-Z0-9\._\-\(\)]//g; + my $file; if (process( sub { $file = rset('Fileval')->create({ - name => $upload->filename, + name => $filename, mimetype => $mimetype, content => $upload->content, is_independent => 0, @@ -1757,7 +1812,7 @@ post '/api/file/?' => require_login sub { { return encode_json({ id => $file->id, - filename => $upload->filename, + filename => $filename, url => "/file/".$file->id, is_ok => 1, }); diff --git a/lib/GADS/Datum/File.pm b/lib/GADS/Datum/File.pm index 399c45298..fa8d28e97 100644 --- a/lib/GADS/Datum/File.pm +++ b/lib/GADS/Datum/File.pm @@ -65,10 +65,13 @@ after set_value => sub { # single files. if (@values == 1 && @old == 1) { - my $old_content = $self->schema->resultset('Fileval')->find($old[0])->content; + my $old_value = $self->schema->resultset('Fileval')->find($old[0]); # Only do one fetch here + my $old_content = $old_value->content; + my $old_name = $old_value->name; $changed = 0 if $self->schema->resultset('Fileval')->search({ id => $values[0], content => $old_content, + name => $old_name })->count; } } @@ -247,6 +250,22 @@ has single_content => ( }, ); +has single_id => ( + is => 'rw', + lazy => 1, + builder => sub { + $_[0]->_rset && $_[0]->_rset->id; + }, +); + +has single_rset => ( + is => 'rw', + lazy => 1, + builder => sub { + $_[0]->_rset; + }, +); + around 'clone' => sub { my $orig = shift; my $self = shift; diff --git a/lib/GADS/Filecheck.pm b/lib/GADS/Filecheck.pm index 9609a2b8f..b02e2673e 100644 --- a/lib/GADS/Filecheck.pm +++ b/lib/GADS/Filecheck.pm @@ -26,7 +26,9 @@ sub is_image } sub check_file -{ my ($self, $upload) = @_; +{ my ($self, $upload, %options) = @_; + + my $check_name = $options{check_name} // 1; my $info = $magic->info_from_filename($upload->tempname); @@ -52,9 +54,9 @@ sub check_file unless $ext =~ /^(doc|docx|pdf|jpeg|jpg|png|wav|rtf|xls|xlsx|ods|ppt|pptx|odf|odg|odt|ott|sda|sdc|sdd|sdw|sxc|sxw|odp|sdp|csv|txt|msg|tif|svg)$/i; # As recommended at https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload - error __x"The filename {name} is not allowed. Filenames can only contain alphanumeric characters and a single dot", - name => $upload->filename - unless $upload->filename =~ /[-+_ a-zA-Z0-9]{1,200}\.[a-zA-Z0-9]{1,10}/; + # Brackets have been added to this - above recommendations do not explicitly state that brackets are not allowed - Ticket #1695 + $self->check_name($upload->filename) + if($check_name); error __"Maximum file size is 5 MB" if $upload->size > 5 * 1024 * 1024; @@ -62,4 +64,11 @@ sub check_file return $info->{mime_type}; } +sub check_name { + my ($self, $filename) = @_; + error __x"The filename {name} is not allowed. Filenames can only contain alphanumeric characters and a single dot", + name => $filename + unless $filename =~ /[-+_ a-zA-Z0-9\(\)]{1,200}\.[a-zA-Z0-9]{1,10}/; +} + 1; diff --git a/lib/GADS/Schema/ResultSet/Fileval.pm b/lib/GADS/Schema/ResultSet/Fileval.pm index dba889191..cac618f7b 100644 --- a/lib/GADS/Schema/ResultSet/Fileval.pm +++ b/lib/GADS/Schema/ResultSet/Fileval.pm @@ -19,7 +19,11 @@ sub independent } sub find_with_permission -{ my ($self, $id, $user) = @_; +{ my ($self, $id, $user, %options) = @_; + + # Checks for whether the file is new or not, and therefore whether access is allowed + my $new_file_only = delete $options{new_file_only}; + my $rename_existing = delete $options{rename_existing}; my $fileval = $self->find($id) or return; @@ -32,6 +36,9 @@ sub find_with_permission # This will control access to the file if ($file_rs && $file_rs->layout_id) { + error __"Access to this file is not allowed as it is not a new file" + if $new_file_only; + my $layout = GADS::Layout->new( user => $user, schema => $self->result_source->schema, @@ -63,6 +70,8 @@ sub find_with_permission $file->schema($self->result_source->schema); } else { + error __"Access to this file is not allowed" + if $new_file_only || $rename_existing; $file->schema($self->result_source->schema); } diff --git a/package.json b/package.json index 65acab4cb..08d0428c1 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "keycharm": "^0.3.0", "marked": "^9.1.1", "moment": "^2.24.0", - "npm-run-all": "^4.1.5", "popper.js": "^1.16.1", "propagating-hammerjs": "^1.4.0", "react": "^16.13.1", @@ -60,6 +59,7 @@ "@babel/preset-react": "^7.16.7", "@babel/preset-typescript": "^7.16.7", "@babel/runtime-corejs3": "^7.14.7", + "@jest/globals": "^29.7.0", "@types/jest": "^29.5.6", "@types/jquery": "^3.5.24", "@types/jstree": "^3.3.46", @@ -84,7 +84,6 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "mini-css-extract-plugin": "^2.7.2", - "npm-watch": "^0.6.0", "postcss-loader": "^3.0.0", "sass": "^1.23.7", "sass-loader": "^8.0.0", @@ -99,7 +98,6 @@ "browserslist": [ "last 2 versions", "Firefox ESR", - "not dead", - "IE 11" + "not dead" ] } diff --git a/src/frontend/components/button/_button.scss b/src/frontend/components/button/_button.scss index 25a7bdbf1..261b9849f 100644 --- a/src/frontend/components/button/_button.scss +++ b/src/frontend/components/button/_button.scss @@ -255,6 +255,7 @@ $btn-border-radius: 23px; border-bottom: 1px solid transparent; border-radius: 0; color: $brand-secundary; + background: $transparent; &::before { @extend %icon-font; @@ -262,6 +263,7 @@ $btn-border-radius: 23px; content: "\E80a"; margin-right: 0.75rem; color: $brand-secundary; + background: $transparent; transition: all 0.2s ease; } @@ -272,6 +274,7 @@ $btn-border-radius: 23px; box-shadow: none; color: $brand-secundary; text-decoration: none; + background: $transparent; } } @@ -623,3 +626,8 @@ $btn-border-radius: 23px; content: "\E807"; color: $brand-danger; } + +.rename::before { + @extend %icon-font; + content: "\E80b"; +} \ No newline at end of file diff --git a/src/frontend/components/button/lib/rename-button.ts b/src/frontend/components/button/lib/rename-button.ts new file mode 100644 index 000000000..55e11b7bb --- /dev/null +++ b/src/frontend/components/button/lib/rename-button.ts @@ -0,0 +1,199 @@ +import { createElement } from "util/domutils"; + +/** + * Event fired when the file is renamed + */ +interface RenameEvent extends JQuery.Event { + /** + * The button clicked to fire the rename event + */ + target: HTMLButtonElement; + /** + * The old file name + */ + oldName: string; + /** + * The new file name + */ + newName: string; +} + +declare global { + interface JQuery { + /** + * Create a rename button + */ + renameButton(): JQuery; + /** + * Handle the rename event + * @param { RenameEvent } events The event name + * @param { 'rename' } handler The event handler + * @returns {JQuery} the JQuery element + */ + on(events: 'rename', handler: (ev: RenameEvent) => void): JQuery + } +} + +/** + * Rename button class + */ +class RenameButton { + private readonly dataClass = 'rename-button'; + private value: string; + + /** + * Attach event to button + * @param {HTMLButtonElement} button Button to attach the event to + */ + constructor(button: HTMLButtonElement) { + const $button = $(button); + if ($button.data(this.dataClass) === 'true') return; + const data = $button.data('fieldId'); + $button.on('click', (ev) => this.renameClick(data, ev)); + $button.data(this.dataClass, 'true'); + this.createElements($button, data); + } + + /** + * Create the relevant elements in order to perform the rename + * @param {JQuery} button The button element that shall trigger the rename + * @param {string | number} id The file ID to trigger the rename for + */ + private createElements(button: JQuery, id: string | number) { + if (!id) throw new Error("File ID is null or empty"); + if (!button || button.length < 1) throw new Error("Button element is null or empty") + const fileId = id as number ?? parseInt(id.toString()); + if (!fileId) throw new Error("Invalid file id!"); + button.closest(".row") + .append( + createElement('div', { classList: ['col', 'align-content-center'] }) + .append( + createElement("input", { + type: 'text', + id: `file-rename-${fileId}`, + name: `file-rename-${fileId}`, + classList: ['input', 'input--text', 'form-control', 'hidden'], + ariaHidden: 'true' + }) + ) + ).append( + createElement('div', { classList: ['col', 'align-content-center'] }) + .append( + createElement("button", { + id: `rename-confirm-${fileId}`, + type: 'button', + textContent: 'Rename', + ariaHidden: 'true', + classList: ['btn', 'btn-small', 'btn-default', 'hidden'] + }).on('click', (ev: JQuery.ClickEvent) => { + ev.preventDefault(); + this.renameClick(typeof (id) === 'string' ? parseInt(id) : id, ev); + }), + createElement("button", { + id: `rename-cancel-${fileId}`, + type: 'button', + textContent: 'Cancel', + ariaHidden: 'true', + classList: ['btn', 'btn-small', 'btn-danger', 'hidden'] + }) + ) + ); + } + + /** + * Perform click event + * @param {number} id The id of the field + * @param {JQuery.ClickEvent} ev The event object + */ + private renameClick(id: number, ev: JQuery.ClickEvent) { + ev.preventDefault(); + const original = $(`#current-${id}`) + .text() + .split('.') + .slice(0, -1) + .join('.'); + $(`#current-${id}`) + .addClass('hidden') + .attr('aria-hidden', 'true'); + $(`#file-rename-${id}`) + .removeClass('hidden') + .attr('aria-hidden', null) + .trigger('focus') + .val(original) + .on('keydown', (e) => this.renameKeydown(id, $(ev.target), e)) + .on('blur', (e) => { + this.value = (e.target as HTMLInputElement)?.value; + }) + $(`#rename-confirm-${id}`) + .removeClass('hidden') + .attr('aria-hidden', null) + .on('click', (e) => { + this.triggerRename(id, ev.target, e) + }); + $(`#rename-cancel-${id}`) + .removeClass('hidden') + .attr('aria-hidden', null) + .on('click', () => { + const e = $.Event('keydown', { key: 'Escape', code: 27 }); + $(`#file-rename-${id}`).trigger(e); + }) + $(ev.target).addClass('hidden').attr('aria-hidden', 'true'); + } + + /** + * Rename keydown event + * @param {number} id The id of the field + * @param {JQuery} button The button that was clicked + * @param {JQuery.KeyDownEvent} ev The keydown event + */ + private renameKeydown(id: number, button: JQuery, ev: JQuery.KeyDownEvent) { + if (ev.key === 'Escape') { + ev.preventDefault(); + this.hideRenameControls(id, button); + } + } + + /** + * Rename blur event + * @param {number} id The id of the field + * @param {JQuery} button The button that was clicked + * @param {JQuery.BlurEvent} ev The blur event + */ + private triggerRename(id: number, button: JQuery, e: JQuery.Event) { + const previousValue = $(`#current-${id}`).text(); + const extension = '.' + previousValue.split('.').pop(); + const newName = this.value.endsWith(extension) ? this.value : this.value + extension; + if (newName === '' || newName === previousValue) return; + $(`#current-${id}`).text(newName); + const event = $.Event('rename', { oldName: previousValue, newName, target: button }); + $(button).trigger(event); + this.hideRenameControls(id, button); + } + + private hideRenameControls(id: number, button: JQuery) { + $(`#current-${id}`).removeClass('hidden').attr('aria-hidden', 'false'); + $(`#file-rename-${id}`) + .addClass('hidden') + .attr('aria-hidden', 'true') + .off('blur'); + $(`#rename-confirm-${id}`) + .addClass('hidden') + .attr('aria-hidden', 'true') + .off('click'); + $(`#rename-cancel-${id}`) + .addClass('hidden') + .attr('aria-hidden', null) + .off('click'); + $(button).removeClass('hidden').attr('aria-hidden', 'false'); + } +} + +(function ($) { + $.fn.renameButton = function () { + return this.each(function (_: unknown, el: HTMLButtonElement) { + new RenameButton(el); + }); + }; +})(jQuery); + +export { RenameEvent }; diff --git a/src/frontend/components/data-table/_data-table.scss b/src/frontend/components/data-table/_data-table.scss index 81ea25433..f131486fe 100644 --- a/src/frontend/components/data-table/_data-table.scss +++ b/src/frontend/components/data-table/_data-table.scss @@ -375,3 +375,8 @@ button.btn-remove, button.btn-add { margin: $padding-base-vertical 0; } + +.dt-column-order { + display: none; + visibility: collapse; +} diff --git a/src/frontend/components/form-group/input/_input.scss b/src/frontend/components/form-group/input/_input.scss index 85b076a95..f7204caf2 100644 --- a/src/frontend/components/form-group/input/_input.scss +++ b/src/frontend/components/form-group/input/_input.scss @@ -204,6 +204,8 @@ border-radius: $input-border-radius; background-color: rgba($brand-success, 0.2); color: $brand-success; + text-align: center; + vertical-align: middle; } .progress-bar__container--fail { diff --git a/src/frontend/components/form-group/input/lib/documentComponent.ts b/src/frontend/components/form-group/input/lib/documentComponent.ts index d28dffa0b..6de41343e 100644 --- a/src/frontend/components/form-group/input/lib/documentComponent.ts +++ b/src/frontend/components/form-group/input/lib/documentComponent.ts @@ -1,29 +1,39 @@ +import 'components/button/lib/rename-button'; import { upload } from 'util/upload/UploadControl'; import { validateCheckboxGroup } from 'validation'; -import { hideElement, showElement } from 'util/common'; import { formdataMapper } from 'util/mapper/formdataMapper'; +import { logging } from 'logging'; +import { RenameEvent } from 'components/button/lib/rename-button'; +import { fromJson } from 'util/common'; interface FileData { id: number | string; filename: string; } +interface RenameResponse { + id: number | string; + name: string; + is_ok: boolean; +} + class DocumentComponent { readonly type = 'document'; - el: JQuery; - fileInput: JQuery; - error: JQuery; + readonly el: JQuery; + readonly fileInput: JQuery; constructor(el: JQuery | HTMLElement) { - this.el = el instanceof HTMLElement ? $(el) : el; + this.el = $(el); + this.el.closest('.fieldset').find('.rename').renameButton().on('rename', async (ev: RenameEvent) => { + if (!ev) throw new Error("e is not a RenameEvent - this shouldn't happen!") + const $target = $(ev.target); + await this.renameFile($target.data('field-id'), ev.oldName, ev.newName, $('body').data('csrf')); + }); this.fileInput = this.el.find('.form-control-file'); - this.error = this.el.find('.upload__error'); } - init() { + async init() { const url = this.el.data('fileupload-url'); - const $progressBarContainer = this.el.find('.progress-bar__container'); - const $progressBarProgress = this.el.find('.progress-bar__progress'); const tokenField = this.el.closest('form').find('input[name="csrf_token"]'); const csrf_token = tokenField.val() as string; @@ -31,44 +41,58 @@ class DocumentComponent { if (dropTarget) { const dragOptions = { allowMultiple: false }; - (dropTarget).filedrag(dragOptions).on('onFileDrop', (_ev: JQuery.DropEvent, file: File) => { // eslint-disable-line @typescript-eslint/no-explicit-any - this.handleAjaxUpload(url, csrf_token, file); + dropTarget.filedrag(dragOptions).on('onFileDrop', async (_: JQuery.DropEvent, file: File) => { + logging.info('File dropped', file); + await this.handleAjaxUpload(url, csrf_token, file); }); } else { throw new Error('Could not find file-upload element'); } - $('[name="file"]').on('change', (ev) => { + this.fileInput.on('change', async (ev) => { if (!(ev.target instanceof HTMLInputElement)) { throw new Error('Could not find file-upload element'); } - const file = ev.target.files![0]; - const formData = formdataMapper({ file, csrf_token }); - - upload(url, formData, 'POST', this.updateProgress).then((data) => { + try { + const file = ev.target.files![0]; + if (!file || file === undefined || !file.name) return; + const formData = formdataMapper({ file, csrf_token }); + this.showContainer(); + const data = await upload(url, formData, 'POST', this.showProgress.bind(this)); this.addFileToField({ id: data.id, name: data.filename }); - }).catch((error) => { - $progressBarProgress.css('width', '100%'); - $progressBarContainer.addClass('progress-bar__container--fail'); + } catch (error) { this.showException(error); - }); + return; + } }); } - handleAjaxUpload(uri: string, csrf_token: string, file: File) { - hideElement(this.error); - if (!file) throw this.showException('No file provided'); + showProgress(loaded, total) { + let uploadProgression = (loaded / total) * 100; + if (uploadProgression == Infinity) { + // This will occur when there is an error uploading the file or the file is empty + uploadProgression = 100; + } + this.el.find('.progress-bar__container') + .css('width', undefined) + .removeClass('progress-bar__container--fail'); + this.el.find('.progress-bar__percentage').html(uploadProgression === 100 ? 'complete' : `${uploadProgression}%`); + this.el.find('.progress-bar__progress').css('width', `${uploadProgression}%`); + } - const fileData = formdataMapper({ file, csrf_token }); + async handleAjaxUpload(uri: string, csrf_token: string, file: File) { + try { + if (!file) throw this.showException(new Error('No file provided')); - upload(uri, fileData, 'POST', this.updateProgress) - .then((data) => { - this.addFileToField({ id: data.id, name: data.filename }); - }) - .catch((e) => { - this.showException(e); - }); + const fileData = formdataMapper({ file, csrf_token }); + + this.showContainer(); + const data = await upload(uri, fileData, 'POST', this.showProgress.bind(this)); + this.addFileToField({ id: data.id, name: data.filename }); + } catch (e) { + this.showException(e instanceof Error ? e.message : e as string ?? e.toString()); + } } addFileToField(file: { id: number | string; name: string }) { @@ -77,17 +101,23 @@ class DocumentComponent { const fileId = file.id; const fileName = file.name; const field = $fieldset.find('.input--file').data('field'); + const csrf_token = $('body').data('csrf'); - if (!this.el.data('multivalue')) { - $ul.empty(); - } + if (!this.el || !this.el.length || !this.el.data('multivalue')) $ul.empty(); const $li = $(`
  • - - - - Include file. Current file name:${fileName}. +
    +
    + + + ${fileName} + +
    +
  • `); @@ -95,22 +125,61 @@ class DocumentComponent { $ul.closest('.linkspace-field').trigger('change'); validateCheckboxGroup($fieldset.find('.list')); $fieldset.find('input[type="file"]').removeAttr('required'); + const button = `.rename[data-field-id="${file.id}"]`; + const $button = $(button); + $button.renameButton().on('rename', async (ev: RenameEvent) => { + await this.renameFile(fileId as number ?? parseInt(fileId.toString()), ev.oldName, ev.newName, csrf_token, true); + }); } - showException(e: string | Error) { - this.error.html(e instanceof Error ? e.message : e); - showElement(this.error); + private async renameFile(fileId: number, oldName: string, newName: string, csrf_token: string, is_new: boolean = false) { // for some reason using the ev.target doesn't allow for changing of the data attribute - I don't know why, so I've used the button itself + try { + this.hideException(); + const filename = newName; + const url = `/api/file/${fileId}`; + const mappedData = formdataMapper({ csrf_token, filename, is_new: is_new ? 1 : 0 }); + const data = await upload(url, mappedData, 'PUT') + if (is_new) { + $(`#current-${fileId}`).text(data.name); + } else { + $(`#current-${fileId}`).closest('li').remove(); + const { id, name } = data; + this.addFileToField({ id, name }); + } + } catch (error) { + this.showException(error); + const current = $(`#current-${fileId}`); + current.text(oldName); + } } - private updateProgress(loaded: number, total: number) { - if (!this.el.data('multivalue')) { - const uploadProgression = Math.round((loaded / total) * 10000) / 100 + '%'; - this.el.find('.progress-bar__percentage').html(uploadProgression); - this.el.find('.progress-bar__progress').css('width', uploadProgression); - } + showContainer() { + const container = $(this.el.find('.progress-bar__container')) + container.show() + } + + showException(e: any) { + this.showContainer(); + logging.info('Error uploading file', e); + const error = typeof e == 'string' ? (fromJson(e) as Error).message : e.message; + this.el.find('.progress-bar__container') + .css('width', '100%') + .addClass('progress-bar__container--fail'); + this.el.find('.progress-bar__percentage').html(error); + } + + hideException() { + this.el.find('.progress-bar__container') + .css('width', undefined) + .removeClass('progress-bar__container--fail') + .hide(); } } +/** + * Create a new document component + * @param {JQuery | HTMLElement} el The element to attach the document component to + */ export default function documentComponent(el: JQuery | HTMLElement) { - new DocumentComponent(el).init(); + Promise.all([(new DocumentComponent(el)).init()]); } diff --git a/src/frontend/components/form-group/input/lib/filnameChecker.test.ts b/src/frontend/components/form-group/input/lib/filnameChecker.test.ts new file mode 100644 index 000000000..07bcb6ad7 --- /dev/null +++ b/src/frontend/components/form-group/input/lib/filnameChecker.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from '@jest/globals'; +import { checkFilename } from './filenameChecker'; + +describe('File name checker', () => { + it('should concatinate if the file name has multiple extensions', () => { + const name = 'file.name.txt'; + expect(checkFilename(name)).toBe('filename.txt'); + }); + + it('should throw an error if the file name has no extension', () => { + const name = 'file'; + expect(() => checkFilename(name)).toThrowError('Invalid file name - no extension found'); + }); + + it('should throw an error if the file name has no file name', () => { + const name = '.txt'; + expect(() => checkFilename(name)).toThrowError('Invalid file name - no file name found'); + }); + + it('should throw an error if the file name has invalid characters in extension', () => { + const name = 'file.na#me'; + expect(() => checkFilename(name)).toThrowError('Invalid file name - invalid characters in extension'); + }); + + it('should return the file name if it is valid', () => { + const name = 'file.txt'; + expect(checkFilename(name)).toBe('file.txt'); + }); + + for (const name of ['filena#me.txt', 'f\\i|l/e?n<>a#~m==e.txt', 'f\\i|l/e?.n<>a#~m==e.txt']) { + it(`should return the corrected file name with input ${name}`, () => { + expect(checkFilename(name)).toBe('filename.txt'); + }); + } +}); \ No newline at end of file diff --git a/src/frontend/css/stylesheets/base/_global.scss b/src/frontend/css/stylesheets/base/_global.scss index 0228f7f10..097917358 100644 --- a/src/frontend/css/stylesheets/base/_global.scss +++ b/src/frontend/css/stylesheets/base/_global.scss @@ -101,4 +101,4 @@ table.table-bordered { width: 98%; margin: 0 auto; } -} \ No newline at end of file +} diff --git a/src/frontend/js/lib/logging.js b/src/frontend/js/lib/logging.js index 896d46343..bea4144c4 100644 --- a/src/frontend/js/lib/logging.js +++ b/src/frontend/js/lib/logging.js @@ -1,30 +1,31 @@ class Logging { constructor() { this.allowLogging = + window.test || location.hostname === 'localhost' || location.hostname === '127.0.0.1' || location.hostname.endsWith('.peek.digitpaint.nl') } - log(message) { + log(...message) { if(this.allowLogging) { console.log(message) } } - info(message) { + info(...message) { if(this.allowLogging) { console.info(message) } } - warn(message) { + warn(...message) { if(this.allowLogging) { console.warn(message) } } - error(message) { + error(...message) { if(this.allowLogging) { console.error(message) } diff --git a/src/frontend/js/lib/util/domutils/index.ts b/src/frontend/js/lib/util/domutils/index.ts new file mode 100644 index 000000000..4a8f27d70 --- /dev/null +++ b/src/frontend/js/lib/util/domutils/index.ts @@ -0,0 +1 @@ +export { createElement } from './lib/elementFactory'; diff --git a/src/frontend/js/lib/util/domutils/lib/elementFactory.test.ts b/src/frontend/js/lib/util/domutils/lib/elementFactory.test.ts new file mode 100644 index 000000000..01325e1b8 --- /dev/null +++ b/src/frontend/js/lib/util/domutils/lib/elementFactory.test.ts @@ -0,0 +1,43 @@ +import '../../../../../testing/globals.definitions'; +import { createElement } from "./elementFactory"; +import { describe, it, expect } from "@jest/globals" + +describe('Element factory tests', () => { + it('Should create a basic DIV element', () => { + const expected = $(document.createElement('div')); + const result = createElement('div', {}); + expect(result).toEqual(expected); + }); + + it('Should create a DIV element with an ID', () => { + const el = document.createElement('div'); + el.id = "testElement"; + const expected = $(el); + const result = createElement('div', { id: 'testElement' }); + expect(result).toEqual(expected); + }); + + it('Should create a DIV element with a Class', () => { + const el = document.createElement('div'); + el.classList.add("testClass"); + const expected = $(el); + const result = createElement('div', { classList: ['testClass'] }); + expect(result).toEqual(expected); + }); + + it('Should create a DIV element with multiple Classes', () =>{ + const el=document.createElement('div'); + el.classList.add("testClass","testClass2"); + const expected = $(el); + const result = createElement('div', {classList: ['testClass', 'testClass2']}); + expect(result).toEqual(expected); + }); + + it('Should create a text input element', () => { + const el = document.createElement('input'); + el.type = 'text'; + const expected = $(el); + const result = createElement('input', {type: 'text'}); + expect(result).toEqual(expected); + }); +}); \ No newline at end of file diff --git a/src/frontend/js/lib/util/domutils/lib/elementFactory.ts b/src/frontend/js/lib/util/domutils/lib/elementFactory.ts new file mode 100644 index 000000000..8ffd93dbd --- /dev/null +++ b/src/frontend/js/lib/util/domutils/lib/elementFactory.ts @@ -0,0 +1,16 @@ +function createElement(type: 'button', definition: object): JQuery +function createElement(type: 'div', definition: object): JQuery +function createElement(type: 'input', definition: object): JQuery +function createElement(type: 'button' | 'div' | 'input', definition: object): JQuery { + const el = document.createElement(type); + for (const c in definition) { + if (Array.isArray(definition[c]) && el[c].add) { + el[c].add(...definition[c]); + } else { + el[c] = definition[c]; + } + } + return $(el); +} + +export { createElement } \ No newline at end of file diff --git a/src/frontend/js/lib/util/filedrag/index.js b/src/frontend/js/lib/util/filedrag/index.ts similarity index 72% rename from src/frontend/js/lib/util/filedrag/index.js rename to src/frontend/js/lib/util/filedrag/index.ts index 3fb25e0ee..7d666540e 100644 --- a/src/frontend/js/lib/util/filedrag/index.js +++ b/src/frontend/js/lib/util/filedrag/index.ts @@ -1,5 +1,15 @@ import FileDrag from "./lib/filedrag"; +import { FileDragOptions } from "./lib/filedrag"; + +declare global { + interface JQuery { + filedrag(options: FileDragOptions): JQuery; + } +} + +export {} + (function ($) { $.fn.filedrag = function (options) { options = $.extend({ diff --git a/src/frontend/js/lib/util/filedrag/lib/filedrag.test.ts b/src/frontend/js/lib/util/filedrag/lib/filedrag.test.ts index ebcaab851..c33acb443 100644 --- a/src/frontend/js/lib/util/filedrag/lib/filedrag.test.ts +++ b/src/frontend/js/lib/util/filedrag/lib/filedrag.test.ts @@ -42,8 +42,7 @@ describe('FileDrag class tests', () => { const fileDrag = new FileDragTest(document.createElement('div')); fileDrag.setDragging(true); expect(fileDrag.getDragging()).toBeTruthy(); - const e = $.Event('dragleave'); - (e).originalEvent = { pageX: 0, pageY: 0 }; + const e = $.Event('dragleave', { originalEvent: { pageX: 0, pageY: 0 } }); $(document).trigger(e); expect(fileDrag.getDragging()).toBeFalsy(); }); @@ -79,9 +78,7 @@ describe('FileDrag class tests', () => { expect(dropZone).toBeDefined(); const e = $.Event('dragenter'); $(document).trigger(e); - const e2 = $.Event('dragleave'); - (e2).originalEvent = { pageX: 0, pageY: 0 }; - (e2).stopPropagation = jest.fn(); + const e2 = $.Event('dragleave', { originalEvent: { pageX: 0, pageY: 0 }, preventDefault: jest.fn() }); $(document).trigger(e2); expect(child.style.display).toBe(''); expect(child.style.visibility).toBe(''); @@ -103,28 +100,28 @@ describe('FileDrag class tests', () => { it('triggers the event as expected when a file is dropped', () => { const child = createBaseDOM(); - const dropFunction = jest.fn((files)=>{ + const dropFunction = jest.fn((files) => { const myFile = files; expect(myFile).toBeDefined(); expect(myFile.name).toBe('test.txt'); }); - const fileDrag = new FileDragTest(child, (file)=>dropFunction(file)); + const fileDrag = new FileDragTest(child, (file) => dropFunction(file)); fileDrag.setDragging(true); const parent = child.parentElement; expect(parent).toBeDefined(); const dropZone = parent!.querySelector('.drop-zone'); expect(dropZone).toBeDefined(); - const e = $.Event('drop'); - (e).originalEvent = { - dataTransfer: { - files: [ - { - name: 'test.txt', - }, - ], - }, - }; - (e).stopPropagation = jest.fn(); + const e = $.Event('drop', { + originalEvent: { + dataTransfer: { + files: [ + { + name: 'test.txt', + }, + ], + }, + }, preventDefault: jest.fn() + }); $(dropZone!).trigger(e); expect(dropFunction).toHaveBeenCalled(); }); diff --git a/src/frontend/js/lib/util/filedrag/lib/filedrag.ts b/src/frontend/js/lib/util/filedrag/lib/filedrag.ts index ba2c77dc8..ab00d9375 100644 --- a/src/frontend/js/lib/util/filedrag/lib/filedrag.ts +++ b/src/frontend/js/lib/util/filedrag/lib/filedrag.ts @@ -1,6 +1,6 @@ import { hideElement, showElement } from "util/common"; -interface FileDragOptions { +export interface FileDragOptions { allowMultiple?: boolean; debug?: boolean; } @@ -11,7 +11,7 @@ class FileDrag { // for testing protected dragging: boolean = false; - constructor(private element: T, private options: FileDragOptions = {}, private onDrop?: (files: FileList | File) => void) { + constructor(element: T, private options: FileDragOptions = {}, private onDrop?: (files: FileList | File) => void) { if (options.debug) console.log('FileDrag', element, options); this.el = $(element); this.initElements() @@ -24,14 +24,15 @@ class FileDrag { this.dropZone.on('dragenter', (e) => { if (!this.dragging) return; if(!this.dropZone.hasClass('dragging')) this.dropZone.addClass('dragging'); - e.stopPropagation(); + e.preventDefault(); }); this.dropZone.on('dragleave', (e) => { if (!this.dragging) return; if(this.dropZone.hasClass('dragging')) this.dropZone.removeClass('dragging'); - e.stopPropagation(); + e.preventDefault(); }); this.dropZone.on('drop', (e) => { + e.preventDefault(); if (!this.dragging) return; this.dragging = false; if(this.el.hasClass('dragging')) this.el.removeClass('dragging'); @@ -46,7 +47,6 @@ class FileDrag { this.onDrop(e.originalEvent.dataTransfer.files[0]); } $(document).trigger('drop'); - e.stopPropagation(); }); } @@ -68,15 +68,15 @@ class FileDrag { showElement(this.el); }); $(document).on('drop', (e) => { + e.preventDefault(); if (!this.dragging) return; this.dragging = false; hideElement(this.dropZone); showElement(this.el); - e.stopPropagation(); }) $(document).on('dragover', (e) => { if (!this.dragging) return; - e.stopPropagation(); + e.preventDefault(); }); } diff --git a/tsconfig.json b/tsconfig.json index ca9f3e885..6dcc08557 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,9 @@ "validation": [ "js/lib/validation" ], + "logging": [ + "js/lib/logging" + ], } }, "include": [ diff --git a/views/edit.tt b/views/edit.tt index 82ba929c1..41cd4d753 100755 --- a/views/edit.tt +++ b/views/edit.tt @@ -853,27 +853,26 @@ '
    '; '
      '; FOREACH file IN editvalue.files; - '
    • '; - ''; - INCLUDE fields/sub/checkbox.tt - id = "file_checkbox_" _ col.id _ "_" _ file.id - name = field - label = "Include file." - value = file.id - checked = 1 - filter = "html"; - - ''; - ''; - 'Current file name: '; - ''; - file.name | html; - '.'; - ''; - '
    • '; + %] +
    • +
      +
      + + + [% file.name %] + +
      +
      +
    • + [% END; + ''; '
    '; '
    '; + '
    '; INCLUDE fields/file.tt id = col.id