digitalfabrik/integreat-cms

View on GitHub
integreat_cms/static/src/js/analytics/statistics-charts.ts

Summary

Maintainability
A
0 mins
Test Coverage
import {
    Chart,
    ChartData,
    LineElement,
    PointElement,
    LineController,
    CategoryScale,
    LinearScale,
    Legend,
    Tooltip,
} from "chart.js";

export type AjaxResponse = {
    exportLabels: Array<string>;
    chartData: ChartData;
};

// Register all components that are being used - the others will be excluded from the final webpack build
// See https://www.chartjs.org/docs/latest/getting-started/integration.html#bundlers-webpack-rollup-etc for details
Chart.register(LineElement, PointElement, LineController, CategoryScale, LinearScale, Legend, Tooltip);

// global variable for export labels (better for csv than the readable labels)
let exportLabels: Array<string>;

/*
 * This function updates the chart according to the dates currently selected in the form.
 */
const updateChart = async (): Promise<void> => {
    // Get Chart instance
    const chart = Chart.instances[0];

    // Get HTML elements
    const chartNetworkError = document.getElementById("chart-network-error");
    const chartServerError = document.getElementById("chart-server-error");
    const chartHeavyTrafficError = document.getElementById("chart-heavy-traffic-error");
    const chartLoading = document.getElementById("chart-loading");
    const chartLabelHelpText = document.getElementById("chart-label-help-text");

    // Hide error in case it was shown before
    chartNetworkError.classList.add("hidden");
    chartServerError.classList.add("hidden");
    chartHeavyTrafficError.classList.add("hidden");

    // Initialize default fetch parameters
    let parameters = {};

    const HTTP_STATUS_OK = 200;
    const HTTP_STATUS_BAD_REQUEST = 400;
    const HTTP_STATUS_GATEWAY_TIMEOUT = 504;

    // Get form
    const statisticsForm = document.getElementById("statistics-form") as HTMLFormElement;

    // If form exists (which is the case on the statistics page), perform some extra steps
    if (statisticsForm) {
        // Get form error elements
        const chartClientErrors = document.getElementsByClassName("chart-client-error");
        // Restore look of potentially incorrect fields
        Array.from(chartClientErrors).forEach((element) => {
            element.classList.add("hidden");
        });
        Array.from(statisticsForm.elements).forEach((element) => {
            element.classList.remove("border-2", "border-red-500");
            element.classList.add("border");
        });
        // define fetch parameters - send POST parameters with form data
        parameters = {
            method: "POST",
            body: new FormData(statisticsForm),
        };
    }

    // Show loading icon
    chartLoading.classList.remove("hidden");

    // Get AJAX URL
    const url = chart.canvas.getAttribute("data-statistics-url");

    try {
        const response = await fetch(url, parameters);

        if (response.status === HTTP_STATUS_OK) {
            // The response text contains the data from Matomo as JSON.
            const data = (await response.json()) as AjaxResponse;
            chart.data = data.chartData;
            chart.update();
            // Save export labels
            exportLabels = data.exportLabels;
            // Show help text
            chartLabelHelpText?.classList.remove("hidden");
        } else if (response.status === HTTP_STATUS_BAD_REQUEST) {
            // Client error - invalid form parameters supplied
            const data = await response.json();
            console.error("Invalid form parameters supplied.", data);
            // Mark fields red and show error message
            for (const [fieldId, errors] of Object.entries(data.errors)) {
                const formField = document.getElementById(`id_${fieldId}`);
                formField.classList.remove("border");
                formField.classList.add("border-2", "border-red-500");
                const formFieldError = document.getElementById(`${fieldId}_error`);
                formFieldError.querySelector("span").textContent = errors.toString();
                formFieldError.classList.remove("hidden");
            }
        } else if (response.status === HTTP_STATUS_GATEWAY_TIMEOUT) {
            console.error("Server Error (504):", await response.json());
            chartHeavyTrafficError.classList.remove("hidden");
        } else {
            // Server error - CMS or Matomo server down/malfunctioning
            console.error("Server Error:", await response.json());
            chartServerError.classList.remove("hidden");
        }
    } catch (error) {
        // Network error during fetch
        console.error("Network error during fetch:", error);
        chartNetworkError.classList.remove("hidden");
    } finally {
        // Hide loading icon
        chartLoading.classList.add("hidden");
    }
};

