FEATURE: Use spreadsheet editor for building tables

This commit is contained in:
Keegan George 2022-07-19 16:33:10 -07:00
parent a50cd73dcf
commit 3eee3da853
17 changed files with 211 additions and 538 deletions

View File

@ -2,6 +2,5 @@
@import "vendor/jspreadsheet-datatables";
@import "vendor/jspreadsheet-theme";
@import "vendor/jsuites";
@import "table-builder";
@import "table-editor";
@import "post/table-edit-decorator";
@import "modal/insert-table-modal";

View File

@ -3,22 +3,24 @@ import { action } from "@ember/object";
import showModal from "discourse/lib/show-modal";
export default apiInitializer("0.11.1", (api) => {
api.modifyClass("component:d-editor", {
api.modifyClass("controller:composer", {
pluginId: "discourse-table-builder",
@action
showTableBuilder(event) {
showModal("table-builder-modal").set("toolbarEvent", event);
showTableBuilder() {
showModal("insert-table-modal").setProperties({
toolbarEvent: this.toolbarEvent,
tableHtml: null,
});
},
});
api.onToolbarCreate((toolbar) => {
toolbar.addButton({
api.addToolbarPopupMenuOptionsCallback(() => {
return {
id: "table-builder",
group: "insertions",
action: "showTableBuilder",
icon: "table",
sendAction: (event) => toolbar.context.send("showTableBuilder", event),
title: themePrefix("discourse_table_builder.composer.button"),
});
label: themePrefix("discourse_table_builder.composer.button"),
};
});
});

View File

@ -35,7 +35,7 @@ export default apiInitializer("0.11.1", (api) => {
return ajax(`/posts/${this.id}`, { type: "GET" })
.then((post) => {
showModal("table-editor-modal", {
showModal("insert-table-modal", {
model: post,
}).setProperties({
tableHtml: tempTable,

View File

@ -1,34 +0,0 @@
import GlimmerComponent from "discourse/components/glimmer";
import { action } from "@ember/object";
export default class BodyRow extends GlimmerComponent {
get disableRemoveRow() {
if (this.args.allRows.length > 1) {
return false;
} else {
return true;
}
}
@action
addBodyValue() {
const columnId = this.args.columnId;
const rowId = this.args.row.id;
const value = this.bodyRowValue;
this.args.setRowValue(columnId, rowId, value);
}
@action
addRow() {
const columnId = this.args.columnId;
const rowId = this.args.allRows.length + 1;
this.args.addRow(columnId, rowId);
}
@action
removeRow() {
this.args.removeRow(this.args.columnId, this.args.row);
}
}

View File

@ -1,53 +0,0 @@
import GlimmerComponent from "discourse/components/glimmer";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
export default class HeaderColumn extends GlimmerComponent {
@tracked alignment;
get disableRemoveColumn() {
if (this.args.tableItems.length > 1) {
return false;
} else {
return true;
}
}
@action
addColumn() {
const newColumnId = this.args.tableItems.length + 1;
this.args.addColumn(newColumnId);
}
@action
removeColumn() {
this.args.removeColumn(this.args.column);
}
@action
addColumnHeader() {
const index = this.args.columnId - 1;
this.args.setColumnHeader(index, this.columnHeaderValue);
}
@action
addRow(columnId, rowId) {
this.args.addRow(columnId, rowId);
}
@action
removeRow(columnId, row) {
this.args.removeRow(columnId, row);
}
@action
setRowValue(columnId, rowId, value) {
this.args.setRowValue(columnId, rowId, value);
}
@action
alignColumn(alignment) {
this.args.alignColumn(this.args.columnId, alignment);
this.alignment = alignment;
}
}

View File

@ -1,4 +1,3 @@
// import Controller from "@ember/controller";
import { action } from "@ember/object";
import loadScript from "discourse/lib/load-script";
import { arrayToTable, findTableRegex, tableToObj } from "../lib/utilities";
@ -6,26 +5,115 @@ import Component from "@ember/component";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
import { schedule } from "@ember/runloop";
export default Component.extend({
tagName: "",
showEditReason: false,
spreadsheet: null,
// Lifecycle Methods:
didInsertElement() {
this._super(...arguments);
this.loadLibraries().then(() => {
this.buildTable(this.tableHtml);
schedule("afterRender", () => {
this.loadLibraries().then(() => {
if (this.isEditingTable) {
this.buildPopulatedTable(this.tableHtml);
} else {
this.buildNewTable();
}
});
});
},
willDestroyElement() {
this._super(...arguments);
this.spreadsheet?.destroy();
},
// Getters:
get isEditingTable() {
if (this.tableHtml) {
return true;
}
return false;
},
get modalAttributes() {
if (this.isEditingTable) {
return {
title: themePrefix("discourse_table_builder.edit.modal.title"),
insertTable: {
title: themePrefix("discourse_table_builder.edit.modal.create"),
icon: "pencil-alt",
},
};
} else {
return {
title: themePrefix("discourse_table_builder.modal.title"),
insertTable: {
title: themePrefix("discourse_table_builder.modal.create"),
icon: "plus",
},
};
}
},
// Actions:
@action
showEditReasonField() {
if (this.showEditReason) {
return this.set("showEditReason", false);
} else {
return this.set("showEditReason", true);
}
},
@action
cancelTableInsertion() {
this.triggerModalClose();
},
@action
insertTable() {
const updatedHeaders = this.spreadsheet.getHeaders().split(","); // keys
const updatedData = this.spreadsheet.getData(); // values
const markdownTable = this.buildTableMarkdown(updatedHeaders, updatedData);
if (!this.isEditingTable) {
this.toolbarEvent.addText(markdownTable);
return this.triggerModalClose();
} else {
return this.updateTable(markdownTable);
}
},
// Helper Methods:
loadLibraries() {
return loadScript(settings.theme_uploads.jsuites).then(() => {
return loadScript(settings.theme_uploads.jspreadsheet);
});
},
buildTable(table) {
buildNewTable() {
const data = [
["", "", ""],
["", "", ""],
["", "", ""],
];
const columns = [
{ title: "Column 1", width: 150 },
{ title: "Column 2", width: 150 },
{ title: "Column 3", width: 150 },
];
return this.buildSpreadsheet(data, columns);
},
buildPopulatedTable(table) {
const tableObject = tableToObj(table);
const headings = [];
const tableData = [];
@ -47,47 +135,20 @@ export default Component.extend({
};
});
return this.buildSpreadsheet(tableData, columns);
},
buildSpreadsheet(data, columns, opts = {}) {
const spreadsheetContainer = document.querySelector("#spreadsheet");
// eslint-disable-next-line no-undef
this.spreadsheet = jspreadsheet(spreadsheetContainer, {
data: tableData,
data,
columns,
...opts,
});
},
@action
showEditReasonField() {
if (this.showEditReason) {
return this.set("showEditReason", false);
} else {
return this.set("showEditReason", true);
}
},
@action
cancelTableEdit() {
this.triggerModalClose();
},
@action
editTable() {
const updatedHeaders = this.spreadsheet.getHeaders().split(","); // keys
const updatedData = this.spreadsheet.getData(); // values
const markdownTable = this.buildTableMarkdown(updatedHeaders, updatedData);
const tableId = this.get("tableId");
const postId = this.model.id;
const newRaw = markdownTable;
const editReason =
this.get("editReason") ||
I18n.t(themePrefix("discourse_table_builder.edit.default_edit_reason"));
const raw = this.model.raw;
const newPostRaw = this.buildUpdatedPost(tableId, raw, newRaw);
this.updateTable(postId, newPostRaw, editReason);
},
buildUpdatedPost(tableId, raw, newRaw) {
const tableToEdit = raw.match(findTableRegex());
let editedTable;
@ -101,7 +162,20 @@ export default Component.extend({
return editedTable;
},
updateTable(postId, raw, edit_reason) {
updateTable(markdownTable) {
const tableId = this.get("tableId");
const postId = this.model.id;
const newRaw = markdownTable;
const editReason =
this.get("editReason") ||
I18n.t(themePrefix("discourse_table_builder.edit.default_edit_reason"));
const raw = this.model.raw;
const newPostRaw = this.buildUpdatedPost(tableId, raw, newRaw);
return this.sendTableUpdate(postId, newPostRaw, editReason);
},
sendTableUpdate(postId, raw, edit_reason) {
return ajax(`/posts/${postId}.json`, {
type: "PUT",
data: {

View File

@ -1,153 +0,0 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { A } from "@ember/array";
export default class extends Controller {
@tracked tableItems = A([
{ column: 1, rows: A([{ id: 1 }]) },
{ column: 2, rows: A([{ id: 1 }]) },
]);
resetData() {
this.tableItems = A([
{ column: 1, rows: A([{ id: 1 }]) },
{ column: 2, rows: A([{ id: 1 }]) },
]);
}
@action
cancelTableCreation() {
this.send("closeModal");
}
createDivider(alignment) {
switch (alignment) {
case "left":
return ":--";
break;
case "right":
return "--:";
break;
case "center":
return ":--:";
break;
default:
return "--";
break;
}
}
buildTable(table) {
const headings = [];
const divider = [];
const rows = [];
table.forEach((item) => {
headings.push(item.header);
divider.push(this.createDivider(item.alignment));
item.rows.forEach((r) => rows.push(r));
});
// Make an object for each row rather than by column
const rowItems = rows.reduce((row, { id, content }) => {
row[id] ??= { id, content: [] };
if (Array.isArray(content)) {
row[id].content = row[id].value.concat(content);
} else {
row[id].content.push(content);
}
return row;
}, {});
const header = `|${headings.join("|")}|\n`;
const tableDivider = `|${divider.join("|")}|\n`;
let rowValues;
Object.values(rowItems).forEach((item) => {
item.content.forEach((line) => {
if (line === undefined) {
line = "";
}
if (rowValues) {
rowValues += `${line}|`;
} else {
rowValues = `|${line}|`;
}
});
rowValues += "\n";
});
let tableMarkdown = header + tableDivider + rowValues;
this.toolbarEvent.addText(tableMarkdown);
}
@action
createTable() {
this.buildTable(this.tableItems);
this.resetData();
this.send("closeModal");
}
@action
removeColumn(column) {
this.tableItems.removeObject(column);
}
@action
addColumn(columnId) {
this.tableItems.pushObject({
column: columnId,
rows: A([{ id: 1 }]),
});
}
@action
setColumnHeader(index, value) {
this.tableItems[index].header = value;
}
@action
addRow(columnId, rowId) {
this.tableItems.find((item) => {
if (item.column === columnId) {
item.rows.pushObject({ id: rowId });
}
});
}
@action
removeRow(columnId, row) {
this.tableItems.find((item) => {
if (item.column === columnId) {
if (item.rows.length === 1) {
// do not allow deleting if only one row left
return;
} else {
item.rows.removeObject(row);
}
}
});
}
@action
setRowValue(columnId, rowId, value) {
const index = columnId - 1;
this.tableItems[index].rows.find((row) => {
if (row.id === rowId) {
row.content = value;
}
});
}
@action
alignColumn(columnId, alignment) {
this.tableItems.find((item) => {
if (item.column === columnId) {
item.alignment = alignment;
}
});
}
}

View File

@ -1,20 +0,0 @@
<div class="body-row">
<TextField
@value={{bodyRowValue}}
@class="table-builder-input"
@placeholderKey={{theme-prefix "discourse_table_builder.modal.body"}}
{{on "change" this.addBodyValue}}
/>
<DButton
@icon="plus"
@title={{theme-prefix "discourse_table_builder.modal.buttons.add_row"}}
@action={{action "addRow"}}
/>
<DButton
@icon="trash-alt"
@class="btn-danger"
@title={{theme-prefix "discourse_table_builder.modal.buttons.remove_row"}}
@action={{action "removeRow"}}
@disabled={{this.disableRemoveRow}}
/>
</div>

View File

@ -1,62 +0,0 @@
<div class="header-column">
<div class="header-row">
<TextField
@value={{columnHeaderValue}}
@class="table-builder-input"
@id="header-column-{{@columnId}}"
@placeholderKey={{theme-prefix "discourse_table_builder.modal.header"}}
{{on "change" this.addColumnHeader}}
/>
<div class="body-inputs">
{{#each @column.rows as |item|}}
<BodyRow
@addRow={{this.addRow}}
@columnId={{@columnId}}
@allRows={{@column.rows}}
@row={{item}}
@removeRow={{this.removeRow}}
@setRowValue={{this.setRowValue}}
/>
{{/each}}
</div>
</div>
<div class="column-action-buttons column-aligned-{{this.alignment}}">
<DButton
@icon="align-left"
@class="btn-align-left"
@title={{theme-prefix "discourse_table_builder.modal.buttons.align_left"}}
@action={{action "alignColumn" "left"}}
/>
<DButton
@icon="align-center"
@class="btn-align-center"
@title={{theme-prefix
"discourse_table_builder.modal.buttons.align_center"
}}
@action={{action "alignColumn" "center"}}
/>
<DButton
@icon="align-right"
@class="btn-align-right"
@title={{theme-prefix
"discourse_table_builder.modal.buttons.align_right"
}}
@action={{action "alignColumn" "right"}}
/>
<DButton
@icon="trash-alt"
@class="btn-danger"
@action={{action "removeColumn"}}
@title={{theme-prefix
"discourse_table_builder.modal.buttons.remove_column"
}}
@disabled={{this.disableRemoveColumn}}
/>
<DButton
@icon="plus"
@action={{action "addColumn"}}
@title={{theme-prefix "discourse_table_builder.modal.buttons.add_column"}}
/>
</div>
</div>

View File

@ -1,38 +1,62 @@
<DModalBody
@title={{theme-prefix "discourse_table_builder.edit.modal.title"}}
@class="table-editor-modal"
>
<div class="edit-reason">
<DButton
@icon="info-circle"
@title={{theme-prefix
"discourse_table_builder.edit.modal.trigger_reason"
}}
@action={{action "showEditReasonField"}}
@class="btn btn-icon btn-flat no-text btn-edit-reason"
/>
{{#if showEditReason}}
<TextField
@value={{this.editReason}}
@placeholderKey={{theme-prefix
"discourse_table_builder.edit.modal.reason"
<DModalBody @title={{this.modalAttributes.title}} @class="insert-table-modal">
{{#if this.isEditingTable}}
<div class="edit-reason">
<DButton
@icon="info-circle"
@title={{theme-prefix
"discourse_table_builder.edit.modal.trigger_reason"
}}
@action={{action "showEditReasonField"}}
@class="btn btn-icon btn-flat no-text btn-edit-reason"
/>
{{/if}}
</div>
{{#if showEditReason}}
<TextField
@value={{this.editReason}}
@placeholderKey={{theme-prefix
"discourse_table_builder.edit.modal.reason"
}}
/>
{{/if}}
</div>
{{/if}}
<div id="spreadsheet" tabindex="1" class="jexcel_container"></div>
</DModalBody>
<div class="modal-footer">
<DButton
@class="btn-edit-table"
@label={{theme-prefix "discourse_table_builder.edit.modal.create"}}
@icon="pencil-alt"
@action={{action "editTable"}}
/>
<DButton
@class="btn-flat"
@label={{theme-prefix "discourse_table_builder.edit.modal.cancel"}}
@action={{action "cancelTableEdit"}}
/>
<div class="primary-actions">
<DButton
@class="btn-insert-table"
@label={{this.modalAttributes.insertTable.title}}
@icon={{this.modalAttributes.insertTable.icon}}
@action={{action "insertTable"}}
/>
<DButton
@class="btn-flat"
@label={{theme-prefix "discourse_table_builder.edit.modal.cancel"}}
@action={{action "cancelTableInsertion"}}
/>
</div>
<div class="secondary-actions">
<DPopover>
<DButton class="trigger" @icon="question" />
<ul>
<h4>{{theme-i18n "discourse_table_builder.modal.help.title"}}</h4>
<li>
<kbd>
{{theme-i18n "discourse_table_builder.modal.help.enter_key"}}
</kbd>
{{theme-i18n "discourse_table_builder.modal.help.new_row"}}
</li>
<li>
<kbd>
{{theme-i18n "discourse_table_builder.modal.help.tab_key"}}
</kbd>
{{theme-i18n "discourse_table_builder.modal.help.new_col"}}
</li>
<li>{{theme-i18n "discourse_table_builder.modal.help.options"}}</li>
</ul>
</DPopover>
</div>
</div>

View File

@ -3,4 +3,5 @@
@tableHtml={{this.tableHtml}}
@tableId={{this.tableId}}
@triggerModalClose={{action "closeEditModal"}}
@toolbarEvent={{this.toolbarEvent}}
/>

View File

@ -1,40 +0,0 @@
<DModalBody
@title={{theme-prefix "discourse_table_builder.modal.title"}}
@class="table-builder-modal"
>
<form id="insert-markdown-table-form" {{action "createTable" on="submit"}}>
<section class="header-section">
<div class="table-header-fields">
{{#each this.tableItems as |item|}}
<HeaderColumn
@column={{item}}
@columnId={{item.column}}
@tableItems={{this.tableItems}}
@addColumn={{this.addColumn}}
@removeColumn={{this.removeColumn}}
@setColumnHeader={{this.setColumnHeader}}
@addRow={{this.addRow}}
@removeRow={{this.removeRow}}
@setRowValue={{this.setRowValue}}
@alignColumn={{this.alignColumn}}
/>
{{/each}}
</div>
</section>
</form>
</DModalBody>
<div class="modal-footer">
<DButton
@class="btn-primary btn-build-table"
@label={{theme-prefix "discourse_table_builder.modal.create"}}
@icon="plus"
@action={{action "createTable"}}
/>
<DButton
@class="btn-flat"
@label={{theme-prefix "discourse_table_builder.modal.cancel"}}
@action={{action "cancelTableCreation"}}
/>
</div>

View File

@ -7,16 +7,13 @@ en:
title: "Table Builder"
create: "Build Table"
cancel: "cancel"
header: "Header"
body: "Body"
buttons:
add_column: "Add Column"
remove_column: "Remove Column"
add_row: "Add Row"
remove_row: "Remove Row"
align_left: "Align Left"
align_center: "Align Center"
align_right: "Align Right"
help:
title: "Using the Spreadsheet Editor"
enter_key: "Enter"
tab_key: "Tab"
new_row: "at the end of a row to insert a new row."
new_col: "at the end of a column to insert a new column."
options: "Right-click on cells to access more options in a dropdown menu."
edit:
btn_edit: "Edit Table"
modal:

View File

@ -1,9 +1,4 @@
.open-popup-link {
display: inline;
margin-inline: 0.25em;
}
.btn-edit-table {
.btn-insert-table {
background: var(--tertiary);
color: var(--secondary);
@ -23,7 +18,7 @@
}
}
.table-editor-modal {
.insert-table-modal-modal {
display: flex;
flex-direction: column;
align-items: flex-start;
@ -34,4 +29,20 @@
color: var(--primary-high);
}
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
.secondary-actions .tippy-content {
h4 {
color: var(--primary);
}
li {
margin-block: 0.25rem;
color: var(--primary-high);
}
}
}
}

View File

@ -0,0 +1,4 @@
.open-popup-link {
display: inline;
margin-inline: 0.25em;
}

View File

@ -1,77 +0,0 @@
.table-builder-modal-modal {
.modal-inner-container {
--modal-max-width: 90vw;
}
}
.table-header-fields {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
.table-builder-input {
font-weight: bold;
}
.header-column {
border: 1px solid var(--primary-low-mid);
padding: 1rem;
display: flex;
flex-direction: column;
}
.column-action-buttons {
display: flex;
width: 100%;
gap: 0.5rem;
margin-top: auto;
border-top: 1px solid var(--primary-low-mid);
padding-top: 1rem;
.btn {
flex: 1;
}
&.column-aligned {
&-right .btn-align-right {
background: var(--primary-high);
.d-icon {
color: var(--primary-very-low);
}
}
&-left .btn-align-left {
background: var(--primary-high);
.d-icon {
color: var(--primary-very-low);
}
}
&-center .btn-align-center {
background: var(--primary-high);
.d-icon {
color: var(--primary-very-low);
}
}
}
}
.body-inputs {
display: flex;
flex-direction: column;
.table-builder-input {
font-weight: normal;
}
}
.body-row {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 1rem;
gap: 0.5rem;
input {
margin-bottom: 0;
}
}
}