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

💻 autosave feature #5311

Merged
merged 26 commits into from
Apr 10, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b12d83b
- a generic function for autosaving
hasan-sh Mar 25, 2024
c5ceebd
autosave adventure
hasan-sh Mar 25, 2024
66ae653
autosave with custom events
hasan-sh Mar 25, 2024
47b0043
autosave class page
hasan-sh Mar 25, 2024
874b5c1
autosave profile page
hasan-sh Mar 25, 2024
9c53265
bundles
hasan-sh Mar 25, 2024
baaf8b2
remove save buttons
hasan-sh Mar 25, 2024
f18d2df
Merge branch 'main' into autosave
hasan-sh Mar 25, 2024
10d6365
🤖 Automatically update generated files
hasan-sh Mar 25, 2024
868b686
do not trigger update here
hasan-sh Mar 26, 2024
888bdb0
fix tests
hasan-sh Mar 26, 2024
139196f
Merge branch 'main' into autosave
hasan-sh Mar 27, 2024
ab87a3d
bundle file
hasan-sh Mar 27, 2024
59c4313
tests
hasan-sh Mar 27, 2024
27d539a
Merge branch 'main' into autosave
hasan-sh Mar 27, 2024
e02ec78
🤖 Automatically update generated files
hasan-sh Mar 27, 2024
07077e1
test dev mode with new class
hasan-sh Mar 27, 2024
5096f50
Merge branch 'autosave' of github.com:hedyorg/hedy into autosave
hasan-sh Mar 27, 2024
2a76bea
password needs confirmation.
hasan-sh Apr 2, 2024
d27d70a
Merge branch 'main' into autosave
hasan-sh Apr 2, 2024
2f3597a
🤖 Automatically update generated files
hasan-sh Apr 2, 2024
68fb2fb
Merge branch 'main' into autosave
hasan-sh Apr 8, 2024
e9e71bc
more robust tests
hasan-sh Apr 8, 2024
7079377
add autosave function in respective files.
hasan-sh Apr 10, 2024
57ef4b3
Merge branch 'main' into autosave
hasan-sh Apr 10, 2024
3dedc47
🤖 Automatically update generated files
hasan-sh Apr 10, 2024
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
6 changes: 0 additions & 6 deletions messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -1409,9 +1409,6 @@ msgstr ""
msgid "runs_over_time"
msgstr ""

msgid "save"
msgstr ""

msgid "save_parse_warning"
msgstr ""

Expand Down Expand Up @@ -1790,9 +1787,6 @@ msgstr ""
msgid "update_adventure_prompt"
msgstr ""

msgid "update_profile"
msgstr ""

msgid "update_public"
msgstr ""

