Merge pull request #1 from keegangeorge/feature-edit-table

FEATURE: Ability to edit markdown tables in posts
This commit is contained in:
Keegan George
2022-07-18 11:32:17 -07:00
committed by GitHub
19 changed files with 34512 additions and 79 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
A theme component that adds a button to the composer tools to easily build tables in markdown.
## To Do List:
## To Do List
- [X] Add table builder functionality
- [ ] Possibly add the ability to edit tables
- [X] Add front-end tests
- [X] Add front-end tests
+4 -1
View File
@@ -8,7 +8,10 @@
"theme_version": "0.0.1",
"minimum_discourse_version": null,
"maximum_discourse_version": null,
"assets": {},
"assets": {
"jsuites": "assets/jsuites.js",
"jspreadsheet": "assets/jspreadsheet.js"
},
"modifiers": {
"svg_icons": [
"align-left",
+16859
View File
File diff suppressed because one or more lines are too long
+13310
View File
File diff suppressed because one or more lines are too long
+6 -76
View File
@@ -1,77 +1,7 @@
.table-builder-modal-modal {
.modal-inner-container {
--modal-max-width: 90vw;
}
}
@import "vendor/jspreadsheet";
@import "vendor/jspreadsheet-datatables";
@import "vendor/jspreadsheet-theme";
@import "vendor/jsuites";
.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;
}
}
}
@import "table-builder";
@import "table-editor";
@@ -0,0 +1,87 @@
import { apiInitializer } from "discourse/lib/api";
import showModal from "discourse/lib/show-modal";
import { schedule } from "@ember/runloop";
import I18n from "I18n";
import { iconNode } from "discourse-common/lib/icon-library";
import { create } from "virtual-dom";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default apiInitializer("0.11.1", (api) => {
const site = api.container.lookup("site:main");
const currentUser = api.getCurrentUser();
function createButton() {
const openPopupBtn = document.createElement("button");
openPopupBtn.classList.add(
"open-popup-link",
"btn-default",
"btn",
"btn-icon-text"
);
const expandIcon = create(
iconNode("pencil-alt", { class: "edit-table-icon" })
);
const openPopupText = document.createTextNode(
I18n.t(themePrefix("discourse_table_builder.edit.btn_edit"))
);
openPopupBtn.append(expandIcon, openPopupText);
return openPopupBtn;
}
function generateModal(event) {
const table = event.target.parentNode.lastElementChild;
const tempTable = table.cloneNode(true);
const tableId = event.target.getAttribute("data-table-id");
return ajax(`/posts/${this.id}`, { type: "GET" })
.then((post) => {
showModal("table-editor-modal", {
model: post,
}).setProperties({
tableHtml: tempTable,
tableId,
});
})
.catch(popupAjaxError);
}
function generatePopups(tables, attrs) {
tables.forEach((table, index) => {
if (site.isMobileDevice) {
return;
}
const popupBtn = createButton();
popupBtn.setAttribute("data-table-id", index); // sets a table id so each table can be distinctly edited
table.parentNode.classList.add("fullscreen-table-wrapper");
const expandBtn = document.querySelector(".open-popup-link");
if (table.parentNode.contains(expandBtn)) {
expandBtn.parentNode.insertBefore(popupBtn, expandBtn);
} else {
table.parentNode.insertBefore(popupBtn, table);
}
popupBtn.addEventListener("click", generateModal.bind(attrs), false);
});
}
api.decorateCookedElement(
(post, helper) => {
const postOwner = helper.widget.attrs.username;
if (postOwner !== currentUser.username) {
return;
}
schedule("afterRender", () => {
const tables = post.querySelectorAll("table");
generatePopups(tables, helper.widget.attrs);
});
},
{
onlyStream: true,
id: "edit-table",
}
);
});
@@ -0,0 +1,131 @@
// import Controller from "@ember/controller";
import { action } from "@ember/object";
import loadScript from "discourse/lib/load-script";
import { arrayToTable, findTableRegex, tableToObj } from "../lib/utilities";
import Component from "@ember/component";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
export default Component.extend({
tagName: "",
showEditReason: false,
didInsertElement() {
this._super(...arguments);
this.loadLibraries().then(() => {
this.buildTable(this.tableHtml);
});
},
loadLibraries() {
return loadScript(settings.theme_uploads.jsuites).then(() => {
return loadScript(settings.theme_uploads.jspreadsheet);
});
},
buildTable(table) {
const tableObject = tableToObj(table);
const headings = [];
const tableData = [];
tableObject.forEach((object) => {
// Build Headings
if (!headings.includes(...Object.keys(object))) {
headings.push(...Object.keys(object));
}
// Build Table Data
tableData.push([...Object.values(object)]);
});
const columns = headings.map((heading) => {
return {
title: heading,
width: heading.length * 15,
};
});
const spreadsheetContainer = document.querySelector("#spreadsheet");
// eslint-disable-next-line no-undef
this.spreadsheet = jspreadsheet(spreadsheetContainer, {
data: tableData,
columns,
});
},
@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;
if (tableToEdit.length > 1) {
editedTable = raw.replace(tableToEdit[tableId], newRaw);
} else {
editedTable = raw.replace(findTableRegex(), newRaw);
}
return editedTable;
},
updateTable(postId, raw, edit_reason) {
return ajax(`/posts/${postId}.json`, {
type: "PUT",
data: {
post: {
raw,
edit_reason,
},
},
})
.catch(popupAjaxError)
.finally(() => {
this.triggerModalClose();
});
},
buildTableMarkdown(headers, data) {
const table = [];
data.forEach((row) => {
const result = {};
headers.forEach((key, index) => (result[key] = row[index]));
table.push(result);
});
return arrayToTable(table);
},
});
@@ -0,0 +1,9 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
export default class extends Controller {
@action
closeEditModal() {
this.send("closeModal");
}
}
+95
View File
@@ -0,0 +1,95 @@
/* eslint-disable */
/**
* Generate an object from an HTML table
*
* @see {@link https://gist.github.com/mattheo-gist/4151867|GitHub}
*
* @param {Table} table HTML Table element
*
* @return {Object} A JavaScript object
*/
export function tableToObj(table) {
var rows = table.rows;
var propCells = rows[0].cells;
var propNames = [];
var results = [];
var obj, row, cells;
// Use the first row for the property names
// Could use a header section but result is the same if
// there is only one header row
for (var i = 0, iLen = propCells.length; i < iLen; i++) {
propNames.push(propCells[i].textContent || propCells[i].innerText);
}
// Use the rows for data
// Could use tbody rows here to exclude header & footer
// but starting from 1 gives required result
for (var j = 1, jLen = rows.length; j < jLen; j++) {
cells = rows[j].cells;
obj = {};
for (var k = 0; k < iLen; k++) {
obj[propNames[k]] = cells[k].textContent || cells[k].innerText;
}
results.push(obj);
}
return results;
}
/**
* Generate markdown table from an array of objects
*
* @see {@link https://github.com/Nijikokun/array-to-table|GitHub}:
*
* @param {Array} array Array of objects
* @param {Array} columns Optional, table column names, otherwise taken from the keys of the first object
* @param {String} alignment Optional table alignment. Can be 'center' (default), 'left' or 'right'
*
* @return {String} Markdown table
*/
export function arrayToTable(array, columns, alignment = "center") {
var table = "";
var separator = {
left: ":---",
right: "---:",
center: "---",
};
// Generate column list
var cols = columns ? columns.split(",") : Object.keys(array[0]);
// Generate table headers
table += cols.join(" | ");
table += "\r\n";
// Generate table header seperator
table += cols
.map(function () {
return separator[alignment] || separator.center;
})
.join(" | ");
table += "\r\n";
// Generate table body
array.forEach(function (item) {
table +=
cols
.map(function (key) {
return String(item[key] || "");
})
.join(" | ") + "\r\n";
});
// Return table
return table;
}
/**
*
* @returns a regular experssion finding all markdown tables
*/
export function findTableRegex() {
return /((\r?){2}|^)([^\r\n]*\|[^\r\n]*(\r?\n)?)+(?=(\r?\n){2}|$)/gm;
}
@@ -0,0 +1,38 @@
<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"
}}
/>
{{/if}}
</div>
<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>
@@ -0,0 +1,6 @@
<SpreadsheetEditor
@model={{this.model}}
@tableHtml={{this.tableHtml}}
@tableId={{this.tableId}}
@triggerModalClose={{action "closeEditModal"}}
/>
+9
View File
@@ -17,5 +17,14 @@ en:
align_left: "Align Left"
align_center: "Align Center"
align_right: "Align Right"
edit:
btn_edit: "Edit Table"
modal:
title: "Edit Table"
cancel: "cancel"
create: "Update Table"
reason: "why are you editing?"
trigger_reason: "Add reason for edit"
default_edit_reason: "Update Table with Table Editor"
theme_metadata:
description: "Adds a button to the composer to easily build tables in markdown"
+77
View File
@@ -0,0 +1,77 @@
.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;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
.open-popup-link {
display: inline;
margin-inline: 0.25em;
}
.btn-edit-table {
background: var(--tertiary);
color: var(--secondary);
.d-icon {
color: var(--secondary);
}
.discourse-no-touch & {
&:hover {
background-color: var(--tertiary-hover);
color: var(--secondary);
.d-icon {
color: var(--secondary);
}
}
}
}
.table-editor-modal {
display: flex;
flex-direction: column;
align-items: flex-start;
.btn-edit-reason {
background: none;
.d-icon {
color: var(--primary-high);
}
}
}
+85
View File
@@ -0,0 +1,85 @@
table.jexcel {
border-right: 1px solid transparent;
}
table.jexcel > thead {
background-color: #fff;
}
table.jexcel > thead > tr > td {
border-top: 1px solid transparent;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
border-bottom: 1px solid #000;
background-color: #fff;
padding: 10px;
font-weight: bold;
}
table.jexcel > thead > tr > td.selected {
background-color: #eee;
}
table.jexcel > tbody > tr > td {
padding: 8px;
border-right: 1px solid transparent;
border-left: 1px solid transparent;
}
table.jexcel > tbody > tr > td:first-child {
background-color: #fff;
}
table.jexcel > tbody > tr.selected > td:first-child {
background-color: #eee;
}
.jexcel_toolbar {
background-color: transparent;
border: 0px;
border-bottom: 1px solid #ccc;
border-top: 1px solid #ccc;
margin-bottom: 0px;
}
.jexcel_pagination > div > div {
border: 1px solid transparent;
background-color: #fff;
}
.jexcel_page_selected {
background: linear-gradient(to bottom, #fff 0%, #dcdcdc 100%);
border: 1px solid #979797 !important;
font-weight: normal;
color: #333 !important;
border-radius: 2px;
}
/*.jexcel > div > table > tbody > tr,
.jexcel > div > table > thead
{
border-left:1px solid transparent;
}
.jexcel > div > table > tbody > tr > td
{
padding:10px;
border-right:1px solid transparent;
}
.jexcel > div > table > tbody > td.edition
{
padding:0px;
padding-left:4px;
}
.jexcel > div > table > tbody > tr > td:first-child
{
background-color:#fff;
font-weight:bold;
}
.jexcel > div > table > tbody > tr.selected > td:first-child
{
background-color:#eee;
}
.jexcel > div > table > thead > tr > td.selected
{
background-color:#eee;
}*/
+215
View File
@@ -0,0 +1,215 @@
:root {
--jexcel_header_color: var(--primary-high);
--jexcel_header_color_highlighted: var(--primary-high);
--jexcel_header_background: var(--primary-very-low);
--jexcel_header_background_highlighted: var(--primary-low);
--jexcel_content_color: var(--primary);
--jexcel_content_color_highlighted: var(--primary-high);
--jexcel_content_background: var(--secondary);
--jexcel_content_background_highlighted: var(--tertiary-very-low);
--jexcel_menu_background: var(--secondary);
--jexcel_menu_background_highlighted: var(--secondary-very-high);
--jexcel_menu_color: var(--primary-medium);
--jexcel_menu_color_highlighted: var(--primary);
--jexcel_border_color: var(--primary-low-mid);
--jexcel_border_color_highlighted: var(--tertiary-high);
--active_color: var(--primary-very-low);
--active-color: var(--active_color);
}
.jexcel {
border-bottom: 1px solid var(--jexcel_border_color);
border-right: 1px solid var(--jexcel_border_color);
}
.jexcel_corner {
background-color: var(--tertiary);
}
.jexcel > tbody > tr > td,
.jexcel > thead > tr > td {
border-top: 1px solid var(--jexcel_border_color);
border-left: 1px solid var(--jexcel_border_color);
background-color: var(--jexcel_content_background);
color: var(--jexcel_content_color);
}
.jexcel > tbody > tr > td:first-child,
.jexcel > thead > tr > td {
background-color: var(--jexcel_header_background);
color: var(--jexcel_header_color);
}
.jexcel > thead > tr > td.selected,
.jexcel > tbody > tr.selected > td:first-child {
background-color: var(--jexcel_header_background_highlighted);
color: var(--jexcel_header_color_highlighted);
}
.jexcel > tbody > tr > td.jexcel_cursor a {
color: var(--active-color);
}
.jexcel_pagination > div > div {
color: var(--jexcel_header_color);
background: var(--jexcel_header_background);
border: 1px solid var(--jexcel_border_color);
}
.jexcel_page,
.jexcel_container input,
.jexcel_container select {
color: var(--jexcel_header_color);
background: var(--jexcel_header_background);
border: 1px solid var(--jexcel_border_color);
}
.jexcel_contextmenu.jcontextmenu {
border: 1px solid var(--jexcel_border_color);
background: var(--jexcel_menu_background);
color: var(--jexcel_menu_color);
box-shadow: 0 12px 12px rgba(0, 0, 0, 0.15);
}
.jcontextmenu > div a {
color: var(--jexcel_menu_color);
}
.jcontextmenu > div:not(.contextmenu-line):hover a {
color: var(--jexcel_menu_color_highlighted);
}
.jcontextmenu > div:not(.contextmenu-line):hover {
background: var(--jexcel_menu_background_highlighted);
}
.jexcel_dropdown .jdropdown-container,
.jexcel_dropdown .jdropdown-content {
background-color: var(--jexcel_content_background);
color: var(--jexcel_content_color);
}
.jexcel_dropdown .jdropdown-item {
color: var(--jexcel_content_color);
}
.jexcel_dropdown .jdropdown-item:hover,
.jexcel_dropdown .jdropdown-selected,
.jexcel_dropdown .jdropdown-cursor {
background-color: var(--jexcel_content_background_highlighted);
color: var(--jexcel_content_color_highlighted);
}
.jexcel .jcalendar-content {
background-color: var(--jexcel_header_background);
color: var(--jexcel_header_color);
}
.jexcel .jcalendar-content > table {
background-color: var(--jexcel_content_background);
color: var(--jexcel_content_color);
}
.jexcel .jcalendar-weekday {
background-color: var(--jexcel_content_background_highlighted);
color: var(--jexcel_content_color_highlighted);
}
.jexcel .jcalendar-sunday {
color: var(--jexcel_header_color);
}
.jexcel .jcalendar-selected {
background-color: var(--jexcel_content_background_highlighted);
color: var(--jexcel_content_color_highlighted);
}
.jexcel_toolbar i.jexcel_toolbar_item {
color: var(--jexcel_content_color);
}
.jexcel_toolbar i.jexcel_toolbar_item:hover {
background: var(--jexcel_content_background_highlighted);
color: var(--jexcel_content_color_highlighted);
}
.jexcel_toolbar {
background: var(--jexcel_header_background);
}
.jexcel_content::-webkit-scrollbar-track {
background: var(--jexcel_background_head);
}
.jexcel_content::-webkit-scrollbar-thumb {
background: var(--jexcel_background_head_highlighted);
}
.jexcel_border_main {
border: 1px solid #000;
border-color: var(--jexcel_border_color_highlighted);
}
.jexcel .highlight {
background-color: var(--jexcel_content_background_highlighted);
}
.jexcel .highlight-bottom {
border-bottom: 1.5px solid var(--jexcel_border_color_highlighted);
}
.jexcel .highlight-right {
border-right: 1.5px solid var(--jexcel_border_color_highlighted);
}
.jexcel .highlight-left {
border-left: 1.5px solid var(--jexcel_border_color_highlighted);
}
.jexcel .highlight-top {
border-top: 1.5px solid var(--jexcel_border_color_highlighted);
}
.jexcel .copying-top {
border-top-color: var(--jexcel_border_color_highlighted);
}
.jexcel .copying-right {
border-right-color: var(--jexcel_border_color_highlighted);
}
.jexcel .copying-left {
border-left-color: var(--jexcel_border_color_highlighted);
}
.jexcel .copying-bottom {
border-bottom-color: var(--jexcel_border_color_highlighted);
}
.jexcel_border_main,
.jexcel .highlight-top.highlight-left,
.jexcel .highlight-top,
.jexcel .highlight-left {
-webkit-box-shadow: unset;
box-shadow: unset;
}
table.jexcel > thead > tr > td {
border-top: 1px solid var(--jexcel_border_color);
border-right: 1px solid var(--jexcel_border_color);
border-bottom: none;
background-color: var(--jexcel_header_background);
&:first-child {
border-left: 1px solid var(--jexcel_border_color);
}
}
table.jexcel > thead > tr > td.selected {
background-color: var(--jexcel_header_background_highlighted);
color: var(--jexcel_header_color_highlighted);
}
table.jexcel > tbody > tr > td {
border-right: 1px solid var(--jexcel_border_color);
&:first-child {
border-left: 1px solid var(--jexcel_border_color);
}
}
+692
View File
File diff suppressed because one or more lines are too long
+2847
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
extend_content_security_policy:
type: list
default: "script_src: http://localhost:4200/"