DEV Community

Cover image for How to Build a Survey With KwesForms and Astro
Paul Scanlon
Paul Scanlon

Posted on • Originally published at paulie.dev

How to Build a Survey With KwesForms and Astro

In this post I'll explain how I built a survey form using KwesForms and Astro, complete with some fancy data viz to me help better understand the results.

To give you a taste of what's to come, below you'll find links to a live preview and the GitHub repo.

Screenshot of paulie.dev Readers Survery

Getting Started

To get started with KwesForms, you'll first need to Sign up, then you can create your first form. Here's the Getting Started guide from the KwesForms docs.

When using KwesForms with Astro, I've found it easier to load KwesForms from the CDN and use Astro's is:inline script directive, E.g.

<script is:inline src="https://kwesforms.com/v2/kwes-script.js"></script>
Enter fullscreen mode Exit fullscreen mode

Add the KwesForms script to the bottom of any Astro page that will use KwesForms. You can see the src for my survey page here: index.astro.

Building a Form

When following the setup guide from the KwesForms docs you'll be given code snippets to copy and paste into your page. A very minimal example looks like the below.

<form redirect="/submitted" action="https://kwesforms.com/api/foreign/forms/swj...">
  <label for="name">Your Name</label>
  <input type="text" name="name" />

  <button type="submit">Submit</button>
</form>
Enter fullscreen mode Exit fullscreen mode

I've added an extra attribute to the above code snippet to control the behavior of my form after data has been been submitted. Using the redirect attribute I can redirect to a /submitted page.

Form Fields

I've mainly used HTML select and radio buttons for my form, and below i'll explain how each of them work.

Select

The first field in my survey form is a standard HTML select. Nothing extra is required here from the KwesForms side of things.

<label for="time">Time</label>
<select id="time" name="time">
  <option hidden>Please select</option>
  <option>🌞 Daytime</option>
  <option>🌚 Nighttime</option>
  <option>🌓 Anytime</option>
</select>
Enter fullscreen mode Exit fullscreen mode

Radio Group

The second set of fields in my form are a "radio group" and have been wrapped in an HTML fieldset element. You'll also notice I've given the fieldset an attribute of data-kw-group. Defining a group using this data- attribute allows KwesForms to apply any rules that may have been defined to each element within the group.

<fieldset data-kw-group rules="required">
  <div>
    <input type="radio" id="novice" name="experience" value="1-5" />
    <label for="novice">1-5</label>
  </div>
  <div>
    <input type="radio" id="intermediate" name="experience" value="5-10" />
    <label for="intermediate">5-10</label>
  </div>
  <div>
    <input type="radio" id="proficient" name="experience" value="10-20" />
    <label for="proficient">10-20</label>
  </div>
  <div>
    <input type="radio" id="expert" name="experience" value="20+" />
    <label for="expert">20+</label>
  </div>
</fieldset>
Enter fullscreen mode Exit fullscreen mode

Repeater Fields

The third set of fields in my form are super cool! KwesForms calls them repeater fields. These are fields that you define once in your code, but can be duplicated or removed by the user to enable multi-input answers.

<fieldset repeater name="technology">
  <div repeater-group>
    <div>
      <label for="technology">Technology</label>
      <select id="technology" name="technology">
        <option hidden>Please select</option>
        <option>React</option>
        <option>Qwik</option>
        <option>Astro</option>
        <option>Remix</option>
        <option>Serverless</option>
        <option>Edge</option>
        <option>Postgres</option>
        <option>SQL</option>
        <option>CSS</option>
        <option>SVG</option>
      </select>
    </div>
  </div>
</fieldset>
Enter fullscreen mode Exit fullscreen mode

You'll see from the image below that KwesForms automatically injects an "add" button, and in cases where a field has been added, KwesForms will automatically inject a "remove" buttons — I told you they were super cool!

Screenshot of repeater fields

Validation

KwesForms is client-side form solution and will perform client-side validation and comes built in with some handy announce fields which you can style however you like, but what's really cool is that KwesForms also handles server-side validation for you automatically!

