diff --git a/jest.config.js b/jest.config.js index f3639d77e..f7fea59ff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -94,6 +94,9 @@ const config = { "^validation$": "/src/frontend/js/lib/validation", "^logging$": "/src/frontend/js/lib/logging", "^util/(.*)$": "/src/frontend/js/lib/util/$1", + "^components/(.*)$": "/src/frontend/components/$1", + "^set-field-values$": "/src/frontend/js/lib/set-field-values", + "^guid$": "/src/frontend/js/lib/guid", }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader diff --git a/lib/GADS.pm b/lib/GADS.pm index 7a830eac1..529d8b817 100644 --- a/lib/GADS.pm +++ b/lib/GADS.pm @@ -4796,7 +4796,13 @@ sub _process_edit { # The "source" parameter is user input, make sure still valid my $source_curval = $layout->column(param('source'), permission => 'read'); - try { $record->write(dry_run => 1, parent_curval => $source_curval) }; + my %options = (dry_run => 1, parent_curval => $source_curval); + # If this is being submitted as part of an autosave recovery, allow + # mandatory values to be empty. This is because a field may have + # since been made mandatory, and we don't want this to cause the + # autosave recovery to fail + $options{force_mandatory} = 1 if param 'autosave'; + try { $record->write(%options) }; if (my $e = $@->wasFatal) { push @validation_errors, $e->reason eq 'PANIC' ? 'An unexpected error occurred' : $e->message; diff --git a/lib/GADS/API.pm b/lib/GADS/API.pm index 42a08c4cc..1ebd96649 100644 --- a/lib/GADS/API.pm +++ b/lib/GADS/API.pm @@ -1448,6 +1448,17 @@ any ['get', 'post'] => '/api/users' => require_any_role [qw/useradmin superadmin return encode_json $return; }; +get '/api/get_key' => require_login sub { + my $user = logged_in_user; + + my $key = $user->encryption_key; + + return to_json { + error => 0, + key => $key + } +}; + sub _success { my $msg = shift; send_as JSON => { diff --git a/lib/GADS/Role/Presentation/Column/Curcommon.pm b/lib/GADS/Role/Presentation/Column/Curcommon.pm index 8a61a6802..784ab86f8 100644 --- a/lib/GADS/Role/Presentation/Column/Curcommon.pm +++ b/lib/GADS/Role/Presentation/Column/Curcommon.pm @@ -1,5 +1,6 @@ package GADS::Role::Presentation::Column::Curcommon; +use JSON qw(encode_json); use Moo::Role; sub after_presentation @@ -12,6 +13,7 @@ sub after_presentation $return->{data_filter_fields} = $self->data_filter_fields; $return->{typeahead_use_id} = 1; $return->{limit_rows} = $self->limit_rows; + $return->{modal_field_ids} = encode_json $self->curval_field_ids; # Expensive to build, so avoid if possible. Only needed for an edit, and no # point if they are filtered from record values as they will be rebuilt # anyway diff --git a/lib/GADS/Schema/Result/User.pm b/lib/GADS/Schema/Result/User.pm index 7de2dbe93..4570d4680 100644 --- a/lib/GADS/Schema/Result/User.pm +++ b/lib/GADS/Schema/Result/User.pm @@ -16,6 +16,8 @@ use GADS::Config; use GADS::Email; use HTML::Entities qw/encode_entities/; use Log::Report; +use MIME::Base64 qw/encode_base64url/; +use Digest::SHA qw/hmac_sha256 sha256/; use Moo; extends 'DBIx::Class::Core'; @@ -1226,4 +1228,23 @@ sub export_hash }; } +has encryption_key => ( + is => 'lazy', +); + +sub _build_encryption_key { + my $self = shift; + + my $header_json = '{"typ":"JWT","alg":"HS256"}'; + # This is a string because encode_json created the JSON string in an arbitrary order and we need the same key _every time_! + my $payload_json = '{"sub":"' . $self->id . '","user":"' . $self->username . '"}'; + my $header_b64 = encode_base64url($header_json); + my $payload_b64 = encode_base64url($payload_json); + my $input = "$header_b64.$payload_b64"; + my $secret = sha256($self->password); + my $sig = encode_base64url(hmac_sha256($input, $secret)); + + return encode_base64url(sha256("$input.$sig")); +} + 1; diff --git a/src/frontend/components/button/lib/cancel-button.ts b/src/frontend/components/button/lib/cancel-button.ts new file mode 100644 index 000000000..0bbaf24d8 --- /dev/null +++ b/src/frontend/components/button/lib/cancel-button.ts @@ -0,0 +1,15 @@ +import { clearSavedFormValues } from "./common"; + +export default function createCancelButton(el: HTMLElement | JQuery) { + const $el = $(el); + if ($el[0].tagName !== 'BUTTON') return; + $el.data('cancel-button', "true"); + $el.on('click', async () => { + const href = $el.data('href'); + await clearSavedFormValues($el.closest('form')); + if (href) + window.location.href = href; + else + window.history.back(); + }); +} \ No newline at end of file diff --git a/src/frontend/components/button/lib/common.test.ts b/src/frontend/components/button/lib/common.test.ts new file mode 100644 index 000000000..33729acaf --- /dev/null +++ b/src/frontend/components/button/lib/common.test.ts @@ -0,0 +1,23 @@ +import "../../../testing/globals.definitions"; +import {layoutId, recordId, table_key} from "./common"; + +describe("Common button tests",()=>{ + it("should populate table_key",()=>{ + expect(table_key()).toBe("linkspace-record-change-undefined-0"); // Undefined because $('body').data('layout-identifier') is not defined + }); + + it("should have a layoutId", ()=>{ + $('body').data('layout-identifier', 'layoutId'); + expect(layoutId()).toBe('layoutId'); + }); + + it("should have a recordId", ()=>{ + expect(isNaN(parseInt(location.pathname.split('/').pop() ?? ""))).toBe(true); + expect(recordId()).toBe(0); + }); + + it("should populate table_key fully",()=>{ + $('body').data('layout-identifier', 'layoutId'); + expect(table_key()).toBe("linkspace-record-change-layoutId-0"); + }); +}); diff --git a/src/frontend/components/button/lib/common.ts b/src/frontend/components/button/lib/common.ts new file mode 100644 index 000000000..347ba1fcd --- /dev/null +++ b/src/frontend/components/button/lib/common.ts @@ -0,0 +1,32 @@ +import gadsStorage from "util/gadsStorage"; + +export async function clearSavedFormValues($form: JQuery) { + if (!$form || $form.length === 0) return; + const layout = layoutId(); + const record = recordId(); + const ls = storage(); + const item = await ls.getItem(table_key()); + + if (item) ls.removeItem(`linkspace-record-change-${layout}-${record}`); + await Promise.all($form.find(".linkspace-field").map(async (_, el) => { + const field_id = $(el).data("column-id"); + const item = await gadsStorage.getItem(`linkspace-column-${field_id}-${layout}-${record}`); + if (item) gadsStorage.removeItem(`linkspace-column-${field_id}-${layout}-${record}`); + })); +} + +export function layoutId() { + return $('body').data('layout-identifier'); +} + +export function recordId() { + return isNaN(parseInt(location.pathname.split('/').pop())) ? 0 : parseInt(location.pathname.split('/').pop()); +} + +export function table_key() { + return `linkspace-record-change-${layoutId()}-${recordId()}`; +} + +export function storage() { + return location.hostname === 'localhost' || window.test ? localStorage : gadsStorage; +} \ No newline at end of file diff --git a/src/frontend/components/button/lib/component.ts b/src/frontend/components/button/lib/component.ts index 3823dd99f..6ae1ced67 100644 --- a/src/frontend/components/button/lib/component.ts +++ b/src/frontend/components/button/lib/component.ts @@ -105,6 +105,12 @@ class ButtonComponent extends Component { createRemoveUnloadButton(el); }); }); + map.set('btn-js-cancel', (el) => { + import(/* webpackChunkName: "cancel-button" */ './cancel-button') + .then(({default: createCancelButton}) => { + createCancelButton(el); + }); + }); ButtonComponent.staticButtonsMap = map; } diff --git a/src/frontend/components/button/lib/rename-button.ts b/src/frontend/components/button/lib/rename-button.ts index 0beb4f7e4..f1a004082 100644 --- a/src/frontend/components/button/lib/rename-button.ts +++ b/src/frontend/components/button/lib/rename-button.ts @@ -187,12 +187,14 @@ class RenameButton { } } -(function ($) { - $.fn.renameButton = function () { - return this.each(function (_: unknown, el: HTMLButtonElement) { - new RenameButton(el); - }); - }; -})(jQuery); +if(typeof jQuery !== 'undefined') { + (function ($) { + $.fn.renameButton = function () { + return this.each(function (_: unknown, el: HTMLButtonElement) { + new RenameButton(el); + }); + }; + })(jQuery); +} export { RenameEvent }; diff --git a/src/frontend/components/button/lib/submit-draft-record-button.ts b/src/frontend/components/button/lib/submit-draft-record-button.ts index 3bea9b682..1c983e810 100644 --- a/src/frontend/components/button/lib/submit-draft-record-button.ts +++ b/src/frontend/components/button/lib/submit-draft-record-button.ts @@ -1,13 +1,16 @@ +import { clearSavedFormValues } from "./common"; + /** * Create a submit draft record button * @param element {JQuery} The button element */ export default function createSubmitDraftRecordButton(element: JQuery) { - element.on("click", (ev: JQuery.ClickEvent) => { + element.on("click", async (ev: JQuery.ClickEvent) => { const $button = $(ev.target).closest('button'); const $form = $button.closest("form"); // Remove the required attribute from hidden required dependent fields $form.find(".form-group *[aria-required]").removeAttr('required'); + await clearSavedFormValues(ev.target.closest("form")); }); } diff --git a/src/frontend/components/button/lib/submit-record-button.ts b/src/frontend/components/button/lib/submit-record-button.ts index 0f5638e2c..8bed70160 100644 --- a/src/frontend/components/button/lib/submit-record-button.ts +++ b/src/frontend/components/button/lib/submit-record-button.ts @@ -1,4 +1,5 @@ import {validateRequiredFields} from "validation"; +import { clearSavedFormValues } from "./common"; /** * Button to submit records @@ -13,7 +14,7 @@ export default class SubmitRecordButton { * @param el {JQuery} Element to create as a button */ constructor(private el: JQuery) { - this.el.on("click", (ev: JQuery.ClickEvent) => { + this.el.on("click", async (ev: JQuery.ClickEvent) => { const $button = $(ev.target).closest('button'); const $form = $button.closest("form"); const $requiredHiddenRecordDependentFields = $form.find(".form-group[data-has-dependency='1'][style*='display: none'] *[aria-required]"); @@ -46,6 +47,7 @@ export default class SubmitRecordButton { if ($button.prop("name")) { $button.after(``); } + await clearSavedFormValues($form); } else { // Re-add the required attribute to required dependent fields $requiredHiddenRecordDependentFields.attr('required', ''); diff --git a/src/frontend/components/form-group/autosave/_autosave.scss b/src/frontend/components/form-group/autosave/_autosave.scss new file mode 100644 index 000000000..8011cead7 --- /dev/null +++ b/src/frontend/components/form-group/autosave/_autosave.scss @@ -0,0 +1,32 @@ +.field--changed { + input, .jstree-container-ul, .form-control { + background-color: $field-highlight; + border-radius: $input-border-radius; + } +} + +.modal-autosave { + max-height: 20rem; + overflow-y: auto; + overflow-x: hidden; +} + +li.li-success { + list-style: none; + &::before { + content: '\2713'; + color: green; + font-size: 1.5em; + margin-right: 0.5em; + } +} + +li.li-error { + list-style: none; + &::before { + content: '\2717'; + color: red; + font-size: 1.5em; + margin-right: 0.5em; + } +} \ No newline at end of file diff --git a/src/frontend/components/form-group/autosave/index.js b/src/frontend/components/form-group/autosave/index.js new file mode 100644 index 000000000..15608e56f --- /dev/null +++ b/src/frontend/components/form-group/autosave/index.js @@ -0,0 +1,8 @@ +import { initializeComponent } from 'component'; +import AutosaveComponent from './lib/component'; +import AutosaveModal from './lib/modal'; + +export default (scope) => { + initializeComponent(scope, '.linkspace-field', AutosaveComponent); + initializeComponent(scope, '#restoreValuesModal', AutosaveModal); +}; diff --git a/src/frontend/components/form-group/autosave/lib/autosave.test.ts b/src/frontend/components/form-group/autosave/lib/autosave.test.ts new file mode 100644 index 000000000..c3c958447 --- /dev/null +++ b/src/frontend/components/form-group/autosave/lib/autosave.test.ts @@ -0,0 +1,38 @@ +import "../../../../testing/globals.definitions"; +import AutosaveBase from './autosaveBase'; + +class TestAutosave extends AutosaveBase { + initAutosave(): void { + console.log('initAutosave'); + } +} + +describe('AutosaveBase', () => { + beforeAll(() => { + document.body.innerHTML = ` + +
+ + `; + $('body').data('layout-identifier', 1); + }); + + afterAll(()=>{ + document.body.innerHTML = ''; + }); + + it('should return layoutId', () => { + const autosave = new TestAutosave(document.getElementById('test')!); + expect(autosave.layoutId).toBe(1); + }); + + it('should return recordId', () => { + const autosave = new TestAutosave(document.getElementById('test')!); + expect(autosave.recordId).toBe(0); + }); + + it('should return table_key', () => { + const autosave = new TestAutosave(document.getElementById('test')!); + expect(autosave.table_key).toBe('linkspace-record-change-1-0'); + }); +}); diff --git a/src/frontend/components/form-group/autosave/lib/autosaveBase.ts b/src/frontend/components/form-group/autosave/lib/autosaveBase.ts new file mode 100644 index 000000000..cf88aac8e --- /dev/null +++ b/src/frontend/components/form-group/autosave/lib/autosaveBase.ts @@ -0,0 +1,35 @@ +import { Component } from "component"; +import gadsStorage from "util/gadsStorage"; + +export default abstract class AutosaveBase extends Component { + constructor(element: HTMLElement) { + super(element); + this.initAutosave(); + } + + get isClone() { + return location.search.includes('from'); + } + + get layoutId() { + return $('body').data('layout-identifier'); + } + + get recordId() { + return $('body').find('.form-edit').data('current-id') || 0; + } + + get storage() { + return gadsStorage; + } + + get table_key() { + return `linkspace-record-change-${this.layoutId}-${this.recordId}`; + } + + columnKey($field: JQuery) { + return `linkspace-column-${$field.data('column-id')}-${this.layoutId}-${this.recordId}`; + } + + abstract initAutosave(): void; +} \ No newline at end of file diff --git a/src/frontend/components/form-group/autosave/lib/component.js b/src/frontend/components/form-group/autosave/lib/component.js new file mode 100644 index 000000000..b51ce7425 --- /dev/null +++ b/src/frontend/components/form-group/autosave/lib/component.js @@ -0,0 +1,63 @@ +import { getFieldValues } from "get-field-values"; +import AutosaveBase from './autosaveBase'; + +class AutosaveComponent extends AutosaveBase { + async initAutosave() { + const $field = $(this.element); + const self = this; + if ($field.data('is-readonly')) return; + + // For each field, when it changes save the value to the local storage + $field.on('change', async function () { + if ($field.hasClass('field--changed')) $field.removeClass('field--changed'); + let values = getFieldValues($field, false, false, true); + values = (Array.isArray(values) ? values : [values]).filter(function (element) { + return !!element; + }); + + const column_key = self.columnKey($field); + + const $form = $field.closest('form'); + if ($form.hasClass('curval-edit-form')) { + // For curval modals, don't write anything immediately in case user + // cancels the modal. Store in form data in the modal and then save + // locally on submit + let existing = $form.data('autosave-changes') || {}; + existing[column_key] = values; + $form.data('autosave-changes', existing); + } else if ($field.data('value-selector') != 'noshow') { + // If this field is a curval with an add button, then make sure that + // any values that have been added (and will already be in storage) + // are retained. These will be returned from getFieldValues() as guids, + // but we need to retain all the associated values + if ($field.data('show-add') && await self.storage.getItem(column_key)) { + // Get all existing values for this curval + let existing = JSON.parse(await self.storage.getItem(column_key)); + // Map them into an index + let indexed = existing.filter((item) => !Number.isInteger(item)).reduce((a, v) => ({ ...a, [v.identifier]: v }), {}); + // For each value, if it's not an ID then get the full set of values + // that were previously retrieved from local storage + values = values.map((item) => Number.isInteger(item) ? item : indexed[item]); + } + await self.storage.setItem(column_key, JSON.stringify(values)); + if(!(await self.storage.getItem(self.table_key))) await self.storage.setItem(self.table_key, true); + } else { + // Delete any values now deleted + let existing = await self.storage.getItem(column_key) ? JSON.parse(await self.storage.getItem(column_key)) : []; + existing = existing.filter((item) => values.includes(item.identifier)); + await self.storage.setItem(column_key, JSON.stringify(existing)); + // And flag that something has changed (even if nothing has been + // deleted, this will need setting if the change was triggered as a + // result of a modal submit for a curval add - everything else will + // have already been saved) + if(!(await self.storage.getItem(self.table_key))) await self.storage.setItem(self.table_key, true); + } + }); + + if(this.isClone) { + $field.trigger('change'); + } + } +} + +export default AutosaveComponent; diff --git a/src/frontend/components/form-group/autosave/lib/modal.js b/src/frontend/components/form-group/autosave/lib/modal.js new file mode 100644 index 000000000..4abdf17f5 --- /dev/null +++ b/src/frontend/components/form-group/autosave/lib/modal.js @@ -0,0 +1,59 @@ +import { setFieldValues, CurvalError } from "set-field-values"; +import AutosaveBase from './autosaveBase'; + +class AutosaveModal extends AutosaveBase { + async initAutosave() { + const $modal = $(this.element); + const $form = $('.form-edit'); + + $modal.find('.btn-js-restore-values').on('click', async (e) => { + e.preventDefault(); + + let errored = false; + + let $list = $("
    "); + const $body = $modal.find(".modal-body"); + $body.html("

    Restoring values...

    ").append($list); + await Promise.all($form.find('.linkspace-field').map(async (_,field) => { + const $field = $(field); + await this.storage.getItem(this.columnKey($field)).then(json => { + let values = json ? JSON.parse(json) : undefined; + return values && Array.isArray(values) ? values : undefined; + }).then(values => { + const $editButton = $field.closest('.card--topic').find('.btn-js-edit'); + if ($editButton && $editButton.length) $editButton.trigger('click'); + if (Array.isArray(values)) { + setFieldValues($field, values); + $field.addClass("field--changed"); + const name = $field.data("name"); + let $li = $(`
  • Restored ${name}
  • `); + $list.append($li); + } + }).catch(e => { + const name = $field.data("name"); + const $li = $(`
  • Failed to restore ${name}
    • ${e.message}
  • `); + console.error(e); + $list.append($li); + errored = true; + }); + })).then(() => { + $body.append(`

    ${errored ? "Values restored with errors." : "All values restored."} Please check that all field values are as expected.

    `); + }).catch(e => { + $body.append(`

    Critical error restoring values

    ${e}

    `); + }).finally(() => { + $modal.find(".modal-footer").find("button:not(.btn-cancel)").hide(); + $modal.find(".modal-footer").find(".btn-cancel").text("Close"); + }); + }); + + const item = await this.storage.getItem(this.table_key); + + if (item){ + $modal.modal('show'); + $modal.find('.btn-js-delete-values').attr('disabled', 'disabled').hide(); + } + } + +} + +export default AutosaveModal; diff --git a/src/frontend/components/form-group/input/lib/documentComponent.ts b/src/frontend/components/form-group/input/lib/documentComponent.ts index 6de41343e..77cb2ca2e 100644 --- a/src/frontend/components/form-group/input/lib/documentComponent.ts +++ b/src/frontend/components/form-group/input/lib/documentComponent.ts @@ -1,4 +1,5 @@ import 'components/button/lib/rename-button'; +import 'util/filedrag'; import { upload } from 'util/upload/UploadControl'; import { validateCheckboxGroup } from 'validation'; import { formdataMapper } from 'util/mapper/formdataMapper'; @@ -83,7 +84,7 @@ class DocumentComponent { async handleAjaxUpload(uri: string, csrf_token: string, file: File) { try { - if (!file) throw this.showException(new Error('No file provided')); + if (!file) this.showException(new Error('No file provided')); const fileData = formdataMapper({ file, csrf_token }); @@ -103,7 +104,9 @@ class DocumentComponent { const field = $fieldset.find('.input--file').data('field'); const csrf_token = $('body').data('csrf'); - if (!this.el || !this.el.length || !this.el.data('multivalue')) $ul.empty(); + if (!this.el || !this.el.length || !this.el.closest('.linkspace-field').data('is-multivalue')) { + $ul.empty(); + } const $li = $(`
  • @@ -181,5 +184,7 @@ class DocumentComponent { * @param {JQuery | HTMLElement} el The element to attach the document component to */ export default function documentComponent(el: JQuery | HTMLElement) { - Promise.all([(new DocumentComponent(el)).init()]); + const component = new DocumentComponent(el); + component.init(); + return component; } diff --git a/src/frontend/components/modal/modals/curval/index.js b/src/frontend/components/modal/modals/curval/index.js index 9e1763f2b..7b496874d 100644 --- a/src/frontend/components/modal/modals/curval/index.js +++ b/src/frontend/components/modal/modals/curval/index.js @@ -1,10 +1,5 @@ -import { getComponentElements, initializeComponent } from 'component' +import { initializeComponent } from 'component'; +import CurvalModalComponent from './lib/component'; -export default (scope) => { - if (!getComponentElements(scope, '.modal--curval').length) return; - - import(/* webpackChunkName="modal" */ "./lib/component") - .then(({ default: CurvalModalComponent }) => { - initializeComponent(scope, '.modal--curval', CurvalModalComponent) - }); -} +// Originally this was an async load - this has been changed as the autosave code becomes overcomplex with async +export default (scope) => initializeComponent(scope, '.modal--curval', CurvalModalComponent); diff --git a/src/frontend/components/modal/modals/curval/lib/component.js b/src/frontend/components/modal/modals/curval/lib/component.js index af16f7842..58728b908 100644 --- a/src/frontend/components/modal/modals/curval/lib/component.js +++ b/src/frontend/components/modal/modals/curval/lib/component.js @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-this-alias */ import ModalComponent from '../../../lib/component' -import { getFieldValues } from "get-field-values" +import { setFieldValues } from "set-field-values" import { guid as Guid } from "guid" import { initializeRegisteredComponents } from 'component' import { validateRadioGroup, validateCheckboxGroup } from 'validation' +import gadsStorage from 'util/gadsStorage' class CurvalModalComponent extends ModalComponent { @@ -20,37 +21,91 @@ class CurvalModalComponent extends ModalComponent { this.setupModal() this.setupSubmit() } - - curvalModalValidationSucceeded(form, values) { + + // Set the value of a curval. In order to ensure consistent data, this + // function opens a modal edit for each curval, makes the changes, and then + // submits. It does this synchronously so that the modal is only processing + // one curval value at a time + setValue($target, rows) { + const layout_id = $target.data("column-id") + const $m = this.el + + let index = 0 + const self = this + autosaveLoadValue() + // Submit a single value for processing, and then once completed call the + // next one + function autosaveLoadValue() { + + if (index >= rows.length) return // Finished? + + const id = location.pathname.split("/").pop() + const record_id = isNaN(parseInt(id)) ? 0 : parseInt(id) + + let current_id = rows[index].identifier + let values = rows[index].values + + // New records will have a GUID rather than record ID + let guid + if (!/^\d+$/.test(current_id)) { + guid = current_id + current_id = null + } + const instance_name = $target.data("curval-instance-name") + // Load the modal and load each value into its fields + $m.find(".modal-body").load(self.getURL(current_id, instance_name, layout_id), function(){ + initializeRegisteredComponents($m.get(0)) + $m.find('.linkspace-field').each(function(){ + const $field = $(this) + const key = `linkspace-column-${$field.data('column-id')}-${$('body').data('layout-identifier')}-${record_id}` + const vals = values[key] + if (vals) { + setFieldValues($field, vals) + } + }) + let $form = $m.find('.curval-edit-form') + $form.data("guid", guid) + ++index + $form.trigger('submit', autosaveLoadValue) + }) + } + } + + async curvalModalValidationSucceeded(form, values) { const form_data = form.serialize() const modal_field_ids = form.data("modal-field-ids") const col_id = form.data("curval-id") - const current_id = form.data("current-id") - const instance_name = form.data("instance-name") let guid = form.data("guid") - const hidden_input = $("").attr({ - type: "hidden", - name: "field" + col_id, - value: form_data - }) const $formGroup = $("div[data-column-id=" + col_id + "]") const valueSelector = $formGroup.data("value-selector") - + const self=this + const $field = $(`#curval_list_${col_id}`).closest('.linkspace-field') + const current_id = form.data('current-id') + + const textValue = jQuery + .map(modal_field_ids, function(element) { + const value = values["field" + element] + return $("
    ") + .text(value) + .html() + }) + .join(", ") + if (valueSelector === "noshow") { - const self=this; + // No strict requirement for alias here, but it is needed below, so for the sake of consistency const row_cells = $('', self.context) - jQuery.map(modal_field_ids, function(element) { - const control = form.find('[data-column-id="' + element + '"]') - let value = getFieldValues(control) - value = values["field" + element] - value = $("
    ", self.context).text(value).html() + jQuery.map($field.data("modal-field-ids"), function(element) { + let value = values["field" + element] + value = $("
    ").text(value).html() row_cells.append( - $('', self.context).append(value) + $('').append(value) ) }) + const col_id = $field.data("column-id") + const instance_name = $field.data("curval-instance-name") const editButton = $( ` ' + ? '' : "" $search.before( - `
  • ${textValue}${deleteButton}
  • ` + `
  • ${textValue}${deleteButton}
  • ` ).before(' ') // Ensure space between elements in widget const inputType = multi ? "checkbox" : "radio" @@ -145,10 +209,36 @@ class CurvalModalComponent extends ModalComponent { import(/* webpackChunkName: "select-widget" */ '../../../../form-group/select-widget/lib/component') .then(({ default: SelectWidgetComponent }) => { new SelectWidgetComponent($widget[0]) - }); + }) + } + + // Update autosave values for all changes in this edit + const id = location.pathname.split("/").pop() + const record_id = isNaN(parseInt(id)) ? 0 : parseInt(id) + const parent_key = `linkspace-column-${col_id}-${$('body').data('layout-identifier')}-${record_id}`; + let existing = await gadsStorage.getItem(parent_key) ? (JSON.parse(await gadsStorage.getItem(parent_key))) : [] + const identifier = current_id || guid + // "existing" is the existing values for this curval + // Pull out the current record if it exists + let existing_row = existing.filter((item) => item.identifier == identifier)[0] || {identifier: identifier} + // And then remove it from the array so that we can re-add it in a moment + existing = existing.filter((item) => Number.isInteger(item) || item.identifier != identifier) + // Retrieve all the changes from the modal record form + let changes = form.data('autosave-changes') + // Changes will not exist if this update is being triggered by the restore of a previous autosave + if (changes) { + // Add them into the saved values for the single curval field + existing_row.values ||= {} + for (let [column_key, values] of Object.entries(changes)) { + existing_row.values[column_key] = values + } + existing.push(existing_row) + // Store as array for consistency with other field types + await gadsStorage.setItem(parent_key, JSON.stringify(existing)) } $(this.element).modal('hide') + $formGroup.trigger("change", true) } updateWidgetState($widget, multi, required) { @@ -181,23 +271,26 @@ class CurvalModalComponent extends ModalComponent { setupModal() { this.el.on('show.bs.modal', (ev) => { const button = ev.relatedTarget - const layout_id = $(button).data("layout-id") - const instance_name = $(button).data("instance-name") + const $field = $(button).closest('.linkspace-field') + const layout_id = $field.data("column-id") + const instance_name = $field.data("curval-instance-name") const current_id = $(button).data("current-id") const hidden = $(button) .closest(".table-curval-item") .find(`input[name=field${layout_id}]`) const form_data = hidden.val() const mode = hidden.length ? "edit" : "add" - const $formGroup = $(button).closest('.form-group') let guid - if ($formGroup.find('.table-curval-group').length) { - this.context = $formGroup.find('.table-curval-group') - } else if ($formGroup.find('.select-widget').length) { - this.context = $formGroup.find('.select-widget') + if ($field.find('.table-curval-group').length) { + this.context = $field.find('.table-curval-group') + } else if ($field.find('.select-widget').length) { + this.context = $field.find('.select-widget') } + // For edits, write a guid to the row now (if it hasn't already been + // written), which will be matched on submission. + // For new records, a guid is written on submission if (mode === "edit") { guid = hidden.data("guid") if (!guid) { @@ -207,18 +300,14 @@ class CurvalModalComponent extends ModalComponent { } const $m = $(this.element) - const self = this; + const self = this $m.find(".modal-body").text("Loading...") - const url = current_id - ? `/record/${current_id}` - : `/${instance_name}/record/` - $m.find(".modal-body").load( - this.getURL(url, layout_id, form_data, $formGroup), + this.getURL(current_id, instance_name, layout_id, form_data), function() { if (mode === "edit") { - $m.find("form").data("guid", guid); + $m.find("form").data("guid", guid) } initializeRegisteredComponents(self.element) } @@ -239,24 +328,21 @@ class CurvalModalComponent extends ModalComponent { } - getURL(url, layout_id, form_data, $formGroup) { - const devURLs = window.siteConfig && window.siteConfig.urls.curvalTableForm && window.siteConfig.urls.curvalSelectWidgetForm + getURL(current_id, instance_name, layout_id, form_data) { - if (devURLs) { - if ($formGroup.data('value-selector') === 'noshow') { - return window.siteConfig.urls.curvalTableForm - } else { - return window.siteConfig.urls.curvalSelectWidgetForm - } - } else { - return `${url}?include_draft&modal=${layout_id}&${form_data}` - } + let url = current_id + ? `/record/${current_id}` + : `/${instance_name}/record/` + + url = `${url}?include_draft&modal=${layout_id}` + if (form_data) url = url + `&${form_data}` + return url } setupSubmit() { - const self = this; + const self = this - $(this.element).on("submit", ".curval-edit-form", function(e) { + $(this.element).on("submit", ".curval-edit-form", function(e, autosaveLoadValue) { // Don't show close warning when user clicks submit button self.el.off('hide.bs.modal') @@ -272,8 +358,10 @@ class CurvalModalComponent extends ModalComponent { if (devData) { self.curvalModalValidationSucceeded($form, devData.values) } else { + let url = $form.attr("action") + "?validate&include_draft&source=" + $form.data("curval-id") + if (autosaveLoadValue) url = url + '&autosave=1' $.post( - $form.attr("action") + "?validate&include_draft&source=" + $form.data("curval-id"), + url, form_data, function(data) { if (data.error === 0) { @@ -288,13 +376,14 @@ class CurvalModalComponent extends ModalComponent { ) .fail(function(jqXHR, textstatus, errorthrown) { const errorMessage = `Oops! Something went wrong: ${textstatus}: ${errorthrown}` - self.curvalModalValidationFailed($form, errorMessage); + self.curvalModalValidationFailed($form, errorMessage) }) .always(function() { $form.removeClass("edit-form--validating") - }); + if (autosaveLoadValue) autosaveLoadValue() + }) } - }); + }) } } diff --git a/src/frontend/css/stylesheets/definitions/_variables.scss b/src/frontend/css/stylesheets/definitions/_variables.scss index 7f7c29505..5570aedf6 100644 --- a/src/frontend/css/stylesheets/definitions/_variables.scss +++ b/src/frontend/css/stylesheets/definitions/_variables.scss @@ -11,6 +11,8 @@ $brand-success: #18856B; $brand-success-light: #26D2A9; $brand-info-light: #ABA9EB; +$field-highlight: lighten($brand-warning, 40%); + $warning: $brand-warning; $brand-secundary-hover: $brand-primary; diff --git a/src/frontend/css/stylesheets/general.scss b/src/frontend/css/stylesheets/general.scss index 1087b5f9c..d576209b1 100644 --- a/src/frontend/css/stylesheets/general.scss +++ b/src/frontend/css/stylesheets/general.scss @@ -48,7 +48,8 @@ @import "dropdown/dropdown", "dropdown/dropdown-group"; // form group -@import "form-group/checkbox/checkbox", +@import "form-group/autosave/autosave", + "form-group/checkbox/checkbox", "form-group/checkbox/checkbox-reveal", "form-group/date-range/date-range", "form-group/query-builder/query-builder", diff --git a/src/frontend/js/lib/get-field-values.js b/src/frontend/js/lib/get-field-values.js index 5dd62cdca..7e82792c4 100644 --- a/src/frontend/js/lib/get-field-values.js +++ b/src/frontend/js/lib/get-field-values.js @@ -1,3 +1,5 @@ +import 'jstree'; + // General function to format date as per backend const format_date = function(date) { if (!date) return undefined; @@ -14,7 +16,7 @@ const format_date = function(date) { }; // get the value from a field, depending on its type -const getFieldValues = function($depends, filtered, for_code, form_value) { +const getFieldValues = function($depends, filtered, for_code, for_autosave) { const type = $depends.data("column-type"); // If a field is not shown then treat it as a blank value (e.g. if fields @@ -37,7 +39,7 @@ const getFieldValues = function($depends, filtered, for_code, form_value) { if ($depends.data('value-selector') == "noshow") { $depends.find('.table-curval-group').find('input').each(function(){ const item = $(this); - values.push(item) + values.push(item); }); } else if (filtered) { // Field is type "filval". Therefore the values are any visible value in @@ -82,12 +84,12 @@ const getFieldValues = function($depends, filtered, for_code, form_value) { return undefined; } } - } else if (form_value) { + } else if (for_autosave) { values = $.map(values, function(item) { if (item) { // If this is a newly added item, return the form data instead of the // ID (which won't be saved yet) - return item.data("list-id") || item.data("form-data"); + return item.data("list-id") || item.data("guid"); } else { return null; } @@ -105,7 +107,7 @@ const getFieldValues = function($depends, filtered, for_code, form_value) { const jstree = $depends.find('.jstree').jstree(true); $depends.find(".selected-tree-value").each(function() { const $node = $(this); - if (form_value) { + if (for_autosave) { values.push($node.val()); } else if (for_code) { // Replicate backend format. @@ -116,7 +118,7 @@ const getFieldValues = function($depends, filtered, for_code, form_value) { let parents = {}; ps.filter(id => id !== '#').reverse().forEach(function(id, index) { parents["parent"+(index+1)] = jstree.get_node(id).text; - }) + }); values.push({ value: node.text, parents: parents @@ -175,17 +177,17 @@ const getFieldValues = function($depends, filtered, for_code, form_value) { } else { return codevals[0]; } - } else if (form_value) { + } else if (for_autosave) { values = dateranges.map(function(dr) { return { from: dr.from.val(), to: dr.to.val() - } - }) + }; + }); } else { values = dateranges.map(function(dr) { return dr.from.val() + ' to ' + dr.to.val(); - }) + }); } } else if (type === "date") { @@ -195,12 +197,12 @@ const getFieldValues = function($depends, filtered, for_code, form_value) { const $df = $(this); return for_code ? format_date($df.datepicker("getDate")) : $df.val(); }).get(); - if (for_code || form_value) { + if (for_code || for_autosave) { return values; } } else { const $df = $depends.find(".form-control"); - if (for_code || form_value) { + if (for_code || for_autosave) { return format_date($df.datepicker("getDate")); } else { values = [$df.val()]; @@ -210,13 +212,13 @@ const getFieldValues = function($depends, filtered, for_code, form_value) { } else if (type === "file") { values = $depends.find("input:checkbox:checked").map(function(){ - if (form_value) { + if (for_autosave) { return { id: $(this).val(), filename: $(this).data('filename') - } + }; } else { - return $(this).data('filename') + return $(this).data('filename'); } }).get(); diff --git a/src/frontend/js/lib/set-field-value.test.ts b/src/frontend/js/lib/set-field-value.test.ts new file mode 100644 index 000000000..f68ce3512 --- /dev/null +++ b/src/frontend/js/lib/set-field-value.test.ts @@ -0,0 +1,870 @@ +import "../../testing/globals.definitions"; +import "components/button/lib/rename-button"; +import inputComponent from "../../components/form-group/input"; +import buttonComponent from "../../components/button"; +import multipleSelectComponent from "../../components/form-group/multiple-select"; +import selectWidgetComponent from "../../components/form-group/select-widget"; +import textAreaComponent from "../../components/form-group/textarea"; +import { describe, it, expect } from '@jest/globals'; +import { setFieldValues } from "./set-field-values"; + +declare global { + interface JQuery { + renameButton: (options?: any) => JQuery; + filedrag: (options?: any) => JQuery; + } +} + +(($)=>{ + $.fn.renameButton = jest.fn().mockReturnThis(); + $.fn.filedrag = jest.fn().mockReturnThis(); +})(jQuery); + +const stringDom = ` +
    +
    +
    + +
    +
    + +
    +
    +
    + `; + +const multiStringDom = ` +
    +
    +
    +
    + text +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    + `; + +const enumField = ` +
    + +
    +
    + enum +
    +
    +
    +
    +
      +
    • Select option
    • + + +
    +
    + +
    +
    +
    +
    + `; + +const multiEnumField = ` +
    +
    +
    + enum +
    +
    +
    +
    +
      +
    • Select option(s)
    • + + + +
    +
    + +
    +
    + enum is a required field. +
    +
    +
    +
    +`; + +const personField = ` +
    + +
    +
    + Person +
    +
    +
    +
    +
      +
    • Select option
    • + + +
    +
    + +
    +
    +
    +
    +`; + +const multiPersonField = ` +
    +
    +
    + Person +
    +
    +
    +
    +
      +
    • Select option(s)
    • + + + +
    +
    + +
    +
    +
    +
    +`; + +const dateRangeField = ` +
    +
    +
    + DateRange +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + to +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +`; + +const dateRangeMultiField = ` +
    +
    +
    +
    + DateRange +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + to +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +`; + +const dateField = ` +
    +
    +
    + +
    +
    + +
    +
    +
    +`; + +const multiDateField = ` +
    +
    +
    +
    + Date +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +`; + +const intgrField = ` +
    +
    +
    + +
    +
    + +
    +
    +
    +`; + +const multiIntgrField = ` +
    +
    +
    +
    + Intgr +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +
    +`; + +const fileDom = ` +
    +
    +
    + File +
    + +
    +
      +
    +
    +
    +
    +
    +
    +

    0%

    +
    +
    +
    + +
    + + +
    +
    +
    + +
    +
    +
    + + +
    +
    +`; + +const multiFileDom = ` +
    +
    +
    + File +
    + +
    +
      +
    +
    +
    +
    +
    +
    +

    0%

    +
    +
    +
    + +
    + + +
    +
    +
    + +
    +
    +
    + + +
    +
    +`; + +const textAreaDom = ` +
    +
    +
    + +
    +
    + +
    +
    +
    +`; + +describe('setFieldValue', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('Should error if passed in value is not an array?', () => { + const dom = $(stringDom)[0]; + document.body.appendChild(dom); + const field = $(dom); + expect(() => setFieldValues(field, 'test')).toThrowError('Attempt to set value for text without array'); + }); + + describe('String field', () => { + it('Should set the value of a string field', () => { + const dom = $(stringDom)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + setFieldValues(field, ['test']); + expect(field.find('input').val()).toBe('test'); + }); + + it('Should set the value of a multi string field', () => { + const dom = $(multiStringDom)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + setFieldValues(field, ['test1', 'test2', 'test3']); + const inputs = $('input'); + let i = 1; + inputs.each((_, input) => { + expect($(input).val()).toBe(`test${i++}`); + }); + expect(inputs.length).toBe(3); + }); + + it('Should set the value of a string field to an empty string', () => { + const dom = $(stringDom)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + setFieldValues(field, []); + expect(field.find('input').val()).toBe(''); + }); + }); + + describe('Enum field', () => { + it('Sets an enum field', () => { + const dom = $(enumField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [6]; + setFieldValues(field, values); + const input = field.find('input[type="radio"]'); + for (const val of input) { + if (values.includes(Number.parseInt(val.value))) { + expect(val.checked).toBe(true); + } else { + expect(val.checked).toBe(false); + } + } + }); + + it('Sets a multi enum field', () => { + const dom = $(multiEnumField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [6, 7]; + setFieldValues(field, values); + const input = field.find('input[type="checkbox"]'); + for (const val of input) { + if (values.includes(Number.parseInt(val.value))) { + expect(val.checked).toBe(true); + } else { + expect(val.checked).toBe(false); + } + } + }); + }); + + describe('Person field', () => { + it('Sets a person field', () => { + const dom = $(personField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [1]; + setFieldValues(field, values); + const input = field.find('input[type="radio"]'); + for (const val of input) { + if (values.includes(Number.parseInt(val.value))) { + expect(val.checked).toBe(true); + } else { + expect(val.checked).toBe(false); + } + } + }); + + it('Sets a multi person field', () => { + const dom = $(multiPersonField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [1, 4]; + setFieldValues(field, values); + const input = field.find('input[type="checkbox"]'); + for (const val of input) { + if (values.includes(Number.parseInt(val.value))) { + expect(val.checked).toBe(true); + } else { + expect(val.checked).toBe(false); + } + } + }); + }); + + describe('dateRange', () => { + it('Sets a date range field', () => { + const dom = $(dateRangeField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [{ from: '2021-01-01', to: '2021-01-31' }]; + setFieldValues(field, values); + const from = field.find('input[id$="_from"]'); + const to = field.find('input[id$="_to"]'); + expect(from.val()).toBe('2021-01-01'); + expect(to.val()).toBe('2021-01-31'); + }); + + it('Sets a multi date range field', () => { + const dom = $(dateRangeMultiField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [{ from: '2021-01-01', to: '2021-01-31' }, { from: '2021-02-01', to: '2021-02-28' }]; + setFieldValues(field, values); + const inputs = $('input'); + let i = 0; + inputs.each((_, input) => { + if (i % 2 === 0) { + expect($(input).val()).toBe(values[i / 2].from); + } else { + expect($(input).val()).toBe(values[Math.floor(i / 2)].to); + } + i++; + }); + expect(inputs.length).toBe(4); + }); + }); + + describe('Date', () => { + it('Sets a date field', () => { + const dom = $(dateField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = ['2021-01-01']; + setFieldValues(field, values); + const input = field.find('input'); + expect(input.val()).toBe('2021-01-01'); + }); + + it('Sets a multi date field', () => { + const dom = $(multiDateField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = ['2021-01-01', '2021-01-02', '2021-01-03']; + setFieldValues(field, values); + const inputs = $('input'); + let i = 0; + inputs.each((_, input) => { + expect($(input).val()).toBe(values[i++]); + }); + expect(inputs.length).toBe(3); + }); + + it('sets a date field using an object', ()=>{ + const dom = $(dateField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [{"year":2024,"month":11,"day":12,"hour":0,"minute":0,"second":0,"epoch":1731369600}]; + setFieldValues(field, values); + const input = field.find('input'); + expect(input.val()).toBe(`${values[0].year}-${values[0].month}-${values[0].day}`); + }); + }); + + describe('Intgr', () => { + it('Sets an integer field', () => { + const dom = $(intgrField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [1]; + setFieldValues(field, values); + const input = field.find('input'); + expect(input.val()).toBe('1'); + }); + + it('Sets a multi integer field', () => { + const dom = $(multiIntgrField)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [1, 2, 3]; + setFieldValues(field, values); + const inputs = $('input'); + let i = 1; + inputs.each((_, input) => { + expect($(input).val()).toBe(`${i++}`); + }); + expect(inputs.length).toBe(3); + }); + }); + + describe('File', () => { + it('Should set a file field', () => { + const dom = $(fileDom)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $(dom); + const values = [{ id: 1, filename: 'test' }]; + setFieldValues(field, values); + const inputs = field.find('input[type="checkbox"]'); + let i = 0; + inputs.each((_, input) => { + expect(Number.parseInt($(input).val() ?? "")).toBe(values[i++].id); + }); + }); + + it('Should set a multi file field', () => { + const dom = $(multiFileDom)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + document.body.appendChild(dom); + const field = $("#fileDom"); + const values = [{ id: 1, filename: 'test' }, { id: 2, filename: 'test1' }]; + setFieldValues(field, values); + const inputs = field.find('input[type="checkbox"]'); + let i = 1; + inputs.each((_, input) => { + expect(Number.parseInt($(input).val() as string)).toBe(i++); + }); + expect(inputs.length).toBe(2); + }); + }); + + describe('Text area', ()=> { + it('Should set a text area field', () => { + const dom = $(textAreaDom)[0]; + inputComponent(dom); + buttonComponent(dom); + multipleSelectComponent(dom); + selectWidgetComponent(dom); + textAreaComponent(dom); + document.body.appendChild(dom); + const $field = $(dom); + const values = ['test']; + setFieldValues($field, values); + const input = $field.find('textarea'); + expect(input.val()).toBe('test'); + }); + }); +}); \ No newline at end of file diff --git a/src/frontend/js/lib/set-field-values.js b/src/frontend/js/lib/set-field-values.js index 295892296..f5a1eac8d 100644 --- a/src/frontend/js/lib/set-field-values.js +++ b/src/frontend/js/lib/set-field-values.js @@ -1,3 +1,7 @@ +import CurvalModalComponent from 'components/modal/modals/curval' +import documentComponent from 'components/form-group/input/lib/documentComponent' +import "jstree"; + /* Set the value of a field, depending on its type. @@ -9,14 +13,22 @@ "id" key or a "text" key, with the required value */ +export class CurvalError extends Error { + constructor(message) { + super(message); + this.name = "CurvalError"; + } + + field; +} + const setFieldValues = function($field, values) { const type = $field.data("column-type") const name = $field.data("name") if (!Array.isArray(values)) { - console.error(`Attempt to set value for ${name} without array`) - return + throw new Error(`Attempt to set value for ${name} without array`) } if (type === "enum") { @@ -55,17 +67,42 @@ const setFieldValues = function($field, values) { } else if (type === "string" || type === "intgr") { + if(values.length===0) set_string($field, "") values.forEach(function(value, index){ let $single = prepare_multi($field, index) set_string($single, value) }) - } else { + } else if (type === "file") { - console.error(`Unable to set value for field ${name}: ${type}`) + $field.find(".fileupload__files").empty(); + // Component needs to be set up above .input--document div but below the + // fieldset div. The latter also has a .input class but it should be the + // former that becomes the component + let filecomp = (documentComponent($field.find('.file-upload'))); + values.forEach(function(value){ + filecomp.addFileToField({ id: value.id, name: value.filename }); + }) + + } else if (type === "curval") { + // Curval values can be either integers (existing record IDs) or completely + // new draft records + let ids = values.filter((item) => Number.isInteger(item)); + // For IDs, set them as normal enums + if ($field.data("is-multivalue")) { + set_enum_multi($field, ids); + } else { + set_enum_single($field, ids); + } + // For draft records, resubmit them through the modal + let records = values.filter((item) => !Number.isInteger(item)); + let curval = (CurvalModalComponent($field.closest('.content-block')))[0]; + curval.setValue($field, records); + } else { + throw new Error(`Unable to set value for field ${name}: ${type}`); } -}; +} // Deal with either single value field or field with multiple inputs. Create as // many inputs as required @@ -84,25 +121,32 @@ const prepare_multi = function($field, index) { const set_enum_single = function($element, values) { + const type = $element.data("column-type") // Accept ID or text value, specified depending on the key of the value // object values.forEach(function(value){ let $option let val - if (value.hasOwnProperty('id')) { + if (/^\d+$/.test(value)) { // Value could be a stringified integer + $option = $element.find(`input[value='${value}']`) + } else if (Object.prototype.hasOwnProperty.call(value, 'id')) { val = value['id'] $option = $element.find(`input[value='${val}']`) - } else if (value.hasOwnProperty('text')) { + } else if (Object.prototype.hasOwnProperty.call(value, 'text')) { val = value['text'] $option = $element.find(`input[data-value='${val}']`) } else { - console.error("Unknown key for single enum") + throw new Error("Unknown value or key for single enum") } if ($option.length) { $option.trigger("click") } else { let name = $element.data("name") - console.log(`Unknown value ${val} for ${name}`) + if(type === 'curval') { + throw new CurvalError(`Unable to set value for ${name} - the data may have been changed or removed`) + }else{ + throw new Error(`Unknown value ${val} for ${name}`) + } } }) @@ -114,12 +158,14 @@ const set_enum_multi = function($element, values) { const text_hash = {} values.forEach((elem) => { - if (elem.hasOwnProperty('id')) { + if (/^\d+$/.test(elem)) { // Value could be a stringified integer + id_hash[elem] = false + } else if (Object.prototype.hasOwnProperty.call(elem, 'id')) { id_hash[elem.id] = false - } else if (elem.hasOwnProperty('text')) { + } else if (Object.prototype.hasOwnProperty.call(elem, 'text')) { text_hash[elem.text] = false } else { - console.error("Unknown key for multi enum") + throw new Error("Unknown value or key for multi enum") } }) @@ -129,35 +175,57 @@ const set_enum_multi = function($element, values) { let $check = $(this).find('input') // Mark an option checked if either the id or text value match the // submitted values - if (id_hash.hasOwnProperty($check.val()) || text_hash.hasOwnProperty($check.data("value"))) { + if (Object.prototype.hasOwnProperty.call(id_hash, $check.val()) || Object.prototype.hasOwnProperty.call(text_hash, $check.data("value"))) { if (!$check.is(":checked")) { $check.trigger("click") } - if (id_hash.hasOwnProperty($check.val())) id_hash[$check.val] = true - if (text_hash.hasOwnProperty($check.data("value"))) text_hash[$check.data("value")] = true + if (Object.prototype.hasOwnProperty.call(id_hash, $check.val())) id_hash[$check.val()] = true + if (Object.prototype.hasOwnProperty.call(text_hash, $check.data("value"))) text_hash[$check.data("value")] = true } else { if ($check.is(":checked")) { $check.trigger("click") } } - }); + }) // Report any values that weren't used let name = $element.data("name") + let type = $element.data("column-type") for (const [value, used] of Object.entries(id_hash)) { - if (!used) { - console.log(`Unmatched value ${value} for ${name}`) + if (!used && type !== "curval") { + throw new CurvalError(`Unable to set value for ${name} - the data may have been changed or removed`) + } else if (!used) { + throw new Error(`Unmatched value ${value} for ${name}`) } } for (const [value, used] of Object.entries(text_hash)) { if (!used) { - console.log(`Unmatched value ${value} for ${name}`) + if (!used && type !== "curval") { + throw new CurvalError(`Unable to set value for ${name} - the data may have been changed or removed`) + } else if (!used) { + throw new Error(`Unmatched value ${value} for ${name}`) + } } } } const set_date = function($element, value) { - $element.find('input').datepicker('update', new Date(value * 1000)); + const $input = $element.find('input') + if (/^\d+$/.test(value)) { + // Assume epoch + $input.datepicker('update', new Date(value * 1000)) + } else { + // Otherwise assume string + if (typeof value === 'object'){ + if(value.epoch) { + $input.datepicker('update', new Date(value.epoch * 1000)) + } else { + $input.datepicker('setDate', `${value.year}-${value.month}-${value.day}`) + } + } else { + $input.datepicker('setDate', value) + } + } } const set_daterange = function($element, value) { @@ -166,12 +234,13 @@ const set_daterange = function($element, value) { } const set_string = function($element, value) { - $element.find('input').val(value) + const $input = $element.find('input').length? $element.find('input') : $element.find('textarea'); + $input.val(value).trigger("change") } const set_tree = function($field, values) { - let $jstree = $field.find('.jstree').jstree(true); + let $jstree = $field.find('.jstree').jstree(true) let nodes = $jstree.get_json('#', { flat: true }) // Create a hash to map all the text values to ids, in case value is supplied @@ -182,20 +251,22 @@ const set_tree = function($field, values) { }) $jstree.deselect_all() values.forEach(function(value){ - let id; - if (value.hasOwnProperty('id')) { + let id + if (/^\d+$/.test(value)) { // Value could be a stringified integer + id = value + } else if (Object.prototype.hasOwnProperty.call(value, 'id')) { id = value['id'] - } else if (value.hasOwnProperty('text')) { - if (nodes_hash.hasOwnProperty(value['text'])) { + } else if (Object.prototype.hasOwnProperty.call(value, 'text')) { + if (Object.prototype.hasOwnProperty.call(nodes_hash, value['text'])) { id = nodes_hash[value['text']] } else { console.debug("Unknown text value for tree: " + value['text']) } } else { - console.error("Unknown value key for tree") + throw new Error("Unknown value key for tree") } $jstree.select_node(id) }) } -export { setFieldValues }; +export { setFieldValues } diff --git a/src/frontend/js/lib/util/encryptedStorage/index.ts b/src/frontend/js/lib/util/encryptedStorage/index.ts new file mode 100644 index 000000000..3f7d832f4 --- /dev/null +++ b/src/frontend/js/lib/util/encryptedStorage/index.ts @@ -0,0 +1 @@ +export { EncryptedStorage } from './lib/encryptedStorage'; diff --git a/src/frontend/js/lib/util/encryptedStorage/lib/encryptedStorage.test.ts b/src/frontend/js/lib/util/encryptedStorage/lib/encryptedStorage.test.ts new file mode 100644 index 000000000..91843b3cd --- /dev/null +++ b/src/frontend/js/lib/util/encryptedStorage/lib/encryptedStorage.test.ts @@ -0,0 +1,84 @@ +import { EncryptedStorage } from './encryptedStorage'; + +class TestStorage implements Storage { + private map = new Map(); + + [name: string]: any; + length: number; + + clear(): void { + this.map.clear(); + this.length = 0; + } + getItem(key: string): string | null { + const ret = this.map.get(key); + if (ret === undefined) { + return null; + } + return ret; + } + key(index: number): string | null { + const keys = Array.from(this.map.keys()); + if (keys.length <= index) { + return null; + } + return keys[index]; + } + removeItem(key: string): void { + if (this.map.has(key)) { + this.map.delete(key); + this.length = this.map.size; + this[key] = undefined; + } + } + setItem(key: string, value: string): void { + this.map.set(key, value); + this[key] = value; + this.length = this.map.size; + } +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const encryptedStorageMock = new EncryptedStorage((data: string, key: string) => Promise.resolve(data), (data: string, key: string) => Promise.resolve(data), new TestStorage()); + +describe('EncryptedStorage', () => { + it('should set and get an item', async () => { + const key = 'key'; + const value = 'value'; + const encryptionKey = 'encryptionKey'; + await encryptedStorageMock.setItem(key, value, encryptionKey); + const result = await encryptedStorageMock.getItem(key, encryptionKey); + expect(result).toBe(value); + }); + + it('should remove an item', async () => { + const key = 'key'; + const value = 'value'; + const encryptionKey = 'encryptionKey'; + await encryptedStorageMock.setItem(key, value, encryptionKey); + encryptedStorageMock.removeItem(key); + const result = await encryptedStorageMock.getItem(key, encryptionKey); + expect(result).toBe(null); + }); + + it('should clear all items', async () => { + const key = 'key'; + const value = 'value'; + const encryptionKey = 'encryptionKey'; + await encryptedStorageMock.setItem(key, value, encryptionKey); + encryptedStorageMock.clear(); + const result = await encryptedStorageMock.getItem(key, encryptionKey); + expect(result).toBe(null); + expect(encryptedStorageMock.length).toBe(0); + }); + + it('should get length', async () => { + const testStorage = new TestStorage(); + if (!testStorage.key) throw new Error('key is not defined'); + const key = 'key'; + const value = 'value'; + const encryptionKey = 'encryptionKey'; + await encryptedStorageMock.setItem(key, value, encryptionKey); + expect(encryptedStorageMock.length).toBe(1); + }); +}); diff --git a/src/frontend/js/lib/util/encryptedStorage/lib/encryptedStorage.ts b/src/frontend/js/lib/util/encryptedStorage/lib/encryptedStorage.ts new file mode 100644 index 000000000..8eda9083d --- /dev/null +++ b/src/frontend/js/lib/util/encryptedStorage/lib/encryptedStorage.ts @@ -0,0 +1,49 @@ +import { decrypt, encrypt } from "util/encryption"; + +class EncryptedStorage { + private static _instance: EncryptedStorage; + + static instance() { + if (!EncryptedStorage._instance) { + EncryptedStorage._instance = new EncryptedStorage(encrypt, decrypt); + } + return EncryptedStorage._instance; + } + + private storage: Storage; + + constructor(private encrypt: (data:string, key:string) => Promise, private decrypt: (data:string, key:string) => Promise, storage?: Storage) { + this.storage = storage || window.localStorage; + } + + async setItem(key: string, value: string, encryptionKey: string) { + const encryptedValue = await this.encrypt(value, encryptionKey); + this.storage.setItem(key, encryptedValue); + } + + async getItem(key: string, encryptionKey: string) { + const encryptedValue = this.storage.getItem(key); + if (!encryptedValue) { + return null; + } + return await this.decrypt(encryptedValue, encryptionKey); + } + + removeItem(key: string) { + this.storage.removeItem(key); + } + + clear() { + this.storage.clear(); + } + + key(index: number) { + return this.storage.key(index); + } + + get length() { + return this.storage.length; + } +} + +export { EncryptedStorage }; diff --git a/src/frontend/js/lib/util/encryption/index.ts b/src/frontend/js/lib/util/encryption/index.ts new file mode 100644 index 000000000..95a159b7c --- /dev/null +++ b/src/frontend/js/lib/util/encryption/index.ts @@ -0,0 +1 @@ +export { encrypt, decrypt } from './lib/encryption'; \ No newline at end of file diff --git a/src/frontend/js/lib/util/encryption/lib/encryption.ts b/src/frontend/js/lib/util/encryption/lib/encryption.ts new file mode 100644 index 000000000..5f528d61c --- /dev/null +++ b/src/frontend/js/lib/util/encryption/lib/encryption.ts @@ -0,0 +1,55 @@ +export async function encrypt(data: string, password: string): Promise { + const key = createKey(password, "encrypt"); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoder = new TextEncoder(); + const encoded = encoder.encode(data); + const encrypted = crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv + }, + await key, + encoded + ); + const ivArray = Array.from(iv); + const encryptedArray = Array.from(new Uint8Array(await encrypted)); + const result = ivArray.concat(encryptedArray); + return btoa(JSON.stringify(result)); +} + +export async function decrypt(data: string, password: string): Promise { + const key = createKey(password, "decrypt"); + const decoded = JSON.parse(atob(data)); + const iv = new Uint8Array(decoded.slice(0, 12)); + const encrypted = new Uint8Array(decoded.slice(12)); + const decrypted = crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv + }, + await key, + encrypted + ); + const decoder = new TextDecoder(); + return decoder.decode(await decrypted); +} + + +async function createKey(password: string, mode: "encrypt" | "decrypt") { + const salt = new TextEncoder().encode("salt"); + const encoder = new TextEncoder(); + const deriver = crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"]); + const key = crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + hash: "SHA-256" + }, + await deriver, + { name: "AES-GCM", length: 256 }, + true, + [mode] + ); + return await key; +} diff --git a/src/frontend/js/lib/util/filedrag/index.ts b/src/frontend/js/lib/util/filedrag/index.ts index 7d666540e..e5a11345d 100644 --- a/src/frontend/js/lib/util/filedrag/index.ts +++ b/src/frontend/js/lib/util/filedrag/index.ts @@ -8,23 +8,25 @@ declare global { } } -export {} +export { } -(function ($) { - $.fn.filedrag = function (options) { - options = $.extend({ - allowMultiple: true, - debug: false - }, options); +if (typeof jQuery !== "undefined") { + (function ($) { + $.fn.filedrag = function (options) { + options = $.extend({ + allowMultiple: true, + debug: false + }, options); - if (!this.data("filedrag")) { - this.data("filedrag", "true"); - new FileDrag(this, options, (files) => { - if(options.debug) console.log("onFileDrop", files); - this.trigger("onFileDrop", files) - }) - } + if (!this.data("filedrag")) { + this.data("filedrag", "true"); + new FileDrag(this, options, (files) => { + if (options.debug) console.log("onFileDrop", files); + this.trigger("onFileDrop", files) + }) + } - return this; - }; -})(jQuery); \ No newline at end of file + return this; + }; + })(jQuery); +} \ No newline at end of file diff --git a/src/frontend/js/lib/util/gadsStorage/index.ts b/src/frontend/js/lib/util/gadsStorage/index.ts new file mode 100644 index 000000000..f9d246979 --- /dev/null +++ b/src/frontend/js/lib/util/gadsStorage/index.ts @@ -0,0 +1,5 @@ +import { GadsStorage } from "./lib/gadsStorage"; + +const gadsStorage = new GadsStorage(); + +export default gadsStorage; \ No newline at end of file diff --git a/src/frontend/js/lib/util/gadsStorage/lib/gadsStorage.ts b/src/frontend/js/lib/util/gadsStorage/lib/gadsStorage.ts new file mode 100644 index 000000000..828231718 --- /dev/null +++ b/src/frontend/js/lib/util/gadsStorage/lib/gadsStorage.ts @@ -0,0 +1,56 @@ +import { EncryptedStorage } from "util/encryptedStorage"; + +class GadsStorage { + private get test() { + return location.hostname==="localhost"; + } + + private storage: EncryptedStorage | Storage; + private storageKey: string; + + constructor() { + this.test && console.log("Using localStorage"); + this.storage = this.test ? localStorage : EncryptedStorage.instance(); + } + + private async getStorageKey() { + const fetchResult = await fetch("/api/get_key"); + const data = await fetchResult.json(); + if (data.error !== 0) { + throw new Error("Failed to get storage key"); + } + this.storageKey = data.key; + } + + async setItem(key: string, value: string) { + if (!this.storageKey) { + await this.getStorageKey(); + } + await this.storage.setItem(key, value, this.storageKey); + } + + async getItem(key: string) { + if (!this.storageKey) { + await this.getStorageKey(); + } + return await this.storage.getItem(key, this.storageKey); + } + + removeItem(key: string) { + this.storage.removeItem(key); + } + + clear() { + this.storage.clear(); + } + + key(index: number) { + return this.storage.key(index); + } + + get length() { + return this.storage.length; + } +} + +export { GadsStorage }; \ No newline at end of file diff --git a/src/frontend/js/site.js b/src/frontend/js/site.js index a9cc1d73c..73dbc0ecb 100644 --- a/src/frontend/js/site.js +++ b/src/frontend/js/site.js @@ -1,84 +1,86 @@ -import "regenerator-runtime/runtime.js" -import { initializeRegisteredComponents, registerComponent } from 'component' -import 'bootstrap' -import 'components/graph/lib/chart' -import 'util/filedrag' +import "regenerator-runtime/runtime.js"; +import { initializeRegisteredComponents, registerComponent } from 'component'; +import 'bootstrap'; +import 'components/graph/lib/chart'; +import 'util/filedrag'; // Components -import AddTableModalComponent from 'components/modal/modals/new-table' -import CalcFieldsComponent from 'components/form-group/calc-fields' -import CalculatorComponent from 'components/calculator' -import CheckboxComponent from 'components/form-group/checkbox' -import CollapsibleComponent from 'components/collapsible' -import CurvalModalComponent from 'components/modal/modals/curval' -import DashboardComponent from 'components/dashboard' -import DataTableComponent from 'components/data-table' -import DependentFieldsComponent from 'components/form-group/dependent-fields' -import DisplayConditionsComponent from 'components/form-group/display-conditions' -import ExpandableCardComponent from 'components/card' -import FilterComponent from 'components/form-group/filter' -import GlobeComponent from 'components/globe' -import GraphComponent from 'components/graph' -import InputComponent from 'components/form-group/input' -import MoreLessComponent from 'components/more-less' -import MultipleSelectComponent from 'components/form-group/multiple-select' -import OrderableSortableComponent from 'components/sortable/orderable-sortable' -import PopoverComponent from 'components/popover' -import RadioGroupComponent from 'components/form-group/radio-group' -import RecordPopupComponent from 'components/record-popup' -import SelectComponent from 'components/form-group/select' -import SelectWidgetComponent from 'components/form-group/select-widget' -import SidebarComponent from 'components/sidebar' -import SortableComponent from 'components/sortable' -import TextareaComponent from 'components/form-group/textarea' -import TimelineComponent from 'components/timeline' -import TippyComponent from 'components/timeline/tippy' -import TreeComponent from 'components/form-group/tree' -import UserModalComponent from 'components/modal/modals/user' -import ValueLookupComponent from 'components/form-group/value-lookup' -import MarkdownComponent from "components/markdown" -import ButtonComponent from "components/button" -import SelectAllComponent from "components/select-all" -import HelpView from "components/help-view" -import PeopleFilterComponent from "components/form-group/people-filter" +import AddTableModalComponent from 'components/modal/modals/new-table'; +import AutosaveComponent from 'components/form-group/autosave'; +import CalcFieldsComponent from 'components/form-group/calc-fields'; +import CalculatorComponent from 'components/calculator'; +import CheckboxComponent from 'components/form-group/checkbox'; +import CollapsibleComponent from 'components/collapsible'; +import CurvalModalComponent from 'components/modal/modals/curval'; +import DashboardComponent from 'components/dashboard'; +import DataTableComponent from 'components/data-table'; +import DependentFieldsComponent from 'components/form-group/dependent-fields'; +import DisplayConditionsComponent from 'components/form-group/display-conditions'; +import ExpandableCardComponent from 'components/card'; +import FilterComponent from 'components/form-group/filter'; +import GlobeComponent from 'components/globe'; +import GraphComponent from 'components/graph'; +import InputComponent from 'components/form-group/input'; +import MoreLessComponent from 'components/more-less'; +import MultipleSelectComponent from 'components/form-group/multiple-select'; +import OrderableSortableComponent from 'components/sortable/orderable-sortable'; +import PopoverComponent from 'components/popover'; +import RadioGroupComponent from 'components/form-group/radio-group'; +import RecordPopupComponent from 'components/record-popup'; +import SelectComponent from 'components/form-group/select'; +import SelectWidgetComponent from 'components/form-group/select-widget'; +import SidebarComponent from 'components/sidebar'; +import SortableComponent from 'components/sortable'; +import TextareaComponent from 'components/form-group/textarea'; +import TimelineComponent from 'components/timeline'; +import TippyComponent from 'components/timeline/tippy'; +import TreeComponent from 'components/form-group/tree'; +import UserModalComponent from 'components/modal/modals/user'; +import ValueLookupComponent from 'components/form-group/value-lookup'; +import MarkdownComponent from "components/markdown"; +import ButtonComponent from "components/button"; +import SelectAllComponent from "components/select-all"; +import HelpView from "components/help-view"; +import PeopleFilterComponent from "components/form-group/people-filter"; // Register them -registerComponent(AddTableModalComponent) -registerComponent(ButtonComponent) -registerComponent(CalcFieldsComponent) -registerComponent(CalculatorComponent) -registerComponent(CheckboxComponent) -registerComponent(CollapsibleComponent) -registerComponent(CurvalModalComponent) -registerComponent(DashboardComponent) -registerComponent(DataTableComponent) -registerComponent(DependentFieldsComponent) -registerComponent(DisplayConditionsComponent) -registerComponent(ExpandableCardComponent) -registerComponent(FilterComponent) -registerComponent(GlobeComponent) -registerComponent(GraphComponent) -registerComponent(InputComponent) -registerComponent(MoreLessComponent) -registerComponent(MultipleSelectComponent) -registerComponent(OrderableSortableComponent) -registerComponent(PopoverComponent) -registerComponent(RadioGroupComponent) -registerComponent(RecordPopupComponent) -registerComponent(SelectComponent) -registerComponent(SelectWidgetComponent) -registerComponent(SidebarComponent) -registerComponent(SortableComponent) -registerComponent(TextareaComponent) -registerComponent(TimelineComponent) -registerComponent(TippyComponent) -registerComponent(TreeComponent) -registerComponent(UserModalComponent) -registerComponent(ValueLookupComponent) -registerComponent(MarkdownComponent) -registerComponent(SelectAllComponent) -registerComponent(HelpView) -registerComponent(PeopleFilterComponent) +registerComponent(AddTableModalComponent); +registerComponent(ButtonComponent); +registerComponent(CalcFieldsComponent); +registerComponent(CalculatorComponent); +registerComponent(CheckboxComponent); +registerComponent(CollapsibleComponent); +registerComponent(CurvalModalComponent); +registerComponent(DashboardComponent); +registerComponent(DataTableComponent); +registerComponent(DependentFieldsComponent); +registerComponent(DisplayConditionsComponent); +registerComponent(ExpandableCardComponent); +registerComponent(FilterComponent); +registerComponent(GlobeComponent); +registerComponent(GraphComponent); +registerComponent(InputComponent); +registerComponent(MoreLessComponent); +registerComponent(MultipleSelectComponent); +registerComponent(OrderableSortableComponent); +registerComponent(PopoverComponent); +registerComponent(RadioGroupComponent); +registerComponent(RecordPopupComponent); +registerComponent(SelectComponent); +registerComponent(SelectWidgetComponent); +registerComponent(SidebarComponent); +registerComponent(SortableComponent); +registerComponent(TextareaComponent); +registerComponent(TimelineComponent); +registerComponent(TippyComponent); +registerComponent(TreeComponent); +registerComponent(UserModalComponent); +registerComponent(ValueLookupComponent); +registerComponent(MarkdownComponent); +registerComponent(SelectAllComponent); +registerComponent(HelpView); +registerComponent(PeopleFilterComponent); +registerComponent(AutosaveComponent); // Initialize all components at some point -initializeRegisteredComponents(document.body) +initializeRegisteredComponents(document.body); diff --git a/src/frontend/testing/globals.definitions.ts b/src/frontend/testing/globals.definitions.ts index 2b0f53253..e0c09799f 100644 --- a/src/frontend/testing/globals.definitions.ts +++ b/src/frontend/testing/globals.definitions.ts @@ -3,11 +3,12 @@ import { XmlHttpRequestLike } from "../js/lib/util/upload/UploadControl"; declare global { interface Window { $: JQueryStatic; + jQuery: JQueryStatic; alert: (message?: any)=>void; } } -window.$ = require("jquery"); +window.$ = window.jQuery = require("jquery"); // eslint-disable-line @typescript-eslint/no-require-imports window.alert = jest.fn(); export function mockJQueryAjax() { diff --git a/views/edit.tt b/views/edit.tt index 41cd4d753..74f50e28c 100755 --- a/views/edit.tt +++ b/views/edit.tt @@ -11,6 +11,7 @@ class="[% IF edit_modal %]curval-edit-form[% ELSE %]form-edit[% END %]" method="post" enctype="multipart/form-data" + data-current-id="[% cur_id %]" [% IF edit_modal; action = cur_id @@ -19,7 +20,6 @@ %] action="[% action %]" [%# This form also used for bulk actions %] data-curval-id="[% edit_modal %]" - data-current-id="[% cur_id %]" data-modal-field-ids="[% modal_field_ids %]" data-instance-name="[% layout.identifier %]" [% END; %] @@ -242,10 +242,14 @@ }); ELSIF NOT view_modal; left_buttons.push({ - type = "link", - target = url.page _ "/" _ layout_obj.identifier _ "/data", + type = "button", class = "btn btn-cancel btn-js-cancel", label = "Cancel" + button_type = "button" + data_attributes = [{ + name = "href" + value = url.page _ "/" _ layout_obj.identifier _ "/data" + }] }); IF record.is_draft; @@ -312,9 +316,11 @@ [% END %] -[% INCLUDE wizard/curval.tt modalId="curvalModal"; %] -[% INCLUDE wizard/record_details.tt modalId="detailsModal"; %] -[% INCLUDE wizard/read_more.tt modalId = "readMore"; %] +[% UNLESS edit_modal %] + [% INCLUDE wizard/curval.tt modalId="curvalModal"; %] + [% INCLUDE wizard/record_details.tt modalId="detailsModal"; %] + [% INCLUDE wizard/read_more.tt modalId = "readMore"; %] +[% END %] [% IF record.user_can_delete; @@ -340,6 +346,21 @@ %] +[% + IF editable AND NOT edit_modal; + modal_description = "There are unsaved values from the last time you edited + " _ layout.record_name_with_article _ ". Would you like to restore the + values into this " _ layout.record_name _ "? The values will not be saved + until you submit this " _ layout.record_name _ "."; + + INCLUDE wizard/restore_values.tt + modalId = "restoreValuesModal" + label = "Restore unsaved record values" + description = modal_description; + END; + +%] + [% BLOCK panel_start; block_topic = topic.topic; @@ -502,6 +523,9 @@ data-column-id="[% col.id %]" data-column-type="[% col.type %]" data-value-selector="[% col.value_selector %]" + data-show-add="[% col.show_add %]" + data-modal-field-ids="[% col.modal_field_ids %]" + data-curval-instance-name="[% col.layout_parent.identifier %]" data-name="[% col.name | html %]" data-name-short="[% col.name_short %]" [% col.lookup_endpoint && 'data-lookup-endpoint="' _ col.lookup_endpoint _ '"' %] @@ -853,21 +877,28 @@ '
    '; '
      '; FOREACH file IN editvalue.files; - %] -
    • -
      -
      - - - [% file.name %] - -
      -
      -
    • - [% + '
    • '; + ''; + INCLUDE fields/sub/checkbox.tt + id = "file_checkbox_" _ col.id _ "_" _ file.id + name = field + label = "Include file." + value = file.id + checked = 1 + filter = "html" + data_attributes = [{ + name = "filename" + value = file.name + }]; + + ''; + ''; + 'Current file name: '; + ''; + file.name | html; + '.'; + ''; + '
    • '; END; ''; '
    '; @@ -1085,8 +1116,6 @@ class="btn btn-js-curval-modal btn-add-link" data-toggle="modal" data-target=#curvalModal - data-layout-id="[% col.id %]" - data-instance-name="[% col.layout_parent.identifier %]" > Add @@ -1135,6 +1164,16 @@ id = "curval-current-id-" _ val.id name = field value = val.as_query OR val.id + data_attributes = [{ + name = "restore_value" + value = val.as_query OR val.id + },{ + name = "list-id" + value = val.id + },{ + name = "list-text" + value = val.value + }] } } modal = { diff --git a/views/fields/hidden.tt b/views/fields/hidden.tt index bcde1b167..098e66f5c 100644 --- a/views/fields/hidden.tt +++ b/views/fields/hidden.tt @@ -11,8 +11,7 @@ -%]