Skip to content

Commit

Permalink
Convert NodeTree into ES6 (AlchemyCMS#1782)
Browse files Browse the repository at this point in the history
* Install sortablejs npm package

* Revert "Add favicon to assets manifest"

The favicon is already linked with the images folder.

* Convert NodeTree and utils into ES6 modules

And make the `on` delegated event handler work with multiple base nodes

* Fix base node folding

* Extract ajax and events into own utils modules
  • Loading branch information
tvdeyen authored Apr 27, 2020
1 parent addc211 commit 5093856
Show file tree
Hide file tree
Showing 14 changed files with 311 additions and 127 deletions.
1 change: 0 additions & 1 deletion app/assets/config/alchemy_manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
//= link alchemy/menubar.js
//= link alchemy/print.css
//= link alchemy/welcome.css
//= link alchemy/favicon.ico
//= link tinymce/plugins/alchemy_link/plugin.min.js
//= link tinymce/tinymce.min.js
//= link_directory ../stylesheets/tinymce/skins/alchemy/ .css
Expand Down
3 changes: 0 additions & 3 deletions app/assets/javascripts/alchemy/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@
//= require requestAnimationFrame
//= require select2
//= require handlebars
//= require sortable/Sortable.min
//= require alchemy/templates
//= require alchemy/alchemy.base
//= require alchemy/alchemy.utils
//= require alchemy/alchemy.autocomplete
//= require alchemy/alchemy.browser
//= require alchemy/alchemy.buttons
Expand All @@ -40,7 +38,6 @@
//= require alchemy/alchemy.link_dialog
//= require alchemy/alchemy.list_filter
//= require alchemy/alchemy.initializer
//= require alchemy/alchemy.node_tree
//= require alchemy/alchemy.page_sorter
//= require alchemy/alchemy.uploader
//= require alchemy/alchemy.preview_window
Expand Down
66 changes: 0 additions & 66 deletions app/assets/javascripts/alchemy/alchemy.node_tree.js

This file was deleted.

45 changes: 0 additions & 45 deletions app/assets/javascripts/alchemy/alchemy.utils.js

This file was deleted.

2 changes: 1 addition & 1 deletion app/assets/stylesheets/alchemy/nodes.scss
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
margin: 0;
padding: 0;

.folded > li {
&.folded > li {
display: none;
}
}
Expand Down
72 changes: 72 additions & 0 deletions app/javascript/alchemy/admin/node_tree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Sortable from "sortablejs"
import ajax from "./utils/ajax"
import { on } from "./utils/events"

function displayNodeFolders() {
document.querySelectorAll("li.menu-item").forEach((el) => {
const leftIconArea = el.querySelector(".nodes_tree-left_images")
const list = el.querySelector(".children")
const node = { folded: el.dataset.folded === "true", id: el.dataset.id }

if (list.children.length > 0 || node.folded) {
leftIconArea.innerHTML = HandlebarsTemplates.node_folder({ node: node })
} else {
leftIconArea.innerHTML = " "
}
})
}

function onFinishDragging(evt) {
const url = Alchemy.routes.move_api_node_path(evt.item.dataset.id)
const data = {
target_parent_id: evt.to.dataset.nodeId,
new_position: evt.newIndex
}

ajax("PATCH", url, data)
.then(() => {
const message = Alchemy.t("Successfully moved menu item")
Alchemy.growl(message)
displayNodeFolders()
})
.catch((error) => {
Alchemy.growl(error.message || error, "error")
})
}

function handleNodeFolders() {
on("click", ".nodes_tree", ".node_folder", function () {
const nodeId = this.dataset.nodeId
const menu_item = this.closest("li.menu-item")
const url = Alchemy.routes.toggle_folded_api_node_path(nodeId)
const list = menu_item.querySelector(".children")

ajax("PATCH", url)
.then(() => {
list.classList.toggle("folded")
menu_item.dataset.folded =
menu_item.dataset.folded == "true" ? "false" : "true"
displayNodeFolders()
})
.catch((error) => {
Alchemy.growl(error.message || error)
})
})
}

export default function NodeTree() {
handleNodeFolders()
displayNodeFolders()

document.querySelectorAll(".nodes_tree ul.children").forEach((el) => {
new Sortable(el, {
group: "nodes",
animation: 150,
fallbackOnBody: true,
swapThreshold: 0.65,
handle: ".node_name",
invertSwap: true,
onEnd: onFinishDragging
})
})
}
124 changes: 124 additions & 0 deletions app/javascript/alchemy/admin/utils/__tests__/ajax.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import xhrMock from "xhr-mock"
import ajax from "../ajax"

const token = "s3cr3t"

beforeEach(() => {
document.head.innerHTML = `<meta name="csrf-token" content="${token}">`
xhrMock.setup()
})

describe("ajax('get')", () => {
it("sends X-CSRF-TOKEN header", async () => {
xhrMock.get("/users", (req, res) => {
expect(req.header("X-CSRF-TOKEN")).toEqual(token)
return res.status(200).body('{"message":"Ok"}')
})
await ajax("get", "/users")
})

it("sends Content-Type header", async () => {
xhrMock.get("/users", (req, res) => {
expect(req.header("Content-Type")).toEqual(
"application/json; charset=utf-8"
)
return res.status(200).body('{"message":"Ok"}')
})
await ajax("get", "/users")
})

it("sends Accept header", async () => {
xhrMock.get("/users", (req, res) => {
expect(req.header("Accept")).toEqual("application/json")
return res.status(200).body('{"message":"Ok"}')
})
await ajax("get", "/users")
})

it("returns JSON", async () => {
xhrMock.get("/users", (_req, res) => {
return res.status(200).body('{"email":"mail@example.com"}')
})
await ajax("get", "/users").then((res) => {
expect(res.data).toEqual({ email: "mail@example.com" })
})
})

it("JSON parse errors get rejected", async () => {
xhrMock.get("/users", (_req, res) => {
return res.status(200).body('email => "mail@example.com"')
})
expect.assertions(1)
await ajax("get", "/users").catch((e) => {
expect(e.message).toMatch("Unexpected token")
})
})

it("network errors get rejected", async () => {
xhrMock.get("/users", () => {
return Promise.reject(new Error())
})
expect.assertions(1)
await ajax("get", "/users").catch((e) => {
expect(e.message).toEqual("An error occurred during the transaction")
})
})

it("server errors get rejected", async () => {
xhrMock.get("/users", (_req, res) => {
return res.status(401).body('{"error":"Unauthorized"}')
})
expect.assertions(1)
await ajax("get", "/users").catch((e) => {
expect(e.error).toEqual("Unauthorized")
})
})

it("server errors parsing errors get rejected", async () => {
xhrMock.get("/users", (_req, res) => {
return res.status(401).body("Unauthorized")
})
expect.assertions(1)
await ajax("get", "/users").catch((e) => {
expect(e.message).toMatch("Unexpected token")
})
})
})

describe("ajax('post')", () => {
it("sends X-CSRF-TOKEN header", async () => {
xhrMock.post("/users", (req, res) => {
expect(req.header("X-CSRF-TOKEN")).toEqual(token)
return res.status(200).body('{"message":"Ok"}')
})
await ajax("post", "/users")
})

it("sends Content-Type header", async () => {
xhrMock.post("/users", (req, res) => {
expect(req.header("Content-Type")).toEqual(
"application/json; charset=utf-8"
)
return res.status(200).body('{"message":"Ok"}')
})
await ajax("post", "/users")
})

it("sends Accept header", async () => {
xhrMock.post("/users", (req, res) => {
expect(req.header("Accept")).toEqual("application/json")
return res.status(200).body('{"message":"Ok"}')
})
await ajax("post", "/users")
})

it("sends JSON data", async () => {
xhrMock.post("/users", (req, res) => {
expect(req.body()).toEqual('{"email":"mail@example.com"}')
return res.status(200).body('{"message":"Ok"}')
})
await ajax("post", "/users", { email: "mail@example.com" })
})
})

afterEach(() => xhrMock.teardown())
38 changes: 38 additions & 0 deletions app/javascript/alchemy/admin/utils/__tests__/events.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { on } from "../events"

describe("on", () => {
const callback = jest.fn()

beforeEach(() => {
document.body.innerHTML = `
<ul class="list">
<li class="first item"><span>One</span></li>
<li class="second item">Two</li>
</ul>
`
})

it("adds event listener to base node", () => {
const baseNode = document.querySelector(".list")
const spy = jest.spyOn(baseNode, "addEventListener")
on("click", ".list", ".item", callback)
expect(spy).toHaveBeenCalledWith("click", expect.any(Function))
spy.mockReset()
})

it("event triggered on matching child node calls callback", () => {
const childNode = document.querySelector(".first.item")
on("click", ".list", ".item", callback)
childNode.click()
expect(callback).toHaveBeenCalledWith(expect.any(MouseEvent))
})

it("event triggered on child of registered target still calls callback", () => {
const child = document.querySelector(".first.item span")
on("click", ".list", ".item", callback)
child.click()
expect(callback).toHaveBeenCalledWith(expect.any(MouseEvent))
})

afterEach(() => callback.mockReset())
})
Loading

0 comments on commit 5093856

Please sign in to comment.