diff --git a/app/assets/javascripts/components/saved_annotation_input.ts b/app/assets/javascripts/components/saved_annotation_input.ts index c1ce1a9b5a..cdce9e9b70 100644 --- a/app/assets/javascripts/components/saved_annotation_input.ts +++ b/app/assets/javascripts/components/saved_annotation_input.ts @@ -2,6 +2,8 @@ import { customElement, property } from "lit/decorators.js"; import { html, TemplateResult } from "lit"; import { ShadowlessLitElement } from "components/shadowless_lit_element"; import "components/datalist_input"; +import { getSavedAnnotations, SavedAnnotation } from "state/SavedAnnotations"; +import { stateMixin } from "state/StateMixin"; /** * This component represents an input for a saved annotation id. @@ -18,7 +20,7 @@ import "components/datalist_input"; * @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 { +export class SavedAnnotationInput extends stateMixin(ShadowlessLitElement) { @property({ type: String }) name = ""; @property({ type: Number, attribute: "course-id" }) @@ -30,22 +32,18 @@ export class SavedAnnotationInput extends ShadowlessLitElement { @property({ type: String }) value: string; - @property({ state: true }) - savedAnnotations: {title: string, id: string, annotation_text: string}[] = []; + state = ["getSavedAnnotations"]; - get options(): {label: string, value: string}[] { - return this.savedAnnotations.map(sa => ({ label: sa.title, value: sa.id.toString() })); - } - - connectedCallback(): void { - super.connectedCallback(); - this.fetchAnnotations(); + get savedAnnotations(): SavedAnnotation[] { + return getSavedAnnotations({ + "course_id": this.courseId.toString(), + "exercise_id": this.exerciseId.toString(), + "user_id": this.userId.toString() + }); } - async fetchAnnotations(): Promise { - 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(); + get options(): {label: string, value: string}[] { + return this.savedAnnotations.map(sa => ({ label: sa.title, value: sa.id.toString() })); } processInput(e: CustomEvent): void { diff --git a/app/assets/javascripts/state/PubSub.ts b/app/assets/javascripts/state/PubSub.ts new file mode 100644 index 0000000000..62eff08649 --- /dev/null +++ b/app/assets/javascripts/state/PubSub.ts @@ -0,0 +1,53 @@ +/** + * heavily based on https://github.com/hankchizljaw/vanilla-js-state-management/blob/master/src/js/lib/pubsub.js + * Copyright (c) 2018 Andy Bell + * + * + */ +export class PubSub { + events: Map) => any>>; + + constructor() { + this.events = new Map) => any>>(); + } + + /** + * Either create a new event instance for passed `event` name + * or push a new callback into the existing collection + * + * @param {string} event + * @param {function} callback + * @memberof PubSub + */ + subscribe(event: string, callback: (...params: Array) => any): void { + // If there's not already an event with this name set in our collection + // go ahead and create a new one and set it with an empty array, so we don't + // have to type check it later down-the-line + if (!this.events.has(event)) { + this.events.set(event, []); + } + + // We know we've got an array for this event, so push our callback in there with no fuss + this.events.get(event).push(callback); + } + + /** + * If the passed event has callbacks attached to it, loop through each one + * and call it + * + * @param {string} event + * @param {array} params - function params + * @memberof PubSub + */ + publish(event: string, ...params: Array): void { + // There's no event to publish to, so bail out + if (!this.events.has(event)) { + return; + } + + // Get each subscription and call its callback with the passed data + this.events.get(event).map(callback => callback(...params)); + } +} + +export const events = new PubSub(); diff --git a/app/assets/javascripts/state/SavedAnnotations.ts b/app/assets/javascripts/state/SavedAnnotations.ts new file mode 100644 index 0000000000..880b2c816d --- /dev/null +++ b/app/assets/javascripts/state/SavedAnnotations.ts @@ -0,0 +1,81 @@ +import { events } from "state/PubSub"; +import { updateURLParameter } from "util.js"; +export type SavedAnnotation = {title: string, id: number, annotation_text: string}; +const URL = "/saved_annotations"; + +let fetchParams: Record; +let savedAnnotations: SavedAnnotation[]; +let savedAnnotationsById: Map; + +export async function fetchSavedAnnotations(params?: Record): Promise> { + if (params !== undefined) { + fetchParams = params; + } + let url = `${URL}.json`; + for (const param in fetchParams) { + // eslint-disable-next-line no-prototype-builtins + if (fetchParams.hasOwnProperty(param)) { + url = updateURLParameter(url, param, fetchParams[param]); + } + } + const response = await fetch(url); + savedAnnotations = await response.json(); + events.publish("getSavedAnnotations"); + return savedAnnotations; +} + +export async function fetchSavedAnnotation(id: number): Promise { + const url = `${URL}/${id}.json`; + const response = await fetch(url); + savedAnnotationsById.set(id, await response.json()); + events.publish(`getSavedAnnotation${id}`); + return savedAnnotationsById.get(id); +} + +export async function createSavedAnnotation(form: FormData): Promise { + const url = `${URL}.json`; + const response = await fetch(url, { + method: "post", + body: form, + }); + const savedAnnotation: SavedAnnotation = await response.json(); + events.publish("fetchSavedAnnotations"); + events.publish(`fetchSavedAnnotation${savedAnnotation.id}`, savedAnnotation.id); + return savedAnnotation.id; +} + +export async function updateSavedAnnotation(number, id: number, form: FormData): Promise { + const url = `${URL}/${id}`; + await fetch(url, { + method: "put", + body: form, + }); + events.publish("fetchSavedAnnotations"); + events.publish(`fetchSavedAnnotation${id}`, id); +} + +export async function deleteSavedAnnotation(id: number): Promise { + const url = `${URL}/${id}`; + await fetch(url, { + method: "delete", + }); + events.publish("fetchSavedAnnotations"); + events.publish(`fetchSavedAnnotation${id}`, id); +} + +export function getSavedAnnotations(params?: Record): Array { + if (savedAnnotations === undefined) { + events.subscribe("fetchSavedAnnotations", fetchSavedAnnotations); + fetchSavedAnnotations(params); + } + return savedAnnotations || []; +} + +export function getSavedAnnotation(id: number): SavedAnnotation { + if (!savedAnnotationsById.has(id)) { + events.subscribe(`fetchSavedAnnotation${id}`, fetchSavedAnnotation); + fetchSavedAnnotation(id); + } + return savedAnnotationsById.get(id); +} + diff --git a/app/assets/javascripts/state/StateMixin.ts b/app/assets/javascripts/state/StateMixin.ts new file mode 100644 index 0000000000..1493494294 --- /dev/null +++ b/app/assets/javascripts/state/StateMixin.ts @@ -0,0 +1,22 @@ +import { LitElement } from "lit"; +import { events } from "state/PubSub"; + +type Constructor = new (...args: any[]) => LitElement; + +export function stateMixin(superClass: T): T { + class StateMixinClass extends superClass { + state: string[]; + connectedCallback(): void { + super.connectedCallback(); + this.initReactiveState(); + } + + private initReactiveState(): void { + this.state = this.state || []; + this.state.forEach( event => events.subscribe(event, () => this.requestUpdate())); + } + } + + // Cast return type to the superClass type passed in + return StateMixinClass as T; +}