Plugin/tableview/index.ts

/**
 * Copyright (c) 2021 ~ present NAVER Corp.
 * billboard.js project is licensed under the MIT license
 */
import {
	isArray,
	isBoolean,
	isFunction,
	isNumber,
	isObjectType,
	isString,
	tplProcess
} from "../../module/util";
import Plugin from "../Plugin";
import {defaultStyle, tpl} from "./const";
import Options from "./Options";

export interface TableViewOptions {
	selector?: string;
	categoryTitle?: string;
	categoryFormat?: (v: Date | number | string) => string;
	class?: string;
	style?: boolean;
	title?: string;
	updateOnToggle?: boolean;
	nullString?: string;
	numberFormat?: (v: number | string) => string;
}

/**
 * Expected value type for each supported option, used to validate user input.
 * Acts as the single source of truth for the set of allowed option keys.
 * @private
 */
const optionValidators: { [key in keyof Required<TableViewOptions>]: (v: unknown) => boolean } = {
	selector: isString,
	categoryTitle: isString,
	categoryFormat: isFunction,
	class: isString,
	style: isBoolean,
	title: isString,
	updateOnToggle: isBoolean,
	nullString: isString,
	numberFormat: isFunction
};

/**
 * Check whether the given value is a valid TableView options object.
 * An empty object is valid (every option falls back to its default) and
 * unknown keys are ignored, so only a known option holding a value of the
 * wrong type makes the object invalid. An explicit `undefined` value is
 * allowed so a consumer can opt out of an option and let it fall back to
 * the default.
 * @param {unknown} options Value given to the TableView constructor
 * @returns {boolean} `true` when `options` is a valid TableView options object
 * @private
 */
export function isValidTableViewOptions(options: unknown): options is TableViewOptions {
	if (!isObjectType(options) || isArray(options) || options === null) {
		return false;
	}

	return Object.entries(options).every(([key, value]) => {
		const validate = optionValidators[key];

		// unknown keys are ignored; known keys must match their expected type
		return !isFunction(validate) || value === undefined || validate(value);
	});
}

/**
 * Table view plugin.<br>
 * Generates table view for bound dataset.
 * - **NOTE:**
 *   - Plugins aren't built-in. Need to be loaded or imported to be used.
 *   - Non required modules from billboard.js core, need to be installed separately.
 * @class plugin-tableview
 * @param {object} options table view plugin options
 * @augments Plugin
 * @returns {TableView}
 * @example
 * // Plugin must be loaded before the use.
 * <script src="$YOUR_PATH/plugin/billboardjs-plugin-tableview.js"></script>
 *
 *  var chart = bb.generate({
 *     ...
 *     plugins: [
 *        new bb.plugin.tableview({
 *          selector: "#my-table-view",
 *          categoryTitle: "Category",
 *          categoryFormat: function(v) {
 *              // do some transformation
 *              ...
 *              return v;
 *          },
 *          class: "my-class-name",
 *          style: true,
 *          title: "My Data List",
 *          updateOnToggle: false,
 *          nullString: "N/A",
 *          numberFormat: function(v) {
 *              // do some transformation like number formatting
 *              // return typeof v === "number" ? v.toFixed(2) : v;
 *              // or use d3.format
 *              // return typeof v === "number" ? d3.format(".2f")(v) : v;
 *              // or use Intl.NumberFormat
 *              // return typeof v === "number" ? new Intl.NumberFormat("en-US", {minimumFractionDigits: 2, maximumFractionDigits: 2}).format(v) : v;
 *              // or any other number formatting library
 *              ...
 *              return v;
 *          }
 *        }),
 *     ]
 *  });
 * @example
 * import {bb} from "billboard.js";
 * import TableView from "billboard.js/dist/billboardjs-plugin-tableview";
 *
 * bb.generate({
 *     ...
 *     plugins: [
 *        new TableView({ ... })
 *     ]
 * })
 */
export default class TableView extends Plugin {
	private element;

	constructor(options: TableViewOptions = {}) {
		super(options);

		// warn (but don't throw) on invalid options so that unsupported values
		// simply fall back to their defaults, keeping behavior non-breaking.
		if (!isValidTableViewOptions(options)) {
			console?.error?.(
				"[billboard.js] TableView plugin received invalid options; unsupported values will be ignored.",
				options
			);
		}

		this.config = new Options();

		return this;
	}

	$beforeInit(): void {
		this.loadConfig();
	}

	$init(): void {
		const {class: className, selector, style} = this.config;
		let element = document.querySelector(
			selector || `.${className || defaultStyle.class}`
		);

		if (!element) {
			const chart = this.$$.$el.chart.node();

			element = document.createElement("table");
			chart.parentNode.insertBefore(element, chart.nextSibling);
		}

		if (element.tagName !== "TABLE") {
			const table = document.createElement("table");

			element.appendChild(table);
			element = table;
		}

		// append default css style
		if (style && !document.getElementById(defaultStyle.id)) {
			const s = document.createElement("style");

			s.id = defaultStyle.id;
			s.innerHTML = defaultStyle.rule;

			(document.head || document.getElementsByTagName("head")[0])
				.appendChild(s);
		}

		element.classList.add(...[style && defaultStyle.class, className].filter(Boolean));

		this.element = element;
	}

	/**
	 * Generate table
	 * @private
	 */
	generateTable(): void {
		const {$$, config, element} = this;
		const dataToShow = $$.filterTargetsToShow($$.data.targets);

		let thead = tplProcess(tpl.thead, {
			title: dataToShow.length ? this.config.categoryTitle : ""
		});
		let tbody = "";
		const rows: (number | string)[][] = [];

		dataToShow.forEach(v => {
			thead += tplProcess(tpl.thead, {title: v.id});

			// make up value rows
			v.values.forEach((d, i: number) => {
				if (!rows[i]) {
					rows[i] = [d.x];
				}

				rows[i].push(d.value);
			});
		});

		rows.forEach(v => {
			tbody += `<tr>${
				v.map((d, i) =>
					tplProcess(i ? tpl.tbody : tpl.tbodyHeader, {
						value: i === 0 ?
							config.categoryFormat.bind(this)(d) :
							(isNumber(d) ? config.numberFormat.bind(this)(d) : config.nullString)
					})
				).join("")
			}</tr>`;
		});

		element.innerHTML = tplProcess(tpl.body, {
			...config,
			title: config.title || $$.config.title_text || "",
			thead,
			tbody
		});
	}

	$redraw(): void {
		const {state} = this.$$;
		const doNotUpdate = state.resizing || (!this.config.updateOnToggle && state.toggling);

		!doNotUpdate && this.generateTable();
	}

	$willDestroy(): void {
		this.element.parentNode?.removeChild(this.element);

		// remove default css style when left one chart instance
		if (this.$$.charts.length === 1) {
			const s = document.getElementById(defaultStyle.id);

			s?.parentNode?.removeChild(s);
		}
	}
}