Skip to content

Commit

Permalink
Merge pull request #5723 from dodona-edu/feat/table-input
Browse files Browse the repository at this point in the history
Introduce editable table for creating score distributions
  • Loading branch information
jorg-vr authored Dec 17, 2024
2 parents 8b1c533 + 82a1b6c commit 92ed5a9
Show file tree
Hide file tree
Showing 39 changed files with 921 additions and 582 deletions.
333 changes: 333 additions & 0 deletions app/assets/javascripts/components/input_table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
import { customElement, property } from "lit/decorators.js";
import { html, PropertyValues, render, TemplateResult } from "lit";
import jspreadsheet, { CellValue, Column, CustomEditor, JspreadsheetInstance } from "jspreadsheet-ce";
import { createRef, ref, Ref } from "lit/directives/ref.js";
import { DodonaElement } from "components/meta/dodona_element";
import { fetch, ready } from "utilities";
import { i18n } from "i18n/i18n";
import { Tooltip } from "bootstrap";
import { watchMixin } from "components/meta/watch_mixin";

type CellData = string | number | boolean;
type ScoreItem = {
id: number | null;
name: string;
description?: string;
maximum: string;
visible: boolean;
order?: number;
}

type ColumnWithTooltip = Column & { tooltip?: string };

const toBoolean = (value: CellValue): boolean => {
return value === "true" || value === true;
};

/**
* A spreadsheet table to edit score items.
*
* @element d-score-item-input-table
*
* @fires cancel - When the cancel button is clicked.
*
* @prop {string} route - The route to send the updated score items to.
* @prop {ScoreItem[]} scoreItems - The original score items, that will be displayed in the table.
*/
@customElement("d-score-item-input-table")
export class ScoreItemInputTable extends watchMixin(DodonaElement) {
@property({ type: String })
route: string = "";
@property({ type: Array, attribute: "score-items" })
scoreItems: ScoreItem[] = [];
@property({ type: Boolean, attribute: "total-visible" })
totalVisible: boolean = false;

tableRef: Ref<HTMLDivElement> = createRef();
table: JspreadsheetInstance;

@property({ state: true })
hasErrors: boolean = false;
@property({ state: true })
_totalVisible: boolean = false;

watch = {
totalVisible: () => {
this._totalVisible = this.totalVisible;
}
};

toggleTotalVisible(): void {
this._totalVisible = !this._totalVisible;
}

get tableWidth(): number {
return this.tableRef.value.clientWidth;
}

get descriptionColWidth(): number {
if (!this.tableRef.value) {
return 200;
}

// full width - borders - name column - maximum column - visible column - index column - delete column
const variableWidth = this.tableWidth - 14 - 200 - 75 - 75 - 50 - 30;
return Math.max(200, variableWidth);
}

get data(): CellData[][] {
return [
...this.scoreItems.map(item => [
item.id,
item.name,
item.description,
item.maximum,
item.visible
]),
["", "", "", "", false]
];
}

get editedScoreItems(): ScoreItem[] {
const tableData = this.table.getData();

const scoreItems = tableData.map((row: CellData[], index: number) => {
return {
id: row[0] as number | null,
name: row[1] as string,
description: row[2] as string,
maximum: (row[3] as string).replace(",", "."), // replace comma with dot for float representation
visible: toBoolean(row[4]),
order: index,
};
});

// filter out empty rows
return scoreItems.filter(item => !(item.name === "" && item.maximum === "" && item.description === "" && item.visible === false));
}

deleteCellRow(cell: HTMLTableCellElement): void {
const row = cell.parentElement as HTMLTableRowElement;
this.table.deleteRow(row.rowIndex-1);
}

createDeleteButton(cell: HTMLTableCellElement): HTMLTableCellElement {
const button = html`<button
class="btn btn-icon d-btn-danger btn-icon-inline"
title="${i18n.t("js.score_items.jspreadsheet.deleteRow")}"
@click="${() => this.deleteCellRow(cell)}">
<i class="mdi mdi-18 mdi-delete"></i>
</button>`;
render(button, cell);
return cell;
}

customCheckboxEditor(): CustomEditor {
const updateCell = (cell: HTMLTableCellElement): void => {
this.table.setValue(cell, !toBoolean(this.table.getValue(cell)));
};
return {
createCell: (cell: HTMLTableCellElement) => {
const current = cell.innerHTML === "true";
const checkbox = html`<div class="form-check" contenteditable="false" style="white-space: normal;">
<input type="checkbox"
class="form-check-input"
?checked="${current}"
@change="${() => updateCell(cell)}">
</div>`;
cell.innerHTML = "";
render(checkbox, cell);
return cell;
},
openEditor: () => false,
closeEditor: (cell: HTMLTableCellElement) => {
return toBoolean(this.table.getValue(cell));
},
updateCell: (cell: HTMLTableCellElement, value: CellValue) => {
const checkbox = cell.querySelector("input");
if (checkbox) {
checkbox.checked = toBoolean(value);
}
return toBoolean(value);
}
};
}

get columnConfig(): ColumnWithTooltip[] {
return [
{ type: "hidden", title: "id" },
{ type: "text", title: i18n.t("js.score_items.name"), width: 200, align: "left" },
{ type: "text", title: i18n.t("js.score_items.description"), width: this.descriptionColWidth, align: "left", tooltip: i18n.t("js.score_items.description_help") },
{ type: "numeric", title: i18n.t("js.score_items.maximum"), width: 75, align: "left", tooltip: i18n.t("js.score_items.maximum_help") },
{ type: "html", title: i18n.t("js.score_items.visible"), width: 75, align: "left", tooltip: i18n.t("js.score_items.visible_help"), editor: this.customCheckboxEditor() },
{ type: "html", title: " ", width: 30, align: "center", readOnly: true, editor: {
createCell: (cell: HTMLTableCellElement) => this.createDeleteButton(cell),
} },
];
}

async initTable(): Promise<void> {
// Wait for translations to be present
await ready;

this.table = jspreadsheet(this.tableRef.value, {
root: this,
data: this.data,
columns: this.columnConfig,
text: {
copy: i18n.t("js.score_items.jspreadsheet.copy"),
deleteSelectedRows: i18n.t("js.score_items.jspreadsheet.deleteSelectedRows"),
insertANewRowAfter: i18n.t("js.score_items.jspreadsheet.insertNewRowAfter"),
insertANewRowBefore: i18n.t("js.score_items.jspreadsheet.insertNewRowBefore"),
paste: i18n.t("js.score_items.jspreadsheet.paste"),
},
about: false,
allowDeleteColumn: false,
allowDeleteRow: true,
allowInsertColumn: false,
allowInsertRow: true,
allowManualInsertColumn: false,
allowManualInsertRow: true,
allowRenameColumn: false,
columnResize: false,
columnSorting: false,
minSpareRows: 1,
parseFormulas: false,
selectionCopy: false,
wordWrap: true,
defaultRowHeight: 30,
allowExport: false,
});

// init tooltips
this.columnConfig.forEach((column, index) => {
const td = this.tableRef.value.querySelector(`thead td[data-x="${index}"]`);
if (td && column.tooltip) {
td.setAttribute("title", column.tooltip);
new Tooltip(td);
}
});

// mark header and menu as non-editable
this.tableRef.value.querySelector("thead").setAttribute("contenteditable", "false");
this.tableRef.value.querySelector(".jexcel_contextmenu").setAttribute("contenteditable", "false");


// update description column width when the window is resized
new ResizeObserver(() => {
this.table.setWidth(2, this.descriptionColWidth);
}).observe(this.tableRef.value);
}

protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);