Spam Protection

Another feature I really like about KwesForms is it's built-in spam protection, powered by AI!

Data Collection

Now that you have a form set up and capturing data, you might be asking, where does the data go?

Every form submission can be viewed in the KwesForms Dashboard and there are a number of filters and actions available to you for viewing, sorting or deleting data.

Screenshot of KwesForms submissions

More Data

But... whilst looking at a list of data is good, being able to transform this data into something more fancy is what i'm really interested in.

KwesForms API

KwesForms has an API which can be used to "fetch" all data submitted by any form. Once you have a method in place for requesting data, you can do whatever you like with it. Which is what i've done on the /results page.

By using a little bit of High School Maths and the SVG element I've created some Data Visualization to make it easier to understand the data.

Screenshot of Bar Chart on Results page

To achieve this I'm making a request to the KwesForms API using the Get All Submissions endpoint.

The request I've used in my survey runs server-side inside of Astro's code fences and looks a bit like the below.

// src/pages/results.astro
---
const response = await fetch(
  `https://kwes.io/api/v1/forms/${import.meta.env.KWESFORMS_FORM_ID}/submissions?mode=production`,
  {
    method: 'GET',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json',
      Authorization: `Bearer ${import.meta.env.KWESFORMS_API_KEY}`,
    },
    redirect: 'follow',
  }
);

const data = await response.json();
---
Enter fullscreen mode Exit fullscreen mode

There's two additional bits of information you'll need to make an API call, they are as follows.

  • FORM_ID: You'll find this in your browser's address bar and it'll be a four digit number E.g. kwesforms.com/websites/4411/forms/4694

  • API_KEY: You can find this under the account dropdown and it'll be a long alpha-numeric number E.g. gwEvpqTVnGDA4s5vOHeIAcOxSO566jXZ5EPjfFj...

The response for the above endpoint will look similar to the below but naturally, it'll depend on the fields you have in your form.

[
  {
    ...
    "time": "🌞 Daytime",
    "experience": "1-5",
    "technology": [
      {
        "technology__1": "CSS"
      }
    ]
  },
  {
    ...
    "time": "🌚 Nighttime",
    "experience": "20+",
    "technology": [
      {
        "technology__1": "Edge"
      },
      {
        "technology__2": "Remix"
      }
    ]
  }
]

Enter fullscreen mode Exit fullscreen mode

Data Visualization

To create a data visualization from this data it first needs to be grouped, and then counted. For the time and experience data the process is pretty much the same but I've created two functions to group each of these.

groupByTime

This function groups the data by the time key from the response.

export const groupByTime = (data) => {
  const groupedData = data.reduce((result, item) => {
    const { time } = item;
    const existingItemIndex = result.findIndex((obj) => obj.name === time);

    if (existingItemIndex !== -1) {
      result[existingItemIndex].count++;
    } else {
      result.push({ name: time, count: 1 });
    }

    return result;
  }, []);

  return groupedData;
};

// usage
const byTime = groupByTime(data);
Enter fullscreen mode Exit fullscreen mode

You can see the src for this function here: group-by-time.js

The result of this function will look similar to the below.

[
  {
    "name": "🌞 Daytime",
    "count": 5
  },
  {
    "name": "🌚 Nighttime",
    "count": 7
  },
  {
    "name": "🌓 Anytime",
    "count": 1
  }
]
Enter fullscreen mode Exit fullscreen mode

groupByExperience

This function groups the data by the experience key from the response.

export const groupByExperience = (data) => {
  const groupedData = data.reduce((result, item) => {
    const { experience } = item;
    const existingItemIndex = result.findIndex((obj) => obj.name === experience);

    if (existingItemIndex !== -1) {
      result[existingItemIndex].count++;
    } else {
      result.push({ name: experience, count: 1 });
    }

    return result;
  }, []);

  return groupedData;
};

// usage
const byExperience = groupByExperience(data);
Enter fullscreen mode Exit fullscreen mode

