feat: Add currency management feature with CRUD operations
Some checks failed
Run Tests / test (push) Failing after 5m2s
Some checks failed
Run Tests / test (push) Failing after 5m2s
- Introduced a new template for currency overview and management (`currencies.html`). - Updated footer to include attribution to AllYouCanGET. - Added "Currencies" link to the main navigation header. - Implemented end-to-end tests for currency creation, update, and activation toggling. - Created unit tests for currency API endpoints, including creation, updating, and activation toggling. - Added a fixture to seed default currencies for testing. - Enhanced database setup tests to ensure proper seeding and migration handling.
This commit is contained in:
537
static/js/currencies.js
Normal file
537
static/js/currencies.js
Normal file
@@ -0,0 +1,537 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const dataElement = document.getElementById("currencies-data");
|
||||
const editorSection = document.getElementById("currencies-editor");
|
||||
const tableBody = document.getElementById("currencies-table-body");
|
||||
const tableEmptyState = document.getElementById("currencies-table-empty");
|
||||
const metrics = {
|
||||
total: document.getElementById("currency-metric-total"),
|
||||
active: document.getElementById("currency-metric-active"),
|
||||
inactive: document.getElementById("currency-metric-inactive"),
|
||||
};
|
||||
|
||||
const form = document.getElementById("currency-form");
|
||||
const existingSelect = document.getElementById("currency-form-existing");
|
||||
const codeInput = document.getElementById("currency-form-code");
|
||||
const nameInput = document.getElementById("currency-form-name");
|
||||
const symbolInput = document.getElementById("currency-form-symbol");
|
||||
const statusSelect = document.getElementById("currency-form-status");
|
||||
const resetButton = document.getElementById("currency-form-reset");
|
||||
const feedbackElement = document.getElementById("currency-form-feedback");
|
||||
|
||||
const saveButton = form ? form.querySelector("button[type='submit']") : null;
|
||||
|
||||
const uppercaseCode = (value) =>
|
||||
(value || "").toString().trim().toUpperCase();
|
||||
const normalizeSymbol = (value) => {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = String(value).trim();
|
||||
return trimmed ? trimmed : null;
|
||||
};
|
||||
|
||||
const normalizeApiBase = (value) => {
|
||||
if (!value || typeof value !== "string") {
|
||||
return "/api/currencies";
|
||||
}
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
};
|
||||
|
||||
let currencies = [];
|
||||
let apiBase = "/api/currencies";
|
||||
let defaultCurrencyCode = "USD";
|
||||
|
||||
const buildCurrencyRecord = (record) => {
|
||||
if (!record || typeof record !== "object") {
|
||||
return null;
|
||||
}
|
||||
const code = uppercaseCode(record.code);
|
||||
return {
|
||||
id: record.id ?? null,
|
||||
code,
|
||||
name: record.name || "",
|
||||
symbol: record.symbol || "",
|
||||
is_active: Boolean(record.is_active),
|
||||
is_default: code === defaultCurrencyCode,
|
||||
};
|
||||
};
|
||||
|
||||
const findCurrencyIndex = (code) => {
|
||||
return currencies.findIndex((item) => item.code === code);
|
||||
};
|
||||
|
||||
const upsertCurrency = (record) => {
|
||||
const normalized = buildCurrencyRecord(record);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const existingIndex = findCurrencyIndex(normalized.code);
|
||||
if (existingIndex >= 0) {
|
||||
currencies[existingIndex] = normalized;
|
||||
} else {
|
||||
currencies.push(normalized);
|
||||
}
|
||||
currencies.sort((a, b) => a.code.localeCompare(b.code));
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const replaceCurrencyList = (records) => {
|
||||
if (!Array.isArray(records)) {
|
||||
return;
|
||||
}
|
||||
currencies = records
|
||||
.map((record) => buildCurrencyRecord(record))
|
||||
.filter((record) => record !== null)
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
};
|
||||
|
||||
const applyPayload = () => {
|
||||
if (!dataElement) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(dataElement.textContent || "{}");
|
||||
if (parsed && typeof parsed === "object") {
|
||||
if (parsed.default_currency_code) {
|
||||
defaultCurrencyCode = uppercaseCode(parsed.default_currency_code);
|
||||
}
|
||||
if (parsed.currency_api_base) {
|
||||
apiBase = normalizeApiBase(parsed.currency_api_base);
|
||||
}
|
||||
if (Array.isArray(parsed.currencies)) {
|
||||
replaceCurrencyList(parsed.currencies);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Unable to parse currencies payload", error);
|
||||
}
|
||||
};
|
||||
|
||||
const showFeedback = (message, type = "success") => {
|
||||
if (!feedbackElement) {
|
||||
return;
|
||||
}
|
||||
feedbackElement.textContent = message;
|
||||
feedbackElement.classList.remove("hidden", "success", "error");
|
||||
feedbackElement.classList.add(type);
|
||||
};
|
||||
|
||||
const hideFeedback = () => {
|
||||
if (!feedbackElement) {
|
||||
return;
|
||||
}
|
||||
feedbackElement.classList.add("hidden");
|
||||
feedbackElement.classList.remove("success", "error");
|
||||
feedbackElement.textContent = "";
|
||||
};
|
||||
|
||||
const setButtonLoading = (button, isLoading) => {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
button.disabled = isLoading;
|
||||
button.classList.toggle("is-loading", isLoading);
|
||||
};
|
||||
|
||||
const updateMetrics = () => {
|
||||
const total = currencies.length;
|
||||
const active = currencies.filter((item) => item.is_active).length;
|
||||
const inactive = total - active;
|
||||
if (metrics.total) {
|
||||
metrics.total.textContent = String(total);
|
||||
}
|
||||
if (metrics.active) {
|
||||
metrics.active.textContent = String(active);
|
||||
}
|
||||
if (metrics.inactive) {
|
||||
metrics.inactive.textContent = String(inactive);
|
||||
}
|
||||
};
|
||||
|
||||
const renderExistingOptions = (
|
||||
selectedCode = existingSelect ? existingSelect.value : ""
|
||||
) => {
|
||||
if (!existingSelect) {
|
||||
return;
|
||||
}
|
||||
const placeholder = existingSelect.querySelector("option[value='']");
|
||||
const placeholderClone = placeholder ? placeholder.cloneNode(true) : null;
|
||||
existingSelect.innerHTML = "";
|
||||
if (placeholderClone) {
|
||||
existingSelect.appendChild(placeholderClone);
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
currencies.forEach((currency) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = currency.code;
|
||||
option.textContent = currency.name
|
||||
? `${currency.name} (${currency.code})`
|
||||
: currency.code;
|
||||
if (selectedCode === currency.code) {
|
||||
option.selected = true;
|
||||
}
|
||||
fragment.appendChild(option);
|
||||
});
|
||||
existingSelect.appendChild(fragment);
|
||||
if (
|
||||
selectedCode &&
|
||||
!currencies.some((item) => item.code === selectedCode)
|
||||
) {
|
||||
existingSelect.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const renderTable = () => {
|
||||
if (!tableBody) {
|
||||
return;
|
||||
}
|
||||
tableBody.innerHTML = "";
|
||||
if (!currencies.length) {
|
||||
if (tableEmptyState) {
|
||||
tableEmptyState.classList.remove("hidden");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (tableEmptyState) {
|
||||
tableEmptyState.classList.add("hidden");
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
currencies.forEach((currency) => {
|
||||
const row = document.createElement("tr");
|
||||
|
||||
const codeCell = document.createElement("td");
|
||||
codeCell.textContent = currency.code;
|
||||
row.appendChild(codeCell);
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.textContent = currency.name || "—";
|
||||
row.appendChild(nameCell);
|
||||
|
||||
const symbolCell = document.createElement("td");
|
||||
symbolCell.textContent = currency.symbol || "—";
|
||||
row.appendChild(symbolCell);
|
||||
|
||||
const statusCell = document.createElement("td");
|
||||
statusCell.textContent = currency.is_active ? "Active" : "Inactive";
|
||||
if (currency.is_default) {
|
||||
statusCell.textContent += " (Default)";
|
||||
}
|
||||
row.appendChild(statusCell);
|
||||
|
||||
const actionsCell = document.createElement("td");
|
||||
const editButton = document.createElement("button");
|
||||
editButton.type = "button";
|
||||
editButton.className = "btn";
|
||||
editButton.dataset.action = "edit";
|
||||
editButton.dataset.code = currency.code;
|
||||
editButton.textContent = "Edit";
|
||||
editButton.style.marginRight = "0.5rem";
|
||||
|
||||
const toggleButton = document.createElement("button");
|
||||
toggleButton.type = "button";
|
||||
toggleButton.className = "btn";
|
||||
toggleButton.dataset.action = "toggle";
|
||||
toggleButton.dataset.code = currency.code;
|
||||
toggleButton.textContent = currency.is_active ? "Deactivate" : "Activate";
|
||||
if (currency.is_default && currency.is_active) {
|
||||
toggleButton.disabled = true;
|
||||
toggleButton.title = "The default currency must remain active.";
|
||||
}
|
||||
|
||||
actionsCell.appendChild(editButton);
|
||||
actionsCell.appendChild(toggleButton);
|
||||
|
||||
row.appendChild(actionsCell);
|
||||
fragment.appendChild(row);
|
||||
});
|
||||
tableBody.appendChild(fragment);
|
||||
};
|
||||
|
||||
const refreshUI = (selectedCode) => {
|
||||
currencies.sort((a, b) => a.code.localeCompare(b.code));
|
||||
renderTable();
|
||||
renderExistingOptions(selectedCode);
|
||||
updateMetrics();
|
||||
};
|
||||
|
||||
const findCurrency = (code) =>
|
||||
currencies.find((item) => item.code === code) || null;
|
||||
|
||||
const setFormForCurrency = (currency) => {
|
||||
if (!form || !codeInput || !nameInput || !symbolInput || !statusSelect) {
|
||||
return;
|
||||
}
|
||||
if (!currency) {
|
||||
form.reset();
|
||||
if (existingSelect) {
|
||||
existingSelect.value = "";
|
||||
}
|
||||
codeInput.readOnly = false;
|
||||
codeInput.value = "";
|
||||
nameInput.value = "";
|
||||
symbolInput.value = "";
|
||||
statusSelect.disabled = false;
|
||||
statusSelect.value = "true";
|
||||
statusSelect.title = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingSelect) {
|
||||
existingSelect.value = currency.code;
|
||||
}
|
||||
codeInput.readOnly = true;
|
||||
codeInput.value = currency.code;
|
||||
nameInput.value = currency.name || "";
|
||||
symbolInput.value = currency.symbol || "";
|
||||
statusSelect.value = currency.is_active ? "true" : "false";
|
||||
if (currency.is_default) {
|
||||
statusSelect.disabled = true;
|
||||
statusSelect.value = "true";
|
||||
statusSelect.title = "The default currency must remain active.";
|
||||
} else {
|
||||
statusSelect.disabled = false;
|
||||
statusSelect.title = "";
|
||||
}
|
||||
};
|
||||
|
||||
const resetFormState = () => {
|
||||
setFormForCurrency(null);
|
||||
};
|
||||
|
||||
const parseError = async (response, fallbackMessage) => {
|
||||
try {
|
||||
const detail = await response.json();
|
||||
if (detail && typeof detail === "object" && detail.detail) {
|
||||
return detail.detail;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
const fetchCurrenciesFromApi = async () => {
|
||||
const url = `${apiBase}/?include_inactive=true`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const list = await response.json();
|
||||
if (Array.isArray(list)) {
|
||||
replaceCurrencyList(list);
|
||||
refreshUI(existingSelect ? existingSelect.value : undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Unable to refresh currency list", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback();
|
||||
if (!form || !codeInput || !nameInput || !statusSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editingCode = existingSelect
|
||||
? uppercaseCode(existingSelect.value)
|
||||
: "";
|
||||
const codeValue = uppercaseCode(codeInput.value);
|
||||
const nameValue = (nameInput.value || "").trim();
|
||||
const symbolValue = normalizeSymbol(symbolInput ? symbolInput.value : "");
|
||||
const isActive = statusSelect.value !== "false";
|
||||
|
||||
if (!nameValue) {
|
||||
showFeedback("Provide a currency name.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingCode) {
|
||||
if (!codeValue || codeValue.length !== 3) {
|
||||
showFeedback("Provide a three-letter currency code.", "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = editingCode
|
||||
? {
|
||||
name: nameValue,
|
||||
symbol: symbolValue,
|
||||
is_active: isActive,
|
||||
}
|
||||
: {
|
||||
code: codeValue,
|
||||
name: nameValue,
|
||||
symbol: symbolValue,
|
||||
is_active: isActive,
|
||||
};
|
||||
|
||||
const targetCode = editingCode || codeValue;
|
||||
const url = editingCode
|
||||
? `${apiBase}/${encodeURIComponent(editingCode)}`
|
||||
: `${apiBase}/`;
|
||||
|
||||
setButtonLoading(saveButton, true);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: editingCode ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseError(
|
||||
response,
|
||||
editingCode
|
||||
? "Unable to update the currency."
|
||||
: "Unable to create the currency."
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const updated = upsertCurrency(result);
|
||||
defaultCurrencyCode = uppercaseCode(defaultCurrencyCode);
|
||||
refreshUI(updated ? updated.code : targetCode);
|
||||
|
||||
if (editingCode) {
|
||||
showFeedback("Currency updated successfully.");
|
||||
if (updated) {
|
||||
setFormForCurrency(updated);
|
||||
}
|
||||
} else {
|
||||
showFeedback("Currency created successfully.");
|
||||
resetFormState();
|
||||
}
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
} finally {
|
||||
setButtonLoading(saveButton, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (code, button) => {
|
||||
const record = findCurrency(code);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
hideFeedback();
|
||||
const nextState = !record.is_active;
|
||||
const url = `${apiBase}/${encodeURIComponent(code)}/activation`;
|
||||
setButtonLoading(button, true);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_active: nextState }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await parseError(
|
||||
response,
|
||||
nextState
|
||||
? "Unable to activate the currency."
|
||||
: "Unable to deactivate the currency."
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const updated = upsertCurrency(result);
|
||||
refreshUI(updated ? updated.code : code);
|
||||
if (existingSelect && existingSelect.value === code && updated) {
|
||||
setFormForCurrency(updated);
|
||||
}
|
||||
const actionMessage = nextState
|
||||
? `Currency ${code} activated.`
|
||||
: `Currency ${code} deactivated.`;
|
||||
showFeedback(actionMessage);
|
||||
} catch (error) {
|
||||
showFeedback(error.message || "An unexpected error occurred.", "error");
|
||||
} finally {
|
||||
setButtonLoading(button, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableClick = (event) => {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const code = uppercaseCode(button.dataset.code);
|
||||
const action = button.dataset.action;
|
||||
if (!code || !action) {
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
const currency = findCurrency(code);
|
||||
if (currency) {
|
||||
setFormForCurrency(currency);
|
||||
hideFeedback();
|
||||
if (nameInput) {
|
||||
nameInput.focus();
|
||||
}
|
||||
}
|
||||
} else if (action === "toggle") {
|
||||
handleToggle(code, button);
|
||||
}
|
||||
};
|
||||
|
||||
applyPayload();
|
||||
if (editorSection && editorSection.dataset.defaultCode) {
|
||||
defaultCurrencyCode = uppercaseCode(editorSection.dataset.defaultCode);
|
||||
currencies = currencies.map((record) => {
|
||||
return record
|
||||
? {
|
||||
...record,
|
||||
is_default: record.code === defaultCurrencyCode,
|
||||
}
|
||||
: record;
|
||||
});
|
||||
}
|
||||
apiBase = normalizeApiBase(apiBase);
|
||||
|
||||
refreshUI();
|
||||
|
||||
if (form) {
|
||||
form.addEventListener("submit", handleSubmit);
|
||||
}
|
||||
|
||||
if (existingSelect) {
|
||||
existingSelect.addEventListener("change", (event) => {
|
||||
const selectedCode = uppercaseCode(event.target.value);
|
||||
if (!selectedCode) {
|
||||
hideFeedback();
|
||||
resetFormState();
|
||||
return;
|
||||
}
|
||||
const currency = findCurrency(selectedCode);
|
||||
if (currency) {
|
||||
setFormForCurrency(currency);
|
||||
hideFeedback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (resetButton) {
|
||||
resetButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
hideFeedback();
|
||||
resetFormState();
|
||||
});
|
||||
}
|
||||
|
||||
if (codeInput) {
|
||||
codeInput.addEventListener("input", () => {
|
||||
const value = uppercaseCode(codeInput.value).slice(0, 3);
|
||||
codeInput.value = value;
|
||||
});
|
||||
}
|
||||
|
||||
if (tableBody) {
|
||||
tableBody.addEventListener("click", handleTableClick);
|
||||
}
|
||||
|
||||
fetchCurrenciesFromApi();
|
||||
});
|
||||
Reference in New Issue
Block a user