This tutorial will guide you through building an application using Baserow as its foundation. Baserow is an open-source platform that empowers you to build scalable databases and applications without code.
We will develop a PDF generator to easily convert your Baserow table data into a PDF file. This can be useful for sharing information with others in a clear and formatted way, or for keeping a record of your data offline.
Users input their Baserow API token generated in settings and table ID into the application. The application then sends an API request to retrieve each row of table data and generates a PDF file within the browser. As a result, the application does not transmit any sensitive data to other domains and does not require backend services for operation. Essentially, Baserow serves as our backend.
The interface will look like a piece of A4 paper. You can add, move, and change the size of different parts by dragging them around. This will show you how your PDF will look in the end. You’ll also be able to change the width of text boxes and adjust things like the size of the font, how far apart the lines are, and the color of the text.
To complete this tutorial, you’ll need the following:
Our tutorial can be broken down into three main steps:
We’ll be using Vue.js as the main framework for our app. Firstly, we need to initialize our project using the official Vue project scaffolding tool.
Ensure you have one of the latest versions of Node.js installed. Navigate to the directory where you plan to create your project and run the following command:
npm create vue@latest baserow-pdf-generator
Here, ‘baserow-pdf-generator’ is the name of our project, but you can use any name you prefer.
You will be asked about several optional features that could be included in your project. You can select ‘No’ for almost each of them or choose whatever you like. I personally always choose ESLint for code quality, as I like my code to look nice and clean. Also, I’m going to use Pinia for state management. Again, both of them are not necessary, especially in such a small project. But I like to use them to keep all my applications in the same style.
Navigate to a new project directory, install the necessary dependencies, and start the project. Execute each command in your terminal one by one:
cd baserow-pdf-generator
npm i
npm run dev
Our application is now up and running. You can access it in your browser by visiting the URL displayed in the terminal, typically it’s http://localhost:5173/
You will see the Vue.js welcome page, which we need to clean up a bit. Open the project folder in any code editor and delete all files within the /components
folder. Next, open the App.vue file and leave the script
and template
tags empty.
The first step that users of our application will see is the Authentication form. Since we need to fetch data from the Baserow database, users should have the ability to specify the table ID from which they want to generate PDFs. Another crucial piece of data is the Baserow API token – the primary “key” to access our data from Baserow. That’s it – our authentication form will consist of only two fields.
Let’s create a separate file for our form component, I’ll call it Token.vue
. Import it in App.vue and call it in the <template>
tag.
To make our app a bit more stylish, I’m going to use Vuetify– an open-source UI library for Vue. You can do the same – just install it by running npm i vuetify
. Also, I’m using Vuelidate to easily validate our form. Both of these packages are optional.
Here’s how the TokenView.vue template tag looks like after adding a form with inputs:
<template>
<form>
<v-container>
<v-row>
<v-col cols="12" md="5">
<v-text-field
v-model="formState.apiKey"
:error-messages="v$.apiKey.$errors.map((e) => e.$message)"
label="API key"
type="password"
required
@input="v$.apiKey.$touch"
@blur="v$.apiKey.$touch"
/>
</v-col>
<v-col cols="12" md="7" class="d-flex ga-5">
<v-text-field
v-model="formState.tableId"
:error-messages="v$.tableId.$errors.map((e) => e.$message)"
label="Table ID"
required
@input="v$.tableId.$touch"
@blur="v$.tableId.$touch"
/>
<v-btn size="x-large" color="indigo-darken-2" @click="set">Connect</v-btn>
<v-btn size="x-large" variant="outlined" color="danger" @click="clear">Clear</v-btn>
</v-col>
</v-row>
</v-container>
</form>
</template>
Let’s go to <script>
now. First, let’s add the initial state and rules for validators:
<script setup>
const initialState = {
apiKey: '',
tableId: ''
}
const formState = reactive({
...initialState
})
const rules = {
apiKey: { required },
tableId: { required, numeric }
}
const v$ = useVuelidate(rules, formState)
</script>
The methods:
Clear
method is needed to clear inputs from filled symbols and reset errors.Set
method checks if fields are valid. If yes, it calls the fetchData
method that fetches our first data portion from Baserow – an array of field names.To make working with all the data in our application a lot easier, I’m going to use the Pinia store (as was specified in 1.1).
After creating the store file, we can describe the method for field fetching:
async getFields(formData) {
try {
this.error = null
this.loading = true
const response = await getFields(formData)
this.fields = response
} catch (error) {
this.error = error.message
} finally {
this.loading = false
}
}
…and the getFields
API method inside data.api.js:
async function getFields(credentials) {
const apiKey = credentials?.apiKey
const tableId = credentials?.tableId
const response = await fetch(`${BASE_URL}fields/table/${tableId}/`, getHeaders(apiKey))
const data = await response.json()
if (data.error) {
throw new Error(data.detail)
}
return data
}
As you can see, we use the default method fetch()
to request data.
We can store credentials in the browser’s memory to avoid forcing users to enter them each time the application page is loaded. One possible solution is to use the browser’s cookies. I’m using the vue-cookies library for this.
After setting them in our app, we can utilize this library in our TokenView:
const saveCredentials = () => {
if (!dataStore.error) {
$cookies.set('credentials', formState, 0)
}
}
const getCredentialsFromStore = () => {
const credentials = $cookies.get('credentials')
if (credentials) {
formState.apiKey = credentials?.apiKey
formState.tableId = credentials?.tableId
return true
}
}
onMounted(() => {
if (getCredentialsFromStore()) {
fetchData(formState)
}
})
The saveCredentials
method will save the inserted data in cookies, and getCredentialsFromStore
will retrieve them from cookies immediately after the page is loaded (thanks to the onMounted
hook).
That’s all for authentication.
Our next step is to create a canvas. This is the main visual part of our application, allowing users to drag field names onto it and modify their font properties.
Let’s begin by creating the main view file, MainView.vue
. This can be immediately imported and used in App.vue
right after <TokenView />
.
MainView
will consist of two parts: a Canvas and a side block, which includes a list of fields and a large green button for generating PDF files.
<template>
<v-container>
<v-row>
<FieldConfigure v-if="dataStore.configurableId" />
</v-row>
<v-row>
<v-col>
<CanvasView ref="canvas" />
</v-col>
<v-col cols="3">
<v-btn
class="mb-2"
color="success"
size="x-large"
block
@click="generatePdf"
>
Generate PDF
</v-btn>
<FieldsView />
</v-col>
</v-row>
</v-container>
</template>
As stated above, the Fields view is simply a list of fields that were fetched earlier. This component is as straightforward as it sounds:
<script setup>
import { computed } from 'vue'
import { useDataStore } from '@/stores/dataStore'
import FieldItem from '@/components/FieldItem.vue'
const dataStore = useDataStore()
const fields = computed(() => {
return dataStore.fields
})
</script>
<template>
<v-list>
<FieldItem v-for="field in fields" :key="field.id" :field="field" />
</v-list>
</template>
The CanvasView component is slightly more complex. I use the vue-draggable-resizable library to make our fields appear as resizable elements. While this library was designed for other purposes, its functionality is suitable for our requirements. We’ll create this draggable element as a separate component. The final file will look like this:
<script setup>
import { ref, onMounted, watch, nextTick } from 'vue'
import VueDraggableResizable from 'vue-draggable-resizable'
import { useDataStore } from '@/stores/dataStore'
const dataStore = useDataStore()
const props = defineProps({
index: Number,
options: Object
})
const element = ref()
const onDragStop = (x, y) => {
emitUpdateField({ x, y })
}
const onResizeStop = (x, y, width, height) => {
emitUpdateField({ x, y, width, height })
}
const emitUpdateField = async (values) => {
await dataStore.draggableFields.splice(props.index, 1, { ...props.options, ...values })
}
const onActivated = () => {
dataStore.setConfigurableId(props.options.id)
}
onMounted(() => {
nextTick(() => {
emitUpdateField({
width: element.value.width,
height: element.value.height
})
})
})
</script>
<template>
<vue-draggable-resizable
ref="element"
:style="{
fontSize: options.fontSize + 'px',
color: options.color
}"
:parent="true"
:x="options.x"
:y="options.y"
:w="options.width"
:h="options.height"
:grid="[options.grid, options.grid]"
@dragStop="onDragStop"
@resizeStop="onResizeStop"
@activated="onActivated"
>
<span ref="elementInner" class="no-select">{{ options.title }}</span>
</vue-draggable-resizable>
</template>
<style>
.no-select {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>
Once a field is changed, its properties will be saved into the store by the emitUpdateField
function.
As you can see, we used vue-draggable-resizable
as our root and only element. Most of the parameters for it are passed as options
props from the parent component (CanvasView). As soon as we interact with the element (@dragStop
, @resizeStop
) and change its values (width/height/position by x/y axis), it passes them back to the parent component (emitUpdateField
). The last thing is “setting” in the dataStore
which element we are interacting with at this moment (onActivated
). We will need to add a new action to our data store for this:
setConfigurableId(id) {
this.configurableId = id
}
FieldConfigure.vue
is a component consisting of a set of inputs. These inputs allow you to adjust properties of fields such as font size, line height, and more.
As mentioned, it’s simply a set of inputs, so the template will look like this:
<template>
<v-col class="inputs-row d-flex gc-2">
<v-text-field
v-model="fontSize"
label="Font Size"
type="number"
density="compact"
dense
outlined
hide-spin-buttons
hide-details
>
<template #prepend-inner>
<v-btn icon variant="plain" size="x-small" @click="changeFontSize(-1)">
<v-icon>mdi-minus</v-icon>
</v-btn>
</template>
<template #append-inner>
<v-btn icon variant="plain" size="x-small" @click="changeFontSize(1)">
<v-icon>mdi-plus</v-icon>
</v-btn>
</template>
</v-text-field>
<v-text-field
v-model="lineHeight"
label="Line Height"
type="number"
density="compact"
dense
outlined
hide-spin-buttons
hide-details
>
<template #prepend-inner>
<v-btn icon variant="plain" size="x-small" @click="changeLineHeight(-1)">
<v-icon>mdi-minus</v-icon>
</v-btn>
</template>
<template #append-inner>
<v-btn icon variant="plain" size="x-small" @click="changeLineHeight(1)">
<v-icon>mdi-plus</v-icon>
</v-btn>
</template>
</v-text-field>
</v-col>
</template>
We need the ID of the field that we are going to modify. Here, the configurableId
parameter that we saved earlier is useful. Once any parameter of any field is changed, it will also be updated in the store.
const activeFieldIndex = computed(() =>
dataStore.draggableFields.findIndex((field) => field.id === dataStore.configurableId)
)
let activeField = dataStore.draggableFields[activeFieldIndex.value]
const fontSize = ref()
const changeFontSize = (diff) => {
fontSize.value = Number(fontSize.value) + diff
}
watch(fontSize, (newValue) => {
dataStore.draggableFields[activeFieldIndex.value].fontSize = Number(newValue)
})
const lineHeight = ref()
const changeLineHeight = (diff) => {
lineHeight.value = Number(lineHeight.value) + diff
}
watch(lineHeight, (newValue) => {
dataStore.draggableFields[activeFieldIndex.value].lineHeight = Number(newValue)
})
const setInitialValues = () => {
fontSize.value = activeField.fontSize
lineHeight.value = activeField.lineHeight
}
Lastly, let’s focus on our main component CanvasView.vue
. The first thing we need to do is generate an array of selected fields. To achieve this, add a method inside FieldItem.vue
that sets the clicked item into the store:
...
const toggleSelection = () => {
dataStore.toggleSelection(props.field.id)
}
...
@click="toggleSelection"
...
Now, we can iterate through the array of selected items inside the Canvas component:
<template>
<div class="canvas bg-grey-lighten-2">
<v-responsive
class="canvas__inner"
:style="{
backgroundSize: '20px 20px, 20px 20px'
}"
:aspect-ratio="9 / 16"
>
<DraggableItem
v-for="(field, index) in selectedFields"
:key="index"
:index="index"
:options="field"
/>
</v-responsive>
</div>
</template>
<style>
@import 'vue-draggable-resizable/style.css';
.canvas {
height: 842px; /* PDF's height and width */
width: 595px;
margin: auto;
}
.canvas__inner {
font-family: Courier;
background: linear-gradient(-90deg, rgba(0, 0, 0, 0.1) 1px, transparent 1px),
linear-gradient(rgba(0, 0, 0, 0.1) 1px, transparent 1px);
}
.draggable {
cursor: grab;
}
</style>
Here, we’ve specified the aspect ratio of an A4 letter. However, you can set any format you prefer, or even allow the user to change it via input or multi select option.
The main method in the Canvas component generates a PDF, which we expose to the parent component. This way, we can call it by pressing a button from outside of the Canvas:
defineExpose({ generatePdf })
The method itself will also combine the third part of this tutorial, which is called:
For file generation, we will take each row of data from the Baserow table, replace the fields with the actual values behind those field names, and assign predefined styles to those values.
Install the pdf-lib library to easily generate PDFs from HTML. To emulate this HTML, we can insert a root tag into the template and “hide” it from the user by adding a few lines of CSS:
<div ref="hidden" class="hidden"></div>
...
.hidden {
position: absolute;
top: -9999px;
left: -9999px;
}
Finally, everything is ready for our main and final method:
const generatePdf = async () => {
const rows = dataStore.rows
rows.forEach(async (row) => {
// Create PDF for each row
const pdfDoc = await PDFDocument.create()
const font = await pdfDoc.embedFont(StandardFonts.Courier)
const page = pdfDoc.addPage()
selectedFields.value.forEach(async (field) => {
const { x, y, width, fontSize, lineHeight } = field
const { height } = page.getSize()
const rowText = row['field_' + field.id]
// Emulate htmlElement to calculate block's height and split text into rows
const textElement = document.createElement('p')
textElement.style.fontFamily = 'Courier'
textElement.style.display = 'inline-block'
textElement.style.width = width + 'px'
textElement.style.fontSize = fontSize + 'px'
textElement.style.lineHeight = lineHeight + 'px'
textElement.textContent = rowText
hidden.value.append(textElement)
textElement.innerHTML = wrapWords(textElement)
const textLines = getLines(textElement)
page.drawText(textLines, {
x,
y: height - y - fontSize + fontSize,
lineHeight: lineHeight - lineHeight,
size: fontSize,
font
})
// Clear hidden block with temporary text
hidden.value.textContent = ''
})
const pdfBytes = await pdfDoc.save()
download(pdfBytes, `Baserow_${$cookies.get('credentials').tableId}_${row.id}.pdf`)
})
}
As you can see, we are going through each row of data that was fetched and saved in dataStore
. Then, for each row, we create a PDF document using PDFDocument.create()
. After this, we are adding each selected field to the PDF, but style them in the emulated HTML. Finally, I’m using the downloadjs library to download the generated PDF file!
Of course, the application can be improved with a more fancy UI and cool new features such as changing not only the font size and line height but also the text style, font family, background color, etc., adding the ability to choose how to generate PDFs: each document for each row or put each row as a separate page inside a single file, adding multipage functionality, and so on.
The full source code of the application and a working demo can be found on GitHub.
The following articles may also be helpful:
In case you’ve run into an issue while following this tutorial, feel free to reach out to ask for help in the Baserow community.