Is there documentation or guide on Theme?

My google search for theme guide point me to the old documentation at www2. Is there a recent guide on how to modify Invoice theme? E.g. to add an additional column in the Invoice edit/create page in the line item row.

I also notice changes to the theme using theme enhancer (which works great!) does not save when I request it to save the template. Or do we need to do a manual save somewhere?

Thank you!

Are you confusing save with implement? I had the (wrong) assumption that when you used the theme enhancer, it would automatically replace your current theme, it doesn’t. You need to change the theme in your invoice to the new one you have generated in the form defaults. It will only work from that point on. It doesn’t change anything before that point.

I do face this issue sometimes not always.

In case you want to do extensive Theme work beyond what Theme Enhancer can do for you, you need to be familiar with DOM and javascript.

That aside, you just need to create a New Theme and you will get this code:

Default theme code

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1.0" />
	<!-- Styling for consistent look and feel -->
	<style>
		*, ::after, ::before, ::backdrop, ::file-selector-button {
			margin: 0;
			padding: 0;
		}

		*, ::after, ::before, ::backdrop, ::file-selector-button {
			box-sizing: border-box;
			border: 0 solid;
		}

		body {
			margin: 0;
			padding: 30px;
			font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
			color: #171717;
			font-size: 12px;
			line-height: 1.428571429;
			min-width: 800px;
		}

		table {
			font-size: 12px;
		}

		tr#table-headers th {
			font-weight: bold;
			padding: 5px 10px;
			border: 1px solid #000;
			text-align: start
		}

		tbody#table-rows td {
			padding: 5px 10px;
			border-left: 1px solid #000;
			border-right: 1px solid #000;
			text-align: start;
			vertical-align: top
		}

		tbody#table-rows tr:last-child td {
			padding-bottom: 30px;
			border-bottom: 1px solid #000;
		}
	</style>