The result of this function will look similar to the below.

[
  {
    "name": "1-5",
    "count": 4
  },
  {
    "name": "20+",
    "count": 4
  },
  {
    "name": "5-10",
    "count": 2
  },
  {
    "name": "10-20",
    "count": 3
  }
]
Enter fullscreen mode Exit fullscreen mode

You can see the src for this function here: group-by-experience.js

groupByTechnology

This function uses a forEach to iterate over the technology array and then groups the data by the key name.

export const groupByTechnology = (data) => {
  const groupedData = data.reduce((result, item) => {
    const { technology } = item;

    technology.forEach((key) => {
      const name = Object.values(key)[0];
      const existingItemIndex = result.findIndex((obj) => obj.name === name);

      if (existingItemIndex !== -1) {
        result[existingItemIndex].count++;
      } else {
        result.push({ name, count: 1 });
      }
    });

    return result;
  }, []);

  return groupedData;
};

// usage
const byTechnology = groupByTechnology(data);
Enter fullscreen mode Exit fullscreen mode

The result of this function will look similar to the below.

[
  {
    "name": "CSS",
    "count": 2
  },
  {
    "name": "SQL",
    "count": 1
  },
  {
    "name": "Edge",
    "count": 1
  },
  {
    "name": "Remix",
    "count": 3
  },
  {
    "name": "Qwik",
    "count": 5
  }
]
Enter fullscreen mode Exit fullscreen mode

You can see the src for this function here: group-by-technology.js

In call cases, the returned shape is an array of objects containing a name and count value. This data can now be used to drive the data visualization.

Creating the Bar Chart

The Bar Chart I've used in my survey has been "hand cranked". You could install a charting library if you want but, you might find it harder to style, and in some cases it might only work when JavaScript is enabled in the browser.

Using my "hand-cranked" approach, the chart will be super lightweight, you won't be sending any additional client-side JavaScript to browser, it can be rendered on the server, and will work even if JavaScript is disabled in the browser.

Data Fetching

The first step is to create a Bar Chart component, fetch the data and pass it on to the component via a prop named data E.g.

// src/pages/results.astro

---
import { groupByTechnology } from '../utils/group-by-technology'
import BarChart from '../components/bar-chart.astro';

const response = await fetch('https://kwes.io/api/v1/forms/...');

const data = await response.json();

const byTechnology = groupByTechnology(data);
---

<BarChart data={byTechnology} />
Enter fullscreen mode Exit fullscreen mode

Setting Up Defaults

The next step is destructure the data from Astro.props, install / import d3-scale and setup some defaults.

// src/components/bar-chart.astro
---
const { data } = Astro.props;

import { scaleLinear } from 'd3-scale';

const chartWidth = 1920;
const chartHeight = 1080;

const paddingL = 100;
const paddingR = 100;
const paddingT = 100;
const paddingB = 180;
const gap = 20;
const maxValue = data.reduce((items, item) => Math.max(items, item.count), 0);
const xAxis = new Array(8).fill(null);
const axisPaddingB = 80;
const axisPaddingT = 100;
const xValues = scaleLinear().domain([0, maxValue]).range([0, maxValue]).ticks(xAxis.length);
const colorValues = scaleLinear().domain([0, data.length]).range(['#f056c7', '#0091f7', '#58e6d9']);
---
Enter fullscreen mode Exit fullscreen mode

Bar Chart Properties

This step is used to create a properties object that will drive the chart. Each value, x, y, width, height, etc can be calculated by using values from the data set.

// src/components/bar-chart.astro
---

const { data } = Astro.props;

import { scaleLinear } from 'd3-scale';

const chartWidth = 1920;
const chartHeight = 1080;

const paddingL = 100;
const paddingR = 100;
const paddingT = 100;
const paddingB = 180;
const gap = 20;
const maxValue = data.reduce((items, item) => Math.max(items, item.count), 0);
const xAxis = new Array(8).fill(null);
const axisPaddingB = 80;
const axisPaddingT = 100;
const xValues = scaleLinear().domain([0, maxValue]).range([0, maxValue]).ticks(xAxis.length);
const colorValues = scaleLinear().domain([0, data.length]).range(['#f056c7', '#0091f7', '#58e6d9']);

