Skip to content

Commit

Permalink
Use datalist to save annotations
Browse files Browse the repository at this point in the history
  • Loading branch information
jorg-vr committed Jun 16, 2022
1 parent f8b2df7 commit f8aad09
Show file tree
Hide file tree
Showing 4 changed files with 103 additions and 42 deletions.
58 changes: 18 additions & 40 deletions app/assets/javascripts/code_listing/code_listing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
UserAnnotationFormData
} from "code_listing/user_annotation";
import { createUserAnnotation, getAllUserAnnotations } from "code_listing/question_annotation";
import "components/saved_annotation_input";

const annotationGlobalAdd = "#add_global_annotation";
const annotationsGlobal = "#feedback-table-global-annotations";
Expand Down Expand Up @@ -384,12 +385,14 @@ export class CodeListing {
</div>
<div class="annotation-controls-block">
${this.questionMode ? "" : `
<input type="hidden" class="saved-annotation-id" value="${annotation?.savedAnnotationId || ""}"/>
<span class="dropdown">
<input class="dropdown-toggle form-control annotation-search-saved" placeholder="${I18n.t("js.user_annotation.use_saved")}">
<ul class="dropdown-menu">
</ul>
</span>
<d-saved-annotation-input
name="saved_annotation_id"
course-id="${this.courseId}"
exercise-id="${this.exerciseId}"
user-id="${this.userId}"
class="saved-annotation-input"
value="${annotation?.savedAnnotationId || ""}"
></d-saved-annotation-input>
`}
<span class="annotation-submission-button-container">
${annotation && annotation.removable ? `
Expand All @@ -411,12 +414,14 @@ export class CodeListing {
const deleteButton = form.querySelector<HTMLButtonElement>(annotationFormDelete);
const sendButton = form.querySelector<HTMLButtonElement>(annotationFormSubmit);
const inputField = form.querySelector<HTMLTextAreaElement>("textarea");
const saveSearchField = form.querySelector<HTMLInputElement>(".annotation-search-saved");
const searchDropdown = new bootstrap.Dropdown(form.querySelector<HTMLButtonElement>(".dropdown-toggle"));
const searchResults = form.querySelector<HTMLUListElement>(".dropdown-menu");
const savedAnnotationField = form.querySelector<HTMLInputElement>(".saved-annotation-id");
const savedAnnotationInput = form.querySelector<HTMLInputElement>(".saved-annotation-input");

this.setupSaveAutocomplete(saveSearchField, inputField, savedAnnotationField, searchDropdown, searchResults);
savedAnnotationInput.addEventListener("input", (e: CustomEvent) => {
console.log(e.detail);
if (e.detail.text) {
inputField.value = e.detail.text;
}
});

if (annotation !== null) {
inputField.rows = annotation.rawText.split("\n").length + 1;
Expand Down Expand Up @@ -473,35 +478,9 @@ export class CodeListing {
return form;
}

private setupSaveAutocomplete(input: HTMLInputElement, textField: HTMLTextAreaElement, savedAnnotationField: HTMLInputElement, dropdown: bootstrap.Dropdown, resultsContainer: HTMLUListElement): void {
input.addEventListener("input", async () => {
fetch(`/saved_annotations.json?course_id=${this.courseId}&exercise_id=${this.exerciseId}&user_id=${this.userId}&filter=${input.value}`)
.then(response => response.json())
.then(results => {
resultsContainer.innerHTML = "";
results.forEach(savedAnnotation => {
const element = document.createElement<HTMLLIElement>("li");
const innerElement = document.createElement<HTMLAnchorElement>("a");
innerElement.classList.add("dropdown-item");
innerElement.innerText = savedAnnotation.title;
innerElement.addEventListener("click", () => {
input.value = savedAnnotation.title;
textField.value = savedAnnotation.annotation_text;
savedAnnotationField.value = savedAnnotation.id;
dropdown.hide();
});
element.appendChild(innerElement);
resultsContainer.appendChild(element);
});
dropdown.show();
});
});
}

private createNewAnnotationForm(line: number | null): HTMLFormElement {
const onSubmit = async (form: HTMLFormElement): Promise<void> => {
const inputField = form.querySelector<HTMLTextAreaElement>("textarea");
const savedAnnotationField = form.querySelector<HTMLInputElement>(".saved-annotation-id");
inputField.classList.remove("validation-error");

// Run client side validations.
Expand All @@ -513,7 +492,7 @@ export class CodeListing {
"annotation_text": inputField.value,
"line_nr": (line === null ? null : line - 1),
"evaluation_id": this.evaluationId || undefined,
"saved_annotation_id": savedAnnotationField.value || undefined,
"saved_annotation_id": new FormData(form).get("saved_annotation_id") as string || undefined,
};

try {
Expand All @@ -535,7 +514,6 @@ export class CodeListing {
callback: CallableFunction): HTMLFormElement {
const onSubmit = async (form: HTMLFormElement): Promise<void> => {
const inputField = form.querySelector<HTMLTextAreaElement>("textarea");
const savedAnnotationField = form.querySelector<HTMLInputElement>(".saved-annotation-id");

// Run client side validations.
if (!inputField.reportValidity()) {
Expand All @@ -544,7 +522,7 @@ export class CodeListing {

const annotationData: UserAnnotationFormData = {
"annotation_text": inputField.value,
"saved_annotation_id": savedAnnotationField.value || undefined,
"saved_annotation_id": new FormData(form).get("saved_annotation_id") as string || undefined,
};

try {
Expand Down
2 changes: 2 additions & 0 deletions app/assets/javascripts/code_listing/user_annotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface UserAnnotationFormData {
line_nr: number | null;
// eslint-disable-next-line camelcase
evaluation_id: number | undefined;
// eslint-disable-next-line camelcase
saved_annotation_id?: string;
}

export type UserAnnotationEditor = (ua: UserAnnotation, cb: CallableFunction) => HTMLElement;
Expand Down
13 changes: 11 additions & 2 deletions app/assets/javascripts/components/datalist_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { ref, Ref, createRef } from "lit/directives/ref.js";
* @prop {[{label: string, value: string}]} options - The label is used to match the user input, while the value is sent to the server.
* If the user input does not match any label, the value sent to the server wil be ""
* @prop {String} value - the initial value for this field
*
* @fires input - on value change, event details contain {label: string, value: string}
*/
@customElement("d-datalist-input")
export class DatalistInput extends ShadowlessLitElement {
Expand All @@ -30,14 +32,21 @@ export class DatalistInput extends ShadowlessLitElement {
return option?.label;
}

processInput(): void {
processInput(e): void {
const option = this.options.find(option => option.label === this.inputRef.value.value);
this.hiddenInputRef.value.value = option ? option.value : "";
const event = new CustomEvent("input", {
detail: { value: this.hiddenInputRef.value.value, label: this.inputRef.value.value },
bubbles: true,
composed: true }
);
this.dispatchEvent(event);
e.stopPropagation();
}

render(): TemplateResult {
return html`
<input class="form-control" type="text" list="${this.name}-datalist-hidden" ${ref(this.inputRef)} @input=${() => this.processInput()} value="${this.label}">
<input class="form-control" type="text" list="${this.name}-datalist-hidden" ${ref(this.inputRef)} @input=${e => this.processInput(e)} value="${this.label}">
<datalist id="${this.name}-datalist-hidden">
${this.options.map(option => html`<option value="${option.label}">${option.label}</option>`)}
</datalist>
Expand Down
72 changes: 72 additions & 0 deletions app/assets/javascripts/components/saved_annotation_input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { customElement, property } from "lit/decorators.js";
import { html, TemplateResult } from "lit";
import { ShadowlessLitElement } from "components/shadowless_lit_element";
import "components/datalist_input";

/**
* This component represents an input for a saved annotation id.
* The saved annotation can be searched by title using a d-datalist-input.
*
* @element d-saved-annotation-input
*
* @prop {String} name - name of the input field (used in form submit)
* @prop {Number} courseId - used to fetch saved annotations by course
* @prop {Number} exerciseId - used to fetch saved annotations by exercise
* @prop {Number} userId - used to fetch saved annotations by user
* @prop {String} value - the initial saved annotation id
*
* @fires input - on value change, event details contain {title: string, id: string, annotation_text: string}
*/
@customElement("d-saved-annotation-input")
export class SavedAnnotationInput extends ShadowlessLitElement {
@property({ type: String })
name = "";
@property({ type: Number, attribute: "course-id" })
courseId: number;
@property({ type: Number, attribute: "exercise-id" })
exerciseId: number;
@property({ type: Number, attribute: "user-id" })
userId: number;
@property({ type: String })
value: string;

@property({ state: true })
savedAnnotations: {title: string, id: string, annotation_text: string}[] = [];

get options(): {label: string, value: string}[] {
return this.savedAnnotations.map(sa => ({ label: sa.title, value: sa.id.toString() }));
}

connectedCallback(): void {
super.connectedCallback();
this.fetchAnnotations();
}

async fetchAnnotations(): Promise<void> {
const url = `/saved_annotations.json?course_id=${this.courseId}&exercise_id=${this.exerciseId}&user_id=${this.userId}`;
const response = await fetch(url);
this.savedAnnotations = await response.json();
}

processInput(e: CustomEvent): void {
const annotation = this.savedAnnotations.find(sa => sa.id.toString() === e.detail.value.toString());
const event = new CustomEvent("input", {
detail: { id: e.detail.value, title: e.detail.label, text: annotation?.annotation_text },
bubbles: true,
composed: true }
);
this.dispatchEvent(event);
e.stopPropagation();
}

render(): TemplateResult {
return html`
<d-datalist-input
name="${this.name}"
.options=${this.options}
value="${this.value}"
@input="${e => this.processInput(e)}"
></d-datalist-input>
`;
}
}

0 comments on commit f8aad09

Please sign in to comment.