</head>
<body>

	<!-- Top section: document title and optional business logo -->
	<table style="margin-bottom: 20px; width: 100%">
		<tbody>
			<tr>
				<td style="vertical-align: top">
					<div style="font-size: 32px; line-height: 32px; font-weight: bold" id="title">Invoice</div>
				</td>
				<td style="text-align: end" id="business-logo">
				</td>
			</tr>
		</tbody>
	</table>

	<!-- Second section: recipient info, document fields (like date, invoice #), and business info -->
	<table style="margin-bottom: 20px; width: 100%">
		<tbody>
			<tr>
				<td style="vertical-align: top" id="recipient-info"></td>
				<td style="text-align: end; vertical-align: top" id="fields"></td>
				<td style="width: 20px"></td>
				<td style="width: 1px; border-left-width: 1px; border-left-color: #000; border-left-style: solid"></td>
				<td style="width: 20px"></td>
				<td style="width: 1px; white-space: nowrap; vertical-align: top" id="business-info"></td>
			</tr>
		</tbody>
	</table>

	<!-- Description block -->
	<div style="font-weight: bold; font-size: 14px; margin-bottom: 20px" id="description"></div>

	<!-- Main table containing column headers, line items, and totals -->
	<table style="border-collapse: collapse; width: 100%">
		<thead>
			<tr id="table-headers"></tr>
		</thead>
		<tbody id="table-rows">
		</tbody>
		<tbody id="totals">
		</tbody>
	</table>

	<script src="resources/qrcode/qrcode.js"></script>
	<div id="qrcode" style="margin-bottom: 20px"></div>

	<!-- Section for any additional custom fields -->
	<div id="custom-fields"></div>

	<!-- Section for footers -->
	<table><tr><td><div id="footers"></div></td></tr></table>

	<!-- Section for final status (e.g. PAID, VOID) with special styling -->
	<div id="status" style="text-align: center"></div>

	<script>
		// Listen for messages sent from the parent frame (via postMessage API)
		window.addEventListener("message", (event) => {

			if (event.source !== window.parent) return; // Only accept messages from parent
			if (event.data.type !== 'context-response') return; // Ignore irrelevant messages

			// Extract the main data object sent from parent
			const data = event.data.body;

			// Set text direction (LTR or RTL) for the whole document
			document.documentElement.dir = data.direction;

			// Populate title and description if provided
			document.getElementById("title").innerHTML = data.title || "No title";
			document.getElementById("description").innerHTML = data.description || "";

			// Inject business logo image if available
			var businessLogoTd = document.getElementById("business-logo");
			if (data.business.logo) {
				const img = document.createElement("img");
				img.src = data.business.logo;
				img.style = "max-height: 150px; max-width: 300px; display: inline";
				businessLogoTd.appendChild(img);
			}

			// Populate business info section with name and address (convert line breaks)
			const business = data.business || {};
			document.getElementById("business-info").innerHTML = `<strong>${business.name || ""}</strong><br>${business.address ? business.address.replace(/\n/g, "<br>") : ""}`;

			// Populate recipient info section with name and address (convert line breaks)
			const recipient = data.recipient || {};
			document.getElementById("recipient-info").innerHTML = `<strong>${recipient.name || ""}</strong><br>${recipient.address ? recipient.address.replace(/\n/g, "<br>") : ""}`;

			// Insert fields (e.g. issue date, due date) into right-hand side
			const fieldsDiv = document.getElementById("fields");
			fieldsDiv.innerHTML = "";
			(data.fields || []).forEach(f => {
				const div = document.createElement("div");
				div.innerHTML = `<strong>${f.label}</strong><br />${f.text || ""}<br /><br />`;
				fieldsDiv.appendChild(div);
			});

			// Build table headers dynamically based on `columns` definition
			const headersRow = document.getElementById("table-headers");
			headersRow.innerHTML = "";
			(data.table.columns || []).forEach(col => {
				const th = document.createElement("th");
				th.innerHTML = col.label;
				th.style.textAlign = col.align;
				if (col.minWidth) {
					th.style.whiteSpace = 'nowrap';
					th.style.width = '1px';
				}
				else if (col.nowrap) {
					th.style.whiteSpace = 'nowrap';
					th.style.width = '80px';
				}
				headersRow.appendChild(th);
			});

			// Populate main table body with rows and alignments based on column definitions
			const rowsBody = document.getElementById("table-rows");
			rowsBody.innerHTML = "";
			(data.table.rows || []).forEach(row => {
				const tr = document.createElement("tr");
				row.cells.forEach((cell, i) => {
					var col = data.table.columns[i];
					const td = document.createElement("td");
					td.innerHTML = (cell.text || "").split("\n").join("<br />");
					td.style.textAlign = col.align;
					if (col.minWidth) {
						td.style.whiteSpace = 'nowrap';
						td.style.width = '1px';
					}
					else if (col.nowrap) {
						td.style.whiteSpace = 'nowrap';
						td.style.width = '80px';
					}
					tr.appendChild(td);
				});
				rowsBody.appendChild(tr);
			});

			// Populate totals section with subtotals, taxes, grand total, etc.
			const totalsBody = document.getElementById("totals");
			totalsBody.innerHTML = "";
			(data.table.totals || []).forEach(total => {
				const tr = document.createElement("tr");
				const tdLabel = document.createElement("td");
				const tdValue = document.createElement("td");
				tdLabel.innerHTML = total.label;
				tdLabel.colSpan = data.table.columns.length - 1;
				tdLabel.style = 'padding: 5px 10px; text-align: end; vertical-align: top';
				tdValue.innerHTML = total.text;
				tdValue.id = total.key;
				if (total.class) tdValue.classList.add(total.class);
				tdValue.dataset.value = total.number;
				tdValue.style = 'padding: 5px 10px; border: 1px solid #000; text-align: right; white-space: nowrap; vertical-align: top';

				// Bold totals if marked as 'emphasis'
				if (total.emphasis) {
					tdLabel.style.fontWeight = 'bold';
					tdValue.style.fontWeight = 'bold';
				}
				tr.appendChild(tdLabel);
				tr.appendChild(tdValue);
				totalsBody.appendChild(tr);
			});

			// Render custom fields section below the table (e.g. notes, terms)
			const customFieldsDiv = document.getElementById("custom-fields");
			customFieldsDiv.innerHTML = "";
			(data.custom_fields || []).forEach(f => {
				const div = document.createElement("div");
				div.innerHTML = `<strong>${f.label || ""}</strong><br />${(f.text || "").split("\n").join("<br />")}<br /><br />`;
				customFieldsDiv.appendChild(div);
			});

			// Render footers section
			const footersDiv = document.getElementById("footers");
			footersDiv.innerHTML = "";
			(data.footers || []).forEach(f => {
				const div = document.createElement("div");
				div.style = 'margin-top: 20px';
				div.innerHTML = f;
				footersDiv.appendChild(div);

				// Find and execute any script tags
				const scripts = div.querySelectorAll("script");
				scripts.forEach(script => {
					const newScript = document.createElement("script");

					// Copy script attributes if needed
					for (const attr of script.attributes) {
						newScript.setAttribute(attr.name, attr.value);
					}

					// Inline script handling
					if (script.textContent) {
						newScript.textContent = script.textContent;
					}

					// Replace the old <script> with the new one so it executes
					script.parentNode.replaceChild(newScript, script);
				});
			});

			// Display status label (e.g. PAID, CANCELLED) with colored border based on status type
			const statusDiv = document.getElementById("status");
			if (data.emphasis?.text != null) {
				statusDiv.style.marginTop = '40px';
				const span = document.createElement("span");
				span.style = 'border-width: 5px; border-color: #FF0000; border-style: solid; padding: 10px; font-size: 20px; text-transform: uppercase';
				if (data.emphasis.positive) {
					span.style.color = 'green';
					span.style.borderColor = 'green';
				}
				if (data.emphasis.negative) {
					span.style.color = 'red';
					span.style.borderColor = 'red';
				}
				span.innerHTML = data.emphasis.text;
				statusDiv.appendChild(span);
			}

			// This handles QR code on invoices for Saudi Arabia - this is here just temporarily. Better way will be implemented.
			if (data.legacyQrCodeForSaudiArabia) {
				function appendTLV(tag, text, byteList) {
					const encoded = new TextEncoder().encode(text);
					byteList.push(tag);
					byteList.push(encoded.length);
					for (let b of encoded) byteList.push(b);
				}

				const byteList = [];

				let businessName = 'No name';
				if (data.business) businessName = data.business.name;

				let vatNumber = '0000000000000';
				let vatField = data.business.custom_fields.find(item => item.key === "d96d97e8-c857-42c6-8360-443c06a13de9");
				if (vatField) vatNumber = vatField.text;

				let timestamp = new Date((data.timestamp - 621355968000000000) / 10000).toISOString();

				let total = 0;
				let totalElement = document.getElementById('Total');
				if (totalElement != null) total = parseFloat(totalElement.getAttribute('data-value'));

				let vat = 0;
				let taxAmounts = document.getElementsByClassName('taxAmount');
				for (let i = 0; i < taxAmounts.length; i++) {
					vat += parseFloat(taxAmounts[i].getAttribute('data-value'));
				}

				appendTLV(1, businessName, byteList);
				appendTLV(2, vatNumber, byteList);
				appendTLV(3, timestamp, byteList);
				appendTLV(4, total.toFixed(2), byteList);
				appendTLV(5, vat.toFixed(2), byteList);

				// Convert to Uint8Array
				const tlvBytes = Uint8Array.from(byteList);

				// Convert to Base64
				const qrData = btoa(String.fromCharCode(...tlvBytes));

				new QRCode(document.getElementById("qrcode"), {
					text: qrData,
					width: 128,
					height: 128,
					colorDark: "#000000",
					colorLight: "#ffffff",
					correctLevel: QRCode.CorrectLevel.L
				});
			}

			window.parent.postMessage({ type: "resize", width: document.documentElement.scrollWidth+1, height: document.documentElement.scrollHeight+1 }, "*");
		}, false);

		// Request context data from parent frame when page loads
		window.addEventListener("load", () =>
			window.parent.postMessage({ type: "context-request" }, "*")
		);
	</script>

</body>
</html>

How to change code

  1. To change the styling, you can inject css code inside the <style> tag at the top. You can use the classes and ids available in the <body> of the document.

  2. To change the order of sections in the document, you can edit the contents of <body> tag which holds the bare html skeleton of your document. Make sure you don’t delete or change any of the id or class attributes as well as the <script> since these are essential for the document to work.

  3. Finally, you have the <script> tag that is the last element inside your <body> which injects actual content inside the html skeleton. Normally, you don’t need to change anything here but in case you want to selectively treat individual fields, you can by reading the comments – which are done adequately, if I may say so – and this will give you an idea where to change the code.

As I said, direct editing of Themes requires some IT skills. If you find this too difficult, I think Theme Enhancer could do a very good job at this. You can tell the Theme Enhancer to save the theme for you and you can pick up from there.

1 Like

Thank you, you are spot on!

Thank you so much for the detailed reply! Where can I want to add a new column (last column, right), e.g. “Remarks” column, in Invoice Edit page line item? Can this be done in the Theme?

Much appreciated!

Is there a list of placement (besides sales-invoice-view) that can be used in Extension?

Thank you!

I’ll answer your last question first:

You can get this placement from the URLs, it may not be so obvious if you’re using the Desktop version, but you can right click on any link in Manager and Open in a new tab. There you will find the URL paths:

That would be the text string in between / and ? characters.

This should work for any view screen.

Well, that would be totally up to your personal preference, you could argue for it being the last column or maybe even just before Qty column.

It’s totally up to you.

Is extension usable only in “view” form? E.g. The form does not load in the Enhancer (after clicking on Submit in Extension) on Customers main screen.

image

Thanks again!

I am noticing this as well. Is there a way to copy/save the modified codes when Enhancer does not save (despite multiple requests) the modified template?

Thank you!

@akhran there is now new version of theme enhancer. See:

Also, default HTML theme is still rather complex. Once theme enhancer gets good, I want to slim down default HTML theme to absolute minimum. And make HTML as sematic as possible so that theme enhancer has easier time customizing it.

Also, current limitation is that theme enhancer will work on sales invoice view screens only but I will be extending it to all applicable screens soon.

3 Likes

Thank you for the work done in Extension / Theme enhancer. It is nothing short of amazing. Looking forward to having it working in other screens too.

I hope that you will still offer support for code editing, because even though this new Theme Enhancer lowers the bar for the everyday user – which is a good thing, however, advanced users will find it extremely limiting not being able to edit the code.

2 Likes

You can edit the theme code from settings.

@Ealfardan there is and there will be always option to create or edit themes by hand.

3 Likes