Expand Down
36 changes: 36 additions & 0 deletions static/js/appbundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -55067,6 +55067,7 @@ ${i2}` : o2;
addCurlyBracesToCode: () => addCurlyBracesToCode,
add_account_placeholder: () => add_account_placeholder,
append_classname: () => append_classname,
autoSave: () => autoSave,
changeUserEmail: () => changeUserEmail,
change_language: () => change_language,
change_password_student: () => change_password_student,
Expand Down Expand Up @@ -119089,6 +119090,41 @@ pygame.quit()
window.location.replace("/download_microbit_files/");
}
}

// static/js/autosave.ts
var DEBOUNCE_TIMEOUT = 1e3;
var timeoutId = null;
function debounce3(func, delay) {
return (...args) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
}
function autoSave(formId, customEvent = null, triggerGetRequest = null, timeout2 = DEBOUNCE_TIMEOUT) {
const formElement = document.getElementById(formId);
console.log(formId, formElement, customEvent);
const handler2 = debounce3((e) => {
if (!customEvent && e.target.dataset["autosaved"]) {
return;
}
formElement.requestSubmit();
if (triggerGetRequest) {
const element = document.getElementById(triggerGetRequest.elementId);
const eventToTrigger = new Event(triggerGetRequest.trigger);
element.dispatchEvent(eventToTrigger);
}
}, timeout2);
if (customEvent) {
handler2(customEvent);
} else {
formElement.addEventListener("input", handler2);
formElement.addEventListener("change", handler2);
}
}
return js_exports;
})();
/*!
Expand Down
6 changes: 3 additions & 3 deletions static/js/appbundle.js.map

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions static/js/autosave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@

const DEBOUNCE_TIMEOUT = 1000;

let timeoutId: ReturnType<typeof setTimeout> | null = null;
function debounce<F extends (...args: any[]) => any> (func: F, delay: number): (...args: Parameters<F>) => void {
Copy link
Member

Choose a reason for hiding this comment

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

I really like this solution!

return (...args: Parameters<F>) => {
if (timeoutId !== null) {
clearTimeout(timeoutId);
}

timeoutId = setTimeout(() => {
func(...args);
}, delay);
};
}

interface GetRequest {
trigger: string; // event type that we need to trigger.
elementId: string; // element's id to trigger the event on.
}

export function autoSave(formId: string,
customEvent: Event | null=null,
triggerGetRequest: GetRequest | null = null,
timeout: number=DEBOUNCE_TIMEOUT,
) {
// Debounce function to prevent excessive requests
const formElement = document.getElementById(formId) as HTMLFormElement;
console.log(formId, formElement, customEvent)
Copy link
Member

Choose a reason for hiding this comment

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

Opps, can you delete this debug console log?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks will do:)

const handler = debounce((e: Event) => {
if (!customEvent && (e.target as HTMLElement).dataset["autosaved"]) {
// If the event is not from a customEvent with is the element autosaved, we pass.
return
}

// Now we can simply trigger the submit event on the parent form element.
formElement.requestSubmit()

// Sometimes different components depend on each other, that is component A needs to be updated if component B
// has changed. This is made mainly for the customize-class (comp. A) and partial-sortable-adventures (comp. B)
// we need A to be autosaved and since B is autosaved (via htmx), a get request on some of B's elemnts would
// allow us to "update" B when A changes. We'll probably use this approach elsewhere too!
// Moreover, a similar approach should be taken if we decide to allow syncronization between components in our platform.
// That is, one component sends/triggers an event on another.
if (triggerGetRequest) {
const element = document.getElementById(triggerGetRequest.elementId) as HTMLElement;
const eventToTrigger = new Event(triggerGetRequest.trigger);
element.dispatchEvent(eventToTrigger);
}
}, timeout);

if (customEvent) {
// A custom event could be fired by an element that's not within the form but still attributes to the
// submission. For instance, the CKEditor has its own elements and the listeners that we attach here
// don't include those elements.
handler(customEvent);
} else {
// Add event listeners to all form elements
formElement.addEventListener('input', handler);
formElement.addEventListener('change', handler);
}
}
1 change: 1 addition & 0 deletions static/js/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export { loadParsonsExercise } from './parsons';
export * from './user-activity';
export * from './adventure';
export * from './microbit';
export * from './autosave';
16 changes: 13 additions & 3 deletions templates/customize-adventure.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
<div class="flex flex-col gap-2">
<h2>{{_('customize_adventure')}}</h2>
<div class="border-gray-400 border rounded-lg p-4">
<form onsubmit="event.preventDefault (); hedyApp.update_adventure('{{ adventure.id }}', {% if adventure.content|length > 0 %}false{% else %}true{% endif %}, '{{_('update_adventure_prompt')}}')"
<form onsubmit="event.preventDefault (); hedyApp.update_adventure('{{ adventure.id }}', true, '{{_('update_adventure_prompt')}}')"
id="customize_adventure"
data-te-validation-init>
<div class="flex gap-4">
<div class="flex flex-col w-full">
Expand Down Expand Up @@ -92,6 +93,7 @@ <h3 class="text-center mt-0 mb-4">{{_('general_settings')}}</h3>
<label for="search_tags_input" class="inline-block w-40 text-xl">{{_('tags')}}</label>
<div class="flex flex-row w-full relative" id="add_tags">
<input type="text" name="tag" autocomplete="off" placeholder="{{_('tag_input_placeholder')}}" id="search_tags_input"
data-autosaved="true"
class="appearance-none w-full bg-gray-200 border border-gray-200 text-gray-700 py-3 px-4 ltr:pr-8 rtl:pl-8 rounded"
hx-trigger="enter"
hx-post="/tags/create/{{adventure.id}}" hx-target="#tags-list" hx-swap="outerHTML"
Expand Down Expand Up @@ -166,8 +168,7 @@ <h3 class="text-center mt-0 mb-4">{{ _('adventure') }}
</div>
</div>
<div class="flex flex-row justify-end gap-2 my-4">
<button class="blue-btn" id="preview_adventure_button" onclick="hedyApp.preview_adventure();event.preventDefault();">{{_('preview')}}</button>
<button type="submit" class="green-btn" id="save_adventure_button" data-te-submit-btn-ref>{{_('save')}}</button>
<button class="green-btn" id="preview_adventure_button" onclick="hedyApp.preview_adventure();event.preventDefault();">{{_('preview')}}</button>
<button type="reset" class="red-btn" id="remove_adventure_button" onclick="hedyApp.delete_adventure('{{ adventure.id }}', '{{_('delete_adventure_prompt')}}')">{{_('remove')}}</button>
</div>
</form>
Expand Down Expand Up @@ -209,4 +210,13 @@ <h3>{{_('adventure_exp_classes')}}</h3>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
Copy link
Member

Choose a reason for hiding this comment

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

A while ago we were trying to get away from adding scripts to the templates, because we wanted all javascript code to be in a single place. I think you could use initiliaze.ts to initialize these functions in JavaScript.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Why though? I mean we don't really need to initialize anything if the route is not in the wanted page! What I do know is that the main issues with having different scripts is that it could cause a sluggish experience. Was there another (specific) problem we faced? I see that we still use it in some places!!

We could put it in initialize.ts, but it's not as incapsulated as this is! Perhaps it's a matter of docummentation?

Copy link
Member

Choose a reason for hiding this comment

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

I think the reason is that if all code is in one place, it is easy to see what is there. In the past we ran into the issue that similar code was in multiple places and when something was changed, some locations were overlooked. People might not look for js inside an HTML file (it is a sort of separation of concerns)

So I support the idea of having as much in one place as possible!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree about the separation of cencerns concern. Will change it.

Two points. First, so the current scripts that are in html files we still have to clean up? And second there's several downsides of our current approach. The main and crucial one is that gradually more and more TS must be loaded even though it's not in use yet. This affects the overall performance of our app. If you're satisfied with the overall performance, we tackle it when needed. Just good to keep it in mind i think!

Copy link
Member

Choose a reason for hiding this comment

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

Two points. First, so the current scripts that are in html files we still have to clean up?

Yes, I would assume so! There is a lot of places where we still have js/ts in the HTML files, and we like to move that to HTMX or to a typescript file. I don't necessarily think we should do all of that right away, but maybe if you encounter it while working on other things, you can try to clean it up piece by piece. You can also do a bit of cleanup in between other tasks if you think it is a fun palet cleanser!

The main and crucial one is that gradually more and more TS must be loaded even though it's not in use yet.

I don't think that is where the bottle neck of performance is, but yes let's keep it in mind!

hedyApp.autoSave("customize_adventure")
if (window.ckEditor) {
window.ckEditor.model.document.on("change:data", e => hedyApp.autoSave("customize_adventure", customEvent=e))
}
});
</script>

{% endblock %}
18 changes: 9 additions & 9 deletions templates/customize-class.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ <h2>{{_('customize_class')}}: {{class_info.name}}</h2>
available_adventures=available_adventures)
}}

<form onsubmit="event.preventDefault(); hedyApp.save_customizations('{{class_info.id}}')"
id="customize_class">

<div class="flex flex-col lg:flex-row gap-4">
<div class="flex flex-col gap- w-full">
<div class="flex flex-col">
Expand Down Expand Up @@ -176,24 +179,21 @@ <h3 class="px-4"><u>{{_('other_settings')}}</u></h3>
</div>
</div>
{% if not (is_admin and class_info.teacher != username) %}
<div class="flex flex-col gap-2 mt-4">
<div class="flex ml-auto gap-2">
<button data-cy="save_customizations" class="blue-btn"
onclick="hedyApp.save_customizations('{{class_info.id}}')">
{{_('save')}}
</button>
<a href="/for-teachers/class/{{class_info.id}}/preview" class="green-btn" data-cy="preview_class_link">{{_('preview')}}</a>
</div>
<div class="flex ml-auto gap-2">
<button class="red-btn {% if not customizations %}hidden{% endif %}"
id="remove_customizations_button" data-cy="remove_customizations_button"
id="remove_customizations_button" type="button" data-cy="remove_customizations_button"
onclick='hedyApp.restore_customization_to_default({{_('remove_customizations_prompt')|default(None)|tojson}})'>
{{_('remove_customization')}}
</button>
</div>
</div>
{% endif %}
</div>
</div>
</form>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => hedyApp.autoSave("customize_class",
customEvent=null, triggerGetRequest={elementId: "levels-dropdown", trigger: "input"}));
</script>
{% endblock %}
10 changes: 6 additions & 4 deletions templates/profile.html
Copy link
Member

Choose a reason for hiding this comment

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

Is there a way to prevent this page from reloading every time I click on one of the options? The autosave works great, but that makes this page a little bit annoying to use.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Which options do you mean? I see it only reloads if we change the language or keyword language! These require a reload since the Ui changes accordingly!

Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,6 @@ <h2 class="profile-section-body-header">{{_('settings')}}</h2>
{% endfor %}
</select>
</div>
<div class="mb-4">
<button type="submit" class="green-btn mt-4">{{_('update_profile')}}</button>
</div>
</form>
<button class="red-btn" onclick="hedyApp.destroy('{{_('are_you_sure')}}')" id="delete_profile_button">{{_('destroy_profile')}}</button>
</div>
Expand All @@ -174,7 +171,6 @@ <h2 class="profile-section-body-header">{{_('change_password')}}</h2>
<label for="password_repeat" class="inline-block w-72">{{_('repeat_new_password')}}</label>
<input id="password_repeat" name="password_repeat" class="personal-input" minlength="6" type=password role="presentation" autocomplete="new-password" required>
</div>
<button type="submit" class="green-btn mt-2">{{_('change_password')}}</button>
</form>
</div>
{% if not is_teacher and not user_data['teacher_request'] %}
Expand All @@ -185,4 +181,10 @@ <h1 id="teacher_toggle" class="section-header" onclick="$ ('#request-teacher-bod
{% endif %}
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
hedyApp.autoSave("profile",)
hedyApp.autoSave("change_password",)
});
</script>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {loginForTeacher} from '../../tools/login/login.js'
import {goToEditAdventure} from '../../tools/navigation/nav.js'

describe('auto save', () => {
it('should work when changing the name field.', () => {
cy.intercept({
method: "POST",
url: "/for-teachers/customize-adventure",
}).as("customizeAdventure")

loginForTeacher("teacher1");
goToEditAdventure();

// the rest of the fields work similar to this.
cy.get('#custom_adventure_name')
.clear()
.type('changed')

// there's a debouncing of 1000 second before we issue an update.
cy.wait(1000)
cy.wait("@customizeAdventure").should('have.nested.property', 'response.statusCode', 200)

cy.get('#modal_alert_container')
.should('be.visible');
cy.get('#modal_alert_text')
.should('be.visible')

// update it to its expected name
cy.get('#custom_adventure_name')
.clear()
.type('adventure1')
cy.wait(1000)
cy.wait('@customizeAdventure').should('have.nested.property', 'response.statusCode', 200);
})
})
Loading
Loading