SeaTable is an incredibly powerful low-code platform that allows you to develop your own business processes in no time. In one of my first YouTube tutorials, I already showed how you can develop your own Customer Feeback Tool in less than an hour using only Seatable's features and on-boarding tools. If you don't know what I'm talking about, I recommend watching this YouTube video first.
In this article, we will now work together to further develop this SeaTable app and extend it with a custom PHP frontend.
When you reach the limits of SeaTable
You will have noticed that app development using SeaTable is quick and easy. However, the typical 80-20 rule also applies to SeaTable. This means that 80% of the requirements can usually be realized with very manageable effort, while significantly more effort is required for the last 20%. This is exactly what you can see in the YouTube video: The app is developed at the speed of light and no programming skills were required. It took just one hour to develop a first executable version of a new tool that can provide the central functions of a feedback tool. This makes it easy to try out a new process and gain immediate experience with it. Another bonus of SeaTable is that it also takes care of user authentication and static data analysis.
You can certainly try out such an app with your own team, but you would probably rather not present such a solution to your own customers. In order to be able to publish your own app with a clear conscience, the user interface should be simpler and more targeted. The user should only see the information that is really relevant. Configuration options from SeaTable and breaks like jumping to a web form should be avoided. Also, one might not want to create a log-in for the own team in SeaTable for every user.
But don't worry. You will see that all these challenges can be solved with a custom frontend for your SeaTable app.
The development steps for an own frontend at a glance
This tutorial starts with the base we already created in the YouTube video. Based on that, I will show you what steps are necessary to build a tool like the one you can find at https://ideas.seatable.io. This tutorial simplifies some steps but shows the basic procedure.
- We will start with development of simple web pages design using HTML and CSS.
- Next, we will use the SeaTable PHP API to populate these static web pages with the content from SeaTable. For this I will use the Slim PHP framework and the Twig template system.
- Finally, we will do some fine tuning.
Doesn't sound so complicated, does it? So let's get started.
Step 1: Create web page design with HTML and CSS
You probably already have a picture in your head of what your application should look like later. Try to convert exactly this picture into an HTML web page and fill the HTML page with some sample data. For our feedback tool you need three pages in any case:
- On the overview page, all previous ideas and feedbacks are displayed. Ideally, the entries can be sorted or filtered.
- The detail page, on the other hand, shows all the information and comments about a specific idea.
- In addition, we still need a form so that users can submit new ideas.
Since this is not meant to be a design tutorial, I'll limit myself to comparatively simple HTML structures that you're welcome to reuse.
Example for the index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Feedback Tool</title> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" type="text/css" /> <link rel="stylesheet" href="style.css"> <style> body { font-size: 16px; font-family: Roboto; } #frame { padding: 8rem 0px; min-height: 100vh; } #container { margin: 0px auto; width: 100%; max-width: 690px; } .box { position: relative; background: #fff; border-radius: 8px; box-shadow: 0 3px 12px 0 #ccc; } .header { padding: 3rem 4rem; background: linear-gradient(to right,#fd7974, #ff8000); border-top-left-radius: 8px; border-top-right-radius: 8px; } .header h1 { font-size: 1.4rem; color: #1f1f1f; } .header p { font-size: 1rem; color: #333; } .navigation { display: flex; justify-content: space-between;background-color: #f7f7f7; border: 1px solid #ccc; padding: 1.5rem 2rem 1.5rem 4rem; } .suggestion-button a { border-radius: 6px; background-color: #ff8000; color: #fff; padding: 10px 16px; text-decoration: none; font-size: 0.9rem; } .featured { text-align: center; padding-top: 0.2rem; } .featured p { color: #666; font-size: 0.8rem; } .request-list { border: 1px solid #ccc; min-height: 200px; bottom-margin: 30px; border-top: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; padding: 4rem; } .request-box { display: flex; margin-bottom: 16px; } .left { flex: 1 1 0%; padding: 1rem 2rem 1rem 0; } .left a { text-decoration: none; font-size:0.8em; } .right { margin-top: 20px; display: flex; flex-direction: column; } .button { border: 1px solid #ccc; border-radius: 8px; padding: 10px 26px; font-size: 0.8rem; border-left: 3px solid #ff8000; } .request-box h2 { display: block; color: #000; font-weight: 500; font-size: 1rem; margin: 0; } .request-box p { display: block; color: #333; font-size: 0.9rem; margin: 6px 0px 10px 0px; } .status { font-size: 0.8rem; color: #000; background-color: #dedede; padding: 4px 8px; border-radius: 8px; } .comments, .meta { font-size:0.8rem; color: #999; } </style> </head> <body> <div id="frame"> <div id="container"> <div class="box"> <div class="header"> <h1>Customer Feedback</h1> <p>Let us know how we can improve. Vote on existing ideas or suggest new ones.</p> </div> <div class="navigation"> <div> Sort by: <a href="/sort/newest">newest</a> | <a href="/sort/upvotes">upvotes</a> </div> <div class="suggestion-button"> <a href="#">Make a suggestion</a> </div> </div> <div class="request-list"> <!-- first feedback --> <div class="request-box"> <div class="left"> <a href="/r/abc"> <h2>Title</h2> <p>Description: this is a description. I want to describe something... And now there is a line break...</p> <span class="status">New</span> <span class="comments"> 2 Kommentare</span> </a> </div> <div class="right"> <span class="button">1</span> </div> </div> <!-- second feedback --> <div class="request-box"> <div class="left"> <a href="/r/zzz"> <h2>Title other request</h2> <p>Description: this is a description. I want to describe something... And now there is a line break...</p> <span class="status">New</span> <span class="comments"> 2 Kommentare</span> </a> </div> <div class="right"> <span class="button">3</span> </div> </div> </div> </div> <div class="featured"> <p>This application uses <a href="https://seatable.io/">SeaTable</a> as database.</p> </div> </div> </div> </body> </html>
Example for the details.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Feedback Tool</title> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" type="text/css" /> <link rel="stylesheet" href="style.css"> <style> body { font-size: 16px; font-family: Roboto; } #frame { padding: 8rem 0px; min-height: 100vh; } #container { margin: 0px auto; width: 100%; max-width: 690px; } .box { position: relative; background: #fff; border-radius: 8px; box-shadow: 0 3px 12px 0 #ccc; } .header { padding: 3rem 4rem; background: linear-gradient(to right,#fd7974, #ff8000); border-top-left-radius: 8px; border-top-right-radius: 8px; } .header h1 { font-size: 1.4rem; color: #1f1f1f; } .header p { font-size: 1rem; color: #333; } .navigation { display: flex; justify-content: space-between;background-color: #f7f7f7; border: 1px solid #ccc; padding: 1.5rem 2rem 1.5rem 4rem; } .suggestion-button a { border-radius: 6px; background-color: #ff8000; color: #fff; padding: 10px 16px; text-decoration: none; font-size: 0.9rem; } .featured { text-align: center; padding-top: 0.2rem; } .featured p { color: #666; font-size: 0.8rem; } .request-list { border: 1px solid #ccc; min-height: 200px; bottom-margin: 30px; border-top: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; padding: 4rem; } .request-box { display: flex; margin-bottom: 16px; } .left { flex: 1 1 0%; padding: 1rem 2rem 1rem 0; } .left a { text-decoration: none; font-size:0.8em; } .right { margin-top: 20px; display: flex; flex-direction: column; } .button { border: 1px solid #ccc; border-radius: 8px; padding: 10px 26px; font-size: 0.8rem; border-left: 3px solid #ff8000; } .request-box h2 { display: block; color: #000; font-weight: 500; font-size: 1rem; margin: 0; } .request-box p { display: block; color: #333; font-size: 0.9rem; margin: 6px 0px 10px 0px; } .status { font-size: 0.8rem; color: #000; background-color: #dedede; padding: 4px 8px; border-radius: 8px; } .comments, .meta { font-size:0.8rem; color: #999; } </style> </head> <body> <div id="frame"> <div id="container"> <div class="box"> <div class="header"> <h1>Title of the idea</h1> <p>Description</p> <span class="status">New</span> </div> <div class="navigation"> <div><a href="/">< Back</a></div> <div class="suggestion-button"> <a href="#">Upvote</a> </div> </div> <div class="request-list"> <!-- first comment --> <div class="request-box"> <div class="left"> <p>Description: this is a description. I want to describe something... And now there is a line break...</p> <span class="meta">Martin - September 18th, 2022</span> </div> </div> <!-- second comment --> <div class="request-box"> <div class="left"> <p>Description: this is a description. I want to describe something... And now there is a line break...</p> <span class="meta">Steve - September 18th, 2022</span> </div> </div> </div> </div> <div class="featured"> <p>This application uses <a href="https://seatable.io/">SeaTable</a> as database.</p> </div> </div> </div> </body> </html>
Example of a create.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Feedback Tool</title> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" type="text/css" /> <link rel="stylesheet" href="style.css"> <style> body { font-size: 16px; font-family: Roboto; } #frame { padding: 8rem 0px; min-height: 100vh; } #container { margin: 0px auto; width: 100%; max-width: 690px; } .box { position: relative; background: #fff; border-radius: 8px; box-shadow: 0 3px 12px 0 #ccc; } .header { padding: 3rem 4rem; background: linear-gradient(to right,#fd7974, #ff8000); border-top-left-radius: 8px; border-top-right-radius: 8px; } .header h1 { font-size: 1.4rem; color: #1f1f1f; } .header p { font-size: 1rem; color: #333; } .navigation { display: flex; justify-content: space-between;background-color: #f7f7f7; border: 1px solid #ccc; padding: 1.5rem 2rem 1.5rem 4rem; } .suggestion-button a { border-radius: 6px; background-color: #ff8000; color: #fff; padding: 10px 16px; text-decoration: none; font-size: 0.9rem; } .featured { text-align: center; padding-top: 0.2rem; } .featured p { color: #666; font-size: 0.8rem; } .request-list { border: 1px solid #ccc; min-height: 200px; bottom-margin: 30px; border-top: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; padding: 4rem; } .request-box { display: flex; margin-bottom: 16px; } .left { flex: 1 1 0%; padding: 1rem 2rem 1rem 0; } .left a { text-decoration: none; font-size:0.8em; } .right { margin-top: 20px; display: flex; flex-direction: column; } .button { border: 1px solid #ccc; border-radius: 8px; padding: 10px 26px; font-size: 0.8rem; border-left: 3px solid #ff8000; } .request-box h2 { display: block; color: #000; font-weight: 500; font-size: 1rem; margin: 0; } .request-box p { display: block; color: #333; font-size: 0.9rem; margin: 6px 0px 10px 0px; } .status { font-size: 0.8rem; color: #000; background-color: #dedede; padding: 4px 8px; border-radius: 8px; } .comments, .meta { font-size:0.8rem; color: #999; } </style> </head> <body> <div id="frame"> <div id="container"> <div class="box"> <div class="header"> <h1>Make a suggestion</h1> </div> <div class="navigation"> <div><a href="/">< Back</a></div> <div class="suggestion-button"><!--<a href="#">Upvote</a>--></div> </div> <div class="request-list"> <form method="post" action="/"> <label>Title<br/><input type="text" name="title"></label><br/> <label>Description<br/><textarea name="description" cols="40" rows="9"></textarea><br/> <input type="submit"> </form> </div> </div> <div class="featured"> <p>This application uses <a href="https://seatable.io/">SeaTable</a> as database.</p> </div> </div> </div> </body> </html>
Create a new directory for your project and copy these HTML files.
When you open the HTML files with your browser, the start page should look like this, for example.
Step 2: Connecting SeaTable via SeaTable PHP API
Next, we want to dynamically populate the static information in the two HTML pages with data from SeaTable. In this tutorial, I assume that you have a recent PHP version and the PHP package manager composer already installed. You can run the following commands on a Linux shell like this.
So let's first create our folder structure and install the necessary packages:
$ mkdir {public,templates} $ mv *.html public $ touch public/index.php $ composer require slim/slim slim/psr7 slim/twig-view seatable/seatable-api-php
Your folder structure should then look like this, where you will later share only the Public directory in your web server.
/public /public/index.php /templates /templates/index.html /templates/details.html /templates/create.html
Now let's create the first version of our index.php. As already said, I use the PHP framework Slim and the template engine Twig.
<?php declare(strict_types=1); use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface as RequestHandler; use Slim\Factory\AppFactory; use Slim\Views\Twig; use Slim\Views\TwigMiddleware; require __DIR__ . '/../vendor/autoload.php'; // Create App $app = AppFactory::create(); // Create Twig $twig = Twig::create('../templates', ['cache' => false]); $app->add(TwigMiddleware::create($app, $twig)); // Routing and error middlewares $app->addRoutingMiddleware(); $app->addErrorMiddleware(true, true, true); // details page $app->get('/r/{rID}', function (Request $request, Response $response, $args) { $view = Twig::fromRequest($request); return $view->render($response, 'details.html', []); }); // home page $app->get("/", function (Request $request, Response $response) { $view = Twig::fromRequest($request); return $view->render($response, 'index.html', []); }); // Run app $app->run();
Now change to your project directory and start the PHP environment with the following command. From now on you can reach your new app in your browser via http://127.0.0.1:9999. Currently, nothing happens other than Slim fetches the appropriate HTML templates and displays them. So your app should look the same as before.
php -S 127.0.0.1:9999 -t public
Now we add to the index.php to access the data in SeaTable. Add the following sections:
<?php declare(strict_types=1); use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface as RequestHandler; use Slim\Factory\AppFactory; use Slim\Views\Twig; use Slim\Views\TwigMiddleware; require __DIR__ . '/../vendor/autoload.php'; // SeaTable PHP-API class Dtable { private $url = 'https://cloud.seatable.io'; private $base_api_token = 'your-base-api-token'; public function connect() { return new SeaTable\SeaTableApi\SeaTableApi([ 'url' => $this->url, 'base_api_token' => $this->base_api_token, ]); } } // Create App $app = AppFactory::create(); // Create Twig $twig = Twig::create('../templates', ['cache' => false]); $app->add(TwigMiddleware::create($app, $twig)); // Routing and error middlewares $app->addRoutingMiddleware(); $app->addErrorMiddleware(true, true, true); // details page $app->get('/r/{Title}', function (Request $request, Response $response, $args) { $view = Twig::fromRequest($request); return $view->render($response, 'details.html', []); }); // home page $app->get("/", function (Request $request, Response $response) { // connect to seatable and get all entries from table Requests // pass the variables to the twig template $seatable = new DTable(); $conn = $seatable->connect(); $sql = "select * from Requests ORDER BY `Creation` DESC"; $items = $conn->querySql($sql, true); $view = Twig::fromRequest($request); return $view->render($response, 'index.html', ['items' => $items->results]); }); // Run app $app->run();
Now we can access and display all entries from SeaTable in the index.html. Add thus also the index.html
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Feedback Tool</title> <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" type="text/css" /> <link rel="stylesheet" href="style.css"> <style> body { font-size: 16px; font-family: Roboto; } #frame { padding: 8rem 0px; min-height: 100vh; } #container { margin: 0px auto; width: 100%; max-width: 690px; } .box { position: relative; background: #fff; border-radius: 8px; box-shadow: 0 3px 12px 0 #ccc; } .header { padding: 3rem 4rem; background: linear-gradient(to right,#fd7974, #ff8000); border-top-left-radius: 8px; border-top-right-radius: 8px; } .header h1 { font-size: 1.4rem; color: #1f1f1f; } .header p { font-size: 1rem; color: #333; } .navigation { display: flex; justify-content: space-between;background-color: #f7f7f7; border: 1px solid #ccc; padding: 1.5rem 2rem 1.5rem 4rem; } .suggestion-button a { border-radius: 6px; background-color: #ff8000; color: #fff; padding: 10px 16px; text-decoration: none; font-size: 0.9rem; } .featured { text-align: center; padding-top: 0.2rem; } .featured p { color: #666; font-size: 0.8rem; } .request-list { border: 1px solid #ccc; min-height: 200px; bottom-margin: 30px; border-top: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; padding: 4rem; } .request-box { display: flex; margin-bottom: 16px; } .left { flex: 1 1 0%; padding: 1rem 2rem 1rem 0; } .left a { text-decoration: none; font-size:0.8em; } .right { margin-top: 20px; display: flex; flex-direction: column; } .button { border: 1px solid #ccc; border-radius: 8px; padding: 10px 26px; font-size: 0.8rem; border-left: 3px solid #ff8000; } .request-box h2 { display: block; color: #000; font-weight: 500; font-size: 1rem; margin: 0; } .request-box p { display: block; color: #333; font-size: 0.9rem; margin: 6px 0px 10px 0px; } .status { font-size: 0.8rem; color: #000; background-color: #dedede; padding: 4px 8px; border-radius: 8px; } .comments, .meta { font-size:0.8rem; color: #999; } </style> </head> <body> <div id="frame"> <div id="container"> <div class="box"> <div class="header"> <h1>Customer Feedback</h1> <p>Let us know how we can improve. Vote on existing ideas or suggest new ones.</p> </div> <div class="navigation"> <div> Sort by: <a href="/sort/newest">newest</a> | <a href="/sort/upvotes">upvotes</a> </div> <div class="suggestion-button"> <a href="/create">Make a suggestion</a> </div> </div> <div class="request-list"> {% for item in items %} <div class="request-box"> <div class="left"> <a href="/r/{{ item.Title }}"> <h2>{{ item.Title }}</h2> <p>{{ item.Description }}.</p> <span class="status">New</span> <span class="comments"> {{ attribute(item, '# Comments') ?? 0 }} Kommentare</span> </a> </div> <div class="right"> <span class="button">{{ attribute(item, '# Votes') ?? 0 }}</span> </div> </div> {% endfor %} </div> </div> <div class="featured"> <p>This application uses <a href="https://seatable.io/">SeaTable</a> as database.</p> </div> </div> </div> </body> </html>
Reload the home page of your app and admire the many new entries. Assuming, of course, there are already a few entries in your SeaTable. But what exactly did we do. First of all, we created the connection to the SeaTable base in index.php. Using the SQL command we fetch all entries from the desired table. All entries are passed to the Twig template by array. In the index.html we make a For loop and display all entries. The syntax of Twig is very simple and so we can output single values e.g. with {{ item.Title }}. items is the name of the array we passed and Title is the column name from our SeaTable table.
A bit more complicated is the command {{ attribute(item, '# Comments') ?? 0 }}. Twig doesn't handle spaces very well, so you have to access the values of the # Comments column this way. The ?? 0 ensures that if no value is returned, simply 0 is returned.
If you've gotten this far, you've already done the hardest part and have the knowledge necessary to easily add more features. Make the following changes and be amazed at the result:
- Displayed entries can be sorted in two ways.
- New ideas can now be submitted via form.
- The details page also uses SeaTable as a database.
<?php declare(strict_types=1); session_start(); use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface as RequestHandler; use Slim\Factory\AppFactory; use Slim\Views\Twig; use Slim\Views\TwigMiddleware; require __DIR__ . '/../vendor/autoload.php'; // SeaTable PHP-API class Dtable { private $url = 'https://cloud.seatable.io'; private $base_api_token = 'your-base-api-token'; public function connect() { return new SeaTable\SeaTableApi\SeaTableApi([ 'url' => $this->url, 'base_api_token' => $this->base_api_token, ]); } } // Create App $app = AppFactory::create(); // Create Twig $twig = Twig::create('../templates', ['cache' => false]); $app->add(TwigMiddleware::create($app, $twig)); // Routing and error middlewares $app->addRoutingMiddleware(); $app->addErrorMiddleware(true, true, true); // sort by ... $app->get('/sort/{sortby}', function (Request $request, Response $response, $args) { $_SESSION['sortby'] = $args['sortby']; $response = $response->withStatus(302); return $response->withHeader('Location', '/'); }); // Show details page $app->get('/r/{Title}', function (Request $request, Response $response, $args) { // connect to seatable $seatable = new DTable(); $conn = $seatable->connect(); // get items via sql $sql = "select * from Requests WHERE Title = '". $args['Title'] ."' LIMIT 1"; $items = $conn->querySql($sql, true); // get comments $sql = "select * from Comments WHERE `Feature Request` = '". $items->results[0]->Title ."' ORDER BY `Creation` DESC"; $comments = $conn->querySql($sql, true); $view = Twig::fromRequest($request); return $view->render($response, 'details.html', ['items' => $items->results, 'comments' => $comments->results]); }); // Add new feature request. $app->post('/', function (Request $request, Response $response, $args) { $data = $request->getParsedBody(); $seatable = new DTable(); $conn = $seatable->connect(); try { // add new request $new_row = [ "Title" => $data['title'], 'Description' => $data['details'], 'Status' => "New"]; $conn->appendRow("Requests", $new_row); // redirect $_SESSION['sortby'] = "new"; $response = $response->withStatus(302); return $response->withHeader('Location', '/'); } catch (Exception $e){ return $response->withStatus(500); } }); // create page $app->get('/create', function (Request $request, Response $response, $args) { $view = Twig::fromRequest($request); return $view->render($response, 'create.html', []); }); // startpage $app->get("/", function (Request $request, Response $response) { // connect to seatable $seatable = new DTable(); $conn = $seatable->connect(); // neu mit sortierung $sql = "select * from Requests ORDER BY `Creation` DESC"; // default: newest if($_SESSION['sortby'] == "upvotes"){ $sql = "select * from Requests ORDER BY `# Votes` DESC"; } // get items via sql (ohne sortierung) //$sql = "select * from Requests"; $items = $conn->querySql($sql, true); $view = Twig::fromRequest($request); return $view->render($response, 'index.html', ['items' => $items->results]); }); // Run app $app->run();
... <body> <div id="frame"> <div id="container"> <div class="box"> <div class="header"> {% for item in items %} <h1>{{ item.Title }}</h1> <p>{{ item.Description }}</p> <span class="status">New</span> {% endfor %} </div> <div class="navigation"> <div><a href="/">< Back</a></div> <div class="suggestion-button"><!--<a href="#">Upvote</a>--></div> </div> <div class="request-list"> {% for comment in comments %} <div class="request-box"> <div class="left"> <p>{{ comment.Description }}</p> <span class="meta">{{ comment.username|first }} - {{ comment.Creation|date("F jS, Y") }}</span> </div> </div> {% endfor %} </div> </div> <div class="featured"> <p>This application uses <a href="https://seatable.io/">SeaTable</a> as database.</p> </div> </div> </div> </body> </html>
Congratulations. You have developed a custom frontend for your SeaTable app using the SeaTable PHP API.
Finetuning and outlook
You will notice that the development is not finished at this point. Rather, you will probably only get more desire to improve your app further now. You will certainly have enough ideas.
But let me give you a few tips along the way. They will definitely help you.
- Reduce the number of API calls as much as possible. Use the lookup function in SeaTable to retrieve needed information with only one API call.
- If possible, use functions in Twig to control your output. For example, use {{ item.merge_description.0|trim|nl2br }} to remove spaces at the end of a description and make line breaks look nice.
- Save or cache images or graphics on the server and save in SeaTable only the link to these files.
- Use the functions of SeaTable efficiently. With an automation you can e.g. check for duplicate entries in SeaTable and do not have to program this yourself. Statistical evaluations and notifications are an integral part of SeaTable. Save yourself the programming work.
I hope you enjoyed this tutorial and I was able to share with you how easy it is to use SeaTable as a database for its own apps and business processes.