forked from tsai/budibase
49 changed files with 6388 additions and 3227 deletions
@ -0,0 +1,3 @@ |
|||
{ |
|||
"extends": "next/core-web-vitals" |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. |
|||
|
|||
# dependencies |
|||
/node_modules |
|||
/.pnp |
|||
.pnp.js |
|||
|
|||
# testing |
|||
/coverage |
|||
|
|||
# next.js |
|||
/.next/ |
|||
/out/ |
|||
|
|||
# production |
|||
/build |
|||
|
|||
# misc |
|||
.DS_Store |
|||
*.pem |
|||
|
|||
# debug |
|||
npm-debug.log* |
|||
yarn-debug.log* |
|||
yarn-error.log* |
|||
.pnpm-debug.log* |
|||
|
|||
# local env files |
|||
.env.local |
|||
.env.development.local |
|||
.env.test.local |
|||
.env.production.local |
|||
|
|||
# vercel |
|||
.vercel |
|||
|
|||
# typescript |
|||
*.tsbuildinfo |
|||
@ -0,0 +1,41 @@ |
|||
# Budibase API + Next.js example |
|||
|
|||
This is an example of how Budibase can be used as a backend for a Postgres database for a Next.js sales app. You will |
|||
need to follow the walk-through that has been published in the Budibase docs to set up your Budibase app for this example. |
|||
|
|||
## Pre-requisites |
|||
|
|||
To use this example you will need: |
|||
1. [Docker](https://www.docker.com/) |
|||
2. [Docker Compose](https://docs.docker.com/compose/) |
|||
3. [Node.js](https://nodejs.org/en/) |
|||
4. A self-hosted Budibase installation |
|||
|
|||
## Getting Started |
|||
|
|||
The first step is to set up the database - you can do this by going to the `db/` directory and running the command: |
|||
|
|||
```bash |
|||
docker-compose up |
|||
``` |
|||
|
|||
The next step is to follow the example walk-through and set up a Budibase app as it describes. Once you've done |
|||
this you can configure the settings in `next.config.js`, specifically the `apiKey`, `host` and `appName`. |
|||
|
|||
Finally, you can start the dev server with the following command: |
|||
|
|||
```bash |
|||
npm run dev |
|||
# or |
|||
yarn dev |
|||
``` |
|||
|
|||
## Accessing the app |
|||
|
|||
Open [http://localhost:3001](http://localhost:3001) with your browser to see the sales app. |
|||
|
|||
Look in the API routes (`pages/api/sales.ts` and `pages/api/salespeople.ts`) to see how this is integrated with Budibase. |
|||
There is also a utility file where some core functions and types have been defined, in `utilities/index.ts`. |
|||
|
|||
## Attribution |
|||
This example was set up using [Next.js](https://nextjs.org/) and bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). |
|||
@ -0,0 +1,42 @@ |
|||
import Link from "next/link" |
|||
import Image from "next/image" |
|||
import { ReactNotifications } from "react-notifications-component" |
|||
|
|||
function layout(props: any) { |
|||
return ( |
|||
<> |
|||
<nav className="navbar" role="navigation" aria-label="main navigation"> |
|||
<div id="navbar" className="navbar-menu"> |
|||
<div className="logo"> |
|||
<Image alt="logo" src="/bb-emblem.svg" width="50" height="50" /> |
|||
</div> |
|||
<div className="navbar-start"> |
|||
<Link href="/"> |
|||
<a className="navbar-item"> |
|||
List |
|||
</a> |
|||
</Link> |
|||
<Link href="/save"> |
|||
<a className="navbar-item"> |
|||
New sale |
|||
</a> |
|||
</Link> |
|||
</div> |
|||
<div className="navbar-end"> |
|||
<div className="navbar-item"> |
|||
<div className="buttons"> |
|||
<a className="button is-primary" href="https://budibase.readme.io/reference"> |
|||
<strong>API Documentation</strong> |
|||
</a> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</nav> |
|||
<ReactNotifications /> |
|||
{props.children} |
|||
</> |
|||
) |
|||
} |
|||
|
|||
export default layout |
|||
@ -0,0 +1,28 @@ |
|||
import { Store } from "react-notifications-component" |
|||
|
|||
const notifications = { |
|||
error: (error: string, title: string) => { |
|||
Store.addNotification({ |
|||
container: "top-right", |
|||
type: "danger", |
|||
message: error, |
|||
title: title, |
|||
dismiss: { |
|||
duration: 10000, |
|||
}, |
|||
}) |
|||
}, |
|||
success: (message: string, title: string) => { |
|||
Store.addNotification({ |
|||
container: "top-right", |
|||
type: "success", |
|||
message: message, |
|||
title: title, |
|||
dismiss: { |
|||
duration: 3000, |
|||
}, |
|||
}) |
|||
}, |
|||
} |
|||
|
|||
export default notifications |
|||
@ -0,0 +1,17 @@ |
|||
version: "3.8" |
|||
services: |
|||
db: |
|||
container_name: postgres |
|||
image: postgres |
|||
restart: always |
|||
environment: |
|||
POSTGRES_USER: root |
|||
POSTGRES_PASSWORD: root |
|||
POSTGRES_DB: postgres |
|||
ports: |
|||
- "5432:5432" |
|||
volumes: |
|||
- pg_data:/var/lib/postgresql/data/ |
|||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql |
|||
volumes: |
|||
pg_data: |
|||
@ -0,0 +1,21 @@ |
|||
CREATE TABLE IF NOT EXISTS sales_people ( |
|||
person_id SERIAL PRIMARY KEY, |
|||
name varchar(200) NOT NULL |
|||
); |
|||
|
|||
CREATE TABLE IF NOT EXISTS sales ( |
|||
sale_id SERIAL PRIMARY KEY, |
|||
sale_name varchar(200) NOT NULL, |
|||
sold_by INT, |
|||
CONSTRAINT sold_by_fk |
|||
FOREIGN KEY(sold_by) |
|||
REFERENCES sales_people(person_id) |
|||
); |
|||
|
|||
INSERT INTO sales_people (name) |
|||
select 'Salesperson ' || id |
|||
FROM GENERATE_SERIES(1, 50) as id; |
|||
|
|||
INSERT INTO sales (sale_name, sold_by) |
|||
select 'Sale ' || id, floor(random() * 50 + 1)::int |
|||
FROM GENERATE_SERIES(1, 200) as id; |
|||
@ -0,0 +1,7 @@ |
|||
import { components } from "./openapi" |
|||
|
|||
export type App = components["schemas"]["applicationOutput"]["data"] |
|||
export type Table = components["schemas"]["tableOutput"]["data"] |
|||
export type TableSearch = components["schemas"]["tableSearch"] |
|||
export type AppSearch = components["schemas"]["applicationSearch"] |
|||
export type RowSearch = components["schemas"]["searchOutput"] |
|||
File diff suppressed because it is too large
@ -0,0 +1,5 @@ |
|||
/// <reference types="next" />
|
|||
/// <reference types="next/image-types/global" />
|
|||
|
|||
// NOTE: This file should not be edited
|
|||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|||
@ -0,0 +1,16 @@ |
|||
const { join } = require("path") |
|||
/** @type {import('next').NextConfig} */ |
|||
const nextConfig = { |
|||
reactStrictMode: true, |
|||
sassOptions: { |
|||
includePaths: [join(__dirname, "styles")], |
|||
}, |
|||
serverRuntimeConfig: { |
|||
apiKey: |
|||
"bf4d86af933b5ac0af0fdbe4bf7d89ff-f929752a1eeaafb00f4b5e3325097d51a44fe4b39f22ed857923409cc75414b379323a25ebfb4916", |
|||
appName: "sales", |
|||
host: "http://localhost:10000", |
|||
}, |
|||
} |
|||
|
|||
module.exports = nextConfig |
|||
@ -0,0 +1,27 @@ |
|||
{ |
|||
"name": "nextjs-api-sales", |
|||
"version": "0.1.0", |
|||
"private": true, |
|||
"scripts": { |
|||
"dev": "next dev -p 3001", |
|||
"build": "next build", |
|||
"start": "next start", |
|||
"lint": "next lint" |
|||
}, |
|||
"dependencies": { |
|||
"bulma": "^0.9.3", |
|||
"next": "12.1.0", |
|||
"node-fetch": "^3.2.2", |
|||
"node-sass": "^7.0.1", |
|||
"react": "17.0.2", |
|||
"react-dom": "17.0.2", |
|||
"react-notifications-component": "^3.4.1" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/node": "17.0.21", |
|||
"@types/react": "17.0.39", |
|||
"eslint": "8.10.0", |
|||
"eslint-config-next": "12.1.0", |
|||
"typescript": "4.6.2" |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import "../styles/global.sass" |
|||
import type { AppProps } from "next/app" |
|||
import Head from "next/head" |
|||
import Layout from "../components/layout" |
|||
|
|||
function MyApp({ Component, pageProps }: AppProps) { |
|||
return ( |
|||
<Layout> |
|||
<Head> |
|||
<title>BB NextJS Sales Example</title> |
|||
</Head> |
|||
<Component {...pageProps} /> |
|||
</Layout> |
|||
) |
|||
} |
|||
|
|||
export default MyApp |
|||
@ -0,0 +1,46 @@ |
|||
import { getApp, findTable, makeCall } from "../../utilities" |
|||
|
|||
async function getSales(req: any) { |
|||
const { page } = req.query |
|||
const { _id: appId } = await getApp() |
|||
const table = await findTable(appId, "sales") |
|||
return await makeCall("post", `tables/${table._id}/rows/search`, { |
|||
appId, |
|||
body: { |
|||
limit: 10, |
|||
sort: { |
|||
type: "string", |
|||
order: "descending", |
|||
column: "sale_id", |
|||
}, |
|||
paginate: true, |
|||
bookmark: parseInt(page), |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
async function saveSale(req: any) { |
|||
const { _id: appId } = await getApp() |
|||
const table = await findTable(appId, "sales") |
|||
return await makeCall("post", `tables/${table._id}/rows`, { |
|||
body: req.body, |
|||
appId, |
|||
}) |
|||
} |
|||
|
|||
export default async function handler(req: any, res: any) { |
|||
let response: any = {} |
|||
try { |
|||
if (req.method === "POST") { |
|||
response = await saveSale(req) |
|||
} else if (req.method === "GET") { |
|||
response = await getSales(req) |
|||
} else { |
|||
res.status(404) |
|||
return |
|||
} |
|||
res.status(200).json(response) |
|||
} catch (err: any) { |
|||
res.status(400).send(err) |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
import { getApp, findTable, makeCall } from "../../utilities" |
|||
|
|||
async function getSalespeople() { |
|||
const { _id: appId } = await getApp() |
|||
const table = await findTable(appId, "sales_people") |
|||
return await makeCall("post", `tables/${table._id}/rows/search`, { |
|||
appId, |
|||
body: { |
|||
sort: { |
|||
type: "string", |
|||
order: "ascending", |
|||
column: "person_id", |
|||
}, |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
export default async function handler(req: any, res: any) { |
|||
let response: any = {} |
|||
try { |
|||
if (req.method === "GET") { |
|||
response = await getSalespeople() |
|||
} else { |
|||
res.status(404) |
|||
return |
|||
} |
|||
res.status(200).json(response) |
|||
} catch (err: any) { |
|||
res.status(400).send(err) |
|||
} |
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
import type { NextPage } from "next" |
|||
import styles from "../styles/home.module.css" |
|||
import { useState, useEffect, useCallback } from "react" |
|||
import Notifications from "../components/notifications" |
|||
|
|||
const Home: NextPage = () => { |
|||
const [sales, setSales] = useState([]) |
|||
const [currentPage, setCurrentPage] = useState(1) |
|||
const [loaded, setLoaded] = useState(false) |
|||
|
|||
const getSales = useCallback(async (page: Number = 1) => { |
|||
let url = "/api/sales" |
|||
if (page) { |
|||
url += `?page=${page}` |
|||
} |
|||
const response = await fetch(url) |
|||
if (!response.ok) { |
|||
const error = await response.text() |
|||
Notifications.error(error, "Failed to get sales") |
|||
return |
|||
} |
|||
const sales = await response.json() |
|||
// @ts-ignore
|
|||
setCurrentPage(page) |
|||
return setSales(sales.data) |
|||
}, []) |
|||
|
|||
const goToNextPage = useCallback(async () => { |
|||
await getSales(currentPage + 1) |
|||
}, [currentPage, getSales]) |
|||
|
|||
const goToPrevPage = useCallback(async () => { |
|||
if (currentPage > 1) { |
|||
await getSales(currentPage - 1) |
|||
} |
|||
}, [currentPage, getSales]) |
|||
|
|||
useEffect(() => { |
|||
getSales().then(() => { |
|||
setLoaded(true) |
|||
}).catch(() => { |
|||
setSales([]) |
|||
}) |
|||
}, []) |
|||
|
|||
if (!loaded) { |
|||
return null |
|||
} |
|||
|
|||
return ( |
|||
<div className={styles.container}> |
|||
<div className={styles.tableSection}> |
|||
<h1 className="subtitle">Sales</h1> |
|||
<div className={styles.table}> |
|||
<table className="table"> |
|||
<thead> |
|||
<tr> |
|||
<th>Sale ID</th> |
|||
<th>name</th> |
|||
<th>Sold by</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
{sales.map((sale: any) => |
|||
<tr key={sale.sale_id}> |
|||
<th>{sale.sale_id}</th> |
|||
<th>{sale.sale_name}</th> |
|||
<th>{sale.sales_person?.map((person: any) => person.primaryDisplay)[0]}</th> |
|||
</tr> |
|||
)} |
|||
</tbody> |
|||
</table> |
|||
<div className={styles.buttons}> |
|||
<button className="button" onClick={goToPrevPage}>Prev Page</button> |
|||
<button className="button" onClick={goToNextPage}>Next Page</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
export default Home |
|||
@ -0,0 +1,81 @@ |
|||
import type { NextPage } from "next" |
|||
import { useCallback, useEffect, useState } from "react" |
|||
import styles from "../styles/save.module.css" |
|||
import Notifications from "../components/notifications" |
|||
|
|||
const Save: NextPage = () => { |
|||
const [salespeople, setSalespeople] = useState([]) |
|||
const [loaded, setLoaded] = useState(false) |
|||
|
|||
const saveSale = useCallback(async (event: any) => { |
|||
event.preventDefault() |
|||
const sale = { |
|||
sale_name: event.target.name.value, |
|||
sales_person: [event.target.soldBy.value], |
|||
} |
|||
const response = await fetch("/api/sales", { |
|||
method: "POST", |
|||
headers: { |
|||
"Content-Type": "application/json", |
|||
}, |
|||
body: JSON.stringify(sale), |
|||
}) |
|||
if (!response.ok) { |
|||
const error = await response.text() |
|||
Notifications.error(error, "Failed to save sale") |
|||
return |
|||
} |
|||
Notifications.success("Sale saved successfully!", "Sale saved") |
|||
}, []) |
|||
|
|||
const getSalespeople = useCallback(async () => { |
|||
const response: any = await fetch("/api/salespeople") |
|||
if (!response.ok) { |
|||
throw new Error(await response.text()) |
|||
} |
|||
const json = await response.json() |
|||
setSalespeople(json.data) |
|||
}, []) |
|||
|
|||
useEffect(() => { |
|||
getSalespeople().then(() => { |
|||
setLoaded(true) |
|||
}).catch(() => { |
|||
setSalespeople([]) |
|||
}) |
|||
}, []) |
|||
|
|||
if (!loaded) { |
|||
return null |
|||
} |
|||
return ( |
|||
<div className={styles.container}> |
|||
<div className={styles.formSection}> |
|||
<h1 className="subtitle">New sale</h1> |
|||
<form onSubmit={saveSale}> |
|||
<div className="field"> |
|||
<label className="label">Name</label> |
|||
<div className="control"> |
|||
<input id="name" className="input" type="text" placeholder="Text input" /> |
|||
</div> |
|||
</div> |
|||
<div className="field"> |
|||
<label className="label">Sold by</label> |
|||
<div className="control"> |
|||
<div className="select"> |
|||
<select id="soldBy"> |
|||
{salespeople.map((person: any) => <option key={person._id} value={person._id}>{person.name}</option>)} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div className="control"> |
|||
<button className="button is-link">Submit</button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
</div> |
|||
) |
|||
} |
|||
|
|||
export default Save |
|||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 25 KiB |
@ -0,0 +1,26 @@ |
|||
@charset "utf-8" |
|||
|
|||
@import url('https://fonts.googleapis.com/css?family=Roboto:400,700') |
|||
$family-sans-serif: "Roboto", sans-serif |
|||
|
|||
#__next |
|||
display: flex |
|||
flex-direction: column |
|||
justify-content: flex-start |
|||
align-items: stretch |
|||
height: 100vh |
|||
--bg-color: #f5f5f5 |
|||
|
|||
.logo |
|||
padding: 0.75rem |
|||
|
|||
@import "../node_modules/bulma/bulma.sass" |
|||
@import "../node_modules/react-notifications-component/dist/theme.css" |
|||
|
|||
// applied after bulma styles are enabled |
|||
html |
|||
overflow-y: auto |
|||
|
|||
.navbar |
|||
background-color: var(--bg-color) |
|||
color: white |
|||
@ -0,0 +1,30 @@ |
|||
.container { |
|||
width: 100vw; |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding: 5rem 2rem 0; |
|||
align-items: center; |
|||
flex: 1 1 auto; |
|||
} |
|||
|
|||
.buttons { |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.tableSection { |
|||
padding: 2rem; |
|||
background: var(--bg-color); |
|||
width: 800px; |
|||
border-radius: 10px; |
|||
} |
|||
|
|||
.table table { |
|||
width: 100%; |
|||
} |
|||
|
|||
.tableSection h1 { |
|||
text-align: center; |
|||
color: black; |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
.container { |
|||
width: 100vw; |
|||
display: flex; |
|||
flex-direction: column; |
|||
padding: 5rem 2rem 0; |
|||
align-items: center; |
|||
flex: 1 1 auto; |
|||
} |
|||
|
|||
.formSection { |
|||
padding: 2rem; |
|||
background: var(--bg-color); |
|||
width: 400px; |
|||
border-radius: 10px; |
|||
} |
|||
|
|||
.formSection h1 { |
|||
text-align: center; |
|||
color: black; |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "es5", |
|||
"lib": ["dom", "dom.iterable", "esnext"], |
|||
"allowJs": true, |
|||
"skipLibCheck": true, |
|||
"strict": true, |
|||
"forceConsistentCasingInFileNames": true, |
|||
"noEmit": true, |
|||
"esModuleInterop": true, |
|||
"module": "esnext", |
|||
"moduleResolution": "node", |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"jsx": "preserve", |
|||
"incremental": true |
|||
}, |
|||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], |
|||
"exclude": ["node_modules"] |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
import { App, AppSearch, Table, TableSearch } from "../definitions" |
|||
import getConfig from "next/config" |
|||
|
|||
const { serverRuntimeConfig } = getConfig() |
|||
const apiKey = serverRuntimeConfig["apiKey"] |
|||
const appName = serverRuntimeConfig["appName"] |
|||
const host = serverRuntimeConfig["host"] |
|||
|
|||
let APP: App | null = null |
|||
let TABLES: { [key: string]: Table } = {} |
|||
|
|||
export async function makeCall( |
|||
method: string, |
|||
url: string, |
|||
opts?: { body?: any; appId?: string } |
|||
): Promise<any> { |
|||
const fetchOpts: any = { |
|||
method, |
|||
headers: { |
|||
"x-budibase-api-key": apiKey, |
|||
}, |
|||
} |
|||
if (opts?.appId) { |
|||
fetchOpts.headers["x-budibase-app-id"] = opts.appId |
|||
} |
|||
if (opts?.body) { |
|||
fetchOpts.body = |
|||
typeof opts.body !== "string" ? JSON.stringify(opts.body) : opts.body |
|||
fetchOpts.headers["Content-Type"] = "application/json" |
|||
} |
|||
const finalUrl = `${host}/api/public/v1/${url}` |
|||
const response = await fetch(finalUrl, fetchOpts) |
|||
if (response.ok) { |
|||
return response.json() |
|||
} else { |
|||
const error = await response.text() |
|||
console.error("Budibase server error - ", error) |
|||
throw new Error(error) |
|||
} |
|||
} |
|||
|
|||
export async function getApp(): Promise<App> { |
|||
if (APP) { |
|||
return APP |
|||
} |
|||
const apps: AppSearch = await makeCall("post", "applications/search", { |
|||
body: { |
|||
name: appName, |
|||
}, |
|||
}) |
|||
const app = apps.data.find((app: App) => app.name === appName) |
|||
if (!app) { |
|||
throw new Error( |
|||
"Could not find app, please make sure app name in config is correct." |
|||
) |
|||
} |
|||
APP = app |
|||
return app |
|||
} |
|||
|
|||
export async function findTable( |
|||
appId: string, |
|||
tableName: string |
|||
): Promise<Table> { |
|||
if (TABLES[tableName]) { |
|||
return TABLES[tableName] |
|||
} |
|||
const tables: TableSearch = await makeCall("post", "tables/search", { |
|||
body: { |
|||
name: tableName, |
|||
}, |
|||
appId, |
|||
}) |
|||
const table = tables.data.find((table: Table) => table.name === tableName) |
|||
if (!table) { |
|||
throw new Error( |
|||
"Could not find table, please make sure your app is configured with the Postgres datasource correctly." |
|||
) |
|||
} |
|||
TABLES[tableName] = table |
|||
return table |
|||
} |
|||
File diff suppressed because it is too large
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
File diff suppressed because it is too large
Loading…
Reference in new issue