├── .blitz.config.compiled.js ├── .dockerignore ├── .env ├── .env.local.example ├── .env.test.local ├── .eslintrc.js ├── .gitignore ├── .npmrc ├── .prettierignore ├── Dockerfile ├── README.md ├── app ├── api │ ├── auth │ │ └── [...auth].ts │ ├── bdash-query │ │ ├── create.ts │ │ ├── gists.ts │ │ ├── search.ts │ │ ├── token_validation.ts │ │ └── update.ts │ └── hello │ │ └── revision.ts ├── bdash-queries │ ├── mutations │ │ ├── deleteBdashQuery.ts │ │ └── updateBdashQuery.ts │ └── queries │ │ ├── getBdashQueries.ts │ │ └── getBdashQuery.ts ├── core │ ├── components │ │ ├── BdashQueryList.tsx │ │ ├── Chart.module.css │ │ ├── ContentBox.tsx │ │ ├── EditableControls.tsx │ │ ├── Form.tsx │ │ ├── LabeledTextField.tsx │ │ ├── LoadingMain.tsx │ │ ├── LoginForm.tsx │ │ ├── QueryResultChart.tsx │ │ ├── QueryResultSvgChart.tsx │ │ ├── SqlCodeBlock.module.css │ │ ├── SqlCodeBlock.tsx │ │ ├── TextLinker.tsx │ │ └── UserPageContainer.tsx │ ├── hooks │ │ └── useCurrentUser.ts │ ├── layouts │ │ ├── Layout.tsx │ │ ├── LoggedInUser.tsx │ │ ├── NavigationHeader.tsx │ │ └── SearchForm.tsx │ └── lib │ │ ├── Chart.ts │ │ ├── QueryResult.ts │ │ └── env.ts ├── favorites │ ├── mutations │ │ ├── createFavorite.ts │ │ └── deleteFavorite.ts │ └── queries │ │ └── getFavoriteQueries.ts ├── pages │ ├── 404.tsx │ ├── [userName].tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── favorites.tsx │ ├── index.test.tsx │ ├── index.tsx │ ├── query │ │ └── [bdashQueryIdHash].tsx │ ├── search.tsx │ ├── settings.tsx │ └── user │ │ └── [userId].tsx └── users │ ├── mutations │ └── updateUser.ts │ └── queries │ ├── getCurrentUser.ts │ ├── getUserById.ts │ └── getUserByName.ts ├── babel.config.js ├── blitz-env.d.ts ├── blitz.config.js ├── db ├── index.ts ├── migrations │ ├── 20210330125913_init │ │ └── migration.sql │ ├── 20210405030649_fix_column_types │ │ └── migration.sql │ ├── 20210405083322_make_longer_result_tsv_column │ │ └── migration.sql │ ├── 20210407142859_add_id_hash_column │ │ └── migration.sql │ ├── 20210408162258_delete_default_value_from_id_hash │ │ └── migration.sql │ ├── 20210414164437_drop_result_tsv_column │ │ └── migration.sql │ ├── 20210502121522_add_favorite │ │ └── migration.sql │ ├── 20210505045525_add_data_source_info │ │ └── migration.sql │ ├── 20210505082806_drop_metadata_md │ │ └── migration.sql │ ├── 20210510120344_add_result_to_bdash_query │ │ └── migration.sql │ ├── 20220114143053_add_chart_config_column_to_bdash_query │ │ └── migration.sql │ ├── 20220226165724_extend_chart_config │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seeds.ts ├── docker-compose-dev.yml ├── docker-compose-with-app-container.yml ├── integrations └── .keep ├── jest.config.js ├── mailers ├── .keep └── forgotPasswordMailer.ts ├── next-env.d.ts ├── package.json ├── public ├── favicon.ico └── logo.png ├── test ├── setup.ts └── utils.tsx ├── tsconfig.json ├── types.d.ts ├── types.ts └── yarn.lock /.blitz.config.compiled.js: -------------------------------------------------------------------------------- 1 | // blitz.config.js 2 | var {sessionMiddleware, simpleRolesIsAuthorized} = require("blitz"); 3 | module.exports = { 4 | middleware: [ 5 | sessionMiddleware({ 6 | isAuthorized: simpleRolesIsAuthorized 7 | }) 8 | ], 9 | serverRuntimeConfig: { 10 | revision: revision(), 11 | distDir: `${__dirname}/.next` 12 | } 13 | }; 14 | function revision() { 15 | const fs = require("fs"); 16 | const path = require("path"); 17 | try { 18 | return fs.readFileSync(path.join(__dirname, "REVISION")).toString(); 19 | } catch (error) { 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .env.* 4 | .blitz 5 | .next 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # This env file should be checked into source control 2 | # This is the place for default values for all environments 3 | # Values in `.env.local` and `.env.production` will override these values 4 | 5 | # DATABASE_URL=mysql://your-user:your-password@your-host:your-port/your-database 6 | # GOOGLE_CLIENT_ID="***" 7 | # GOOGLE_CLIENT_SECRET="***" 8 | # WEB_HOST="https://your.production.host" 9 | 10 | # This is used to sign and verify tokens. It should be 32 chars long. You can generate via `openssl rand -hex 16`. 11 | # SESSION_SECRET_KEY="********************************" 12 | -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | # This env file should NOT be checked into source control 2 | # This is the place for values that changed for every developer 3 | 4 | # SQLite is ready to go out of the box, but you can switch to Postgres 5 | # by first changing the provider from "sqlite" to "postgres" in the Prisma 6 | # schema file and by second swapping the DATABASE_URL below. 7 | DATABASE_URL=mysql://root:root@127.0.0.1:3307/bdash_server_dev 8 | 9 | GOOGLE_CLIENT_ID="" 10 | GOOGLE_CLIENT_SECRET="" 11 | WEB_HOST="http://localhost:3000" 12 | SESSION_SECRET_KEY="deadbeefdeadbeefdeadbeefdeadbeef" 13 | -------------------------------------------------------------------------------- /.env.test.local: -------------------------------------------------------------------------------- 1 | # THIS FILE SHOULD NOT BE CHECKED INTO YOUR VERSION CONTROL SYSTEM 2 | 3 | # SQLite is ready to go out of the box, but you can switch to Postgres 4 | # by first changing the provider from "sqlite" to "postgres" in the Prisma 5 | # schema file and by second swapping the DATABASE_URL below. 6 | DATABASE_URL=mysql://root:root@localhost:3308/bdash_server_test 7 | 8 | GOOGLE_CLIENT_ID="" 9 | GOOGLE_CLIENT_SECRET="" 10 | WEB_HOST="http://localhost:3000" 11 | SESSION_SECRET_KEY="deadbeefdeadbeefdeadbeefdeadbeef" 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["blitz"], 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | .yarn/cache 4 | .yarn/unplugged 5 | .yarn/build-state.yml 6 | .pnp.* 7 | .npm 8 | web_modules/ 9 | 10 | # blitz 11 | /.blitz/ 12 | /.next/ 13 | *.sqlite 14 | *.sqlite-journal 15 | .now 16 | .blitz-console-history 17 | blitz-log.log 18 | 19 | # misc 20 | .DS_Store 21 | 22 | # local env files 23 | .env.local 24 | .envrc 25 | 26 | # Logs 27 | logs 28 | *.log 29 | 30 | # Runtime data 31 | pids 32 | *.pid 33 | *.seed 34 | *.pid.lock 35 | 36 | # Testing 37 | .coverage 38 | *.lcov 39 | .nyc_output 40 | lib-cov 41 | 42 | # Caches 43 | *.tsbuildinfo 44 | .eslintcache 45 | .node_repl_history 46 | .yarn-integrity 47 | 48 | # Serverless directories 49 | .serverless/ 50 | 51 | # Stores VSCode versions used for testing VSCode extensions 52 | .vscode-test 53 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitkeep 2 | .env* 3 | *.ico 4 | *.lock 5 | db/migrations 6 | .next 7 | .blitz 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=linux/amd64 node:18-buster as base 2 | WORKDIR /app 3 | COPY package.json yarn.lock .npmrc ./ 4 | 5 | FROM base as builder 6 | WORKDIR /app 7 | RUN yarn install --pure-lockfile 8 | COPY . . 9 | RUN yarn run build 10 | 11 | FROM base as production 12 | WORKDIR /app 13 | RUN yarn install --production --pure-lockfile 14 | 15 | FROM base 16 | WORKDIR /app 17 | COPY . . 18 | COPY --from=production /app/node_modules ./node_modules 19 | COPY --from=builder /app/.next ./.next 20 | COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma 21 | ENV TZ Asia/Tokyo 22 | ENV PORT 3000 23 | EXPOSE 3000 24 | CMD ["yarn", "run", "start:production"] 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bdash Server 2 | 3 | This is a web app to share SQLs for data analysis from [Bdash](https://github.com/bdash-app/bdash). 4 | 5 | The features are: 6 | 1. Share as a web page your SQL, query results and charts from Bdash client. 7 | 1. Add descriptions to your query. 8 | 1. Search queries of all users. 9 | 10 | ![screenshot](https://user-images.githubusercontent.com/1413408/115130638-34d03e80-a02c-11eb-905c-c96154a74d67.png) 11 | 12 | Bdash Server is powered by [Blitz.js](https://github.com/blitz-js/blitz) using [Next.js](https://nextjs.org/) and [Prisma](https://www.prisma.io/). 13 | 14 | ## Setup 15 | 16 | Make your own `.env.local` from `.env.local.example` for development. 17 | 18 | ```sh 19 | $ cp .env.local.example .env.local 20 | ``` 21 | 22 | And write your own `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` for OAuth. 23 | 24 | You can generate OAuth web client ID by following the steps described in: https://support.google.com/workspacemigrate/answer/9222992. 25 | 26 | After that, 27 | - Set `http://localhost:3000` as _Authorized JavaScript origins_ 28 | - Set `http://localhost:3000/api/auth/google/callback` as _Authorized redirect URIs_ 29 | 30 | ## Run 31 | 32 | ```sh 33 | $ yarn dev 34 | ``` 35 | 36 | Docker is required. 🐳 37 | 38 | Run db:migrate to setup database. 39 | 40 | ```sh 41 | $ yarn db:migrate 42 | ``` 43 | 44 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 45 | 46 | ### Run production mode (NODE_ENV=production) on local machine 47 | 48 | Run an app container with the image built by [Dockerfile](https://github.com/morishin/bdash-server/blob/main/Dockerfile) for production. 49 | 50 | ```sh 51 | docker compose -f docker-compose-with-app-container.yml up --build 52 | ``` 53 | 54 | ## Tests 55 | 56 | ``` 57 | yarn test 58 | ``` 59 | 60 | ## License 61 | 62 | MIT 63 | -------------------------------------------------------------------------------- /app/api/auth/[...auth].ts: -------------------------------------------------------------------------------- 1 | import { passportAuth } from "blitz" 2 | import db from "db" 3 | import { Strategy as GoogleStrategy } from "passport-google-oauth20" 4 | import { randomBytes } from "crypto" 5 | 6 | export default passportAuth({ 7 | strategies: [ 8 | { 9 | strategy: new GoogleStrategy( 10 | { 11 | clientID: process.env.GOOGLE_CLIENT_ID as string, 12 | clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, 13 | callbackURL: `${process.env.WEB_HOST}/api/auth/google/callback`, 14 | scope: ["email", "profile"], 15 | }, 16 | async function (_token, _tokenSecret, profile, done) { 17 | const email = profile.emails && profile.emails[0]?.value 18 | const accessToken = randomBytes(20).toString("hex") 19 | const user = await db.user.upsert({ 20 | where: { email }, 21 | create: { 22 | email, 23 | name: email.split("@")[0], 24 | icon: profile.photos[0]?.value, 25 | accessToken, 26 | }, 27 | update: { email }, 28 | }) 29 | const publicData = { 30 | userId: user.id, 31 | roles: [user.role], 32 | source: "google", 33 | } 34 | done(null, { publicData }) 35 | } 36 | ), 37 | authenticateOptions: { 38 | hostedDomain: process.env.GOOGLE_HOSTED_DOMAIN || null, 39 | }, 40 | }, 41 | ], 42 | }) 43 | -------------------------------------------------------------------------------- /app/api/bdash-query/create.ts: -------------------------------------------------------------------------------- 1 | import { BlitzApiRequest, BlitzApiResponse } from "blitz" 2 | import db, { Prisma } from "db" 3 | import { createHash } from "crypto" 4 | import { convertTsvToQueryResult } from "app/core/lib/QueryResult" 5 | 6 | type BdashClientRequestBody = { description: string; files: { [key: string]: { content: string } } } 7 | 8 | const postBdashQuery = async (req: BlitzApiRequest, res: BlitzApiResponse) => { 9 | if (req.method !== "POST") { 10 | res.status(405).end() 11 | return 12 | } 13 | 14 | const accessToken = req.headers["authorization"]?.split("token ")[1] 15 | if (accessToken === undefined) { 16 | res.status(401).end() 17 | return 18 | } 19 | 20 | const user = await db.user.findUnique({ where: { accessToken } }) 21 | if (user === null) { 22 | res.status(404).end() 23 | return 24 | } 25 | 26 | const body = req.body as BdashClientRequestBody 27 | 28 | const currentDate = new Date().toString() 29 | const random = Math.random().toString() 30 | const idHash = createHash("md5").update(`${user.id}_${currentDate}_${random}`).digest("hex") 31 | 32 | const data: Prisma.BdashQueryCreateArgs["data"] = { 33 | id_hash: idHash, 34 | userId: user.id, 35 | title: body.description, 36 | description: "", 37 | query_sql: "", 38 | data_source_info: "", 39 | chart_svg: null, 40 | chart_config: null, 41 | result: "", 42 | } 43 | 44 | Object.entries(body.files).forEach(([key, value]) => { 45 | switch (key) { 46 | case "query.sql": 47 | data.query_sql = value.content 48 | break 49 | case "result.tsv": 50 | const queryResult = convertTsvToQueryResult(value.content) 51 | data.result = queryResult ? JSON.stringify(queryResult) : null 52 | break 53 | case "data_source.json": 54 | data.data_source_info = normalizeDataSourceInfo(value.content) 55 | break 56 | case "chart.svg": 57 | data.chart_svg = value.content 58 | break 59 | case "chart.json": 60 | data.chart_config = value.content 61 | break 62 | default: 63 | console.error(`Unexpected file: ${key}`) 64 | break 65 | } 66 | }) 67 | 68 | const bdashQuery = await db.bdashQuery.create({ data, select: { id_hash: true } }) 69 | 70 | res.statusCode = 201 71 | res.setHeader("Content-Type", "application/json") 72 | res.end(JSON.stringify({ 73 | id: bdashQuery.id_hash, 74 | html_url: `${process.env.WEB_HOST}/query/${bdashQuery.id_hash}`, 75 | })) 76 | } 77 | 78 | function normalizeDataSourceInfo(json: string): string | null { 79 | let data 80 | try { 81 | data = JSON.parse(json) 82 | } catch { 83 | return null 84 | } 85 | 86 | if (data === null) return null 87 | if (typeof data !== "object") return null 88 | 89 | Object.keys(data).forEach((key) => { 90 | data[key] = data[key] === null ? "" : String(data[key]) 91 | }) 92 | 93 | return JSON.stringify(data) 94 | } 95 | 96 | export default postBdashQuery 97 | -------------------------------------------------------------------------------- /app/api/bdash-query/gists.ts: -------------------------------------------------------------------------------- 1 | import postBdashQuery from "./create" 2 | 3 | export default postBdashQuery 4 | -------------------------------------------------------------------------------- /app/api/bdash-query/search.ts: -------------------------------------------------------------------------------- 1 | import { BlitzApiRequest, BlitzApiResponse } from "blitz" 2 | import db, { BdashQuery, User } from "db" 3 | 4 | export type SearchBdashQueryResponse = (BdashQuery & { user?: User })[] 5 | 6 | const searchBdashQuery = async (req: BlitzApiRequest, res: BlitzApiResponse) => { 7 | const { q: keyword } = req.query 8 | if (typeof keyword !== "string") { 9 | res.status(400).end() 10 | return 11 | } 12 | 13 | const likeArg = `%${keyword}%` 14 | const searchResults = await db.$queryRaw< 15 | BdashQuery[] 16 | >`SELECT id,id_hash,title,createdAt,userId FROM BdashQuery WHERE title LIKE ${likeArg} OR description LIKE ${likeArg} OR query_sql LIKE ${likeArg};` 17 | const users = await db.user.findMany({ 18 | where: { id: { in: searchResults.map((query) => query.userId) } }, 19 | select: { id: true, name: true, icon: true }, 20 | }) 21 | const bdashQueries = searchResults.map((bdashQuery) => { 22 | const foundUser = users.find((user) => user.id === bdashQuery.userId) 23 | const bdashQueryWithUser: SearchBdashQueryResponse[number] = foundUser 24 | ? Object.assign(bdashQuery, { user: foundUser }) 25 | : bdashQuery 26 | return bdashQueryWithUser 27 | }) 28 | 29 | res.setHeader("Content-Type", "application/json") 30 | res.status(200) 31 | res.send(JSON.stringify(bdashQueries)) 32 | } 33 | 34 | export default searchBdashQuery 35 | -------------------------------------------------------------------------------- /app/api/bdash-query/token_validation.ts: -------------------------------------------------------------------------------- 1 | import { BlitzApiRequest, BlitzApiResponse } from "blitz" 2 | import db from "db" 3 | 4 | async function tokenValidation(req: BlitzApiRequest, res: BlitzApiResponse) { 5 | const { token } = req.body 6 | if (token === undefined) { 7 | res.json({ ok: false, message: "Access Token is required" }) 8 | return 9 | } 10 | 11 | const user = await db.user.findUnique({ where: { accessToken: token } }) 12 | if (user === null) { 13 | res.json({ ok: false, message: "Access Token is invalid" }) 14 | return 15 | } 16 | 17 | res.json({ ok: true }) 18 | } 19 | 20 | export default tokenValidation 21 | -------------------------------------------------------------------------------- /app/api/bdash-query/update.ts: -------------------------------------------------------------------------------- 1 | import { BlitzApiRequest, BlitzApiResponse } from "blitz" 2 | import db, { Prisma } from "db" 3 | import { convertTsvToQueryResult } from "app/core/lib/QueryResult" 4 | 5 | type BdashClientRequestBody = { 6 | idHash: string 7 | description: string 8 | files: { [key: string]: { content: string } } 9 | } 10 | 11 | const putBdashQuery = async (req: BlitzApiRequest, res: BlitzApiResponse) => { 12 | if (req.method !== "PUT") { 13 | res.status(405).end() 14 | return 15 | } 16 | 17 | const accessToken = req.headers["authorization"]?.split("token ")[1] 18 | if (accessToken === undefined) { 19 | res.status(401).end() 20 | return 21 | } 22 | 23 | const user = await db.user.findUnique({ where: { accessToken } }) 24 | if (user === null) { 25 | res.status(404).end() 26 | return 27 | } 28 | 29 | const body = req.body as BdashClientRequestBody 30 | 31 | const bdashQuery = await db.bdashQuery.findUnique({ where: { id_hash: body.idHash } }) 32 | if (bdashQuery === null) { 33 | res.status(404).end() 34 | return 35 | } else if (bdashQuery.userId !== user.id) { 36 | res.status(403).end() 37 | return 38 | } 39 | 40 | const data: Prisma.BdashQueryUpdateArgs["data"] = { 41 | title: body.description, 42 | chart_svg: null, 43 | chart_config: null, 44 | } 45 | 46 | Object.entries(body.files).forEach(([key, value]) => { 47 | switch (key) { 48 | case "query.sql": 49 | data.query_sql = value.content 50 | break 51 | case "result.tsv": 52 | const queryResult = convertTsvToQueryResult(value.content) 53 | data.result = queryResult ? JSON.stringify(queryResult) : null 54 | break 55 | case "data_source.json": 56 | data.data_source_info = normalizeDataSourceInfo(value.content) 57 | break 58 | case "chart.svg": 59 | data.chart_svg = value.content 60 | break 61 | case "chart.json": 62 | data.chart_config = value.content 63 | break 64 | default: 65 | console.error(`Unexpected file: ${key}`) 66 | break 67 | } 68 | }) 69 | 70 | const updatedBdashQuery = await db.bdashQuery.update({ 71 | where: { id_hash: body.idHash }, 72 | data, 73 | select: { id_hash: true }, 74 | }) 75 | 76 | res.statusCode = 200 77 | res.setHeader("Content-Type", "application/json") 78 | res.end( 79 | JSON.stringify({ 80 | id: updatedBdashQuery.id_hash, 81 | html_url: `${process.env.WEB_HOST}/query/${updatedBdashQuery.id_hash}`, 82 | }) 83 | ) 84 | } 85 | 86 | function normalizeDataSourceInfo(json: string): string | null { 87 | let data 88 | try { 89 | data = JSON.parse(json) 90 | } catch { 91 | return null 92 | } 93 | 94 | if (data === null) return null 95 | if (typeof data !== "object") return null 96 | 97 | Object.keys(data).forEach((key) => { 98 | data[key] = data[key] === null ? "" : String(data[key]) 99 | }) 100 | 101 | return JSON.stringify(data) 102 | } 103 | 104 | export default putBdashQuery 105 | -------------------------------------------------------------------------------- /app/api/hello/revision.ts: -------------------------------------------------------------------------------- 1 | import { BlitzApiRequest, BlitzApiResponse, getConfig } from "blitz" 2 | 3 | const getRevision = async (_req: BlitzApiRequest, res: BlitzApiResponse) => { 4 | res.setHeader("Content-Type", "text/plain") 5 | res.status(200) 6 | const { serverRuntimeConfig } = getConfig() 7 | res.send(serverRuntimeConfig.revision || "REVISION file is not found") 8 | } 9 | 10 | export default getRevision 11 | -------------------------------------------------------------------------------- /app/bdash-queries/mutations/deleteBdashQuery.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationError, NotFoundError, resolver } from "blitz" 2 | import db from "db" 3 | import * as z from "zod" 4 | 5 | const DeleteBdashQuery = z.object({ 6 | id: z.number(), 7 | }) 8 | 9 | export default resolver.pipe( 10 | resolver.zod(DeleteBdashQuery), 11 | resolver.authorize(), 12 | async ({ id }, { session }) => { 13 | const query = await db.bdashQuery.findUnique({ where: { id }, select: { userId: true } }) 14 | if (query === null) { 15 | throw new NotFoundError() 16 | } 17 | if (session.userId !== query.userId) { 18 | throw new AuthorizationError() 19 | } 20 | const bdashQuery = await db.bdashQuery.delete({ where: { id } }) 21 | return bdashQuery 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /app/bdash-queries/mutations/updateBdashQuery.ts: -------------------------------------------------------------------------------- 1 | import { AuthorizationError, NotFoundError, resolver } from "blitz" 2 | import db from "db" 3 | import * as z from "zod" 4 | 5 | const UpdateBdashQuery = z.object({ 6 | id: z.number(), 7 | title: z.string().optional(), 8 | description: z.string().optional(), 9 | query_sql: z.string().optional(), 10 | }) 11 | 12 | export default resolver.pipe( 13 | resolver.zod(UpdateBdashQuery), 14 | resolver.authorize(), 15 | async ({ id, ...data }, { session }) => { 16 | const query = await db.bdashQuery.findUnique({ where: { id }, select: { userId: true } }) 17 | if (query === null) { 18 | throw new NotFoundError() 19 | } 20 | if (session.userId !== query.userId) { 21 | throw new AuthorizationError() 22 | } 23 | const bdashQuery = await db.bdashQuery.update({ where: { id }, data }) 24 | return bdashQuery 25 | } 26 | ) 27 | -------------------------------------------------------------------------------- /app/bdash-queries/queries/getBdashQueries.ts: -------------------------------------------------------------------------------- 1 | import { paginate, resolver } from "blitz" 2 | import db from "db" 3 | 4 | type GetBdashQueriesInput = Pick< 5 | NonNullable[0]>, 6 | "where" | "orderBy" | "skip" | "take" 7 | > 8 | 9 | export default resolver.pipe( 10 | resolver.authorize(), 11 | async ({ where, orderBy, skip = 0, take = 100 }: GetBdashQueriesInput) => { 12 | // TODO: in multi-tenant app, you must add validation to ensure correct tenant 13 | const { items: bdashQueries, hasMore, nextPage, count } = await paginate({ 14 | skip, 15 | take, 16 | count: () => db.bdashQuery.count({ where }), 17 | query: (paginateArgs) => 18 | db.bdashQuery.findMany({ 19 | ...paginateArgs, 20 | select: { 21 | id: true, 22 | id_hash: true, 23 | title: true, 24 | userId: true, 25 | createdAt: true, 26 | user: { 27 | select: { 28 | id: true, 29 | name: true, 30 | icon: true, 31 | }, 32 | }, 33 | }, 34 | where, 35 | orderBy, 36 | }), 37 | }) 38 | 39 | return { 40 | bdashQueries, 41 | nextPage, 42 | hasMore, 43 | count, 44 | } 45 | } 46 | ) 47 | -------------------------------------------------------------------------------- /app/bdash-queries/queries/getBdashQuery.ts: -------------------------------------------------------------------------------- 1 | import { resolver, NotFoundError } from "blitz" 2 | import db from "db" 3 | import * as z from "zod" 4 | 5 | const GetBdashQuery = z.object({ 6 | // This accepts type of undefined, but is required at runtime 7 | idHash: z.string().optional().refine(Boolean, "Required"), 8 | }) 9 | 10 | export default resolver.pipe( 11 | resolver.zod(GetBdashQuery), 12 | resolver.authorize(), 13 | async ({ idHash }, { session }) => { 14 | // TODO: in multi-tenant app, you must add validation to ensure correct tenant 15 | const bdashQuery = await db.bdashQuery.findFirst({ 16 | where: { id_hash: idHash }, 17 | include: { user: true }, 18 | }) 19 | 20 | if (!bdashQuery) throw new NotFoundError() 21 | 22 | const fav = await db.favorite.findFirst({ 23 | where: { 24 | bdashQueryId: bdashQuery.id, 25 | userId: session.userId, 26 | }, 27 | }) 28 | 29 | return { bdashQuery, favorite: fav !== null } 30 | } 31 | ) 32 | -------------------------------------------------------------------------------- /app/core/components/BdashQueryList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { UnorderedList, ListItem, Text, Avatar, Box, Stack, HStack } from "@chakra-ui/react" 3 | import { Link } from "blitz" 4 | import { format } from "date-fns" 5 | import type { BdashQuery, User } from "db" 6 | 7 | type Props = { 8 | queries: (Pick & { 9 | user?: Pick 10 | })[] 11 | } 12 | 13 | export const BdashQueryList: React.FC = ({ queries }) => { 14 | return ( 15 | 16 | {queries.map((query) => ( 17 | 26 | 27 | 28 | 29 | {query.title} 30 | 31 | 32 | 33 | {query.user && ( 34 | 35 | by 36 | 37 | 38 | 39 | {query.user.name} 40 | 41 | 42 | 43 | )} 44 | 45 | {format(query.createdAt, "(yyyy-MM-dd)")} 46 | 47 | 48 | 49 | 50 | ))} 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/core/components/Chart.module.css: -------------------------------------------------------------------------------- 1 | .box svg { 2 | width: 100%; 3 | height: calc((100% * 450) / 700); 4 | } 5 | -------------------------------------------------------------------------------- /app/core/components/ContentBox.tsx: -------------------------------------------------------------------------------- 1 | import { Box, BoxProps } from "@chakra-ui/react" 2 | 3 | export const ContentBox: React.FC = (props) => { 4 | return ( 5 | 14 | {props.children} 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/core/components/EditableControls.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, CloseIcon, EditIcon } from "@chakra-ui/icons" 2 | import { ButtonGroup, Flex, IconButton, useEditableControls } from "@chakra-ui/react" 3 | 4 | export const EditableControls = () => { 5 | const { 6 | isEditing, 7 | getSubmitButtonProps, 8 | getCancelButtonProps, 9 | getEditButtonProps, 10 | } = useEditableControls() 11 | 12 | return isEditing ? ( 13 | 14 | } {...(getSubmitButtonProps() as any)} /> 15 | } {...(getCancelButtonProps() as any)} /> 16 | 17 | ) : ( 18 | 19 | } {...(getEditButtonProps() as any)} /> 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /app/core/components/Form.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, PropsWithoutRef } from "react" 2 | import { Form as FinalForm, FormProps as FinalFormProps } from "react-final-form" 3 | import * as z from "zod" 4 | export { FORM_ERROR } from "final-form" 5 | 6 | export interface FormProps> 7 | extends Omit, "onSubmit"> { 8 | /** All your form fields */ 9 | children?: ReactNode 10 | /** Text to display in the submit button */ 11 | submitText?: string 12 | schema?: S 13 | onSubmit: FinalFormProps>["onSubmit"] 14 | initialValues?: FinalFormProps>["initialValues"] 15 | } 16 | 17 | export function Form>({ 18 | children, 19 | submitText, 20 | schema, 21 | initialValues, 22 | onSubmit, 23 | ...props 24 | }: FormProps) { 25 | return ( 26 | { 29 | if (!schema) return 30 | try { 31 | schema.parse(values) 32 | } catch (error) { 33 | return error.formErrors.fieldErrors 34 | } 35 | }} 36 | onSubmit={onSubmit} 37 | render={({ handleSubmit, submitting, submitError }) => ( 38 |
39 | {/* Form fields supplied as children are rendered here */} 40 | {children} 41 | 42 | {submitError && ( 43 |
44 | {submitError} 45 |
46 | )} 47 | 48 | {submitText && ( 49 | 52 | )} 53 | 54 | 59 |
60 | )} 61 | /> 62 | ) 63 | } 64 | 65 | export default Form 66 | -------------------------------------------------------------------------------- /app/core/components/LabeledTextField.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, PropsWithoutRef } from "react" 2 | import { useField } from "react-final-form" 3 | 4 | export interface LabeledTextFieldProps extends PropsWithoutRef { 5 | /** Field name. */ 6 | name: string 7 | /** Field label. */ 8 | label: string 9 | /** Field type. Doesn't include radio buttons and checkboxes */ 10 | type?: "text" | "password" | "email" | "number" 11 | outerProps?: PropsWithoutRef 12 | } 13 | 14 | export const LabeledTextField = forwardRef( 15 | ({ name, label, outerProps, ...props }, ref) => { 16 | const { 17 | input, 18 | meta: { touched, error, submitError, submitting }, 19 | } = useField(name, { 20 | parse: props.type === "number" ? Number : undefined, 21 | }) 22 | 23 | const normalizedError = Array.isArray(error) ? error.join(", ") : error || submitError 24 | 25 | return ( 26 |
27 | 31 | 32 | {touched && normalizedError && ( 33 |
34 | {normalizedError} 35 |
36 | )} 37 | 38 | 54 |
55 | ) 56 | } 57 | ) 58 | 59 | export default LabeledTextField 60 | -------------------------------------------------------------------------------- /app/core/components/LoadingMain.tsx: -------------------------------------------------------------------------------- 1 | import { Center, Spinner } from "@chakra-ui/react" 2 | import React from "react" 3 | 4 | export function LoadingMain() { 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /app/core/components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "blitz" 2 | import { Button, Flex } from "@chakra-ui/react" 3 | 4 | export const LoginForm = () => { 5 | const redirectUrl = window.location.pathname 6 | return ( 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | ) 17 | } 18 | 19 | export default LoginForm 20 | -------------------------------------------------------------------------------- /app/core/components/QueryResultChart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react" 2 | import Chart from "../lib/Chart" 3 | import { QueryResult } from "../lib/QueryResult" 4 | import { ContentBox } from "./ContentBox" 5 | 6 | type Props = { 7 | queryResult: QueryResult 8 | chartConfig: ChartType 9 | } 10 | 11 | const QueryResultChart: React.FC = ({ queryResult, chartConfig }) => { 12 | const chartRef = useRef(null) 13 | useEffect(() => { 14 | drawChart(queryResult, chartConfig, chartRef) 15 | }, [chartConfig, queryResult]) 16 | return ( 17 | 18 |
19 | 20 | ) 21 | } 22 | 23 | export type ChartType = { 24 | readonly id: number 25 | readonly queryId: number 26 | readonly type: "line" | "scatter" | "bar" | "area" | "pie" 27 | readonly xColumn: string 28 | readonly yColumns: Array 29 | readonly groupColumns: Array 30 | readonly stacking: 0 | string 31 | readonly updatedAt: string 32 | readonly createdAt: string 33 | } 34 | 35 | const drawChart = async ( 36 | queryResult: QueryResult, 37 | chartConfig: ChartType, 38 | targetElement: React.RefObject 39 | ): Promise => { 40 | if (targetElement.current === null) return 41 | 42 | const params = { 43 | type: chartConfig.type, 44 | x: chartConfig.xColumn, 45 | y: chartConfig.yColumns, 46 | stacking: chartConfig.stacking, 47 | groupBy: chartConfig.groupColumns, 48 | rows: queryResult.rows.map((row) => 49 | row.map((value) => (typeof value === "string" ? value : Number(value))) 50 | ), 51 | fields: queryResult.columns, 52 | } 53 | 54 | await new Chart(params).drawTo(targetElement.current) 55 | } 56 | 57 | export default QueryResultChart 58 | -------------------------------------------------------------------------------- /app/core/components/QueryResultSvgChart.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import styles from "./Chart.module.css" 3 | import { ContentBox } from "./ContentBox" 4 | 5 | type Props = { 6 | chartSvg: string 7 | } 8 | 9 | export const QueryResultSvgChart: React.FC = ({ chartSvg }) => { 10 | return ( 11 | 12 |
13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /app/core/components/SqlCodeBlock.module.css: -------------------------------------------------------------------------------- 1 | .box pre span { 2 | word-wrap: normal; 3 | } 4 | -------------------------------------------------------------------------------- /app/core/components/SqlCodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from "@chakra-ui/react" 2 | import hljs from "highlight.js/lib/core" 3 | import sql from "highlight.js/lib/languages/sql" 4 | import "highlight.js/styles/a11y-light.css" 5 | import { useEffect } from "react" 6 | import styles from "./SqlCodeBlock.module.css" 7 | 8 | hljs.registerLanguage("sql", sql) 9 | 10 | export const SqlCodeBlock = ({ sql }: { sql: string }) => { 11 | useEffect(() => { 12 | hljs.initHighlighting() 13 | }) 14 | return ( 15 | 16 |
17 |         {sql}
18 |       
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/core/components/TextLinker.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, ReactElement } from "react" 2 | import { Link } from "@chakra-ui/react" 3 | 4 | const LINE_REGEX = /(\r\n|\r|\n)/g 5 | const URL_REGEX = /https?:\/\/[-_.!~*'a-zA-Z0-9;/?:@&=+$,%#\u3000-\u30FE\u4E00-\u9FA0\uFF01-\uFFE3]+/g 6 | type Token = string | { text: string; url: string; type: "link" } 7 | 8 | type Props = { 9 | text: string 10 | } 11 | 12 | export const TextLinker = memo(function TextLinker({ text }) { 13 | const nodes = text.split(LINE_REGEX).map((line, idx) => { 14 | if (LINE_REGEX.test(line)) { 15 | return
16 | } 17 | 18 | return parse(line).map((token, i) => { 19 | if (typeof token === "string") { 20 | return token 21 | } else if (token.type === "link") { 22 | let isExternal: boolean 23 | try { 24 | isExternal = new URL(token.url).hostname !== window.location.hostname 25 | } catch (e) { 26 | console.error(e) 27 | return token.text 28 | } 29 | return ( 30 | 31 | {token.text} 32 | 33 | ) 34 | } 35 | return null 36 | }) 37 | }) 38 | 39 | return <>{nodes} 40 | }) 41 | 42 | const parse = (text: string): Token[] => { 43 | const tokens: Token[] = [] 44 | let result 45 | let currentIndex = 0 46 | 47 | while ((result = URL_REGEX.exec(text))) { 48 | if (currentIndex !== result.index) { 49 | tokens.push(text.slice(currentIndex, result.index)) 50 | } 51 | tokens.push({ text: result[0], url: result[0], type: "link" }) 52 | currentIndex = URL_REGEX.lastIndex 53 | } 54 | 55 | const tail = text.slice(currentIndex) 56 | if (tail !== "") { 57 | tokens.push(tail) 58 | } 59 | 60 | return tokens 61 | } 62 | -------------------------------------------------------------------------------- /app/core/components/UserPageContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Heading, HStack, Button } from "@chakra-ui/react" 2 | import { Head, usePaginatedQuery, useRouter } from "blitz" 3 | import { User } from "db" 4 | import React from "react" 5 | import { BdashQueryList } from "./BdashQueryList" 6 | import { ContentBox } from "./ContentBox" 7 | import getBdashQueries from "app/bdash-queries/queries/getBdashQueries" 8 | 9 | const ITEMS_PER_PAGE = 25; 10 | 11 | type Props = { 12 | user: User 13 | } 14 | 15 | export const UserPageContainer: React.FC = ({ user }) => { 16 | const router = useRouter(); 17 | const page = Number(router.query.page) || 0; 18 | const [{ bdashQueries, hasMore }] = usePaginatedQuery(getBdashQueries, { 19 | orderBy: { createdAt: "desc" }, 20 | skip: ITEMS_PER_PAGE * page, 21 | take: ITEMS_PER_PAGE, 22 | where: { 23 | userId: user.id, 24 | }, 25 | }); 26 | 27 | const goToPreviousPage = () => router.push({ query: { userName: user.name, page: page - 1 } }); 28 | const goToNextPage = () => router.push({ query: { userName: user.name, page: page + 1 } }); 29 | 30 | const isPagerHidden = page === 0 && !hasMore; 31 | 32 | return ( 33 | <> 34 | 35 | {`${user.name}'s queries | Bdash Server`} 36 | 37 | 38 | 39 | 40 | {`${user.name}'s queries`} 41 | 42 | Object.assign(query, { user }))} /> 43 | {!isPagerHidden && ( 44 | 45 | 48 | 51 | 52 | )} 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /app/core/hooks/useCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "blitz" 2 | import getCurrentUser from "app/users/queries/getCurrentUser" 3 | 4 | export const useCurrentUser = () => { 5 | const [user] = useQuery(getCurrentUser, null) 6 | return user 7 | } 8 | -------------------------------------------------------------------------------- /app/core/layouts/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react" 2 | import { Head } from "blitz" 3 | import { Container } from "@chakra-ui/react" 4 | import { NavigationHeader } from "./NavigationHeader" 5 | 6 | type LayoutProps = { 7 | title?: string 8 | children: ReactNode 9 | } 10 | 11 | const Layout = ({ title, children }: LayoutProps) => { 12 | return ( 13 | <> 14 | 15 | {title || "bdash-server"} 16 | 17 | 18 | 19 | 20 | 21 | {children} 22 | 23 | 24 | ) 25 | } 26 | 27 | export default Layout 28 | -------------------------------------------------------------------------------- /app/core/layouts/LoggedInUser.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDownIcon } from "@chakra-ui/icons" 2 | import { 3 | HStack, 4 | Avatar, 5 | Text, 6 | Menu, 7 | MenuButton, 8 | MenuList, 9 | MenuItem, 10 | Button, 11 | } from "@chakra-ui/react" 12 | import { useRouter } from "blitz" 13 | import React from "react" 14 | import { useCurrentUser } from "../hooks/useCurrentUser" 15 | 16 | export const LoggedInUser = () => { 17 | const router = useRouter() 18 | 19 | const currentUser = useCurrentUser() 20 | if (currentUser === null) return null 21 | 22 | return ( 23 | 24 | } 29 | > 30 | 31 | 32 | {currentUser.name} 33 | 34 | 35 | 36 | router.push(`/${currentUser.name}`)}>My Queries 37 | router.push("/favorites")}>Favorites 38 | router.push("/settings")}>Settings 39 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /app/core/layouts/NavigationHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter, useRouterQuery } from "blitz" 2 | import { Box, Flex, Heading, Link, HStack, Text, Image } from "@chakra-ui/react" 3 | import React, { Suspense } from "react" 4 | import { LoggedInUser } from "./LoggedInUser" 5 | import { SearchForm } from "./SearchForm" 6 | 7 | export const NavigationHeader: React.FC = () => { 8 | const query = useRouterQuery() 9 | const router = useRouter() 10 | return ( 11 | 12 | 22 | { 24 | router.push("/") 25 | }} 26 | _hover={{ textDecoration: "none" }} 27 | > 28 | 29 | 30 | 31 | 32 | Bdash Server 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /app/core/layouts/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import { Input, Button, HStack, Box, LayoutProps } from "@chakra-ui/react" 2 | import { useRouter } from "blitz" 3 | import React, { FormEventHandler, useState } from "react" 4 | 5 | type Props = { 6 | keyword?: string 7 | } & LayoutProps 8 | 9 | export const SearchForm: React.FC = ({ keyword, ...layoutProps }) => { 10 | const [inputValue, setInputValue] = useState(keyword || "") 11 | const router = useRouter() 12 | const onSubmit: FormEventHandler = (event) => { 13 | event.preventDefault() 14 | router.push({ pathname: "/search", query: { q: inputValue } }) 15 | } 16 | return ( 17 | 18 |
19 | 20 | { 27 | setInputValue(event.target.value) 28 | }} 29 | /> 30 | 33 | 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /app/core/lib/Chart.ts: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/bdash-app/bdash/blob/9846d67900db6782719c31923e73d7c197845429/src/lib/Chart.ts 2 | import Plotly, { PlotlyHTMLElement } from "plotly.js-basic-dist-min" 3 | import _ from "lodash" 4 | 5 | type Params = { 6 | readonly type: "line" | "scatter" | "bar" | "area" | "pie" 7 | readonly stacking: 0 | string 8 | readonly groupBy: string[] 9 | readonly rows: (string | number)[][] 10 | readonly fields: string[] 11 | readonly x: string 12 | readonly y: string[] 13 | } 14 | 15 | export default class Chart { 16 | private readonly params: Params 17 | 18 | constructor(params: Params) { 19 | this.params = params 20 | } 21 | 22 | drawTo(dom: HTMLElement): Promise { 23 | return Plotly.newPlot(dom, this.getData(), this.getLayout(), { 24 | displayModeBar: false, 25 | responsive: true, 26 | }) 27 | } 28 | 29 | async toSVG({ width }: { width: number }): Promise { 30 | const data = this.getData() 31 | const layout = this.getLayout() 32 | const div = document.createElement("div") 33 | 34 | if (data.length === 0) { 35 | return Promise.resolve(null) 36 | } 37 | 38 | const gd = await Plotly.plot(div, data, layout) 39 | // aspect ratio (450:700) is default of plotly.js 40 | // https://plot.ly/javascript/reference/#layout-width 41 | // https://plot.ly/javascript/reference/#layout-height 42 | const height = Math.floor((width * 450) / 700) 43 | return Plotly.toImage(gd, { format: "svg", width, height }).then((svg) => { 44 | const dataURI = decodeURIComponent(svg) 45 | return dataURI.substr(dataURI.indexOf(",") + 1).replace(/"Open Sans"/g, "'Open Sans'") 46 | }) 47 | } 48 | 49 | getData(): Partial[] { 50 | return this[this.params.type]() 51 | } 52 | 53 | getLayout(): Partial { 54 | const layout: Partial = { 55 | showlegend: true, 56 | margin: { l: 50, r: 50, t: 10, b: 10, pad: 4 }, 57 | hoverlabel: { namelength: -1 }, 58 | xaxis: { automargin: true }, 59 | yaxis: { automargin: true }, 60 | } 61 | 62 | if (this.params.stacking === "enable") { 63 | layout.barmode = "stack" 64 | } 65 | if (this.params.stacking === "percent") { 66 | layout.barmode = "stack" 67 | layout.barnorm = "percent" 68 | } 69 | 70 | return layout 71 | } 72 | 73 | // TODO: Performance tuning 74 | generateChartData(): { x: (string | number)[]; y: (string | number)[]; name: string }[] { 75 | if (!this.params.y) return [] 76 | 77 | if ( 78 | this.params.groupBy.length === 0 || 79 | _.difference(this.params.groupBy, this.params.fields).length > 0 80 | ) { 81 | return this.params.y.map((y) => { 82 | return { 83 | x: this.dataByField(this.params.x), 84 | y: this.dataByField(y), 85 | name: y, 86 | } 87 | }) 88 | } 89 | 90 | // NOTE: Can delete sort? 91 | const groupValues = this.params.groupBy.map((field) => _.uniq(this.dataByField(field)).sort()) 92 | const indices = this.params.groupBy.map((field) => this.rowIndexByFieldName(field)) 93 | const x = _.groupBy(this.params.rows, (row) => indices.map((idx) => row[idx])) 94 | 95 | // The cartesian product of group values 96 | const groupPairs = groupValues.reduce( 97 | (a: any[][], b: any[]) => _.flatMap(a, (x) => b.map((y) => x.concat([y]))), 98 | [[]] 99 | ) 100 | 101 | return _.flatMap(this.params.y, (y) => { 102 | const yIdx = this.rowIndexByFieldName(y) 103 | return groupPairs.map((g) => { 104 | const key = g.join(",") 105 | return { 106 | name: `${y} (${key})`, 107 | x: this.valuesByField( 108 | Object.prototype.hasOwnProperty.call(x, key) ? x[key] : [], 109 | this.params.x 110 | ), 111 | y: this.params.rows 112 | .filter((row) => 113 | // For all group by indices, the values in row and g match. 114 | indices.reduce((a: boolean, b: number, i) => a && row[b] === g[i], true) 115 | ) 116 | .map((row) => row[yIdx]), 117 | } 118 | }) 119 | }) 120 | } 121 | 122 | rowIndexByFieldName(field: string): number { 123 | return this.params.fields.findIndex((f) => f === field) 124 | } 125 | 126 | valuesByField(rows: (string | number)[][], field: string): (string | number)[] { 127 | const idx = this.rowIndexByFieldName(field) 128 | return rows.map((row) => row[idx]) 129 | } 130 | 131 | dataByField(field: string): (string | number)[] { 132 | return this.valuesByField(this.params.rows, field) 133 | } 134 | 135 | line(): Partial[] { 136 | return this.generateChartData().map((data) => ({ 137 | type: "scatter", 138 | x: data.x, 139 | y: data.y, 140 | name: data.name, 141 | mode: "lines", 142 | })) 143 | } 144 | 145 | scatter(): Partial[] { 146 | return this.generateChartData().map((data) => ({ 147 | type: "scatter", 148 | x: data.x, 149 | y: data.y, 150 | name: data.name, 151 | mode: "markers", 152 | marker: { size: 10 }, 153 | })) 154 | } 155 | 156 | bar(): Partial[] { 157 | return this.generateChartData().map((data) => ({ 158 | type: "bar", 159 | x: data.x, 160 | y: data.y, 161 | name: data.name, 162 | })) 163 | } 164 | 165 | area(): Partial[] { 166 | return this.generateChartData().map((data) => ({ 167 | type: "scatter", 168 | x: data.x, 169 | y: data.y, 170 | name: data.name, 171 | mode: "lines", 172 | fill: "tozeroy", 173 | })) 174 | } 175 | 176 | pie(): Partial[] { 177 | return [ 178 | { 179 | type: "pie", 180 | direction: "clockwise", 181 | labels: this.dataByField(this.params.x), 182 | values: this.dataByField(this.params.y[0]), 183 | }, 184 | ] 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /app/core/lib/QueryResult.ts: -------------------------------------------------------------------------------- 1 | import Papa from "papaparse" 2 | 3 | type QueryResultValue = string | number | boolean | null 4 | 5 | export type QueryResult = { 6 | columns: string[] 7 | rows: QueryResultValue[][] 8 | } 9 | 10 | export function convertTsvToQueryResult(tsv: string): QueryResult | null { 11 | const { data } = Papa.parse(tsv.trim(), { delimiter: "\t" }) 12 | const columns = data.shift() 13 | 14 | if (!Array.isArray(columns)) return null 15 | 16 | return { 17 | columns: columns.map(String), 18 | rows: data as QueryResultValue[][], 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/core/lib/env.ts: -------------------------------------------------------------------------------- 1 | function env(key: string): string 2 | function env(key: string, defaultValue: T): string | T 3 | function env(key: string, defaultValue?: T): string | T { 4 | const val = process.env[key] 5 | if (val === undefined) { 6 | if (arguments.length === 1) { 7 | throw new Error(`ENV: ${key} is required.`) 8 | } else { 9 | return defaultValue as T 10 | } 11 | } 12 | return val 13 | } 14 | 15 | export { env } 16 | -------------------------------------------------------------------------------- /app/favorites/mutations/createFavorite.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundError, resolver } from "blitz" 2 | import db, { Prisma } from "db" 3 | import * as z from "zod" 4 | 5 | const CreateFavorite = z.object({ 6 | bdashQueryIdHash: z.string(), 7 | }) 8 | 9 | export default resolver.pipe( 10 | resolver.zod(CreateFavorite), 11 | resolver.authorize(), 12 | async ({ bdashQueryIdHash }, { session }) => { 13 | const query = await db.bdashQuery.findUnique({ 14 | where: { id_hash: bdashQueryIdHash }, 15 | select: { id: true, userId: true }, 16 | }) 17 | if (query === null) { 18 | throw new NotFoundError() 19 | } 20 | const data: Prisma.FavoriteCreateArgs["data"] = { 21 | bdashQueryId: query.id, 22 | userId: session.userId, 23 | } 24 | 25 | await db.favorite.create({ data }) 26 | return null 27 | } 28 | ) 29 | -------------------------------------------------------------------------------- /app/favorites/mutations/deleteFavorite.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundError, resolver } from "blitz" 2 | import db from "db" 3 | import * as z from "zod" 4 | 5 | const DeleteBdashQuery = z.object({ 6 | bdashQueryIdHash: z.string(), 7 | }) 8 | 9 | export default resolver.pipe( 10 | resolver.zod(DeleteBdashQuery), 11 | resolver.authorize(), 12 | async ({ bdashQueryIdHash }, { session }) => { 13 | const query = await db.bdashQuery.findUnique({ 14 | where: { id_hash: bdashQueryIdHash }, 15 | select: { id: true, userId: true }, 16 | }) 17 | if (query === null) { 18 | throw new NotFoundError() 19 | } 20 | const fav = await db.favorite.findFirst({ 21 | where: { 22 | bdashQueryId: query.id, 23 | userId: session.userId, 24 | }, 25 | }) 26 | if (fav === null) { 27 | throw new NotFoundError() 28 | } 29 | await db.favorite.delete({ where: { id: fav.id } }) 30 | return null 31 | } 32 | ) 33 | -------------------------------------------------------------------------------- /app/favorites/queries/getFavoriteQueries.ts: -------------------------------------------------------------------------------- 1 | import { resolver } from "blitz" 2 | import db from "db" 3 | 4 | export default resolver.pipe(resolver.authorize(), async (_, { session }) => { 5 | const favs = await db.favorite.findMany({ 6 | where: { userId: session.userId }, 7 | include: { 8 | bdashQuery: { 9 | select: { 10 | id: true, 11 | id_hash: true, 12 | title: true, 13 | userId: true, 14 | createdAt: true, 15 | user: { 16 | select: { id: true, name: true, icon: true }, 17 | }, 18 | }, 19 | }, 20 | }, 21 | }) 22 | 23 | return favs.map((fav) => fav.bdashQuery) 24 | }) 25 | -------------------------------------------------------------------------------- /app/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Head, ErrorComponent } from "blitz" 2 | 3 | // ------------------------------------------------------ 4 | // This page is rendered if a route match is not found 5 | // ------------------------------------------------------ 6 | export default function Page404() { 7 | const statusCode = 404 8 | const title = "This page could not be found" 9 | return ( 10 | <> 11 | 12 | 13 | {statusCode}: {title} 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/pages/[userName].tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react" 2 | import { useQuery, useParam, BlitzPage } from "blitz" 3 | import Layout from "app/core/layouts/Layout" 4 | import getUserByName from "app/users/queries/getUserByName" 5 | import { UserPageContainer } from "app/core/components/UserPageContainer" 6 | import { LoadingMain } from "app/core/components/LoadingMain" 7 | 8 | const User = () => { 9 | const name = useParam("userName")?.toString() || "" 10 | const [user] = useQuery(getUserByName, { name }) 11 | 12 | return 13 | } 14 | 15 | const ShowUserByNamePage: BlitzPage = () => { 16 | return ( 17 | }> 18 | 19 | 20 | ) 21 | } 22 | 23 | ShowUserByNamePage.authenticate = true 24 | ShowUserByNamePage.getLayout = (page) => {page} 25 | 26 | export default ShowUserByNamePage 27 | -------------------------------------------------------------------------------- /app/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AppProps, 3 | ErrorComponent, 4 | useRouter, 5 | AuthenticationError, 6 | AuthorizationError, 7 | ErrorFallbackProps, 8 | } from "blitz" 9 | import { ErrorBoundary } from "react-error-boundary" 10 | import { useQueryErrorResetBoundary } from "react-query" 11 | import LoginForm from "app/core/components/LoginForm" 12 | import { ChakraProvider } from "@chakra-ui/react" 13 | import { extendTheme } from "@chakra-ui/react" 14 | 15 | export default function App({ Component, pageProps }: AppProps) { 16 | const getLayout = Component.getLayout || ((page) => page) 17 | const router = useRouter() 18 | const { reset } = useQueryErrorResetBoundary() 19 | 20 | return ( 21 | 22 | 27 | {getLayout()} 28 | 29 | 30 | ) 31 | } 32 | 33 | function RootErrorFallback({ error }: ErrorFallbackProps) { 34 | if (error instanceof AuthenticationError) { 35 | return 36 | } else if (error instanceof AuthorizationError) { 37 | return ( 38 | 42 | ) 43 | } else { 44 | return ( 45 | 46 | ) 47 | } 48 | } 49 | 50 | export const theme = extendTheme({ 51 | styles: { 52 | global: { 53 | "html, body": { 54 | color: "gray.600", 55 | lineHeight: "tall", 56 | background: "gray.100", 57 | }, 58 | a: { 59 | textDecoration: "none", 60 | _hover: { 61 | textDecoration: "underline", 62 | }, 63 | }, 64 | }, 65 | }, 66 | }) 67 | -------------------------------------------------------------------------------- /app/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import {Document, Html, DocumentHead, Main, BlitzScript /*DocumentContext*/} from 'blitz' 2 | 3 | class MyDocument extends Document { 4 | // Only uncomment if you need to customize this behaviour 5 | // static async getInitialProps(ctx: DocumentContext) { 6 | // const initialProps = await Document.getInitialProps(ctx) 7 | // return {...initialProps} 8 | // } 9 | 10 | render() { 11 | return ( 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | ) 20 | } 21 | } 22 | 23 | export default MyDocument 24 | -------------------------------------------------------------------------------- /app/pages/favorites.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react" 2 | import { Head, useQuery } from "blitz" 3 | import { Heading } from "@chakra-ui/react" 4 | import Layout from "app/core/layouts/Layout" 5 | import { BdashQueryList } from "app/core/components/BdashQueryList" 6 | import getFavoriteQueries from "app/favorites/queries/getFavoriteQueries" 7 | import { ContentBox } from "app/core/components/ContentBox" 8 | import { LoadingMain } from "app/core/components/LoadingMain" 9 | 10 | function Favorites() { 11 | const [queries] = useQuery(getFavoriteQueries, {}) 12 | return ( 13 | 14 | 15 | Your Favorites 16 | 17 | 18 | 19 | ) 20 | } 21 | 22 | const FavoritesPage = () => { 23 | return ( 24 | <> 25 | 26 | {`Favorites | Bdash Server`} 27 | 28 | }> 29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | FavoritesPage.authenticate = true 36 | FavoritesPage.getLayout = (page) => {page} 37 | 38 | export default FavoritesPage 39 | -------------------------------------------------------------------------------- /app/pages/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "test/utils" 2 | 3 | import Home from "./index" 4 | import { useCurrentUser } from "app/core/hooks/useCurrentUser" 5 | 6 | jest.mock("app/core/hooks/useCurrentUser") 7 | const mockUseCurrentUser = useCurrentUser as jest.MockedFunction 8 | 9 | test.skip("renders blitz documentation link", () => { 10 | // This is an example of how to ensure a specific item is in the document 11 | // But it's disabled by default (by test.skip) so the test doesn't fail 12 | // when you remove the the default content from the page 13 | 14 | // This is an example on how to mock api hooks when testing 15 | mockUseCurrentUser.mockReturnValue({ 16 | id: 1, 17 | name: "User", 18 | email: "user@email.com", 19 | role: "user", 20 | icon: "https://g.morishin.me/icon.png", 21 | accessToken: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", 22 | }) 23 | 24 | const { getByText } = render() 25 | const linkElement = getByText(/Documentation/i) 26 | expect(linkElement).toBeInTheDocument() 27 | }) 28 | -------------------------------------------------------------------------------- /app/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react" 2 | import { Head, BlitzPage, usePaginatedQuery, useRouter } from "blitz" 3 | import Layout from "app/core/layouts/Layout" 4 | import { Button, HStack, Heading } from "@chakra-ui/react" 5 | import { BdashQueryList } from "../core/components/BdashQueryList" 6 | import { LoadingMain } from "../core/components/LoadingMain" 7 | import { ContentBox } from "../core/components/ContentBox" 8 | import getBdashQueries from "app/bdash-queries/queries/getBdashQueries" 9 | 10 | const ITEMS_PER_PAGE = 25 11 | 12 | const Home = () => { 13 | const router = useRouter() 14 | const page = Number(router.query.page) || 0 15 | const [{ bdashQueries, hasMore }] = usePaginatedQuery(getBdashQueries, { 16 | orderBy: { id: "desc" }, 17 | skip: ITEMS_PER_PAGE * page, 18 | take: ITEMS_PER_PAGE, 19 | }) 20 | 21 | const goToPreviousPage = () => router.push({ query: { page: page - 1 } }) 22 | const goToNextPage = () => router.push({ query: { page: page + 1 } }) 23 | 24 | const isPagerHidden = page === 0 && !hasMore 25 | 26 | return ( 27 | <> 28 | 29 | Bdash Server 30 | 31 | 32 | 33 | 34 | All Queries 35 | 36 | Object.assign(query, { user: query.user }))} 38 | /> 39 | {!isPagerHidden && ( 40 | 41 | 44 | 47 | 48 | )} 49 | 50 | 51 | ) 52 | } 53 | 54 | const ShowHomePage: BlitzPage = () => { 55 | return ( 56 | }> 57 | 58 | 59 | ) 60 | } 61 | 62 | ShowHomePage.suppressFirstRenderFlicker = true 63 | ShowHomePage.authenticate = true 64 | ShowHomePage.getLayout = (page) => {page} 65 | 66 | export default ShowHomePage 67 | -------------------------------------------------------------------------------- /app/pages/query/[bdashQueryIdHash].tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, Suspense, useCallback, useMemo, useState } from "react" 2 | import { Head, useQuery, useParam, BlitzPage, Link, useMutation, useRouter, dynamic } from "blitz" 3 | import Layout from "app/core/layouts/Layout" 4 | import getBdashQuery from "app/bdash-queries/queries/getBdashQuery" 5 | import { 6 | Box, 7 | Heading, 8 | Flex, 9 | VStack, 10 | Table, 11 | Thead, 12 | Tbody, 13 | Tr, 14 | Th, 15 | Td, 16 | TableCaption, 17 | Avatar, 18 | HStack, 19 | Text, 20 | IconButton, 21 | Modal, 22 | ModalOverlay, 23 | ModalContent, 24 | ModalHeader, 25 | ModalFooter, 26 | ModalBody, 27 | ModalCloseButton, 28 | useDisclosure, 29 | Button, 30 | Textarea, 31 | Input, 32 | useToast, 33 | useClipboard, 34 | Spacer, 35 | } from "@chakra-ui/react" 36 | import { ChevronDownIcon, ChevronUpIcon, EditIcon, StarIcon } from "@chakra-ui/icons" 37 | import { format } from "date-fns" 38 | import { useCurrentUser } from "app/core/hooks/useCurrentUser" 39 | import updateBdashQuery from "app/bdash-queries/mutations/updateBdashQuery" 40 | import deleteBdashQuery from "app/bdash-queries/mutations/deleteBdashQuery" 41 | import { TextLinker } from "app/core/components/TextLinker" 42 | import createFavorite from "app/favorites/mutations/createFavorite" 43 | import deleteFavorite from "app/favorites/mutations/deleteFavorite" 44 | import { ChartType } from "app/core/components/QueryResultChart" 45 | import { ContentBox } from "app/core/components/ContentBox" 46 | import { LoadingMain } from "app/core/components/LoadingMain" 47 | import { SqlCodeBlock } from "app/core/components/SqlCodeBlock" 48 | import { QueryResult } from "app/core/lib/QueryResult" 49 | import { QueryResultSvgChart } from "app/core/components/QueryResultSvgChart" 50 | 51 | // Avoid rendering chart on server side because plotly.js does not support SSR 52 | const QueryResultChart = dynamic(() => import("app/core/components/QueryResultChart"), { 53 | ssr: false, 54 | }) 55 | 56 | const MAX_DISPLAY_ROWS = 1000 57 | 58 | export const BdashQuery = () => { 59 | const currentUser = useCurrentUser() 60 | const bdashQueryIdHash = useParam("bdashQueryIdHash", "string") 61 | const [bdashQueryResult] = useQuery( 62 | getBdashQuery, 63 | { idHash: bdashQueryIdHash }, 64 | { cacheTime: 1000 } 65 | ) 66 | const { bdashQuery, favorite: currentFav } = bdashQueryResult 67 | const { 68 | isOpen: isOpenEditModal, 69 | onOpen: onOpenEditModal, 70 | onClose: onCloseEditModal, 71 | } = useDisclosure() 72 | const [title, setTitle] = useState(bdashQuery.title) 73 | const [description, setDescription] = useState(bdashQuery.description) 74 | const [querySql, setQuerySql] = useState(bdashQuery.query_sql) 75 | const [editingTitle, setEditingTitle] = useState(bdashQuery.title) 76 | const [editingDescription, setEditingDescription] = useState(bdashQuery.description) 77 | const [editingQuerySql, setEditingQuerySql] = useState(bdashQuery.query_sql) 78 | const [updateBdashQueryMutation] = useMutation(updateBdashQuery) 79 | const [deleteBdashQueryMutation] = useMutation(deleteBdashQuery) 80 | const [createFavoriteMutation] = useMutation(createFavorite) 81 | const [deleteFavoriteMutation] = useMutation(deleteFavorite) 82 | const toast = useToast() 83 | const dataSourceInfo = parseDataSourceInfo(bdashQuery.data_source_info) 84 | const onClickEditSave = useCallback(async () => { 85 | if (currentUser === null) { 86 | return 87 | } 88 | try { 89 | await updateBdashQueryMutation({ 90 | id: bdashQuery.id, 91 | title: editingTitle, 92 | description: editingDescription, 93 | query_sql: editingQuerySql, 94 | }) 95 | setTitle(editingTitle) 96 | setDescription(editingDescription) 97 | setQuerySql(editingQuerySql) 98 | onCloseEditModal() 99 | toast({ 100 | title: "Query updated.", 101 | status: "success", 102 | duration: 9000, 103 | isClosable: true, 104 | }) 105 | } catch (error) { 106 | console.error(error) 107 | window.alert("Failed to update query") 108 | } 109 | }, [ 110 | bdashQuery.id, 111 | currentUser, 112 | editingDescription, 113 | editingQuerySql, 114 | editingTitle, 115 | onCloseEditModal, 116 | toast, 117 | updateBdashQueryMutation, 118 | ]) 119 | const router = useRouter() 120 | const onClickDelete = useCallback(async () => { 121 | if (window.confirm("Are you sure you want to delete this query?")) { 122 | try { 123 | await deleteBdashQueryMutation({ id: bdashQuery.id }) 124 | router.push(`/${bdashQuery.user.name}`) 125 | toast({ 126 | title: "Query deleted.", 127 | status: "success", 128 | duration: 9000, 129 | isClosable: true, 130 | }) 131 | } catch (error) { 132 | console.error(error) 133 | window.alert("Failed to delete query") 134 | } 135 | } 136 | }, [bdashQuery.id, bdashQuery.user.name, deleteBdashQueryMutation, router, toast]) 137 | const onClickEditCancel = useCallback(() => { 138 | onCloseEditModal() 139 | }, [onCloseEditModal]) 140 | const onChangeEditTitle = useCallback((e: React.ChangeEvent) => { 141 | setEditingTitle(e.target.value) 142 | }, []) 143 | const onChangeEditDescription = useCallback((e: React.ChangeEvent) => { 144 | setEditingDescription(e.target.value) 145 | }, []) 146 | const onChangeEditQuerySql = useCallback((e: React.ChangeEvent) => { 147 | setEditingQuerySql(e.target.value) 148 | }, []) 149 | 150 | const queryResult = useMemo(() => { 151 | if (bdashQuery.result === null) return null 152 | try { 153 | return JSON.parse(bdashQuery.result) as QueryResult 154 | } catch (err) { 155 | console.warn(err) 156 | return null 157 | } 158 | }, [bdashQuery.result]) 159 | 160 | const [fav, setFav] = useState(currentFav) 161 | const handleClickFav = useCallback(() => { 162 | if (bdashQueryIdHash === undefined) return 163 | 164 | if (fav === true) { 165 | setFav(false) 166 | deleteFavoriteMutation({ bdashQueryIdHash }) 167 | } else { 168 | setFav(true) 169 | createFavoriteMutation({ bdashQueryIdHash }) 170 | } 171 | }, [bdashQueryIdHash, createFavoriteMutation, deleteFavoriteMutation, fav]) 172 | 173 | const chartConfig = useMemo(() => { 174 | return bdashQuery.chart_config !== null 175 | ? (JSON.parse(bdashQuery.chart_config) as ChartType) 176 | : null 177 | }, [bdashQuery.chart_config]) 178 | 179 | return ( 180 | <> 181 | 182 | {title} | Bdash Server 183 | 184 | 185 | 186 | 187 | {title} 188 | 189 | {bdashQuery.userId === currentUser?.id && ( 190 | } 195 | /> 196 | )} 197 | } 202 | /> 203 | 204 | 205 | 206 | 207 | 208 | by 209 | 210 | {bdashQuery.user.name} 211 | 212 | 213 | 214 | 215 | {format(bdashQuery.createdAt, "(yyyy-MM-dd)")} 216 | 217 | 218 | 219 | {description && ( 220 | 221 | 222 | 223 | 224 | 225 | )} 226 | 227 | 228 | {chartConfig && chartConfig.yColumns.length > 0 && queryResult ? ( 229 | 230 | ) : bdashQuery.chart_svg ? ( 231 | 232 | ) : null} 233 | {queryResult && } 234 | 235 | {dataSourceInfo && } 236 | 237 | 238 | 239 | 240 | 241 | Edit 242 | 243 | 244 | 245 | Title 246 | 247 | 254 | 255 | Description 256 | 257 |