@x5e/gink
Version:
an eventually consistent database
915 lines (853 loc) • 33.5 kB
JavaScript
class Page {
constructor(database) {
this.database = database;
this.pageType = undefined;
this.root = this.getElement("#root");
}
/**
* Edits the HTML to display the contents of a container.
*/
async displayPage(strMuid, currentPage, itemsPerPage) {
this.clearChildren(this.root);
this.pageType = "container";
// Get data from database
const container = await this.database.getContainer(strMuid);
const totalEntries = await this.database.getTotalEntries(container);
// Before we display anything, make sure the page and items per page actually makes sense.
if ((currentPage - 1) * itemsPerPage >= totalEntries) {
// Eventually want a better solution than this, since the hash will be wrong
currentPage = Math.floor(totalEntries / itemsPerPage);
}
const [keyType, valueType] = determineContainerStorage(container);
await this.writeTitle(container);
// Add entry button
const addEntryButton = this.createElement(
"button",
this.root,
"add-entry-button"
);
addEntryButton.innerText = "Add Entry";
addEntryButton.onclick = async () => {
await this.displayAddEntry(container);
};
// If there are no entries, stop here.
if (totalEntries === 0) {
const p = this.createElement("p", this.root);
p.innerText = "No entries.";
return;
}
// Total entries
const numEntries = this.createElement("p", this.root);
numEntries.innerText = `Total entries: ${totalEntries}`;
// Items per page selector
if (totalEntries > 10) {
const itemsPerPageSelect = this.createElement("select", this.root);
const options = ["10", "25", "50", "100", "250", "500", "1000"];
for (const option of options) {
if (Number(option) > totalEntries) break;
const currentOption = this.createElement(
"option",
itemsPerPageSelect
);
currentOption.innerText = option;
currentOption.value = option;
}
itemsPerPageSelect.value = `${itemsPerPage}`;
itemsPerPageSelect.onchange = async () => {
window.location.hash = `${gink.muidToString(container.address)}+${currentPage}+${itemsPerPageSelect.value}`;
};
}
// Range information
const showing = this.createElement("p", this.root);
const lowerBound =
(currentPage - 1) * itemsPerPage + (totalEntries === 0 ? 0 : 1);
const upperBound = (currentPage - 1) * itemsPerPage + itemsPerPage;
const maxEntries =
upperBound >= totalEntries ? totalEntries : upperBound;
showing.innerText = `Showing entries ${lowerBound}-${maxEntries}`;
// Create the paging buttons
const pageButtonsDiv = this.createElement(
"div",
this.root,
"page-buttons-container"
);
pageButtonsDiv.style.fontWeight = "bold";
const prevPage = this.createElement(
"a",
pageButtonsDiv,
undefined,
"page-btn no-select"
);
prevPage.innerText = "<";
if (!this.isFirstPage(currentPage)) {
prevPage.onclick = async () => {
window.location.hash = `${gink.muidToString(container.address)}+${currentPage - 1}+${itemsPerPage}`;
};
} else {
prevPage.style.opacity = 0;
prevPage.style.cursor = "auto";
}
const thisPage = this.createElement(
"p",
pageButtonsDiv,
undefined,
"no-select"
);
thisPage.innerText = `Page ${currentPage}`;
const nextPage = this.createElement(
"a",
pageButtonsDiv,
undefined,
"page-btn no-select"
);
nextPage.innerText = ">";
if (!this.isLastPage(currentPage, itemsPerPage, totalEntries)) {
nextPage.onclick = async () => {
window.location.hash = `${gink.muidToString(container.address)}+${currentPage + 1}+${itemsPerPage}`;
};
} else {
nextPage.style.opacity = 0;
nextPage.style.cursor = "auto";
}
const pageOfEntries = await this.database.getPageOfEntries(
container,
currentPage,
itemsPerPage
);
// Create table based on page of entries.
const containerTable = this.createElement(
"table",
this.root,
"container-table"
);
const headerRow = this.createElement("tr", containerTable);
if (keyType !== "none") {
const keyHeader = this.createElement("th", headerRow);
keyHeader.innerText = "Key";
}
if (valueType !== "none") {
const valueHeader = this.createElement("th", headerRow);
valueHeader.innerText = "Value";
}
// Make sure nothing is broken
if (pageOfEntries.length) {
if (keyType === "none")
gink.ensure(pageOfEntries[0][0] === undefined);
else if (keyType !== "none")
gink.ensure(pageOfEntries[0][0] !== undefined);
if (valueType === "none")
gink.ensure(pageOfEntries[0][1] === undefined);
else if (valueType !== "none")
gink.ensure(pageOfEntries[0][1] !== undefined);
}
// Loop through entries to create table rows
let position = 0;
for (const [key, value] of pageOfEntries) {
const row = this.createElement(
"tr",
containerTable,
undefined,
"entry-row"
);
row.dataset["position"] = position;
row.onclick = async () => {
await this.displayEntry(
key,
value,
Number(row.dataset["position"]),
container
);
};
if (key !== undefined) {
const keyCell = this.createElement("td", row);
keyCell.innerText = await this.getCellValue(key);
}
if (value !== undefined) {
const valCell = this.createElement("td", row);
valCell.innerText = await this.getCellValue(value);
}
position++;
}
}
/**
* Displays the page to add a new entry to the database.
* @param {Container} container gink container as context for displaying entry.
*/
async displayAddEntry(container) {
this.clearChildren(this.root);
this.pageType = "add-entry";
const [keyType, valueType] = determineContainerStorage(container);
await this.writeTitle(container);
this.writeCancelButton();
const entryFields = this.createElement(
"div",
this.root,
"add-entry-container",
"entry-container"
);
let keyInput1, keyInput2, valueInput;
// Key inputs - if container uses keys.
if (keyType !== "none") {
const keyContainer = this.createElement(
"div",
entryFields,
undefined,
"input-container"
);
const keyH2 = this.createElement("h2", keyContainer);
keyH2.innerText = "Key";
keyInput1 = this.createElement(
"input",
keyContainer,
"key-input-1",
"bundle-input"
);
keyInput1.setAttribute("type", "text");
keyInput1.setAttribute("placeholder", "Key");
if (keyType === "muid" || keyType === "pair") {
keyInput1.setAttribute("placeholder", "Muid");
keyInput1.setAttribute("list", "datalist-1");
const datalist1 = this.createElement(
"datalist",
keyInput1,
"datalist-1"
);
await this.enableContainersAutofill(datalist1);
} else {
keyContainer.appendChild(document.createElement("br"));
const sel = keyContainer.appendChild(
document.createElement("select")
);
sel.setAttribute("id", "key-type-select");
const strOption = sel.appendChild(
document.createElement("option")
);
strOption.value = strOption.innerText = "string";
const intOption = sel.appendChild(
document.createElement("option")
);
intOption.value = intOption.innerText = "int";
}
if (keyType === "pair") {
keyInput2 = this.createElement(
"input",
keyContainer,
"key-input-2",
"bundle-input"
);
keyInput2.setAttribute("type", "text");
keyInput2.setAttribute("placeholder", "Muid");
keyInput2.setAttribute("list", "datalist-2");
const datalist2 = this.createElement(
"datalist",
keyInput2,
"datalist-2"
);
await this.enableContainersAutofill(datalist2);
}
}
// Value inputs - if container uses values.
if (valueType !== "none") {
const valueContainer = this.createElement(
"div",
entryFields,
undefined,
"input-container"
);
const valueH2 = this.createElement("h2", valueContainer);
valueH2.innerText = "Value";
valueInput = this.createElement(
"input",
valueContainer,
"value-input",
"bundle-input"
);
valueInput.setAttribute("type", "text");
valueInput.setAttribute("placeholder", "Value");
valueContainer.appendChild(document.createElement("br"));
const sel = valueContainer.appendChild(
document.createElement("select")
);
sel.setAttribute("id", "value-type-select");
const strOption = sel.appendChild(document.createElement("option"));
strOption.value = strOption.innerText = "string";
const intOption = sel.appendChild(document.createElement("option"));
intOption.value = intOption.innerText = "int";
const boolOption = sel.appendChild(
document.createElement("option")
);
boolOption.value = boolOption.innerText = "bool";
const nullOption = sel.appendChild(
document.createElement("option")
);
nullOption.value = nullOption.innerText = "null";
}
// Comment inputs
const commentContainer = this.createElement(
"div",
entryFields,
undefined,
"input-container"
);
const commentH2 = this.createElement("h2", commentContainer);
commentH2.innerText = "Comment";
const commentInput = this.createElement(
"input",
commentContainer,
"comment-input",
"bundle-input"
);
commentInput.setAttribute("type", "text");
commentInput.setAttribute("placeholder", "Bundle message (optional)");
// Button to bundle entry
const submitButton = this.createElement(
"button",
entryFields,
"bundle-button"
);
submitButton.innerText = "Bundle Entry";
submitButton.onclick = async () => {
// If any field is empty don't let submission go any further.
if (keyInput1 && !keyInput1.value) return;
if (keyInput2 && !keyInput2.value) return;
if (valueInput && !valueInput.value) return;
let newKey, newValue, newComment;
if (keyInput1 && !keyInput2) {
if (keyType === "muid") {
newKey = gink.strToMuid(keyInput1.value);
} else if (keyType !== "pair") {
const kt = document.getElementById("key-type-select").value;
newKey = this.convertToStringType(kt, keyInput1.value);
}
} else if (keyInput1 && keyInput2) {
newKey = [
gink.strToMuid(keyInput1.value),
gink.strToMuid(keyInput2.value),
];
}
if (valueInput) {
const vt = document.getElementById("value-type-select").value;
newValue = this.convertToStringType(vt, valueInput.value);
}
newComment = commentInput.value;
if (confirm("Bundle entry?")) {
await this.database.addEntry(
interpretKey(newKey, container),
newValue,
container,
newComment
);
}
await this.displayPage(...this.unwrapHash(window.location.hash));
};
}
/**
* Display a particular entry within a gink container.
* @param {*} key the key of the entry (may be undefined if container doesn't use keys)
* @param {*} value the value of the entry (may be undefined if container doesn't use values)
* @param {number} position the position of the entry. this is only used for sequences.
* @param {Container} container the gink container as context for the entry.
*/
async displayEntry(key, value, position, container) {
this.clearChildren(this.root);
this.pageType = "entry";
await this.writeTitle(container);
this.writeCancelButton();
const entryContainer = this.createElement(
"div",
this.root,
"view-entry",
"entry-container"
);
if (key !== undefined) {
const keyContainer = this.createElement(
"div",
entryContainer,
undefined,
"input-container"
);
const keyH2 = this.createElement("h2", keyContainer);
keyH2.innerText = "Key";
// Determines whether value needs to be a link to another container, etc.
keyContainer.innerHTML += await this.entryValueAsHtml(key);
}
if (value !== undefined) {
const valueContainer = this.createElement(
"div",
entryContainer,
undefined,
"input-container"
);
const valueH2 = this.createElement("h2", valueContainer);
valueH2.innerText = "Value";
// Determines whether value needs to be a link to another container, etc.
valueContainer.innerHTML += await this.entryValueAsHtml(value);
}
// Update and Delete buttons
const buttonContainer = this.createElement(
"div",
this.root,
"update-delete-container"
);
const updateButton = this.createElement(
"button",
buttonContainer,
"update-button"
);
updateButton.innerText = "Update Entry";
updateButton.onclick = async () => {
await this.displayUpdateEntry(key, value, position, container);
};
const deleteButton = this.createElement(
"button",
buttonContainer,
"delete-button"
);
deleteButton.innerText = "Delete Entry";
deleteButton.onclick = async () => {
if (confirm("Delete and bundle?")) {
await this.database.deleteEntry(
interpretKey(key, container),
position,
container
);
}
await this.displayPage(...this.unwrapHash(window.location.hash));
};
}
/**
* Displays the page to update an existing entry.
* @param {*} oldKey previous key from entry page.
* @param {*} oldValue previous value from entry page.
* @param {number} position position of the entry. only used if container is a sequence.
* @param {Container} container gink container for context.
*/
async displayUpdateEntry(oldKey, oldValue, position, container) {
this.clearChildren(this.root);
this.pageType = "update";
const [keyType, valueType] = determineContainerStorage(container);
await this.writeTitle(container);
this.writeCancelButton();
// Main entry container
const entryContainer = this.createElement(
"div",
this.root,
"view-entry",
"entry-container"
);
let keyInput1, keyInput2, valueInput;
// Key - 2 inputs if container uses pairs, 1 input if container uses keys
if (oldKey !== undefined) {
const keyH2 = this.createElement("h2", entryContainer);
keyH2.innerText = "Key";
keyInput1 = this.createElement(
"input",
entryContainer,
"key-input-1",
"bundle-input"
);
if (keyType === "pair") {
keyInput2 = this.createElement(
"input",
entryContainer,
"key-input-2",
"bundle-input"
);
keyInput1.setAttribute(
"placeholder",
gink.muidToString(oldKey[0])
);
keyInput2.setAttribute(
"placeholder",
gink.muidToString(oldKey[1])
);
keyInput1.setAttribute("list", "datalist-1");
const datalist1 = this.createElement(
"datalist",
keyInput1,
"datalist-1"
);
await this.enableContainersAutofill(datalist1);
keyInput2.setAttribute("list", "datalist-2");
const datalist2 = this.createElement(
"datalist",
keyInput2,
"datalist-2"
);
await this.enableContainersAutofill(datalist2);
} else if (keyType === "muid") {
keyInput1.setAttribute(
"placeholder",
gink.muidToString(oldKey.address)
);
keyInput1.setAttribute("list", "datalist-1");
const datalist1 = this.createElement(
"datalist",
keyInput1,
"datalist-1"
);
await this.enableContainersAutofill(datalist1);
} else {
keyInput1.setAttribute("placeholder", oldKey);
const sel = entryContainer.appendChild(
document.createElement("select")
);
sel.setAttribute("id", "key-type-select");
const strOption = sel.appendChild(
document.createElement("option")
);
strOption.value = strOption.innerText = "string";
const intOption = sel.appendChild(
document.createElement("option")
);
intOption.value = intOption.innerText = "int";
}
}
// Value - 1 input if container uses values
if (oldValue !== undefined) {
const valueH2 = this.createElement("h2", entryContainer);
valueH2.innerText = "Value";
valueInput = this.createElement(
"input",
entryContainer,
"value-input",
"bundle-input"
);
valueInput.setAttribute("placeholder", oldValue);
const sel = entryContainer.appendChild(
document.createElement("select")
);
sel.setAttribute("id", "value-type-select");
const strOption = sel.appendChild(document.createElement("option"));
strOption.value = strOption.innerText = "string";
const intOption = sel.appendChild(document.createElement("option"));
intOption.value = intOption.innerText = "int";
const boolOption = sel.appendChild(
document.createElement("option")
);
boolOption.value = boolOption.innerText = "bool";
const nullOption = sel.appendChild(
document.createElement("option")
);
nullOption.value = nullOption.innerText = "null";
}
// Comment - optional for user
const commentH2 = this.createElement("h2", entryContainer);
commentH2.innerText = "Comment";
const commentInput = this.createElement(
"input",
entryContainer,
"comment-input",
"bundle-input"
);
commentInput.setAttribute("placeholder", "Bundle Message (optional)");
// Bundle and Abort buttons
const buttonContainer = this.createElement(
"div",
this.root,
"bundle-abort-container"
);
const bundleButton = this.createElement(
"button",
buttonContainer,
"bundle-button"
);
bundleButton.innerText = "Bundle Entry";
bundleButton.onclick = async () => {
// Assume nothing has changed until we see what user has input.
let newKey = oldKey;
if (keyType === "pair") {
gink.ensure(keyInput1 && keyInput2);
let muid1 = oldKey[0];
let muid2 = oldKey[1];
if (keyInput1.value) {
muid1 = keyInput1.value;
}
if (keyInput2.value) {
muid2 = keyInput2.value;
}
newKey = [muid1, muid2];
} else if (keyType !== "none") {
gink.ensure(keyInput1 && !keyInput2);
if (keyInput1.value) {
const kt = document.getElementById("key-type-select").value;
newKey = this.convertToStringType(kt, keyInput1.value);
}
}
let newValue = oldValue;
if (valueType !== "none") {
gink.ensure(valueInput);
if (valueInput.value) {
const vt =
document.getElementById("value-type-select").value;
newValue = this.convertToStringType(vt, valueInput.value);
}
}
// Its ok if comment has no value, the database will handle that.
let newComment = commentInput.value;
// Nothing has changed. Should this go back to container screen?
if (newKey === oldKey && newValue === oldValue) return;
if (confirm("Bundle updated entry?")) {
await this.database.deleteEntry(
interpretKey(oldKey, container),
position,
container,
newComment
);
await this.database.addEntry(
interpretKey(newKey, container),
newValue,
container,
newComment
);
}
await this.displayPage(...this.unwrapHash(window.location.hash));
};
const abortButton = this.createElement("button", buttonContainer);
abortButton.innerText = "Abort";
abortButton.onclick = async () => {
await this.displayEntry(oldKey, oldValue, position, container);
};
}
/**
* @returns true if there are no previous pages.
*/
isFirstPage(currentPage) {
return currentPage === 1;
}
/**
* @returns true if there are no following pages.
*/
isLastPage(currentPage, itemsPerPage, totalEntries) {
return (currentPage - 1) * itemsPerPage + itemsPerPage >= totalEntries;
}
convertToStringType(typeStr, value) {
switch (typeStr) {
case "string":
return String(value);
case "int":
return Number(value);
case "bool":
return value.toLowerCase() === "true" ? true : false;
case "null":
return null;
default:
throw new Error("not sure how to deal with that type.");
}
}
/**
* Unwraps an expected hash into:
* string muid, page, items per page
* Expecting : FFFFFFFFFFFFFF-FFFFFFFFFFFFF-00004+3+10
* 3 = page, 10 = items per page
* If only a muid is present, assume page 1, 10 items.
* @param {string} hash
* @returns an Array of [stringMuid, pageNumber, itemsPerPage]
*/
unwrapHash(hash) {
let stringMuid = "FFFFFFFFFFFFFF-FFFFFFFFFFFFF-00004";
let pageNumber = 1;
let itemsPerPage = 10;
if (window.location.hash === "#self") {
stringMuid = gink.muidToString(
this.database.getSelfContainer().address
);
} else if (hash) {
hash = hash.substring(1);
let splitHash = hash.split("+");
stringMuid = splitHash[0];
if (!(splitHash.length === 1)) {
pageNumber = splitHash[1];
itemsPerPage = splitHash[2];
}
}
return [stringMuid, Number(pageNumber), Number(itemsPerPage)];
}
/**
* Fills a datalist element with options of all containers that exist
* in the store.
* @param {HTMLDataListElement} htmlDatalistElement
*/
async enableContainersAutofill(htmlDatalistElement) {
gink.ensure(
htmlDatalistElement instanceof HTMLDataListElement,
"Can only fill datalist"
);
const containers = await this.database.getAllContainers();
for (const [strMuid, container] of containers) {
const option = document.createElement("option");
option.value = strMuid;
htmlDatalistElement.appendChild(option);
}
}
/**
* For the entry page - interprets the value and converts it into fitting html
* For example, takes a gink.Container and makes it a link to its container page.
* @param {*} value a string, container, or array of 2 containers (pair)
* @returns a string of HTML
*/
async entryValueAsHtml(value) {
let asHtml;
if (Array.isArray(value) && value.length === 2 && value[0].timestamp) {
let container1 = await this.database.getContainer(value[0]);
let container2 = await this.database.getContainer(value[1]);
asHtml = `
<div id="pair-key-container">
<strong><a href="#${gink.muidToString(container1.address)}+1+10">${container1.constructor.name}</a></strong>, <strong><a href="#${gink.muidToString(container2.address)}+1+10">${container2.constructor.name}</a></strong>
</div>
`;
} else if (value instanceof gink.Container) {
asHtml = `<strong><a href="#${gink.muidToString(value.address)}+1+10">${value.constructor.name}(${gink.muidToString(value.address)})</a></strong>`;
} else {
value = unwrapToString(value);
asHtml = `<p>${value}</p>`;
}
return asHtml;
}
/**
* Takes a value of a number, string, or gink.Container,
* and decides how the value should be displayed in the cell.
* @param {*} value
*/
async getCellValue(value) {
let cellValue;
if (Array.isArray(value) && value.length === 2 && value[0].timestamp) {
let container1 = await this.database.getContainer(value[0]);
let container2 = await this.database.getContainer(value[1]);
cellValue = `${container1.constructor.name}-${container2.constructor.name}`;
} else if (value instanceof gink.Container) {
cellValue = `${value.constructor.name}(${gink.muidToString(value.address)})`;
} else {
value = unwrapToString(value);
if (value.length > 20) {
cellValue = shortenedString(value);
} else {
cellValue = value;
}
}
return cellValue;
}
/**
* Changes the title and header elements of the container page.
*/
async writeTitle(container) {
let titleContainer = this.getElement("#title-container");
if (titleContainer !== null) {
this.clearChildren(titleContainer);
} else {
titleContainer = this.createElement(
"div",
this.root,
"title-container"
);
}
const title = this.createElement("h2", titleContainer, "title-bar");
const muid = container.address;
let containerName = await this.database.getContainerName(container);
if (containerName === undefined) {
if (
muid.timestamp === -1 &&
muid.medallion === -1 &&
muid.offset === 4
) {
containerName = "Root Directory";
} else {
containerName = `${container.constructor.name} (${muid.timestamp},${muid.medallion},${muid.offset})`;
}
}
title.innerText = containerName;
title.onclick = async () => {
await this.writeContainerNameInput(containerName, container);
};
}
async writeContainerNameInput(previousName, container) {
const titleContainer = this.getElement("#title-container");
this.clearChildren(titleContainer);
const containerNameInput = this.createElement(
"input",
titleContainer,
"title-input"
);
containerNameInput.setAttribute("type", "text");
containerNameInput.setAttribute("placeholder", previousName);
const submitButton = this.createElement(
"button",
titleContainer,
undefined,
"container-name-btn"
);
submitButton.innerText = "✓";
submitButton.onclick = async () => {
let newName;
if (!containerNameInput.value) newName = previousName;
else {
await this.database.setContainerName(
container,
containerNameInput.value
);
}
await this.writeTitle(container);
};
const cancelButton = this.createElement(
"button",
titleContainer,
undefined,
"container-name-btn"
);
cancelButton.innerText = "X";
cancelButton.onclick = async () => {
await this.writeTitle(container);
};
}
/**
* Creates an X button at the top left corner of #root.
*/
writeCancelButton() {
const cancelButton = this.createElement(
"button",
this.root,
"cancel-button"
);
cancelButton.innerText = "X";
cancelButton.onclick = async () => {
await this.displayPage(...this.unwrapHash(window.location.hash));
};
}
// HTML Utility Methods
/**
* Gets an HTML element in the DOM based on the selector.
* @param {string} selector HTML selector
* @returns an HTMLElement if found, else undefined.
*/
getElement(selector) {
return document.querySelector(selector);
}
/**
* Creates an HTML Element based on the provided tag.
* Optionally appends to a provided element.
* @param {string} tag type of element to create.
* @param {HTMLElement} appendTo optional HTMLElement to append to.
* @param {string} id optional id for newly created element.
* @param {string} className optional class for newly created element.
* @returns the newly created HTMLElement.
*/
createElement(tag, appendTo, id, className) {
const element = document.createElement(tag);
if (id) element.setAttribute("id", id);
if (className) element.setAttribute("class", className);
if (appendTo) {
appendTo.appendChild(element);
}
return element;
}
/**
* Utility function to clear the children of
* an HTMLElement.
* @param {HTMLElement} node
*/
clearChildren(node) {
while (node.firstChild) {
node.removeChild(node.firstChild);
}
}
}