Skip to content

Commit

Permalink
Implement auto-recover functionality [B: 1403] (#483)
Browse files Browse the repository at this point in the history
  • Loading branch information
droberts-ctrlo authored Jan 20, 2025
1 parent 29bbae0 commit e7ed08e
Show file tree
Hide file tree
Showing 49 changed files with 2,494 additions and 284 deletions.
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]
# 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
- 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",
"^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
6 changes: 5 additions & 1 deletion lib/GADS.pm
Original file line number Diff line number Diff line change
Expand Up @@ -4761,7 +4761,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 Expand Up @@ -4885,6 +4886,9 @@ sub _process_edit
$params->{content_block_custom_classes} = 'content-block--footer';
}

$params->{clone_from} = $clone_from
if $clone_from;

$params->{modal_field_ids} = encode_json $layout->column($modal)->curval_field_ids
if $modal;

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 {
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 @@ -1249,4 +1251,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");
});
});
52 changes: 52 additions & 0 deletions src/frontend/components/button/lib/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import gadsStorage from "util/gadsStorage";

/**
* Clear all saved form values for the current record
* @param $form The form to clear the data for
*/
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}`);
}));
}

/**
* Get the layout identifier from the body data
* @returns The layout identifier
*/
export function layoutId() {
return $('body').data('layout-identifier');
}

/**
* Get the record identifier from the body data
* @returns The record identifier
*/
export function recordId() {
return $('body').find('.form-edit').data('current-id') || 0;
}

/**
* Get the key for the table used for saving form values
* @returns The key for the table
*/
export function table_key() {
return `linkspace-record-change-${layoutId()}-${recordId()}`;
}

/**
* Get the storage object - this originally was used in debugging to allow for the storage object to be mocked
* @returns The storage object
*/
export function storage() {
return 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,17 @@
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');
// As the draft should save all changed values, we clear them from the local storage
await clearSavedFormValues(ev.target.closest("form"));
});
}
5 changes: 4 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,8 @@ export default class SubmitRecordButton {
if ($button.prop("name")) {
$button.after(`<input type="hidden" name="${$button.prop("name")}" value="${$button.val()}" />`);
}
// Clear the saved form values from local storage as they should now be saved to the record
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
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">Auto-recover failed to initialize. ' + e.message ? e.message : e + '</div>');
}
} else {
$('.content-block__main-content').prepend('<div class="alert alert-warning">Auto-recover 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');
});
});
Loading

0 comments on commit e7ed08e

Please sign in to comment.