+ const properties = data
+  .sort((a, b) => b.count - a.count)
+  .map((property, index) => {
+    const { name, count } = property;

+    const x = paddingL;
+    const width = ((chartWidth - paddingL - paddingR) / maxValue) * count;
+    const height = (chartHeight - paddingT - paddingB) / data.length;
+    const y = height * index + paddingT;

+    return {
+      name: name,
+      count: count,
+      font: 40,
+      x: x,
+      textX: x - paddingL / 2,
+      y: y + gap,
+      width: width,
+      height: height - gap,
+      color: colorValues(index * 2),
+    };
+  });
---
Enter fullscreen mode Exit fullscreen mode

Bar Chart Axis

This step is to create some properties to help display the xAxis on the chart.

// src/components/bar-chart.astro
---

const { data } = Astro.props;

import { scaleLinear } from 'd3-scale';

const chartWidth = 1920;
const chartHeight = 1080;

const paddingL = 100;
const paddingR = 100;
const paddingT = 100;
const paddingB = 180;
const gap = 20;
const maxValue = data.reduce((items, item) => Math.max(items, item.count), 0);
const xAxis = new Array(8).fill(null);
const axisPaddingB = 80;
const axisPaddingT = 100;
const xValues = scaleLinear().domain([0, maxValue]).range([0, maxValue]).ticks(xAxis.length);
const colorValues = scaleLinear().domain([0, data.length]).range(['#f056c7', '#0091f7', '#58e6d9']);

const properties = data
 .sort((a, b) => b.count - a.count)
 .map((property, index) => {
   const { name, count } = property;
     ...
 });

+ const axis = xAxis.map((_, index) => {
+  const x = ((chartWidth - paddingR) / xAxis.length - 1) * index;
+  const width = 2;
+  const height = chartHeight - axisPaddingT - axisPaddingB;
+  const y = axisPaddingT;

+  return {
+    x: x + paddingL,
+    y: y,
+    width: width,
+    height: height,
+    value: xValues[index],
+  };
+ });

---
Enter fullscreen mode Exit fullscreen mode

Display The Data

This last step is the finished SVG element complete with two Array.maps that iterate over the properties and axis arrays defined above.

// src/components/bar-chart.astro
---

const { data } = Astro.props;

...

---

+ <svg
+  xmlns='http://www.w3.org/2000/svg'
+  viewBox={`0 0 ${chartWidth} ${chartHeight}`}
+ >

+ {
+   axis.map((axi) => {
+     const { value, x, y, width, height } = axi;

+      return (
+        <>
+          <rect x={x} y={y} width={width} height={height} />
+          <text x={x} y={y + height} text-anchor='middle' font-size={30}>
+            {value}
+          </text>
+        </>
+      );
+    })
+  }

+  {
+    properties.map((property) => {
+      const { name, count, font, x, textX, y, width, height, color } = property;

+     return (
+       <>
+          <rect x={x} y={y} width={width} height={height} fill={color} />
+          <text
+            x={textX}
+            y={y + height / 2 + font / 2.6}
+            class='fill-brand-text font-bold'
+            text-anchor='start'
+            font-size={font}
+          >
+            {`x${count}`}
+          </text>
+          <text
+            x={chartWidth - paddingR - 20}
+            y={y + height / 2 + font / 2.6}
+            text-anchor='end'
+            font-size={font}
+          >
+           {name}
+          </text>
+        </>
+      );
+    })
+  }
+ </svg>

Enter fullscreen mode Exit fullscreen mode

You can see the src for the Bar Chart here: bar-chart.astro

Finished

And that's it, a fully custom survey form complete with data visualization, and all data is stored safely and securely within KwesForms in case you wish to do anything else with it.

Top comments (0)