├── .babelrc ├── .dockerignore ├── .gitignore ├── Procfile ├── README.md ├── apiary.apib ├── app.json ├── client ├── 200.html ├── dist │ └── .gitkeep ├── img │ └── screencast.gif ├── index.html └── src │ ├── api │ └── index.js │ ├── components │ ├── grid.vue │ ├── notification.vue │ ├── sheet-list.vue │ ├── sheet-name.vue │ ├── site-nav.vue │ └── type-modal.vue │ ├── helpers │ └── auth0.js │ ├── main.js │ ├── pages │ ├── home.vue │ ├── layout.vue │ ├── login-callback.vue │ ├── logout.vue │ └── sheet.vue │ ├── router.js │ └── store │ ├── index.js │ └── modules │ ├── db.js │ ├── ui.js │ └── user.js ├── db ├── contacts.sql ├── drop-column.sql ├── drop-table.sql ├── get-schema.sql ├── insert-column.sql ├── insert-table.sql ├── projects.sql ├── rename-column.sql └── rename-table.sql ├── docker-compose.yml ├── dredd.yml ├── package.json ├── server ├── actions.js ├── auth.js ├── index.js ├── queries.js ├── router.js ├── schemas.js └── type-map.js ├── test ├── client │ ├── components │ │ ├── grid.spec.js │ │ ├── notification.spec.js │ │ ├── sheet-list.spec.js │ │ └── sheet-name.spec.js │ ├── fixtures │ │ ├── rows.json │ │ └── schema.json │ ├── helpers │ │ ├── keycodes.js │ │ └── setup.js │ └── util.js └── server │ ├── helpers │ └── db.js │ ├── index.js │ └── integration │ └── _hooks.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | ["transform-object-rest-spread", { "useBuiltIns": true }] 4 | ] 5 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | client/dist/build.js 4 | npm-debug.log 5 | yarn-error.log 6 | .env 7 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node server/index.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dataface 2 | Build and manage data in a Postgres database with a spreadsheet-like interface. 3 | [Demo](https://dataface-demo.herokuapp.com). 4 | 5 | ![screencast of a user editing a spreadsheet-like interface](https://i.imgur.com/qoB2Tji.png) 6 | 7 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) 8 | 9 | ## Motivation 10 | Ideally all data would be managed in a purpose-built application backed by a database, designed by a database expert, but IT departments have to prioritize what applications they build or buy. As a result, a lot of data ends up being managed in a spreadsheet or a Microsoft Access database. These tools are flexible and easy for non-IT staff to build, but IT departments often see them as sources of technical debt: they only support one user at a time, they’re single points of failure since they’re usually not backed up, and they’re difficult to integrate into other systems. 11 | 12 | Dataface aims to be an alternative tool that IT departments can offer non-IT staff to empower them to easily manage their data in a system the IT department would support. For the non-IT staff, dataface is a spreadsheet-like interface for data that lets you create columns and rows, even linking columns between sheets. For the IT department, dataface is a vanilla PostgreSQL database with a REST API and web app on top. This way, the non-IT staff can get started building a database on their own while keeping it standard and portable under the hood, for when the time comes for it to graduate to a full-fledged application. 13 | 14 | ## Roadmap 15 | See the [milestones](https://github.com/timwis/dataface/milestones?direction=asc&sort=due_date) for the major features roadmap. 16 | 17 | ## Development 18 | The `docker-compose.yml` file provides a postgres container, and an 19 | application container. To spin them up, [install docker](https://www.docker.com/community-edition) 20 | and run: 21 | 22 | ```bash 23 | docker-compose up 24 | ``` 25 | Then navigate to `localhost:9966` in the browser. 26 | 27 | ## Testing 28 | To test the client, run: 29 | 30 | ```bash 31 | yarn test:client 32 | ``` 33 | 34 | To test the server, you'll need a throwaway postgres database running 35 | (the tests will wipe it clean afterwards). To run one using docker, use: 36 | 37 | ```bash 38 | docker run -p 5434:5432 postgres 39 | ``` 40 | 41 | Once a postgres database is available, run the server tests while 42 | passing the `DB_URL` environment variable: 43 | 44 | ```bash 45 | DB_URL="postgres://postgres:pwd@localhost:5434/postgres" yarn test:server 46 | ``` 47 | 48 | You can run both the client and server tests together with `yarn test`; 49 | just don't forget the `DB_URL` environment variable: 50 | 51 | ```bash 52 | DB_URL="postgres://postgres:pwd@localhost:5434/postgres" yarn test 53 | ``` 54 | -------------------------------------------------------------------------------- /apiary.apib: -------------------------------------------------------------------------------- 1 | FORMAT: 1A 2 | 3 | # Dataface 4 | 5 | Build and manage data with a spreadsheet-like interface. 6 | 7 | # Group Sheets 8 | 9 | Resources related to sheets (which is what dataface calls database tables). 10 | 11 | ## Sheet Collection [/sheets] 12 | 13 | ### List All Sheets [GET] 14 | 15 | + Response 200 (application/json; charset=utf-8) 16 | + Attributes (array[Sheet]) 17 | 18 | ### Create a New Sheet [POST] 19 | 20 | + Request (application/json) 21 | 22 | {"name": "invoices"} 23 | 24 | + Response 201 (application/json; charset=utf-8) 25 | + Headers 26 | 27 | Location: /sheets/{sheet_name} 28 | 29 | + Body 30 | 31 | {"name": "invoices"} 32 | 33 | ## Sheet [/sheets/{sheet_name}] 34 | 35 | + Parameters 36 | + sheet_name: `people` (string) - Name of the sheet 37 | 38 | ### Get basic information about a Sheet [GET] 39 | 40 | + Response 200 (application/json; charset=utf-8) 41 | + Attributes (Sheet) 42 | 43 | ### Update a Sheet [PATCH] 44 | To update a Sheet send a JSON payload with the updated value for one or more attributes. 45 | 46 | + Request (application/json) 47 | 48 | {"name": "persons"} 49 | 50 | + Response 200 (application/json; charset=utf-8) 51 | 52 | {"name": "persons"} 53 | 54 | ### Delete a Sheet [DELETE] 55 | 56 | + Response 204 57 | 58 | ## Sheet Column Collection [/sheets/{sheet_name}/columns] 59 | 60 | + Parameters 61 | + sheet_name: `people` (string) - Name of the sheet 62 | 63 | ### Get a Sheet's Columns [GET] 64 | 65 | + Response 200 (application/json; charset=utf-8) 66 | + Attributes (array[Column]) 67 | 68 | ### Create a Column [POST] 69 | 70 | + Request (application/json) 71 | 72 | {"name": "email"} 73 | 74 | + Response 201 (application/json; charset=utf-8) 75 | + Headers 76 | 77 | Location: /sheets/{sheet_name}/columns/{column_name} 78 | 79 | + Body 80 | 81 | {"name": "email", "type": "text"} 82 | 83 | ## Sheet Column [/sheets/{sheet_name}/columns/{column_name}] 84 | 85 | + Parameters 86 | + sheet_name: `people` (string) - Name of the sheet 87 | + column_name: `name` (string) - Name of the column 88 | 89 | ### Update a Column [PATCH] 90 | Use this method to rename a column, alter its type or metadata. 91 | 92 | + Request (application/json) 93 | 94 | {"name": "full_name"} 95 | 96 | + Response 200 (application/json; charset=utf-8) 97 | 98 | {"name": "full_name", "type": "text"} 99 | 100 | ### Delete a Column [DELETE] 101 | 102 | + Response 204 103 | 104 | ## Sheet Row Collection [/sheets/{sheet_name}/rows{?id}] 105 | Filter the rows by adding conditions on columns through the querystring parameters. 106 | For example: 107 | 108 | + `?name=John&age=20` 109 | 110 | + Parameters 111 | + sheet_name: `people` (string) - Name of the sheet 112 | + id: `2` (number) - Example of filtering the rows by the `id` column 113 | 114 | ### Get a Sheet's Rows [GET] 115 | 116 | + Response 200 (application/json; charset=utf-8) 117 | + Headers 118 | + Attributes (array[Sample Row]) 119 | 120 | ### Add a Row to a Sheet [POST] 121 | 122 | + Request (application/json) 123 | + Attributes (Sample Row) 124 | 125 | + Response 201 (application/json; charset=utf-8) 126 | + Attributes (Sample Row) 127 | 128 | ### Update a Row in a Sheet [PATCH] 129 | > Don't forget to filter the rows through querystrings to ensure you're limiting your update to a single row! 130 | 131 | + `?id=2` 132 | 133 | + Request (application/json) 134 | + Headers 135 | + Body 136 | 137 | {"name": "Jane"} 138 | 139 | + Response 200 (application/json; charset=utf-8) 140 | + Body 141 | 142 | {"name": "Jane", "age": 35} 143 | 144 | ### Delete a Row in a Sheet [DELETE] 145 | > Don't forget to filter the rows through querystrings to ensure you're limiting your delete to a single row! 146 | 147 | + `?id=2` 148 | 149 | + Response 204 150 | 151 | # Data Structures 152 | 153 | ## Sheet (object) 154 | + name: `people` (string) - Name of the sheet. Should be a valid database table name. 155 | 156 | ## Column (object) 157 | + name: `name` (string) - Name of the column. Should be a valid database column name. 158 | + type: `text` (enum[string]) - The type of data stored in the column 159 | + Members 160 | + `text` 161 | + `number` 162 | + `checkbox` 163 | 164 | ## Sample Row (object) 165 | + name: `John` (string) 166 | + age: `35` (number) 167 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dataface", 3 | "description": "Build and manage data with a spreadsheet-like interface", 4 | "repository": "https://github.com/timwis/dataface", 5 | "buildpacks": [ 6 | { 7 | "url": "heroku/nodejs" 8 | } 9 | ], 10 | "addons": [ 11 | { 12 | "plan": "heroku-postgresql", 13 | "as": "db" 14 | }, 15 | { 16 | "plan": "heroku-redis:hobby-dev" 17 | }, 18 | { 19 | "plan": "auth0:free" 20 | } 21 | ], 22 | "env": { 23 | "SESSION_KEY": { 24 | "description": "A secret key for verifying the integrity of signed cookies", 25 | "generator": "secret" 26 | }, 27 | "NPM_CONFIG_PRODUCTION": { 28 | "description": "Leave this false in order to install devDependencies necessary to build the client app", 29 | "value": "false" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/200.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | dataface 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/dist/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timwis/dataface/0f42f249b20ab0b9d05f550864d910fd4883dfdf/client/dist/.gitkeep -------------------------------------------------------------------------------- /client/img/screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timwis/dataface/0f42f249b20ab0b9d05f550864d910fd4883dfdf/client/img/screencast.gif -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | dataface 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /client/src/api/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const urlJoin = require('url-join') 3 | 4 | const apiHost = process.env.API_HOST || '/api' 5 | const NODE_ENV = process.env.NODE_ENV 6 | const DEBUG = (NODE_ENV !== 'production') 7 | 8 | if (DEBUG) { 9 | axios.interceptors.request.use(function (config) { 10 | config.withCredentials = true 11 | return config 12 | }) 13 | } 14 | 15 | module.exports = { 16 | async getSheets () { 17 | const url = constructUrl('/sheets') 18 | const response = await axios.get(url) 19 | return response.data 20 | }, 21 | 22 | async createSheet (data) { 23 | const url = constructUrl('/sheets') 24 | const response = await axios.post(url, data) 25 | return response.data 26 | }, 27 | 28 | async updateSheet (sheetName, updates) { 29 | const url = constructUrl(`/sheets/${sheetName}`) 30 | const response = await axios.patch(url, updates) 31 | return response.data 32 | }, 33 | 34 | async deleteSheet (sheetName) { 35 | const url = constructUrl(`/sheets/${sheetName}`) 36 | return axios.delete(url) 37 | }, 38 | 39 | async getRows (sheetName) { 40 | const url = constructUrl(`/sheets/${sheetName}/rows`) 41 | const response = await axios.get(url) 42 | return response.data 43 | }, 44 | 45 | async updateRow (sheetName, updates, conditions) { 46 | const url = constructUrl(`/sheets/${sheetName}/rows`) 47 | const params = conditions 48 | const response = await axios.patch(url, updates, { params }) 49 | return response.data 50 | }, 51 | 52 | async createRow (sheetName, data) { 53 | const url = constructUrl(`/sheets/${sheetName}/rows`) 54 | const response = await axios.post(url, data) 55 | return response.data 56 | }, 57 | 58 | async deleteRow (sheetName, conditions) { 59 | const url = constructUrl(`/sheets/${sheetName}/rows`) 60 | const params = conditions 61 | return axios.delete(url, { params }) 62 | }, 63 | 64 | async getColumns (sheetName) { 65 | const url = constructUrl(`/sheets/${sheetName}/columns`) 66 | const response = await axios.get(url) 67 | return response.data 68 | }, 69 | 70 | async createColumn (sheetName, columnName) { 71 | const url = constructUrl(`/sheets/${sheetName}/columns`) 72 | const payload = { name: columnName } 73 | const response = await axios.post(url, payload) 74 | return response.data 75 | }, 76 | 77 | async updateColumn (sheetName, columnName, updates) { 78 | const url = constructUrl(`/sheets/${sheetName}/columns/${columnName}`) 79 | const response = await axios.patch(url, updates) 80 | return response.data 81 | }, 82 | 83 | async deleteColumn (sheetName, columnName) { 84 | const url = constructUrl(`/sheets/${sheetName}/columns/${columnName}`) 85 | return axios.delete(url) 86 | }, 87 | 88 | async authenticate (authCode) { 89 | const url = constructUrl(`/authenticate/?code=${authCode}`) 90 | const response = await axios.post(url) 91 | return response.data 92 | }, 93 | 94 | async logout () { 95 | const url = constructUrl(`/logout`) 96 | return await axios.post(url) 97 | }, 98 | 99 | async getCurrentUser () { 100 | const url = constructUrl(`/user`) 101 | const response = await axios.get(url) 102 | return response.data 103 | } 104 | } 105 | 106 | function constructUrl (path) { 107 | return urlJoin(apiHost, path) 108 | } 109 | -------------------------------------------------------------------------------- /client/src/components/grid.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 285 | 286 | 360 | -------------------------------------------------------------------------------- /client/src/components/notification.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 28 | -------------------------------------------------------------------------------- /client/src/components/sheet-list.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 32 | 33 | 42 | -------------------------------------------------------------------------------- /client/src/components/sheet-name.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 31 | 32 | 41 | -------------------------------------------------------------------------------- /client/src/components/site-nav.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 36 | 37 | 42 | -------------------------------------------------------------------------------- /client/src/components/type-modal.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 97 | 98 | 103 | -------------------------------------------------------------------------------- /client/src/helpers/auth0.js: -------------------------------------------------------------------------------- 1 | const { stringify } = require('query-string') 2 | 3 | const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID 4 | const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN 5 | const AUTH0_CALLBACK_URL = process.env.AUTH0_CALLBACK_URL 6 | 7 | const loginParams = { 8 | response_type: 'code', 9 | scope: 'openid profile', 10 | client_id: AUTH0_CLIENT_ID, 11 | redirect_uri: AUTH0_CALLBACK_URL 12 | } 13 | 14 | module.exports = `https://${AUTH0_DOMAIN}/authorize?${stringify(loginParams)}` 15 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue') 2 | 3 | const layout = require('./pages/layout.vue') 4 | const store = require('./store') 5 | const router = require('./router') 6 | 7 | store.dispatch('getCurrentUser') 8 | .then(() => new Vue({ 9 | el: '#app', 10 | store, 11 | router, 12 | render: h => h(layout) 13 | })) 14 | -------------------------------------------------------------------------------- /client/src/pages/home.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 41 | 42 | 47 | -------------------------------------------------------------------------------- /client/src/pages/layout.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 40 | 41 | 42 | 56 | -------------------------------------------------------------------------------- /client/src/pages/login-callback.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 24 | -------------------------------------------------------------------------------- /client/src/pages/logout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /client/src/pages/sheet.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 66 | 67 | 80 | -------------------------------------------------------------------------------- /client/src/router.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue') 2 | const VueRouter = require('vue-router') 3 | 4 | const Home = require('./pages/home.vue') 5 | const Sheet = require('./pages/sheet.vue') 6 | const LoginCallback = require('./pages/login-callback.vue') 7 | const Logout = require('./pages/logout.vue') 8 | const auth0Url = require('./helpers/auth0') 9 | 10 | Vue.use(VueRouter) 11 | 12 | const routes = [ 13 | { path: '/', component: Home, name: 'home' }, 14 | { path: '/login', beforeEnter: initiateLogin }, 15 | { path: '/callback', component: LoginCallback }, 16 | { path: '/logout', component: Logout }, 17 | { path: '/sheets', component: Sheet, beforeEnter: auth }, 18 | { path: '/sheets/:sheetName', component: Sheet, beforeEnter: auth } 19 | ] 20 | 21 | const router = new VueRouter({ 22 | mode: 'history', 23 | routes 24 | }) 25 | 26 | module.exports = router 27 | 28 | function auth (to, from, next) { 29 | const store = router.app.$store 30 | if (!store.state.user.isAuthenticated) { 31 | next({ 32 | path: '/login', 33 | query: { redirect: to.fullPath } 34 | }) 35 | } else { 36 | next() 37 | } 38 | } 39 | 40 | function initiateLogin () { 41 | window.location.href = auth0Url 42 | } 43 | -------------------------------------------------------------------------------- /client/src/store/index.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue') 2 | const Vuex = require('vuex') 3 | 4 | Vue.use(Vuex) 5 | 6 | const store = new Vuex.Store({ 7 | modules: { 8 | db: require('./modules/db'), 9 | ui: require('./modules/ui'), 10 | user: require('./modules/user') 11 | }, 12 | strict: (process.env.NODE_ENV !== 'production') 13 | }) 14 | 15 | module.exports = store 16 | -------------------------------------------------------------------------------- /client/src/store/modules/db.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue') 2 | const pick = require('lodash/pick') 3 | 4 | const router = require('../../router') 5 | const api = require('../../api') 6 | 7 | module.exports = { 8 | state: { 9 | sheets: [], 10 | activeSheet: { 11 | columns: [], 12 | rows: [], 13 | name: null, 14 | keys: [] 15 | } 16 | }, 17 | mutations: { 18 | receiveSheetList (state, { sheets }) { 19 | state.sheets = sheets 20 | }, 21 | receiveActiveSheet (state, { rows, columns, name }) { 22 | state.activeSheet.rows = rows 23 | state.activeSheet.columns = columns 24 | state.activeSheet.name = name 25 | state.activeSheet.keys = getPrimaryKeys(columns) 26 | }, 27 | receiveSheetInsertion (state, { name }) { 28 | Vue.set(state.sheets, state.sheets.length, { name }) 29 | }, 30 | receiveSheetRename (state, { oldName, newName }) { 31 | state.activeSheet.name = newName 32 | const sheet = state.sheets.find((sheet) => sheet.name === oldName) 33 | sheet.name = newName 34 | }, 35 | receiveSheetRemoval (state, { index }) { 36 | Vue.delete(state.sheets, index) 37 | }, 38 | receiveRow (state, { rowIndex, newRow }) { 39 | Vue.set(state.activeSheet.rows, rowIndex, newRow) 40 | }, 41 | receiveRowRemoval (state, rowIndex) { 42 | Vue.delete(state.activeSheet.rows, rowIndex) 43 | }, 44 | receiveColumn (state, column) { 45 | state.activeSheet.columns.push(column) 46 | }, 47 | receiveColumnUpdate (state, { columnIndex, newColumn }) { 48 | Vue.set(state.activeSheet.columns, columnIndex, newColumn) 49 | }, 50 | receiveColumnRename (state, { oldValue, newValue }) { 51 | // state.activeSheet.columns[columnIndex].name = newValue 52 | const rename = createRename(oldValue, newValue) 53 | state.activeSheet.rows = state.activeSheet.rows.map(rename) 54 | }, 55 | receiveColumnRemoval (state, columnIndex) { 56 | Vue.delete(state.activeSheet.columns, columnIndex) 57 | } 58 | }, 59 | actions: { 60 | async getSheetList ({ commit, dispatch }) { 61 | let sheets 62 | try { 63 | sheets = await api.getSheets() 64 | } catch (err) { 65 | console.error(err) 66 | dispatch('notify', { msg: `Failed to get sheets` }) 67 | return 68 | } 69 | 70 | commit('receiveSheetList', { sheets }) 71 | return Promise.resolve() 72 | }, 73 | async getSheet ({ commit, dispatch }, { name }) { 74 | let rows, columns 75 | try { 76 | columns = await api.getColumns(name) 77 | const firstColumnName = (columns.length) ? columns[0].name : '' 78 | rows = await api.getRows(name, firstColumnName) // order by 79 | } catch (err) { 80 | console.error(err) 81 | dispatch('notify', { msg: `Failed to get sheet ${name}` }) 82 | return 83 | } 84 | 85 | commit('receiveActiveSheet', { rows, columns, name }) 86 | }, 87 | async saveCell ({ state, commit, dispatch }, { rowIndex, columnIndex, newValue }) { 88 | const sheetName = state.activeSheet.name 89 | const columnName = state.activeSheet.columns[columnIndex].name 90 | const updates = { [columnName]: newValue } 91 | const primaryKeys = getPrimaryKeys(state.activeSheet.columns) 92 | const row = state.activeSheet.rows[rowIndex] 93 | const conditions = pick(row, primaryKeys) 94 | const isNewRow = (Object.keys(conditions).length === 0) 95 | 96 | let newRow 97 | try { 98 | if (isNewRow && newValue) { 99 | console.log('create') 100 | newRow = await api.createRow(sheetName, updates) 101 | } else { 102 | console.log('update') 103 | newRow = await api.updateRow(sheetName, updates, conditions) 104 | } 105 | } catch (err) { 106 | console.error(err) 107 | dispatch('notify', { msg: `Failed to save cell` }) 108 | return 109 | } 110 | 111 | if (newRow) { 112 | commit('receiveRow', { rowIndex, newRow }) 113 | } 114 | }, 115 | async removeRow ({ state, commit, dispatch }, rowIndex) { 116 | if (rowIndex === null) return 117 | const sheetName = state.activeSheet.name 118 | const row = state.activeSheet.rows[rowIndex] 119 | const primaryKeys = getPrimaryKeys(state.activeSheet.columns) 120 | const conditions = pick(row, primaryKeys) 121 | 122 | try { 123 | await api.deleteRow(sheetName, conditions) 124 | } catch (err) { 125 | console.error(err) 126 | dispatch('notify', { msg: `Failed to remove row` }) 127 | return 128 | } 129 | 130 | commit('receiveRowRemoval', rowIndex) 131 | }, 132 | async insertColumn ({ state, commit, dispatch }) { 133 | const sheetName = state.activeSheet.name 134 | const columnNames = state.activeSheet.columns.map((col) => col.name) 135 | const nextInSeq = getNextInSequence(columnNames) 136 | const newColumnName = `column_${nextInSeq}` 137 | 138 | let newColumn 139 | try { 140 | newColumn = await api.createColumn(sheetName, newColumnName) 141 | } catch (err) { 142 | console.error(err) 143 | dispatch('notify', { msg: `Error adding column` }) 144 | return 145 | } 146 | 147 | commit('receiveColumn', newColumn) 148 | return Promise.resolve() 149 | }, 150 | async renameColumn ({ state, commit, dispatch }, { columnIndex, oldValue, newValue }) { 151 | const sheetName = state.activeSheet.name 152 | const updates = { name: newValue } 153 | 154 | let newColumn 155 | try { 156 | newColumn = await api.updateColumn(sheetName, oldValue, updates) 157 | } catch (err) { 158 | console.error(err) 159 | dispatch('notify', { msg: `Failed to rename column ${oldValue} to ${newValue}` }) 160 | return 161 | } 162 | 163 | commit('receiveColumnUpdate', { columnIndex, newColumn }) 164 | commit('receiveColumnRename', { oldValue, newValue }) 165 | }, 166 | async setColumnType ({ state, commit, dispatch }, { columnIndex, type }) { 167 | const sheetName = state.activeSheet.name 168 | const columnName = state.activeSheet.columns[columnIndex].name 169 | const updates = { type } 170 | 171 | let newColumn 172 | try { 173 | newColumn = await api.updateColumn(sheetName, columnName, updates) 174 | } catch (err) { 175 | console.error(err) 176 | dispatch('notify', { msg: `Failed to set column ${columnName} to type ${type}` }) 177 | return 178 | } 179 | 180 | commit('receiveColumnUpdate', { columnIndex, newColumn }) 181 | }, 182 | async removeColumn ({ state, commit, dispatch }, columnIndex) { 183 | if (columnIndex === null) return 184 | const sheetName = state.activeSheet.name 185 | const columnName = state.activeSheet.columns[columnIndex].name 186 | 187 | try { 188 | await api.deleteColumn(sheetName, columnName) 189 | } catch (err) { 190 | console.error(err) 191 | dispatch('notify', { msg: `Failed to remove column ${columnName}` }) 192 | return 193 | } 194 | 195 | commit('receiveColumnRemoval', columnIndex) 196 | }, 197 | async insertSheet ({ state, commit, dispatch }) { 198 | const sheetNames = state.sheets.map((sheet) => sheet.name) 199 | const nextInSeq = getNextInSequence(sheetNames) 200 | const name = `sheet_${nextInSeq}` 201 | 202 | try { 203 | await api.createSheet({ name }) 204 | } catch (err) { 205 | console.error(err) 206 | dispatch('notify', { msg: `Failed to add sheet` }) 207 | return 208 | } 209 | 210 | commit('receiveSheetInsertion', { name }) 211 | await dispatch('getSheet', { name }) 212 | await dispatch('insertColumn') 213 | router.push(`/sheets/${name}`) 214 | }, 215 | async renameSheet ({ state, commit, dispatch }, { oldName, newName }) { 216 | const payload = { name: newName } 217 | try { 218 | await api.updateSheet(oldName, payload) 219 | } catch (err) { 220 | console.error(err) 221 | dispatch('notify', { msg: `Failed to rename sheet` }) 222 | return 223 | } 224 | 225 | commit('receiveSheetRename', { oldName, newName }) 226 | router.push(`/sheets/${newName}`) 227 | }, 228 | async removeSheet ({ state, commit, dispatch }, name) { 229 | try { 230 | await api.deleteSheet(name) 231 | } catch (err) { 232 | console.error(err) 233 | dispatch('notify', { msg: `Failed to remove sheet ${name}` }) 234 | return 235 | } 236 | 237 | const index = state.sheets.findIndex((sheet) => sheet.name === name) 238 | commit('receiveSheetRemoval', { index }) 239 | 240 | const newActiveSheetName = determineNextActiveSheet(state, index) 241 | if (newActiveSheetName) { 242 | router.push(`/sheets/${newActiveSheetName}`) 243 | } else { 244 | const emptyPayload = { rows: [], columns: [], name: null } 245 | commit('receiveActiveSheet', emptyPayload) 246 | } 247 | } 248 | } 249 | } 250 | 251 | function determineNextActiveSheet (state, sheetIndex) { 252 | const newMaxIndex = state.sheets.length - 1 253 | const newActiveSheetIndex = Math.min(sheetIndex, newMaxIndex) 254 | const newActiveSheet = state.sheets[newActiveSheetIndex] 255 | return newActiveSheet ? newActiveSheet.name : '' 256 | } 257 | 258 | function getPrimaryKeys (fields) { 259 | return fields.filter((field) => field.constraint === 'PRIMARY KEY') 260 | .map((field) => field.name) 261 | } 262 | 263 | function createRename (oldValue, newValue) { 264 | return function (item) { 265 | item[newValue] = item[oldValue] 266 | delete item[oldValue] 267 | return item 268 | } 269 | } 270 | 271 | function getNextInSequence (names) { 272 | const numbers = names 273 | .map(getTrailingNumber) 274 | .filter(isSequenceMember) 275 | .sort(sortNumeric) 276 | return numbers.length > 0 277 | ? last(numbers) + 1 278 | : names.length + 1 279 | } 280 | 281 | function getTrailingNumber (name) { 282 | const parts = name.split('_') 283 | return +last(parts) 284 | } 285 | 286 | function isSequenceMember (input) { 287 | return input > 0 288 | } 289 | 290 | function sortNumeric (a, b) { 291 | return a - b 292 | } 293 | 294 | function last (arr) { 295 | return arr[arr.length - 1] 296 | } 297 | -------------------------------------------------------------------------------- /client/src/store/modules/ui.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue') 2 | const shortid = require('shortid') 3 | 4 | module.exports = { 5 | state: { 6 | editing: false, 7 | notifications: {} 8 | }, 9 | mutations: { 10 | setEditing (state) { 11 | state.editing = true 12 | }, 13 | setNotEditing (state) { 14 | state.editing = false 15 | }, 16 | createNotification (state, { msg, type, id }) { 17 | Vue.set(state.notifications, id, { msg, type, id }) 18 | }, 19 | dismissNotification (state, id) { 20 | Vue.delete(state.notifications, id) 21 | } 22 | }, 23 | actions: { 24 | notify ({ state, commit }, { msg, type = 'danger', duration = 5000 }) { 25 | const id = shortid.generate() 26 | commit('createNotification', { msg, type, id }) 27 | window.setTimeout(() => commit('dismissNotification', id), duration) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/store/modules/user.js: -------------------------------------------------------------------------------- 1 | const api = require('../../api') 2 | 3 | module.exports = { 4 | state: { 5 | isAuthenticated: false, 6 | displayName: null, 7 | nickname: null, 8 | picture: null 9 | }, 10 | mutations: { 11 | receiveUser (state, user) { 12 | state.isAuthenticated = true 13 | state.displayName = user.displayName 14 | state.nickname = user.nickname 15 | state.picture = user.picture 16 | }, 17 | resetUser (state) { 18 | state.isAuthenticated = false 19 | state.displayName = null 20 | state.nickname = null 21 | state.picture = null 22 | } 23 | }, 24 | actions: { 25 | async finishLogin ({ commit, dispatch }, authCode) { 26 | try { 27 | const user = await api.authenticate(authCode) 28 | commit('receiveUser', user) 29 | } catch (err) { 30 | console.error(err) 31 | dispatch('notify', { msg: `Login failed` }) 32 | } 33 | }, 34 | async getCurrentUser ({ commit }) { 35 | try { 36 | const user = await api.getCurrentUser() 37 | commit('receiveUser', user) 38 | } catch (err) { 39 | // Not logged in 40 | } 41 | }, 42 | async logout ({ commit, dispatch }) { 43 | try { 44 | await api.logout() 45 | commit('resetUser') 46 | } catch (err) { 47 | console.error(err) 48 | dispatch('notify', { msg: `Logout failed` }) 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /db/drop-column.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION drop_column( 2 | table_name VARCHAR(70), 3 | column_name VARCHAR(70) 4 | ) RETURNS VOID AS $$ 5 | BEGIN 6 | EXECUTE format('ALTER TABLE %I DROP COLUMN %s', 7 | table_name, column_name); 8 | END 9 | $$ LANGUAGE plpgsql; 10 | -------------------------------------------------------------------------------- /db/drop-table.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION drop_table( 2 | table_name VARCHAR(70) 3 | ) RETURNS VOID AS $$ 4 | BEGIN 5 | EXECUTE format('DROP TABLE %I', table_name); 6 | END 7 | $$ LANGUAGE plpgsql; 8 | -------------------------------------------------------------------------------- /db/get-schema.sql: -------------------------------------------------------------------------------- 1 | DROP FUNCTION IF EXISTS get_schema(character varying); 2 | CREATE OR REPLACE FUNCTION get_schema(table_name_param VARCHAR(70)) 3 | RETURNS TABLE ( 4 | name information_schema.sql_identifier, 5 | type information_schema.character_data, 6 | length information_schema.cardinal_number, 7 | "default" information_schema.character_data, 8 | "null" BOOLEAN, 9 | "constraint" information_schema.character_data, 10 | custom JSONB 11 | ) AS $$ 12 | SELECT 13 | cols.column_name, 14 | cols.data_type, 15 | cols.character_maximum_length, 16 | cols.column_default, 17 | cols.is_nullable::boolean, 18 | constr.constraint_type, 19 | pg_catalog.col_description(cls.oid, cols.ordinal_position::int)::jsonb 20 | FROM 21 | pg_catalog.pg_class cls, 22 | information_schema.columns cols 23 | LEFT JOIN 24 | information_schema.key_column_usage keys 25 | ON keys.column_name = cols.column_name 26 | AND keys.table_catalog = cols.table_catalog 27 | AND keys.table_schema = cols.table_schema 28 | AND keys.table_name = cols.table_name 29 | LEFT JOIN 30 | information_schema.table_constraints constr 31 | ON constr.constraint_name = keys.constraint_name 32 | WHERE 33 | cols.table_schema = 'public' AND 34 | cols.table_name = table_name_param AND 35 | cols.table_name = cls.relname; 36 | $$ LANGUAGE SQL; 37 | -------------------------------------------------------------------------------- /db/insert-column.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION insert_column( 2 | table_name VARCHAR(70), 3 | column_name VARCHAR(70) 4 | ) RETURNS VOID AS $$ 5 | BEGIN 6 | EXECUTE format('ALTER TABLE %I ADD COLUMN %s TEXT', 7 | table_name, column_name); 8 | END 9 | $$ LANGUAGE plpgsql; 10 | -------------------------------------------------------------------------------- /db/insert-table.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION insert_table( 2 | table_name VARCHAR(70) 3 | ) RETURNS VOID AS $$ 4 | BEGIN 5 | EXECUTE format('CREATE TABLE %I (id SERIAL PRIMARY KEY)', 6 | table_name); 7 | END 8 | $$ LANGUAGE plpgsql; 9 | -------------------------------------------------------------------------------- /db/projects.sql: -------------------------------------------------------------------------------- 1 | create table projects ( 2 | id SERIAL PRIMARY KEY, 3 | title TEXT, 4 | color VARCHAR(50), 5 | city VARCHAR(50) 6 | ); 7 | insert into projects (title, color, city) values ('est phasellus sit', 'Orange', 'Chaumont'); 8 | insert into projects (title, color, city) values ('augue vel accumsan', null, 'Chojnice'); 9 | insert into projects (title, color, city) values ('ridiculus', 'Purple', 'Ngou'); 10 | insert into projects (title, color, city) values ('curae mauris', 'Pink', 'Mets Parni'); 11 | insert into projects (title, color, city) values ('eu pede', 'Teal', 'Alingsås'); 12 | insert into projects (title, color, city) values ('cursus id turpis', 'Fuscia', 'Rauma'); 13 | insert into projects (title, color, city) values ('tempor', 'Crimson', 'Chumpi'); 14 | insert into projects (title, color, city) values ('vulputate', 'Crimson', 'Banraeaba Village'); 15 | insert into projects (title, color, city) values ('tellus nisi eu', null, 'Mari'); 16 | insert into projects (title, color, city) values ('fermentum donec ut', 'Pink', 'Pemba'); 17 | 18 | -------------------------------------------------------------------------------- /db/rename-column.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION rename_column( 2 | table_name VARCHAR(70), 3 | old_name VARCHAR(70), 4 | new_name VARCHAR(70) 5 | ) RETURNS VOID AS $$ 6 | BEGIN 7 | EXECUTE format('ALTER TABLE %I RENAME COLUMN %s TO %s', 8 | table_name, old_name, new_name); 9 | END 10 | $$ LANGUAGE plpgsql; 11 | -------------------------------------------------------------------------------- /db/rename-table.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION rename_table( 2 | old_name VARCHAR(70), 3 | new_name VARCHAR(70) 4 | ) RETURNS VOID AS $$ 5 | BEGIN 6 | EXECUTE format('ALTER TABLE %I RENAME TO %s', 7 | old_name, new_name); 8 | END 9 | $$ LANGUAGE plpgsql; 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | postgres: 4 | image: postgres 5 | volumes: 6 | - ./db/contacts.sql:/docker-entrypoint-initdb.d/contacts.sql 7 | - ./db/projects.sql:/docker-entrypoint-initdb.d/projects.sql 8 | ports: 9 | - "5433:5432" 10 | restart: always 11 | environment: 12 | POSTGRES_USER: postgres 13 | redis: 14 | image: redis 15 | server: 16 | depends_on: 17 | - postgres 18 | - redis 19 | image: jsixc/node-yarn-app:7 20 | volumes: 21 | - ./:/home/app 22 | ports: 23 | - "3000:3000" 24 | - "9966:9966" 25 | environment: 26 | DB_URL: "postgres://postgres:pwd@postgres:5432/postgres" 27 | PORT: 3000 28 | API_HOST: "http://localhost:3000/api" 29 | REDIS_URL: "redis://redis:6379" 30 | SESSION_KEY: secret # fake key for development environment 31 | AUTH0_CLIENT_ID: $AUTH0_CLIENT_ID 32 | AUTH0_CLIENT_SECRET: $AUTH0_CLIENT_SECRET 33 | AUTH0_CALLBACK_URL: "http://localhost:9966/callback" 34 | AUTH0_DOMAIN: $AUTH0_DOMAIN 35 | -------------------------------------------------------------------------------- /dredd.yml: -------------------------------------------------------------------------------- 1 | dry-run: null 2 | hookfiles: ./test/server/integration/_hooks.js 3 | language: nodejs 4 | sandbox: false 5 | server: node server/index.js 6 | server-wait: 3 7 | init: false 8 | custom: {} 9 | names: false 10 | only: [] 11 | reporter: [] 12 | output: [] 13 | header: [] 14 | sorted: false 15 | user: null 16 | inline-errors: false 17 | details: false 18 | method: [] 19 | color: true 20 | level: info 21 | timestamp: false 22 | silent: false 23 | path: [] 24 | hooks-worker-timeout: 5000 25 | hooks-worker-connect-timeout: 1500 26 | hooks-worker-connect-retry: 500 27 | hooks-worker-after-connect-wait: 100 28 | hooks-worker-term-timeout: 5000 29 | hooks-worker-term-retry: 500 30 | hooks-worker-handler-host: 127.0.0.1 31 | hooks-worker-handler-port: 61321 32 | config: ./dredd.yml 33 | blueprint: ./apiary.apib 34 | endpoint: 'http://127.0.0.1:3000/api' 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dataface", 3 | "description": "Build and manage data with a spreadsheet-like interface", 4 | "author": "timwis ", 5 | "private": true, 6 | "main": "server/index.js", 7 | "browser": "client/src/main.js", 8 | "scripts": { 9 | "start": "run-p start:* --npm-path /usr/local/bin/yarn", 10 | "start:server": "nodemon --watch server server/index.js", 11 | "start:client": "budo client/src/main.js:dist/build.js --live --pushstate --dir client", 12 | "build:client": "NODE_ENV=production browserify client/src/main.js -o client/dist/build.js", 13 | "test": "run-s test:*", 14 | "test:server": "NODE_ENV=test dredd", 15 | "test:client": "ava test/client/**/*.spec.js", 16 | "heroku-postbuild": "yarn run build:client" 17 | }, 18 | "engines": { 19 | "node": "7.7.x" 20 | }, 21 | "dependencies": { 22 | "auth0-lock": "^10.19.0", 23 | "axios": "^0.16.1", 24 | "bulma": "^0.4.2", 25 | "kcors": "^2.2.1", 26 | "knex": "^0.13.0", 27 | "koa": "^2.2.0", 28 | "koa-body": "^2.1.0", 29 | "koa-json-schema": "^2.0.0", 30 | "koa-passport": "^3.0.0", 31 | "koa-redis": "^3.0.0", 32 | "koa-router": "^7.2.0", 33 | "koa-session": "^5.4.0", 34 | "koa-static": "^3.0.0", 35 | "koa2-history-api-fallback": "^0.0.5", 36 | "lodash": "^4.17.4", 37 | "passport-auth0": "^0.6.0", 38 | "pg": "^6.2.4", 39 | "postinstall-build-yarn": "^0.0.2", 40 | "query-string": "^4.3.4", 41 | "shortid": "^2.2.8", 42 | "url-join": "^2.0.2", 43 | "vue": "^2.0.1", 44 | "vue-lil-context-menu": "^1.1.0", 45 | "vue-router": "^2.5.3", 46 | "vuex": "^2.3.1" 47 | }, 48 | "devDependencies": { 49 | "ava": "^0.19.1", 50 | "babel-core": "^6.24.1", 51 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 52 | "babelify": "^7.3.0", 53 | "browser-env": "^2.0.31", 54 | "browserify": "^13.0.1", 55 | "budo": "^10.0.3", 56 | "dredd": "^3.5.1", 57 | "localenvify": "^1.0.1", 58 | "nodemon": "^1.11.0", 59 | "npm-run-all": "^4.0.2", 60 | "require-extension-hooks": "^0.2.0", 61 | "require-extension-hooks-babel": "^0.1.1", 62 | "require-extension-hooks-vue": "^0.3.1", 63 | "tough-cookie": "^2.3.2", 64 | "uglify-js": "^2.5.0", 65 | "vueify": "^9.1.0", 66 | "vuenit": "^0.4.2" 67 | }, 68 | "ava": { 69 | "require": [ 70 | "babel-register", 71 | "./test/client/helpers/setup.js" 72 | ], 73 | "source": [ 74 | "client/**/*.{js,vue}", 75 | "!client/dist/**/*" 76 | ] 77 | }, 78 | "browserify": { 79 | "transform": [ 80 | "vueify", 81 | "babelify", 82 | "localenvify" 83 | ] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /server/actions.js: -------------------------------------------------------------------------------- 1 | const mapValues = require('lodash/mapValues') 2 | 3 | const queries = require('./queries') 4 | const { encodeType, decodeType } = require('./type-map') 5 | 6 | module.exports = { 7 | listSheets: queries.listSheets, 8 | getSheet: queries.getSheet, 9 | createSheet, 10 | updateSheet, 11 | deleteSheet: queries.deleteSheet, 12 | getColumns, 13 | createColumn, 14 | updateColumn, 15 | deleteColumn: queries.deleteColumn, 16 | getRows: queries.getRows, 17 | createRow, 18 | updateRow, 19 | deleteRow: queries.deleteRow 20 | } 21 | 22 | async function createSheet (db, { name }) { 23 | await queries.createSheet(db, { name }) 24 | return queries.getSheet(db, name) 25 | } 26 | 27 | async function updateSheet (db, sheetName, { name }) { 28 | const promises = [] 29 | const finalName = name || sheetName 30 | if (name) { 31 | promises.push(queries.renameSheet(db, sheetName, name)) 32 | } 33 | await Promise.all(promises) 34 | return queries.getSheet(db, finalName) 35 | } 36 | 37 | async function getColumns (db, name) { 38 | const columns = queries 39 | .getColumns(db, name) 40 | .map(_mergeCustomProps) 41 | .map(_addFriendlyType) 42 | .map(_addEditable) 43 | return columns 44 | } 45 | 46 | async function createColumn (db, sheetName, { name, type = 'text' }) { 47 | const dbType = encodeType(type) 48 | await queries.createColumn(db, sheetName, { name, dbType }) 49 | const column = await getColumn(db, sheetName, name) 50 | return column 51 | } 52 | 53 | async function getColumn (db, sheetName, columnName) { 54 | const columns = await getColumns(db, sheetName) 55 | const column = columns.find((col) => col.name === columnName) 56 | return column 57 | } 58 | 59 | async function updateColumn (db, sheetName, columnName, { name, type }) { 60 | const dbType = type ? encodeType(type) : undefined 61 | await queries.updateColumn(db, sheetName, columnName, { name, dbType }) 62 | 63 | const finalName = name || columnName 64 | const column = await getColumn(db, sheetName, finalName) 65 | return column 66 | } 67 | 68 | async function createRow (db, sheetName, payload) { 69 | const payloadNoEmptyValues = mapValues(payload, _convertEmptyToNull) 70 | const results = await queries.createRow(db, sheetName, payloadNoEmptyValues) 71 | return results.length ? results[0] : null 72 | } 73 | 74 | async function updateRow (db, sheetName, conditions, payload) { 75 | const payloadNoEmptyValues = mapValues(payload, _convertEmptyToNull) 76 | const results = await queries.updateRow(db, sheetName, conditions, payloadNoEmptyValues) 77 | return results.length ? results[0] : null 78 | } 79 | 80 | function _mergeCustomProps (column) { 81 | Object.assign(column, column.custom) 82 | delete column.custom 83 | return column 84 | } 85 | 86 | function _addFriendlyType (column) { 87 | column.type = decodeType(column.db_type) 88 | return column 89 | } 90 | 91 | function _addEditable (column) { 92 | if (column.default && column.default.startsWith('nextval')) { 93 | column.editable = false 94 | } else { 95 | column.editable = true 96 | } 97 | return column 98 | } 99 | 100 | function _convertEmptyToNull (value) { 101 | return (value === '') ? null : value 102 | } 103 | -------------------------------------------------------------------------------- /server/auth.js: -------------------------------------------------------------------------------- 1 | const passport = require('koa-passport') 2 | const Auth0Strategy = require('passport-auth0') 3 | const pick = require('lodash/pick') 4 | 5 | const { 6 | AUTH0_DOMAIN, 7 | AUTH0_CLIENT_ID, 8 | AUTH0_CLIENT_SECRET, 9 | AUTH0_CALLBACK_URL 10 | } = process.env 11 | 12 | if (AUTH0_DOMAIN && AUTH0_CLIENT_ID && AUTH0_CLIENT_SECRET && AUTH0_CALLBACK_URL) { 13 | const strategy = new Auth0Strategy({ 14 | domain: AUTH0_DOMAIN, 15 | clientID: AUTH0_CLIENT_ID, 16 | clientSecret: AUTH0_CLIENT_SECRET, 17 | callbackURL: AUTH0_CALLBACK_URL 18 | }, function (accessToken, refreshToken, extraParams, profile, done) { 19 | done(null, profile) 20 | }) 21 | 22 | passport.use(strategy) 23 | } 24 | 25 | passport.serializeUser(function (user, done) { 26 | const profile = pick(user, ['displayName', 'picture', 'nickname']) 27 | done(null, profile) 28 | }) 29 | passport.deserializeUser(function (user, done) { 30 | done(null, user) 31 | }) 32 | 33 | module.exports = passport 34 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const Koa = require('koa') 3 | const knex = require('knex') 4 | const koastatic = require('koa-static') 5 | const history = require('koa2-history-api-fallback') 6 | const session = require('koa-session') 7 | const redisStore = require('koa-redis') 8 | 9 | const router = require('./router') 10 | const passport = require('./auth') 11 | 12 | const { PORT = 3000, DB_URL, SESSION_KEY, REDIS_URL, NODE_ENV } = process.env 13 | const DEBUG = (NODE_ENV !== 'production') 14 | assert(DB_URL, 'DB_URL environment variable must be set') 15 | assert(SESSION_KEY || DEBUG, 'SESSION_KEY environment variable must be set') 16 | 17 | const app = new Koa() 18 | app.context.db = knex({ 19 | client: 'pg', 20 | connection: DB_URL, 21 | ssl: true 22 | }) 23 | 24 | // global handler 25 | app.use(async (ctx, next) => { 26 | ctx.type = 'application/json' 27 | try { 28 | await next() 29 | } catch (err) { 30 | if (DEBUG) console.error(err) 31 | const statusCode = err.status || translateErrorCode(err.code) 32 | ctx.throw(statusCode) 33 | } 34 | }) 35 | 36 | app.keys = [SESSION_KEY || ''] 37 | if (DEBUG) app.use(require('kcors')({ credentials: true })) 38 | 39 | const sessionOpts = {} 40 | if (REDIS_URL) sessionOpts.store = redisStore({ url: REDIS_URL }) 41 | app.use(session(sessionOpts, app)) 42 | app.use(passport.initialize()) 43 | app.use(passport.session()) 44 | 45 | app.use(router.routes()) 46 | app.use(router.allowedMethods()) 47 | app.use(history()) 48 | app.use(koastatic('./client')) 49 | 50 | app.listen(PORT) 51 | 52 | // Translate postgres error codes to http status codes 53 | function translateErrorCode (code) { 54 | switch (code) { 55 | case '42P07': 56 | case '42701': 57 | return 409 58 | case '42P01': 59 | case '42703': 60 | return 404 61 | case '22P01': 62 | case '22P02': 63 | case '22P03': 64 | case '22P05': 65 | return 422 66 | default: 67 | return 500 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/queries.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | 3 | module.exports = { 4 | listSheets, 5 | getSheet, 6 | createSheet, 7 | renameSheet, 8 | deleteSheet, 9 | getColumns, 10 | createColumn, 11 | updateColumn, 12 | deleteColumn, 13 | getRows, 14 | createRow, 15 | updateRow, 16 | deleteRow 17 | } 18 | 19 | function listSheets (db) { 20 | return db 21 | .select('tablename AS name') 22 | .from('pg_tables') 23 | .where('schemaname', 'public') 24 | } 25 | 26 | function getSheet (db, sheetName) { 27 | return listSheets(db) 28 | .where('tablename', sheetName) 29 | .then((rows) => { 30 | assert(rows.length > 0) 31 | return rows[0] 32 | }) 33 | } 34 | 35 | function createSheet (db, { name }) { 36 | return db.schema.createTable(name, (table) => { 37 | table.increments('id') 38 | }) 39 | } 40 | 41 | function renameSheet (db, oldName, newName) { 42 | return db.schema.renameTable(oldName, newName) 43 | } 44 | 45 | function deleteSheet (db, name) { 46 | return db.schema.dropTable(name) 47 | } 48 | 49 | function getColumns (db, sheetName) { 50 | return db.raw(` 51 | SELECT 52 | cols.column_name AS name, 53 | cols.data_type AS db_type, 54 | cols.character_maximum_length AS length, 55 | cols.column_default AS default, 56 | cols.is_nullable::boolean AS null, 57 | constr.constraint_type AS constraint, 58 | pg_catalog.col_description(cls.oid, cols.ordinal_position::int)::jsonb AS custom 59 | FROM 60 | pg_catalog.pg_class AS cls, 61 | information_schema.columns AS cols 62 | LEFT JOIN 63 | information_schema.key_column_usage AS keys 64 | ON keys.column_name = cols.column_name 65 | AND keys.table_catalog = cols.table_catalog 66 | AND keys.table_schema = cols.table_schema 67 | AND keys.table_name = cols.table_name 68 | LEFT JOIN 69 | information_schema.table_constraints AS constr 70 | ON constr.constraint_name = keys.constraint_name 71 | WHERE 72 | cols.table_schema = 'public' AND 73 | cols.table_name = ? AND 74 | cols.table_name = cls.relname; 75 | `, sheetName).then((response) => response.rows) 76 | } 77 | 78 | function createColumn (db, sheetName, { name, dbType }) { 79 | return db.schema.alterTable(sheetName, function (t) { 80 | t.specificType(name, dbType) 81 | }) 82 | } 83 | 84 | function updateColumn (db, sheetName, columnName, { name, dbType }) { 85 | return db.schema.alterTable(sheetName, function (t) { 86 | if (name) t.renameColumn(columnName, name) 87 | if (dbType) t.specificType(columnName, dbType).alter() 88 | }) 89 | } 90 | 91 | function deleteColumn (db, sheetName, columnName) { 92 | return db.schema.alterTable(sheetName, function (t) { 93 | t.dropColumn(columnName) 94 | }) 95 | } 96 | 97 | function getRows (db, sheetName) { 98 | return db 99 | .select() 100 | .from(sheetName) 101 | .orderByRaw(1) // first column 102 | } 103 | 104 | function createRow (db, sheetName, payload) { 105 | return db 106 | .insert(payload) 107 | .into(sheetName) 108 | .returning('*') 109 | } 110 | 111 | function updateRow (db, sheetName, conditions, payload) { 112 | return db(sheetName) 113 | .where(conditions) 114 | .update(payload) 115 | .returning('*') 116 | } 117 | 118 | function deleteRow (db, sheetName, conditions) { 119 | return db(sheetName) 120 | .where(conditions) 121 | .del() 122 | } 123 | -------------------------------------------------------------------------------- /server/router.js: -------------------------------------------------------------------------------- 1 | const Router = require('koa-router') 2 | const KoaBody = require('koa-body') 3 | const validate = require('koa-json-schema') 4 | 5 | const actions = require('./actions') 6 | const schemas = require('./schemas') 7 | const passport = require('./auth') 8 | 9 | const router = new Router({ prefix: '/api' }) 10 | const bodyParser = new KoaBody() 11 | 12 | const NODE_ENV = process.env.NODE_ENV 13 | 14 | module.exports = router 15 | 16 | // authenticate auth code 17 | router.post( 18 | '/authenticate', 19 | passport.authenticate('auth0'), 20 | function user (ctx) { 21 | ctx.body = ctx.state.user 22 | } 23 | ) 24 | 25 | if (NODE_ENV === 'test') { 26 | router.post( 27 | '/authenticate-test', 28 | async function user (ctx) { 29 | await ctx.login() 30 | ctx.status = 200 31 | } 32 | ) 33 | } 34 | 35 | // logout 36 | router.post( 37 | '/logout', 38 | function logout (ctx) { 39 | console.log('logging out') 40 | ctx.logout() 41 | ctx.status = 200 42 | } 43 | ) 44 | 45 | // current user 46 | router.get( 47 | '/user', 48 | requireAuth, 49 | function user (ctx) { 50 | ctx.body = ctx.state.user 51 | } 52 | ) 53 | 54 | // list sheets 55 | router.get( 56 | '/sheets', 57 | requireAuth, 58 | async function listSheets (ctx) { 59 | const sheets = await actions.listSheets(ctx.db) 60 | ctx.body = sheets 61 | } 62 | ) 63 | 64 | // create sheet 65 | router.post( 66 | '/sheets', 67 | requireAuth, 68 | bodyParser, 69 | validate(schemas.sheet.create), 70 | async function createSheet (ctx) { 71 | const payload = ctx.request.body 72 | const sheet = await actions.createSheet(ctx.db, payload) 73 | ctx.body = sheet 74 | ctx.status = 201 75 | ctx.set('Location', `/sheets/${payload.name}`) 76 | } 77 | ) 78 | 79 | // get sheet 80 | router.get( 81 | '/sheets/:sheetName', 82 | requireAuth, 83 | async function getSheet (ctx) { 84 | const sheetName = ctx.params.sheetName 85 | const sheet = await actions.getSheet(ctx.db, sheetName) 86 | ctx.body = sheet 87 | } 88 | ) 89 | 90 | // update sheet 91 | router.patch( 92 | '/sheets/:sheetName', 93 | requireAuth, 94 | bodyParser, 95 | validate(schemas.sheet.update), 96 | async function updateSheet (ctx) { 97 | const sheetName = ctx.params.sheetName 98 | const payload = ctx.request.body 99 | const sheet = await actions.updateSheet(ctx.db, sheetName, payload) 100 | ctx.body = sheet 101 | } 102 | ) 103 | 104 | // delete sheet 105 | router.delete( 106 | '/sheets/:sheetName', 107 | requireAuth, 108 | async function deleteSheet (ctx) { 109 | const sheetName = ctx.params.sheetName 110 | await actions.deleteSheet(ctx.db, sheetName) 111 | ctx.status = 204 112 | } 113 | ) 114 | 115 | // get columns 116 | router.get( 117 | '/sheets/:sheetName/columns', 118 | requireAuth, 119 | async function getColumns (ctx) { 120 | const sheetName = ctx.params.sheetName 121 | const columns = await actions.getColumns(ctx.db, sheetName) 122 | ctx.body = columns 123 | } 124 | ) 125 | 126 | // create column 127 | router.post( 128 | '/sheets/:sheetName/columns', 129 | requireAuth, 130 | bodyParser, 131 | validate(schemas.column.create), 132 | async function createColumn (ctx) { 133 | const sheetName = ctx.params.sheetName 134 | const payload = ctx.request.body 135 | const column = await actions.createColumn(ctx.db, sheetName, payload) 136 | ctx.body = column 137 | ctx.status = 201 138 | ctx.set('Location', `/sheets/${sheetName}/columns/${payload.name}`) 139 | } 140 | ) 141 | 142 | // update column 143 | router.patch( 144 | '/sheets/:sheetName/columns/:columnName', 145 | requireAuth, 146 | bodyParser, 147 | validate(schemas.column.update), 148 | async function updateColumn (ctx) { 149 | const { sheetName, columnName } = ctx.params 150 | const payload = ctx.request.body 151 | const column = await actions.updateColumn(ctx.db, sheetName, columnName, payload) 152 | ctx.body = column 153 | } 154 | ) 155 | 156 | // delete column 157 | router.delete( 158 | '/sheets/:sheetName/columns/:columnName', 159 | requireAuth, 160 | async function deleteColumn (ctx) { 161 | const { sheetName, columnName } = ctx.params 162 | await actions.deleteColumn(ctx.db, sheetName, columnName) 163 | ctx.status = 204 164 | } 165 | ) 166 | 167 | // get rows 168 | router.get( 169 | '/sheets/:sheetName/rows', 170 | requireAuth, 171 | async function getRows (ctx) { 172 | const sheetName = ctx.params.sheetName 173 | const rows = await actions.getRows(ctx.db, sheetName) 174 | ctx.body = rows 175 | } 176 | ) 177 | 178 | // create row 179 | router.post( 180 | '/sheets/:sheetName/rows', 181 | requireAuth, 182 | bodyParser, // validation handled by db 183 | async function createRow (ctx) { 184 | const sheetName = ctx.params.sheetName 185 | const payload = ctx.request.body 186 | const row = await actions.createRow(ctx.db, sheetName, payload) 187 | ctx.body = row 188 | ctx.status = 201 189 | } 190 | ) 191 | 192 | // update row 193 | router.patch( 194 | '/sheets/:sheetName/rows', // filtering handled by querystrings 195 | requireAuth, 196 | bodyParser, // validation handled by db 197 | async function updateRow (ctx) { 198 | const sheetName = ctx.params.sheetName 199 | const query = ctx.request.query 200 | const payload = ctx.request.body 201 | 202 | if (Object.keys(query).length < 1) { 203 | ctx.throw(400, 'Missing conditions') 204 | return 205 | } 206 | 207 | const row = await actions.updateRow(ctx.db, sheetName, query, payload) 208 | ctx.body = row 209 | } 210 | ) 211 | 212 | // delete row 213 | router.delete( 214 | '/sheets/:sheetName/rows', 215 | requireAuth, 216 | async function deleteRow (ctx) { 217 | const sheetName = ctx.params.sheetName 218 | const query = ctx.request.query 219 | 220 | if (Object.keys(query).length < 1) { 221 | ctx.throw(400, 'Missing conditions') 222 | return 223 | } 224 | 225 | await actions.deleteRow(ctx.db, sheetName, query) 226 | ctx.status = 204 227 | } 228 | ) 229 | 230 | function requireAuth (ctx, next) { 231 | if (ctx.isAuthenticated()) { 232 | return next() 233 | } else { 234 | ctx.status = 401 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /server/schemas.js: -------------------------------------------------------------------------------- 1 | const { typeMap } = require('./type-map') 2 | 3 | const validTypes = Object.keys(typeMap) 4 | 5 | const schemas = { 6 | sheet: { 7 | properties: { 8 | name: { 9 | type: 'string' 10 | } 11 | } 12 | }, 13 | column: { 14 | properties: { 15 | name: { 16 | type: 'string' 17 | }, 18 | type: { 19 | enum: validTypes 20 | } 21 | } 22 | } 23 | } 24 | 25 | module.exports = { 26 | sheet: { 27 | create: addRequired(schemas.sheet, ['name']), 28 | update: schemas.sheet 29 | }, 30 | column: { 31 | create: addRequired(schemas.column, ['name']), 32 | update: schemas.column 33 | } 34 | } 35 | 36 | function addRequired (source, required) { 37 | return Object.assign({}, source, { required }) 38 | } 39 | -------------------------------------------------------------------------------- /server/type-map.js: -------------------------------------------------------------------------------- 1 | const typeMap = { 2 | text: { 3 | mapsTo: 'text', 4 | mapsFrom: [ 5 | 'character', 6 | 'character varying', 7 | 'text' 8 | ] 9 | }, 10 | number: { 11 | mapsTo: 'numeric', 12 | mapsFrom: [ 13 | 'integer', 14 | 'smallint', 15 | 'bigint', 16 | 'numeric', 17 | 'decimal', 18 | 'real', 19 | 'double precision', 20 | 'money' 21 | ] 22 | }, 23 | checkbox: { 24 | mapsTo: 'boolean', 25 | mapsFrom: ['boolean'] 26 | } 27 | } 28 | 29 | module.exports = { 30 | encodeType, 31 | decodeType, 32 | typeMap 33 | } 34 | 35 | const reverseTypeMap = createReverseTypeMap(typeMap) 36 | 37 | function createReverseTypeMap (typeMap) { 38 | const reverseTypeMap = {} 39 | for (let friendlyType in typeMap) { 40 | typeMap[friendlyType].mapsFrom.forEach((pgType) => { 41 | reverseTypeMap[pgType] = friendlyType 42 | }) 43 | } 44 | return reverseTypeMap 45 | } 46 | 47 | function encodeType (friendlyType) { 48 | const matchedType = typeMap[friendlyType] 49 | return matchedType ? matchedType.mapsTo : null 50 | } 51 | 52 | function decodeType (pgType) { 53 | const matchedType = reverseTypeMap[pgType] 54 | return matchedType || null 55 | } 56 | -------------------------------------------------------------------------------- /test/client/components/grid.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { mount, trigger, mockStore } = require('vuenit') 3 | const keyCodes = require('../helpers/keycodes') 4 | 5 | const Grid = require('../../../client/src/components/grid.vue') 6 | const fixtures = { 7 | columns: require('../fixtures/schema.json'), 8 | rows: require('../fixtures/rows.json') 9 | } 10 | 11 | test('renders correct number of columns', (t) => { 12 | const $store = sampleStore() 13 | const vm = mount(Grid, { inject: { $store } }) 14 | 15 | const expected = fixtures.columns.length + 1 // extra "add" column 16 | const renderedColumns = vm.$find('th') 17 | t.is(renderedColumns.length, expected) 18 | }) 19 | 20 | test('renders column names', (t) => { 21 | const $store = sampleStore() 22 | const vm = mount(Grid, { inject: { $store } }) 23 | const renderedColumns = vm.$find('th') 24 | 25 | fixtures.columns.forEach((column, index) => { 26 | t.is(column.name, renderedColumns[index].textContent) 27 | }) 28 | }) 29 | 30 | test('renders columns with index attributes', (t) => { 31 | const $store = sampleStore() 32 | const vm = mount(Grid, { inject: { $store } }) 33 | const renderedColumns = vm.$find('th:not(.extra-column)') 34 | const HEADER_ROW = '-1' 35 | 36 | renderedColumns.forEach((column, index) => { 37 | t.is(column.getAttribute('data-row-index'), HEADER_ROW) 38 | t.is(column.getAttribute('data-column-index'), index + '') 39 | }) 40 | }) 41 | 42 | test('renders correct number of rows', (t) => { 43 | const $store = sampleStore() 44 | const vm = mount(Grid, { inject: { $store } }) 45 | 46 | const expected = fixtures.rows.length + 1 // extra "add" row 47 | const renderedRows = vm.$find('tbody tr') 48 | t.is(renderedRows.length, expected) 49 | }) 50 | 51 | test('renders row text', (t) => { 52 | const $store = sampleStore() 53 | const vm = mount(Grid, { inject: { $store } }) 54 | 55 | const renderedRows = vm.$find('tbody tr') 56 | fixtures.rows.forEach((row, rowIndex) => { 57 | fixtures.columns.forEach((column, columnIndex) => { 58 | const cell = renderedRows[rowIndex].children[columnIndex] 59 | t.is(cell.textContent, row[column.name] + '') 60 | }) 61 | }) 62 | }) 63 | 64 | test('cells are focusable', (t) => { 65 | const $store = sampleStore() 66 | const vm = mount(Grid, { inject: { $store } }) 67 | 68 | const firstCell = vm.$findOne('tbody td') 69 | firstCell.focus() 70 | t.is(document.activeElement, firstCell) 71 | }) 72 | 73 | test('navigates focus via arrow keys', (t) => { 74 | const $store = sampleStore() 75 | const vm = mount(Grid, { inject: { $store } }) 76 | 77 | const firstCell = vm.$findOne('tbody td') 78 | firstCell.focus() 79 | 80 | // right 81 | keydown('right') 82 | t.is(document.activeElement, firstCell.nextElementSibling) 83 | 84 | // left 85 | keydown('left') 86 | t.is(document.activeElement, firstCell) 87 | 88 | // down 89 | keydown('down') 90 | const secondRow = vm.$find('tbody tr')[1] 91 | const secondRowFirstCell = secondRow.children[0] 92 | t.is(document.activeElement, secondRowFirstCell) 93 | 94 | // up 95 | keydown('up') 96 | t.is(document.activeElement, firstCell) 97 | }) 98 | 99 | test.cb('pressing enter calls setEditing', (t) => { 100 | const $store = sampleStore() 101 | const vm = mount(Grid, { inject: { $store } }) 102 | 103 | $store.when('setEditing').call((context, payload) => { 104 | t.pass() 105 | t.end() 106 | }) 107 | 108 | const firstEditableCell = vm.$findOne('tbody td[contenteditable="true"]') 109 | firstEditableCell.focus() 110 | 111 | keydown('enter') 112 | }) 113 | 114 | test.cb('pressing enter on non-editable cell does not call setEditing', (t) => { 115 | const $store = sampleStore() 116 | const vm = mount(Grid, { inject: { $store } }) 117 | 118 | $store.when('setEditing').call((context, payload) => { 119 | t.fail() 120 | }) 121 | 122 | const index = fixtures.columns.findIndex((column) => column.editable === false) 123 | const firstRow = vm.$findOne('tbody tr') 124 | const cell = firstRow.children[index] 125 | 126 | cell.focus() 127 | keydown('enter') 128 | window.setTimeout(() => t.end()) 129 | }) 130 | 131 | test('pressing enter during edit mode navigates down', (t) => { 132 | const $store = sampleStore() 133 | const vm = mount(Grid, { inject: { $store } }) 134 | 135 | const firstEditableCell = vm.$findOne('tbody td[contenteditable="true"]') 136 | const columnIndex = firstEditableCell.getAttribute('data-column-index') 137 | firstEditableCell.focus() 138 | $store.state.ui.editing = true 139 | keydown('enter') 140 | 141 | const nextRow = firstEditableCell.parentNode.nextElementSibling 142 | const expectedCell = nextRow.children[columnIndex] 143 | t.is(document.activeElement, expectedCell) 144 | }) 145 | 146 | function keydown (key) { 147 | trigger(document.activeElement, 'keydown', { keyCode: keyCodes[key] }) 148 | } 149 | 150 | function sampleStore () { 151 | return mockStore({ 152 | modules: { 153 | db: { 154 | activeSheet: { 155 | columns: fixtures.columns, 156 | rows: fixtures.rows, 157 | keys: ['id'] 158 | } 159 | }, 160 | ui: { editing: false } 161 | } 162 | }) 163 | } 164 | -------------------------------------------------------------------------------- /test/client/components/notification.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | 3 | const { mount } = require('../util') 4 | const Notification = require('../../../client/src/components/notification.vue') 5 | 6 | test('displays message', (t) => { 7 | const vm = mount(Notification, { msg: 'Foo' }) 8 | const text = vm.$el.textContent.trim() 9 | t.is(text, 'Foo') 10 | }) 11 | 12 | test('sets class based on type', (t) => { 13 | const vm = mount(Notification, { type: 'warning' }) 14 | t.true(vm.$el.classList.contains('is-warning')) 15 | }) 16 | 17 | test('clicking delete triggers dismiss event', (t) => { 18 | const vm = mount(Notification) 19 | vm.$on('dismiss', () => t.pass()) 20 | const button = vm.$el.querySelector('button.delete') 21 | button.click() 22 | }) 23 | -------------------------------------------------------------------------------- /test/client/components/sheet-list.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { mount, mockStore } = require('vuenit') 3 | 4 | const SheetList = require('../../../client/src/components/sheet-list.vue') 5 | 6 | test('renders list items', (t) => { 7 | const $store = mockStore({ 8 | modules: { 9 | db: { 10 | sheets: [ { name: 'Foo' }, { name: 'Bar' } ], 11 | activeSheet: {} 12 | } 13 | } 14 | }) 15 | const vm = mount(SheetList, { inject: { $store } }) 16 | const listItems = vm.$find('ul.menu-list li') 17 | t.is(listItems.length, 2) 18 | 19 | const itemText = listItems[1].textContent.trim() 20 | t.is(itemText, 'Bar') 21 | }) 22 | 23 | test.cb('clicking delete calls removeSheet action with sheet name', (t) => { 24 | const $store = mockStore({ 25 | modules: { 26 | db: { 27 | sheets: [ { name: 'Foo' } ], 28 | activeSheet: {} 29 | } 30 | } 31 | }) 32 | $store.when('removeSheet').call((context, payload) => { 33 | t.is(payload, 'Foo') 34 | t.end() 35 | }) 36 | const vm = mount(SheetList, { inject: { $store } }) 37 | const button = vm.$findOne('ul.menu-list li .delete') 38 | button.click() 39 | }) 40 | -------------------------------------------------------------------------------- /test/client/components/sheet-name.spec.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const { mount, trigger, mockStore } = require('vuenit') 3 | 4 | const SheetName = require('../../../client/src/components/sheet-name.vue') 5 | 6 | test('renders sheet name', (t) => { 7 | const $store = mockStore({ 8 | modules: { 9 | db: { 10 | activeSheet: { name: 'users' } 11 | } 12 | } 13 | }) 14 | const vm = mount(SheetName, { inject: { $store } }) 15 | const headerText = vm.$el.textContent 16 | t.is(headerText, 'users') 17 | }) 18 | 19 | test.cb('calls save on blur', (t) => { 20 | const $store = mockStore({ 21 | modules: { 22 | db: { 23 | activeSheet: { name: 'users' } 24 | } 25 | } 26 | }) 27 | $store.when('renameSheet').call((context, payload) => { 28 | t.pass() 29 | t.end() 30 | }) 31 | const vm = mount(SheetName, { inject: { $store } }) 32 | const el = vm.$el 33 | trigger(el, 'focus') 34 | el.textContent = 'changed' 35 | trigger(el, 'blur') 36 | }) 37 | 38 | // Simulating enter works, but the blur event is not triggering 39 | // the blur event listener. This test *should* work. 40 | test.failing.cb('calls save on press enter', (t) => { 41 | const $store = mockStore({ 42 | modules: { 43 | db: { 44 | activeSheet: { name: 'users' } 45 | } 46 | } 47 | }) 48 | $store.when('renameSheet').call((context, payload) => { 49 | t.pass() 50 | t.end() 51 | }) 52 | const vm = mount(SheetName, { inject: { $store } }) 53 | const el = vm.$el 54 | trigger(el, 'focus') 55 | el.textContent = 'changed' 56 | trigger(el, 'keydown', { keyCode: 13 }) 57 | }) 58 | 59 | test.cb('does not save if name unchanged', (t) => { 60 | const $store = mockStore({ 61 | modules: { 62 | db: { 63 | activeSheet: { name: 'users' } 64 | } 65 | } 66 | }) 67 | $store.when('renameSheet').call((context, payload) => { 68 | t.fail() 69 | }) 70 | const vm = mount(SheetName, { inject: { $store } }) 71 | const el = vm.$el 72 | trigger(el, 'focus') 73 | trigger(el, 'blur') 74 | window.setTimeout(() => t.end()) // is there a better way? 75 | }) 76 | -------------------------------------------------------------------------------- /test/client/fixtures/rows.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "id": 1, 3 | "first_name": "Staci", 4 | "last_name": "Phythean", 5 | "email": "sphythean0@bloglines.com", 6 | "gender": "Female", 7 | "ip_address": "53.98.200.83" 8 | }, { 9 | "id": 2, 10 | "first_name": "Amy", 11 | "last_name": "Ramstead", 12 | "email": "aramstead1@netscape.com", 13 | "gender": "Female", 14 | "ip_address": "9.27.75.177" 15 | }, { 16 | "id": 3, 17 | "first_name": "Vinny", 18 | "last_name": "Terrelly", 19 | "email": "vterrelly2@wiley.com", 20 | "gender": "Male", 21 | "ip_address": "97.87.167.80" 22 | }, { 23 | "id": 4, 24 | "first_name": "Whit", 25 | "last_name": "Gemeau", 26 | "email": "wgemeau3@indiegogo.com", 27 | "gender": "Male", 28 | "ip_address": "184.219.254.27" 29 | }, { 30 | "id": 5, 31 | "first_name": "Kora", 32 | "last_name": "McDougall", 33 | "email": "kmcdougall4@ihg.com", 34 | "gender": "Female", 35 | "ip_address": "113.236.0.133" 36 | }, { 37 | "id": 6, 38 | "first_name": "Brig", 39 | "last_name": "Grimshaw", 40 | "email": "bgrimshaw5@harvard.edu", 41 | "gender": "Male", 42 | "ip_address": "128.233.40.109" 43 | }, { 44 | "id": 7, 45 | "first_name": "Ruby", 46 | "last_name": "Songist", 47 | "email": "rsongist6@tamu.edu", 48 | "gender": "Female", 49 | "ip_address": "101.247.12.155" 50 | }, { 51 | "id": 8, 52 | "first_name": "Tori", 53 | "last_name": "Arnold", 54 | "email": "tarnold7@forbes.com", 55 | "gender": "Female", 56 | "ip_address": "22.7.26.117" 57 | }, { 58 | "id": 9, 59 | "first_name": "Marlo", 60 | "last_name": "Fermin", 61 | "email": "mfermin8@123-reg.co.uk", 62 | "gender": "Male", 63 | "ip_address": "189.49.177.132" 64 | }, { 65 | "id": 10, 66 | "first_name": "Simonne", 67 | "last_name": "Vinden", 68 | "email": "svinden9@liveinternet.ru", 69 | "gender": "Female", 70 | "ip_address": "0.108.116.203" 71 | }] -------------------------------------------------------------------------------- /test/client/fixtures/schema.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "name": "id", 3 | "type": "integer", 4 | "length": null, 5 | "default": "nextval('contacts_id_seq'::regclass)", 6 | "null": false, 7 | "constraint": "PRIMARY KEY", 8 | "editable": false, 9 | "custom": null 10 | }, { 11 | "name": "first_name", 12 | "type": "character varying", 13 | "length": 50, 14 | "default": null, 15 | "null": true, 16 | "constraint": null, 17 | "editable": true, 18 | "custom": null 19 | }, { 20 | "name": "last_name", 21 | "type": "character varying", 22 | "length": 50, 23 | "default": null, 24 | "null": true, 25 | "constraint": null, 26 | "editable": true, 27 | "custom": null 28 | }, { 29 | "name": "email", 30 | "type": "character varying", 31 | "length": 50, 32 | "default": null, 33 | "null": true, 34 | "constraint": null, 35 | "editable": true, 36 | "custom": null 37 | }, { 38 | "name": "gender", 39 | "type": "character varying", 40 | "length": 50, 41 | "default": null, 42 | "null": true, 43 | "constraint": null, 44 | "editable": true, 45 | "custom": null 46 | }, { 47 | "name": "ip_address", 48 | "type": "character varying", 49 | "length": 20, 50 | "default": null, 51 | "null": true, 52 | "constraint": null, 53 | "editable": true, 54 | "custom": null 55 | }] 56 | -------------------------------------------------------------------------------- /test/client/helpers/keycodes.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | esc: 27, 3 | tab: 9, 4 | enter: 13, 5 | space: 32, 6 | up: 38, 7 | left: 37, 8 | right: 39, 9 | down: 40, 10 | 'delete': [8, 46] 11 | } 12 | -------------------------------------------------------------------------------- /test/client/helpers/setup.js: -------------------------------------------------------------------------------- 1 | require('browser-env')() 2 | 3 | const Vue = require('vue') 4 | Vue.config.productionTip = false 5 | 6 | const hooks = require('require-extension-hooks') 7 | hooks('vue').plugin('vue').push() 8 | hooks(['vue', 'js']).plugin('babel').push() 9 | -------------------------------------------------------------------------------- /test/client/util.js: -------------------------------------------------------------------------------- 1 | const Vue = require('vue') 2 | 3 | exports.mount = function mount (Component, propsData) { 4 | const Ctor = Vue.extend(Component) 5 | return new Ctor({ propsData }).$mount() 6 | } 7 | -------------------------------------------------------------------------------- /test/server/helpers/db.js: -------------------------------------------------------------------------------- 1 | exports.setup = async function (db) { 2 | await db.schema.raw('create schema if not exists public') 3 | await db.schema 4 | .createTable('people', function (t) { 5 | t.increments('id').primary() 6 | t.text('name') 7 | t.integer('age') 8 | }) 9 | await db('people').insert([ 10 | { name: 'John', age: 19 }, 11 | { name: 'Jane', age: 20 }, 12 | { name: 'Tina', age: 45 }, 13 | { name: 'Isaac', age: 32 } 14 | ]) 15 | } 16 | 17 | exports.teardown = function (db) { 18 | return db.schema.raw('drop schema if exists public cascade') 19 | } 20 | -------------------------------------------------------------------------------- /test/server/index.js: -------------------------------------------------------------------------------- 1 | const test = require('ava') 2 | const assert = require('assert') 3 | const knex = require('knex') 4 | const supertest = require('supertest') 5 | 6 | const createServer = require('../index') 7 | const dbHelper = require('./helpers/db') 8 | 9 | const DB_URI = process.env.DB_URI 10 | assert(DB_URI, 'DB_URI environment variable must be set') 11 | const db = knex({ client: 'pg', connection: DB_URI, ssl: true }) 12 | 13 | test.before(async function (t) { 14 | await dbHelper.teardown(db) 15 | }) 16 | 17 | test.beforeEach(async function (t) { 18 | await dbHelper.setup(db) 19 | t.context.request = supertest(createServer(db).listen()) 20 | }) 21 | 22 | test.afterEach.always(async function (t) { 23 | await dbHelper.teardown(db) 24 | }) 25 | 26 | test('list sheets', async (t) => { 27 | return t.context.request 28 | .get('/sheets') 29 | .expect(200) 30 | .expect('Content-type', 'application/json; charset=utf-8') 31 | .then((res) => { 32 | t.is(res.body.length, 1) 33 | }) 34 | }) 35 | 36 | test('create sheet', async (t) => { 37 | const payload = { name: 'tags' } 38 | return t.context.request 39 | .post('/sheets') 40 | .send(payload) 41 | .expect(201) 42 | .then((res) => { 43 | t.is(res.body.name, 'tags') 44 | }) 45 | }) 46 | 47 | test('create sheet: missing name yields validation error', async (t) => { 48 | return t.context.request 49 | .post('/sheets') 50 | .send({}) 51 | .expect(422) 52 | .then(() => t.pass()) 53 | }) 54 | 55 | test('get sheet', async (t) => { 56 | return t.context.request 57 | .get('/sheets/people') 58 | .expect(200, { name: 'people' }) 59 | .then(() => t.pass()) 60 | }) 61 | 62 | // test.failing('get sheet: invalid name returns 404', async (t) => { 63 | // return t.context.request 64 | // .get('/sheets/foo') 65 | // .expect(404) 66 | // }) 67 | 68 | test('update sheet', async (t) => { 69 | const payload = { name: 'people_renamed' } 70 | return t.context.request 71 | .patch('/sheets/people') 72 | .send(payload) 73 | .expect(200, { name: 'people_renamed' }) 74 | .then(() => t.pass()) 75 | }) 76 | -------------------------------------------------------------------------------- /test/server/integration/_hooks.js: -------------------------------------------------------------------------------- 1 | const hooks = require('hooks') // provided by dredd 2 | const assert = require('assert') 3 | const knex = require('knex') 4 | const axios = require('axios') 5 | const Cookie = require('tough-cookie').Cookie 6 | const urlJoin = require('url-join') 7 | 8 | const dbHelper = require('../helpers/db') 9 | 10 | const API_HOST = 'http://localhost:3000/api' 11 | const DB_URL = process.env.DB_URL 12 | assert(DB_URL, 'DB_URL environment variable must be set') 13 | 14 | let db, cookie 15 | 16 | hooks.beforeAll(async function (transactions, done) { 17 | db = knex({ client: 'pg', connection: DB_URL, ssl: true }) 18 | await dbHelper.teardown(db) 19 | 20 | cookie = getAuthCookie() 21 | done() 22 | }) 23 | 24 | hooks.beforeEach(async function (transaction, done) { 25 | await dbHelper.setup(db) 26 | transaction.request.headers.Cookie = cookie 27 | done() 28 | }) 29 | 30 | hooks.afterEach(async function (transaction, done) { 31 | await dbHelper.teardown(db) 32 | done() 33 | }) 34 | 35 | async function getAuthCookie () { 36 | try { 37 | const endpoint = urlJoin(API_HOST, '/authenticate-test') 38 | const response = await axios.post(endpoint) 39 | cookie = response.headers['set-cookie'] 40 | .map(Cookie.parse) 41 | .map((parsedCookie) => parsedCookie.cookieString()) 42 | .join(';') 43 | } catch (err) { 44 | console.error('Authentication failed', err) 45 | } 46 | } 47 | --------------------------------------------------------------------------------