Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement auto-recover functionality [B: 1403] #483

Open
wants to merge 18 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ const config = {
"^validation$": "<rootDir>/src/frontend/js/lib/validation",
"^logging$": "<rootDir>/src/frontend/js/lib/logging",
"^util/(.*)$": "<rootDir>/src/frontend/js/lib/util/$1",
"^components/(.*)$": "<rootDir>/src/frontend/components/$1",
"^set-field-values$": "<rootDir>/src/frontend/js/lib/set-field-values",
"^guid$": "<rootDir>/src/frontend/js/lib/guid",
},

// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
Expand Down
19 changes: 18 additions & 1 deletion lib/GADS.pm
Original file line number Diff line number Diff line change
Expand Up @@ -4489,6 +4489,17 @@ prefix '/:layout_name' => sub {

};

get '/get_key' => require_login sub {
droberts-ctrlo marked this conversation as resolved.
Show resolved Hide resolved
my $user = logged_in_user;

my $key = $user->encryption_key;

return to_json {
error => 0,
key => $key
}
};

sub reset_text {
my ($dsl, %options) = @_;
my $site = var 'site';
Expand Down Expand Up @@ -4796,7 +4807,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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern about this is that there is then a user way to always override mandatory (if they know what they are doing) - then can just add this parameter to any form submission. We probably need a way of tightening this, or preferably there would be a way of notifying the user that a saved value is now mandatory?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm honest, I'm having some trouble with this one. I don't quite know whether it would be best to check the fields are mandatory (and blank) in the JS and provide feedback in the auto-recover modal or to have a better check (maybe using the user_key rather than just checking the param is 1) backend to ensure this is only called in the correct circumstances. Suggestions welcomed!!

try { $record->write(%options) };
if (my $e = $@->wasFatal)
{
push @validation_errors, $e->reason eq 'PANIC' ? 'An unexpected error occurred' : $e->message;
Expand Down
2 changes: 2 additions & 0 deletions lib/GADS/Role/Presentation/Column/Curcommon.pm
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package GADS::Role::Presentation::Column::Curcommon;

use JSON qw(encode_json);
use Moo::Role;

sub after_presentation
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions lib/GADS/Schema/Result/User.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
15 changes: 15 additions & 0 deletions src/frontend/components/button/lib/cancel-button.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { clearSavedFormValues } from "./common";

export default function createCancelButton(el: HTMLElement | JQuery<HTMLElement>) {
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();
});
}
23 changes: 23 additions & 0 deletions src/frontend/components/button/lib/common.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
32 changes: 32 additions & 0 deletions src/frontend/components/button/lib/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import gadsStorage from "util/gadsStorage";

export async function clearSavedFormValues($form: JQuery<HTMLElement>) {
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;
}
6 changes: 6 additions & 0 deletions src/frontend/components/button/lib/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
16 changes: 9 additions & 7 deletions src/frontend/components/button/lib/rename-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { clearSavedFormValues } from "./common";

/**
* Create a submit draft record button
* @param element {JQuery<HTMLElement>} The button element
*/
export default function createSubmitDraftRecordButton(element: JQuery<HTMLElement>) {
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"));
});
}
4 changes: 3 additions & 1 deletion src/frontend/components/button/lib/submit-record-button.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {validateRequiredFields} from "validation";
import { clearSavedFormValues } from "./common";

/**
* Button to submit records
Expand All @@ -13,7 +14,7 @@ export default class SubmitRecordButton {
* @param el {JQuery<HTMLElement>} Element to create as a button
*/
constructor(private el: JQuery<HTMLElement>) {
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]");
Expand Down Expand Up @@ -46,6 +47,7 @@ export default class SubmitRecordButton {
if ($button.prop("name")) {
$button.after(`<input type="hidden" name="${$button.prop("name")}" value="${$button.val()}" />`);
}
await clearSavedFormValues($form);
} else {
// Re-add the required attribute to required dependent fields
$requiredHiddenRecordDependentFields.attr('required', '');
Expand Down
32 changes: 32 additions & 0 deletions src/frontend/components/form-group/autosave/_autosave.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
8 changes: 8 additions & 0 deletions src/frontend/components/form-group/autosave/index.js
Original file line number Diff line number Diff line change
@@ -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);
};
38 changes: 38 additions & 0 deletions src/frontend/components/form-group/autosave/lib/autosave.test.ts
Original file line number Diff line number Diff line change
@@ -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>
<div id="test"></div>
</body>
`;
$('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');
});
});
36 changes: 36 additions & 0 deletions src/frontend/components/form-group/autosave/lib/autosaveBase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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() {
const id = location.pathname.split('/').pop();
droberts-ctrlo marked this conversation as resolved.
Show resolved Hide resolved
return isNaN(parseInt(id)) ? 0 : id;
}

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;
}
Loading
Loading