pa11y / pa11y-dashboard

Pa11y Dashboard is a web interface which helps you monitor the accessibility of your websites
https://pa11y.org
GNU General Public License v3.0
987 stars 181 forks source link

Add Groupings to pages #254

Open grcameron opened 4 years ago

grcameron commented 4 years ago

Pa11y Dashboard is a great tool, but it's apparent that in our use case (100's of pages across different projects), that it would be really useful to have the following:

The reason for this is to so that I can easily display the progress of our accessibility improvements within the pa11y dashboard UI.

I spoke to @joeyciechanowicz over slack about this, they have a private repo where something similar has been done, but not tested/reviewed/formalised.

jasonday commented 4 years ago

Adding the conversation from slack here for reference:


Grant Cameron Jan 21st at 4:12 AM Hi folks, was wondering if anyone is aware of a fork for pa11y dashboard that offers a way to group and visualise pages (e.g. Site has many areas which has many pages)

Joey Ciechanowicz 1 month ago Hi Grant :wave: I'm not aware of any forks which add this feature. I hacked something together on a private board, but haven't had the time to formalise it and write it up with tests etc to be part of the full pa11y-dashboard

Grant Cameron 1 month ago Thanks Joey, is there anything I could do to help with that? I was going to hack something together myself, but maybe that time could be spent helping formalise something. If there is a plan to do that anyway? If not it's cool I can hack something together for our needs I think.

Joey Ciechanowicz 1 month ago Well, PR's are always always welcome :heart:

Joey Ciechanowicz 1 month ago To get something down proper, it's best to open an issue to discuss the change so as to gather feedback

Joey Ciechanowicz 1 month ago If you want to hack it together yourself, I can try dig out the code I threw together

Joey Ciechanowicz 1 month ago This goes in route and gathers the results, grouping by splitting the name on : route/wallboard.js

// This file is part of Pa11y Dashboard.
//
// Pa11y Dashboard is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pa11y Dashboard is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Pa11y Dashboard.  If not, see <http://www.gnu.org/licenses/>.
'use strict';
​
module.exports = route;
​
function taskState(counts) {
    if (counts.error > 0) {
        return 'error';
    }
​
    if (counts.warning > 0) {
        return 'warning';
    }
​
    if (counts.notice > 0) {
        return 'notice';
    }
​
    return 'none';
}
​
// Route definition
function route(app) {
    app.express.get('/wallboard', (request, response, next) => {
        if (request.query.reload !== undefined) {
            return response.render('reload', {
                layout: false,
                url: 'wallboard'
            });
        }
​
        app.webservice.tasks.get({lastres: true}, (error, tasks) => {
            if (error) {
                return next(error);
            }
​
            const grouped = tasks.reduce((groups, task) => {
                if (!task.last_result) {
                    return groups;
                }
​
                task.overallState = taskState(task.last_result.count);
​
                const parts = task.name.split(':');
                if (parts.length >= 2) {
                    const group = parts[0];
                    task.name = parts.slice(1).join(':');
​
                    if (!groups[group]) {
                        groups[group] = [];
                    }
​
                    groups[group].push(task);
                } else {
                    groups.default.push(task);
                }
​
                return groups;
            }, {default: []});
​
            const groups = Object.entries(grouped).map(([groupName, groupedTasks]) => ({
                groupName,
                defaultGroup: groupName === 'default',
                tasks: groupedTasks
            })).filter(group => group.tasks.length > 0);
​
            response.render('wallboard', {
                groups,
                layout: false
            });
        });
    });
​
    app.express.get('/wallboard-graph', (request, response, next) => {
        if (request.query.reload !== undefined) {
            return response.render('reload', {
                layout: false,
                url: 'wallboard-graph'
            });
        }
​
        app.webservice.tasks.get({}, (error, tasks) => {
            app.webservice.tasks.results({}, (error, results) => {
                if (error) {
                    return next(error);
                }
​
                const tasksLookup = tasks.reduce((acc, curr) => {
                    acc[curr.id] = curr;
                    return acc;
                }, {});
​
                const modifiedResults = results.map(result => ({
                    date: result.date,
                    errors: result.count.error,
                    warnings: result.count.warning,
                    notices: result.count.notices,
                    name: tasksLookup[result.task].name
                }));
​
                response.render('wallboard-graph', {
                    data: modifiedResults,
                    layout: false
                });
            });
        });
    });
}

Collapse

Joey Ciechanowicz 1 month ago This goes in view/wallboard.html and renders /wallboard view/wallboard.html

<!DOCTYPE html>
<html lang="{{lang}}" class="no-javascript">
<head>
    <meta charset="utf-8"/>
    <title>Pa11y Wallboard</title>
    <meta name="description" content="Pa11y Wallboard"/>
