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 32 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ec64c0b
Reset and rerun of commit
droberts-ctrlo Nov 24, 2024
39498e3
Unit test failure fix
droberts-ctrlo Nov 25, 2024
de0bcfd
JSTree and Test fixes
droberts-ctrlo Nov 25, 2024
ef3db97
Fix for textarea not setting values correctly
droberts-ctrlo Nov 25, 2024
f31e3f6
Fix for set-field-values not working with different date formats
droberts-ctrlo Nov 25, 2024
d0f0de6
Updated storage to ignore empty values
droberts-ctrlo Nov 25, 2024
12c606a
Merge branch 'dev' into autosave-reset
droberts-ctrlo Nov 26, 2024
d852b03
Styling and error handling
droberts-ctrlo Nov 26, 2024
fba4f8a
Set max height of modal
droberts-ctrlo Nov 26, 2024
e7ab764
Added overflow to modal
droberts-ctrlo Nov 26, 2024
a227963
Update setdate
droberts-ctrlo Nov 27, 2024
68fc58b
Updated to *remove* clone and allow empty values to be saved
droberts-ctrlo Nov 29, 2024
25dea35
Updated for better erroring of curvals
droberts-ctrlo Dec 2, 2024
70a447d
Update to erroring
droberts-ctrlo Dec 3, 2024
041ba36
Fix for error not displaying correctly in curvals
droberts-ctrlo Dec 5, 2024
73fd8a5
Clearer feedback
droberts-ctrlo Dec 6, 2024
a13063b
Moved `get_key` into API route
droberts-ctrlo Dec 27, 2024
10f3ea0
Updated code to call correct endpoint for key
droberts-ctrlo Dec 27, 2024
d649a50
Updated for clearer feedback on curvals
droberts-ctrlo Jan 2, 2025
48b7d9b
Forgot feedback for successful validation
droberts-ctrlo Jan 2, 2025
906c5d8
Updated to allow for better error handling
droberts-ctrlo Jan 3, 2025
ac6e033
Updated node tests
droberts-ctrlo Jan 3, 2025
b5579e4
Updated GADSStorage tests as they are incompatible with Node 16
droberts-ctrlo Jan 3, 2025
2b85d32
Added basic JSDoc to `GadsStorage`
droberts-ctrlo Jan 3, 2025
d21d155
Fixed autorecover (#27)
droberts-ctrlo Jan 8, 2025
73c1c88
Removed error in restore
droberts-ctrlo Jan 8, 2025
c9f845b
Re-added error handling using different method
droberts-ctrlo Jan 8, 2025
4f101b5
Removed import for testing definitions
droberts-ctrlo Jan 8, 2025
9cd46bb
Disable writing to restore values during recovery
droberts-ctrlo Jan 9, 2025
7ac8772
Removed erroneous clone behaviour
droberts-ctrlo Jan 9, 2025
06baf02
Fix to recovery overwrites
droberts-ctrlo Jan 9, 2025
1f261a5
Removed test condition in GadsStorage
droberts-ctrlo Jan 9, 2025
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
5 changes: 3 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ jobs:

strategy:
matrix:
node-version: [18.x, 20.x]
node-version: [18.x, 20.x, 22.x]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Update to use all currently supported node versions

# See supported Node.js release schedule at https://nodejs.org/en/about/releases/

steps:
Expand All @@ -187,6 +187,7 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
cache: 'npm'
- run: npx update-browserslist-db@latest
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cache and browsers list for 18.x was "complaining"

- run: yarn
- run: yarn jest --passWithNoTests
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",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mapping was inconsistent between this file and tsconfig.json

"^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
3 changes: 2 additions & 1 deletion lib/GADS.pm
Original file line number Diff line number Diff line change
Expand Up @@ -4796,7 +4796,8 @@ 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);
try { $record->write(%options) };
if (my $e = $@->wasFatal)
{
push @validation_errors, $e->reason eq 'PANIC' ? 'An unexpected error occurred' : $e->message;
Expand Down
11 changes: 11 additions & 0 deletions lib/GADS/API.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved to API route for consistency

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 => {
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)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should only work for buttons, and should have the HREF value set (to make the button act like a link where used) - if it isn't, just go back in the history.

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') {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Some of the tests failed here. As this component isn't (currently) tested fully, it is thought that it is valid to remove the plugin functionality should jQuery be 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;
}
}
18 changes: 18 additions & 0 deletions src/frontend/components/form-group/autosave/index.js
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not entirely sure what the difference here is, but it works like this (as opposed to the previous version) so I'm not worried.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { initializeComponent } from 'component';
import AutosaveComponent from './lib/component';
import AutosaveModal from './lib/modal';
import gadsStorage from 'util/gadsStorage';

export default (scope) => {
if (gadsStorage.enabled) {
try {
initializeComponent(scope, '.linkspace-field', AutosaveComponent);
initializeComponent(scope, '#restoreValuesModal', AutosaveModal);
} catch(e) {
console.error(e);
$('.content-block__main-content').prepend('<div class="alert alert-danger">Autosave failed to initialize. ' + e.message ? e.message : e + '</div>');
}
} else {
$('.content-block__main-content').prepend('<div class="alert alert-warning">Autosave is disabled as your browser does not support encryption</div>');
}
};
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');
});
});
35 changes: 35 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,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;
}
Loading
Loading