this.initTable();
}

validate(): boolean {
// Remove all error classes
this.tableRef.value.querySelectorAll("td.error").forEach(cell => {
cell.classList.remove("error");
});

const invalidCells: string[] = [];
const data = this.editedScoreItems;
data.forEach(item => {
const row = item.order + 1;
if (item.name === "") {
invalidCells.push("B" + row);
}
// Check if maximum is a positive number < 1000
// we use a regex instead of parseFloat because parseFloat is too lenient
if (!/^\d{1,3}(\.\d+)?$/.test(item.maximum) || parseFloat(item.maximum) <= 0) {
invalidCells.push("D" + row);
}
});
invalidCells.forEach(cell => {
this.table.getCell(cell).classList.add("error");
});
this.hasErrors = invalidCells.length > 0;
return !this.hasErrors;
}

confirmWarnings(): boolean {
const old = this.scoreItems;
const edited = this.editedScoreItems;
const removed = old.some(item => !edited.some(e => e.id === item.id));
const maxEdited = old.some(item => edited.some(e => e.id === item.id && e.maximum !== item.maximum));

let warnings = "";
if (removed) {
warnings += i18n.t("js.score_items.deleted_warning") + "\n";
}
if (maxEdited) {
warnings += i18n.t("js.score_items.modified_warning") + "\n";
}

return warnings === "" || confirm(warnings);
}

