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

Introduce editable table for creating score distributions #5723

Draft
wants to merge 54 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 53 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
2ea4fd5
DIY first version
jorg-vr Aug 6, 2024
935ce06
Use jspreadsheet
jorg-vr Aug 6, 2024
0df802b
Disable unwanted options
jorg-vr Aug 7, 2024
afee968
Actually save updates
jorg-vr Aug 7, 2024
f6f7f81
Fix new rows
jorg-vr Aug 7, 2024
e45df51
Allow inserting in different order
jorg-vr Aug 7, 2024
94161d2
Add validation
jorg-vr Aug 7, 2024
6258c97
Delete no longer needed options
jorg-vr Aug 7, 2024
c1e43df
Fix toggle
jorg-vr Aug 7, 2024
e516a08
Introduce translations
jorg-vr Aug 8, 2024
4e8e4c9
Introduce translations
jorg-vr Aug 8, 2024
0ddc92a
Add help text
jorg-vr Aug 8, 2024
4d99358
Fix minor bugs
jorg-vr Aug 8, 2024
a52eeff
Show validation warning
jorg-vr Aug 8, 2024
2812c8d
Ask to confirm warnings
jorg-vr Aug 8, 2024
abadbe9
Improve buttons
jorg-vr Aug 8, 2024
2a0a4a8
Remove checkboxes
jorg-vr Aug 8, 2024
40d20f9
Fix row heights
jorg-vr Aug 8, 2024
99b27c8
Merge branch 'main' into feat/table-input
jorg-vr Aug 8, 2024
61ed540
Fix tests
jorg-vr Aug 8, 2024
66c5247
Remove system test
jorg-vr Aug 8, 2024
2451a67
Fix empty case
jorg-vr Aug 8, 2024
c68b4d0
Judge empty rows more kindly
jorg-vr Aug 8, 2024
ad5c0d8
Remove the export option
jorg-vr Aug 8, 2024
defc89e
Avoid type coercion
jorg-vr Aug 9, 2024
41d8fae
Remove comments
jorg-vr Aug 9, 2024
bf47ad7
Add docs
jorg-vr Aug 9, 2024
973371d
Add translations for menu
jorg-vr Aug 9, 2024
a7e363d
Fix dark mode
jorg-vr Aug 9, 2024
f1ac12c
Merge branch 'main' into feat/table-input
jorg-vr Aug 30, 2024
be2eec6
Merge branch 'main' into feat/table-input
jorg-vr Oct 25, 2024
838e3cc
Open edit table on click
jorg-vr Oct 25, 2024
88d9bb4
Align colums left as in non edit version
jorg-vr Oct 25, 2024
370b371
Remove outline of btn
jorg-vr Oct 25, 2024
cc50615
Change header text style
jorg-vr Oct 25, 2024
d443fa0
Improve number validation
jorg-vr Oct 25, 2024
5cb4711
Update warnings
jorg-vr Oct 25, 2024
56982a5
Fix paste in chrome
jorg-vr Oct 28, 2024
cc80dde
Update app/assets/javascripts/components/input_table.ts
jorg-vr Oct 28, 2024
26951de
Merge branch 'main' into feat/table-input
jorg-vr Nov 13, 2024
b26e13a
Fix clickability of max score column
jorg-vr Nov 13, 2024
8352cf2
Merge branch 'main' into feat/table-input
jorg-vr Nov 18, 2024
19706ee
Make cursor point out edditable
jorg-vr Nov 18, 2024
5b72d72
Use text button for edit
jorg-vr Nov 18, 2024
c47f7ce
Add button to empty state
jorg-vr Nov 18, 2024
0aef455
Add delete row button functionality
jorg-vr Nov 18, 2024
d5ab37c
Add title to delete button
jorg-vr Nov 18, 2024
e178e01
Fix checkboxes
jorg-vr Nov 19, 2024
ecaff9e
Remove error text on cancel
jorg-vr Nov 19, 2024
d8e26d0
Get rid of red squibles
jorg-vr Nov 19, 2024
7973375
fix spelling
jorg-vr Nov 19, 2024
c3515e3
Fix colspan
jorg-vr Nov 19, 2024
fe65e92
Update app/assets/javascripts/components/input_table.ts
jorg-vr Nov 20, 2024
19929b8
Fix checkbox allignment in chrome
jorg-vr Nov 26, 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
304 changes: 304 additions & 0 deletions app/assets/javascripts/components/input_table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
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";

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 DodonaElement {
@property({ type: String })
route: string = "";
@property({ type: Array, attribute: "score-items" })
scoreItems: ScoreItem[] = [];

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

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

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"
style="margin: -20px"
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="margin: -16px 0;">
<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,
});
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved

// 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);
}
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
});

// 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);
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
}

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({
score_items: this.editedScoreItems
})
});
if (response.ok) {
const js = await response.text();
eval(js);
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
}
}
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved

cancel(): void {
if (this.table) {
this.table.setData(this.data);
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="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>
`;
}
}
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
44 changes: 44 additions & 0 deletions app/assets/javascripts/i18n/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,28 @@
"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.",
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
"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",
"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."
},
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
"search": {
"filter": {
"course_id": "Course",
Expand Down Expand Up @@ -825,6 +847,28 @@
"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.",
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
"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",
"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."
},
jorg-vr marked this conversation as resolved.
Show resolved Hide resolved
"search": {
"filter": {
"course_id": "Cursus",
Expand Down
Loading
Loading