​
    {{#if noindex}}
    <meta name="robots" content="noindex"/>
    {{/if}}
​
    <link rel="icon" type="image/png" href="favicon.png"/>
​
    <!-- For mobile devices. -->
    <meta name="viewport" content="width=device-width"/>
​
    <style type="text/css">
        :root {
            --error: #ed4b35;
            --warning: #8b8b38;
            --notice: #28628b;
        }
​
        * {
            box-sizing: border-box;
        }
​
        body {
            background: #3d3c3c;
            padding: 0;
            margin: 0;
            font-family: Inconsolata, monospace;
            color: #ada8a8;
        }
​
        .header {
            width: 100%;
            text-align: center;
        }
​
        .groups {
            margin: 5px;
            display: flex;
            flex-wrap: wrap;
            align-content: space-around;
        }
​
        .task {
            background: #2a2929;
            margin: 0 5px 1%;
            width: 16%;
        }
​
        .task__header {
            color: #fff;
            width: 100%;
            padding: 0 5px 0 5px;
            min-height: 55px;
        }
​
        .task__header__group-name {
            font-size: 1em;
            margin: 0;
            color: #ada8a8;
            border-radius: 4px;
            padding: 0 2px 0 2px
        }
​
        .task__header__name {
            margin: 0;
        }
​
        .task--error {
            border-top: 4px solid var(--error);
        }
​
        .task--warning {
            border-top: 4px solid var(--warning);
        }
​
        .task--notice {
            border-top: 4px solid var(--notice);
        }
​
        .task--none {
            border-top: 4px solid #11c560;
        }
​
        .task__counts {
            width: 100%;
            display: flex;
        }
​
        .count {
            width: 33.333%;
            display: inline-block;
            line-height: 40px;
            text-align: center;
            font-size: 1.5em;
            color: white;
        }
​
        .count--error {
            background: var(--error);
        }
​
        .count--warning {
            background: var(--warning);
        }
​
        .count--notice {
            background: var(--notice);
        }
​
        .group-name--1 {
            background: #0069c5;
            color: #fff;
        }
​
        .group-name--2 {
            background: #6c757d;
            color: #fff;
        }
​
        .group-name--3 {
            background: #268942;
            color: #fff;
        }
​
        .group-name--4 {
            background: #b12f3d;
            color: #fff;
        }
​
        .group-name--5 {
            background: #b88707;
            color: #fff;
        }
​
        .group-name--0 {
            background: #fff;
            color: #000;
        }
    </style>
</head>
​
<body>
​
<header class="header">
    <h1 class="header__text">Accessibility</h1>
</header>
​
<section class="groups">
    {{#each groups}}
    {{#each tasks}}
    <div class="task task--{{overallState}}">
        <div class="task__header">
            <h2 class="task__header__name">{{name}}</h2>
​
            {{#unless ../defaultGroup}}
            <h1 class="task__header__group-name group-name--{{mod @../index 6}}">
                {{../groupName}}
            </h1>
            {{/unless}}
        </div>
        {{#last_result}}
        <div class="task__counts">
            <div class="count count--error">{{count.error}}</div>
            <div class="count count--warning">{{count.warning}}</div>
            <div class="count count--notice">{{count.notice}}</div>
        </div>
        {{/last_result}}
    </div>
    {{/each}}
    {{/each}}
</section>
​
</body>
</html>

Collapse

Joey Ciechanowicz 1 month ago This goes in view/wallboard-graph.html and renders /wallboard-graph view/wallboard-graph.html

<!DOCTYPE html>
<html lang="{{lang}}" class="no-javascript">
<head>
    <meta charset="utf-8"/>
    <title>Pa11y Wallboard</title>
    <meta name="description" content="Pa11y Wallboard"/>
​
    {{#if noindex}}
    <meta name="robots" content="noindex"/>
    {{/if}}
​
    <link rel="icon" type="image/png" href="favicon.png"/>
​
    <!-- For mobile devices. -->
    <meta name="viewport" content="width=device-width"/>
​
    <script src="https://d3js.org/d3.v5.min.js"></script>
​
    <style type="text/css">
        :root {
            --error: #ed4b35;
            --warning: #8b8b38;
            --notice: #28628b;
        }
​
        * {
            box-sizing: border-box;
        }
​
        body {
            background: #3d3c3c;
            padding: 0;
            margin: 0;
            font-family: Inconsolata, monospace;
            color: #ada8a8;
        }
​
        html, body, #container {
            height: 100%;
        }
​
        .header {
            width: 100%;
            text-align: center;
        }
​
        #container {
            width: 100%;
            display: grid;
            grid-template-columns: 100%;
            grid-template-rows: 80% 20%;
        }
​
        #graph {
            width: 100%;
            height: 100%;
        }
​
        #labels {
            margin: 0;
        }
​
        li {
            display: inline-block;
            padding-left: 8px;
            font-size: 2rem;
        }
​
        li:before {
            content: '\2022';
            margin-right: 2px;
        }
​
​
        .axis {
            color: #ada8a8;
            stroke-width: 2;
            font-size: 1.4rem;
        }
​
        path {
            fill: none;
            stroke-width: 2;
        }
​
        .legend {
            font-size: 2rem;
        }
    </style>
</head>
​
<body>
​
<header class="header">
    <h1 class="header__text">Converged Accessbility Errors</h1>
</header>
​
<div id="container">
    <svg id="graph"></svg>
    <ul id="labels"></ul>
</div>
​
<script>
    const data = {{{json data}}};
​
    // Set the dimensions of the canvas / graph
    const margin = {top: 10, right: 50, bottom: 35, left: 60};
    const containerDimensions = document.getElementById('graph').getBoundingClientRect();
​
    console.log(containerDimensions);
​
    const width = document.getElementById('graph').clientWidth - margin.left - margin.right;
    const height = Math.floor(containerDimensions.height) - margin.top - margin.bottom;
​
    // Parse the date / time
​
    data.forEach(result => {
        result.date = new Date(result.date);
    });
​
    data.sort((a, b) => {
        return a.date > b.date ?
                1
                : a.date < b.date ?
                        -1
                        : 0;
    });
​
    const timeDomain = d3.extent(data, result => result.date);
    timeDomain[1] = new Date(timeDomain[1].valueOf()).setDate(timeDomain[1].getDate() + 4);
​
    // Set the ranges
    const x = d3.scaleTime()
            .domain(d3.extent(data, result => result.date))
            .range([0, width]);
​
    const y = d3.scaleLinear()
            .domain([0, d3.max(data, result => result.errors) + 1])
            .range([height, 0])
​
    // Define the line
    const line = d3.line()
            .x(result => x(result.date))
            .y(result => y(result.errors));
​
    // Adds the svg canvas
    const svg = d3.select('#graph')
            .attr('width', width + margin.left + margin.right)
            .attr('height', height + margin.top + margin.bottom)
            .append('g')
            .attr('transform',
                    'translate(' + margin.left + ',' + margin.top + ')');
​
    const labels = d3.select('#labels');
​
    // Nest the entries by symbol
    const dataNest = d3.nest()
            .key(result => result.name)
            .entries(data);
​
    // set the colour scale
    const color = d3.scaleOrdinal(d3.schemeCategory10);
​
    legendSpace = width / dataNest.length; // spacing for the legend
​
    // Loop through each symbol / key
    dataNest.forEach((nest, index) => {
​
        svg.append('path')
                .attr('class', 'line')
                .style('stroke', color(nest.key))
                .attr('d', line(nest.values));
​
        // Add the Legend
        labels.append('li')
                .style('color', color(nest.key))
                .text(nest.key);
​
    });
​
    // Add the X Axis
    svg.append('g')
            .attr('class', 'axis')
            .attr('transform', 'translate(0,' + height + ')')
            .call(d3.axisBottom(x));
​
    // Add the Y Axis
    svg.append('g')
            .attr('class', 'axis')
            .call(d3.axisLeft(y));
​
    svg.selectAll('.dot')
            .data(data)
            .enter().append('circle')
            .attr('class', 'dot')
            .attr('cx', result =>  x(result.date))
            .attr('cy', result => y(result.errors))
            .style('fill', result => color(result.name))
            .attr('r', 5)
​
</script>
​
​
</body>
</html>

Collapse

Joey Ciechanowicz 1 month ago Shove this in view/reload.html and then you can use /wallboard?reload=true or /wallboard-graph?reload=true view/reload.html

<!DOCTYPE html>
<html lang="{{lang}}" class="no-javascript">
<head>
    <meta charset="utf-8"/>
    <title>Pa11y Wallboard</title>
    <meta name="description" content="Pa11y Wallboard"/>
​
    {{#if noindex}}
    <meta name="robots" content="noindex"/>
    {{/if}}
​
    <link rel="icon" type="image/png" href="favicon.png"/>
​
    <!-- For mobile devices. -->
    <meta name="viewport" content="width=device-width"/>
​
    <style>
        html, body, iframe { height: 100%; width: 100%}
        * {
            margin: 0;
            padding: 0;
        }
    </style>
</head>
​
<body>
​
<iframe id="wallboard-frame" style="position: absolute; height: 100%; border: none" src="/{{url}}"></iframe>
​
<script type="text/javascript">
    setInterval(() => {
        document.getElementById('wallboard-frame').contentWindow.location.reload();
        window.location.reload();
    }, 5 * 60 * 1000);
</script>
​
</body>
</html>

Collapse

Grant Cameron 1 month ago Thanks so much Joey! I'll raise an issue to discuss further, and in the mean time I'll try out your changes, really appreciate it.

Grant Cameron 1 month ago For reference, created this issue: https://github.com/pa11y/pa11y-dashboard/issues/254 :star2: 1

Grant Cameron 1 month ago This is great, managed to get that going for the time being.

Joey Ciechanowicz 1 month ago fantastic!