-
Notifications
You must be signed in to change notification settings - Fork 291
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
💻 autosave feature #5311
Changes from 18 commits
b12d83b
c5ceebd
66ae653
47b0043
874b5c1
9c53265
baaf8b2
f18d2df
10d6365
868b686
888bdb0
139196f
ab87a3d
59c4313
27d539a
e02ec78
07077e1
5096f50
2a76bea
d27d70a
2f3597a
68fb2fb
e9e71bc
7079377
57ef4b3
3dedc47
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
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 { | ||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Opps, can you delete this debug console log? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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"> | ||
|
@@ -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" | ||
|
@@ -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> | ||
|
@@ -209,4 +210,13 @@ <h3>{{_('adventure_exp_classes')}}</h3> | |
</div> | ||
</div> | ||
</div> | ||
<script> | ||
document.addEventListener("DOMContentLoaded", () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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!
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 %} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
@@ -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); | ||
}) | ||
}) |
There was a problem hiding this comment.
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!