async save(): Promise<void> {
if (!this.validate()) {
return;
}

if (!this.confirmWarnings()) {
return;
}

const response = await fetch(this.route, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
evaluation_exercise: {
visible_score: this._totalVisible,
score_items: this.editedScoreItems
}
})
});
if (response.ok) {
const js = await response.text();
eval(js);
}
}

cancel(): void {
if (this.table) {
this.table.setData(this.data);
this._totalVisible = this.totalVisible;
this.hasErrors = false;
}
this.dispatchEvent(new Event("cancel"));
}


render(): TemplateResult {
return html`
${this.hasErrors ? html`
<div class="alert alert-danger">${i18n.t("js.score_items.validation_warning")}</div>` : ""}
<div style="width: 100%" ${ref(this.tableRef)} contenteditable="true"></div>
<div class="form-check ms-1">
<label class="form-check-label" for="total-visible">
${i18n.t("js.score_items.total_visible")}
</label>
<input type="checkbox"
class="form-check-input"
id="total-visible"
?checked=${this._totalVisible}
@change=${() => this.toggleTotalVisible()}>
</div>
<div class="d-flex justify-content-end">
<button @click=${this.cancel} class="btn btn-text me-1">
${i18n.t("js.score_items.cancel")}
</button>
<button @click=${this.save} class="btn btn-filled">
${i18n.t("js.score_items.save")}
</button>
</div>
`;
}
}
46 changes: 46 additions & 0 deletions app/assets/javascripts/i18n/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,29 @@
"score_item": {
"error": "Error while updating"
},
"score_items": {
"cancel": "Cancel",
"deleted_warning": "You have deleted one or more score items. This will also delete all scores for these items.",
"description": "Description",
"description_help": "A description is optional. Markdown formatting can be used. This is visible to the students.",
"jspreadsheet": {
"copy": "Copy...",
"deleteRow": "Delete row",
"deleteSelectedRows": "Delete selected rows",
"insertNewRowAfter": "Insert new row after",
"insertNewRowBefore": "Insert new row before",
"paste": "Paste..."
},
"maximum": "Maximum",
"maximum_help": "The maximum grade for this score item. The grade should be between 0 and 1000, and works in increments of 0.25.",
"modified_warning": "You have changed the maximum score of one or more score items. This will mark all completed evaluations with this score item as uncompleted.",
"name": "Name",
"save": "Save",
"total_visible": "Make the total score visible to students once the evaluation is released.",
"validation_warning": "All score items must have a name and a maximum score, and the maximum score must be between 0 and 1000.",
"visible": "Visible",
"visible_help": "Make the score item visible to students once the evaluation is released."
},
"search": {
"filter": {
"course_id": "Course",
Expand Down Expand Up @@ -825,6 +848,29 @@
"score_item": {
"error": "Fout bij bijwerken"
},
"score_items": {
"cancel": "Annuleren",
"deleted_warning": "Je hebt een of meerdere scoreonderdelen verwijderd. Dit zal ook de bijhorende scores van de studenten verwijderen.",
"description": "Beschrijving",
"description_help": "Een beschrijving is optioneel en kan in Markdown geschreven worden. Dit is zichtbaar voor de studenten.",
"jspreadsheet": {
"copy": "Kopieer...",
"deleteRow": "Verwijder rij",
"deleteSelectedRows": "Verwijder geselecteerde rijen",
"insertNewRowAfter": "Voeg nieuwe rij toe na deze",
"insertNewRowBefore": "Voeg nieuwe rij toe voor deze",
"paste": "Plak..."
},
"maximum": "Maximum",
"maximum_help": "De maximumscore voor dit scoreonderdeel. Dit moet een getal zijn tussen 0 en 1000 en gaat in stappen van 0.25.",
"modified_warning": "Je hebt de maximumscore van een of meerdere scoreonderdelen aangepast. Alle afgewerkte evaluaties met dit scoreonderdeel zullen terug als onafgewerkt gemarkeerd worden.",
"name": "Naam",
"save": "Opslaan",
"total_visible": "Maak totaal score zichtbaar voor studenten eens de evaluatie vrijgegeven is.",
"validation_warning": "Alle scoreonderdelen moeten een naam en een maximumscore hebben. De maximumscore moet een getal zijn tussen 0 en 1000.",
"visible": "Zichtbaar",
"visible_help": "Maak het scoreonderdeel zichtbaar voor studenten eens de evaluatie vrijgegeven is."
},
"search": {
"filter": {
"course_id": "Cursus",
Expand Down
Loading

0 comments on commit 92ed5a9

Please sign in to comment.