/*
 * This function enables/disables the export button depending on whether an export format is selected or not.
 */
const toggleExportButton = () => {
    // Only activate button if an export format is selected
    const exportFormat = document.getElementById("export-format") as HTMLSelectElement;
    const exportButton = document.getElementById("export-button") as HTMLInputElement;
    if (!exportFormat || !exportButton) {
        return;
    }
    exportButton.disabled = exportFormat.value === "";
};

/*
 * This function initializes a file download by setting the "href" attribute of the download link to the file data
 * and the "download" attribute to the filename.
 * After that, a click on the button is simulated.
 */
const downloadFile = (filename: string, content: string) => {
    const downloadLink = document.getElementById("export-download-link");
    downloadLink.setAttribute("href", content);
    downloadLink.setAttribute("download", filename);
    downloadLink.click();
};

/*
 * This function exports the current data of the chart into either PNG or CSV.
 */
const exportStatisticsData = (): void => {
    // Get Chart instance
    const chart = Chart.instances[0];
    // Get format select field
    const exportFormat = document.getElementById("export-format") as HTMLSelectElement;
    // Build filename
    const filename = `Integreat ${exportFormat.getAttribute("data-filename-prefix")} ${exportLabels[0]} - ${
        exportLabels[exportLabels.length - 1]
    }`;

    if (exportFormat.value === "image") {
        // This is needed to get a white background for the image. The default background is transparent.
        const ctx = chart.canvas.getContext("2d");
        ctx.globalCompositeOperation = "destination-over";
        ctx.fillStyle = "white";
        ctx.fillRect(0, 0, chart.canvas.width, chart.canvas.height);
        // Image Creation of the chart
        const image = chart.toBase64Image();
        // Initiate download
        downloadFile(`${filename}.png`, image);
    } else if (exportFormat.value === "csv") {
        // Convert datasets into the format [["language 1", "hits on day 1", "hits 2", ...], [["language 1", "hits on day 1", ...], ...]
        const datasetsWithLabels: string[][] = chart.data.datasets.map((dataset) =>
            [dataset.label].concat(dataset.data.map(String))
        );
        // Ensure export labels don't contain comma and corrupt CSV
        exportLabels = exportLabels.map((x) => x.replace(",", " - "));
        // Create matrix with date labels in the first row and the hits per language in the subsequent rows
        const csvMatrix: string[][] = [[""].concat(exportLabels)].concat(datasetsWithLabels);
        // Transpose matrix (swap rows and columns) and join to a single csv string
        const csvContent = csvMatrix[0].map((col, i) => csvMatrix.map((row) => row[i]).join(",")).join("\n");
        // Initiate download
        downloadFile(`${filename}.csv`, `data:text/csv;charset=utf-8;base64,${btoa(csvContent)}`);
    } else {
        // eslint-disable-next-line no-alert
        alert("Export format is not supported.");
        console.error("Export format not supported");
    }
};

window.addEventListener("load", async () => {
    // If the page has no diagram, do nothing
    if (!document.getElementById("statistics")) {
        return;
    }

    // Initialize chart
    /* eslint-disable-next-line no-new */
    new Chart("statistics", {
        type: "line",
        data: {
            datasets: [],
        },
        options: {
            plugins: {
                legend: {
                    labels: {
                        usePointStyle: true,
                        pointStyleWidth: 18,
                    },
                },
                tooltip: {
                    usePointStyle: true,
                },
            },
            scales: {
                y: {
                    beginAtZero: true,
                },
            },
        },
    });

    // Initialize chart data
    await updateChart();

    // Initialize export button
    toggleExportButton();

    // Set event handler for updating chart
    const statisticsForm = document.getElementById("statistics-form") as HTMLFormElement;
    statisticsForm?.addEventListener("submit", async (event: Event) => {
        // Prevent form submit
        event.preventDefault();
        // Update chart
        await updateChart();
    });

    // Set event handler for exporting the data
    document.getElementById("export-button")?.addEventListener("click", exportStatisticsData);

    // Event handler for toggling export button
    document.getElementById("export-format")?.addEventListener("change", toggleExportButton);
});