├── .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 | 
6 |
7 | [](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 |
2 |
3 |
18 |
19 |
20 | |
27 | + |
28 |
29 |
30 |
31 |
32 | |
39 | |
40 |
41 |
42 |
48 | |
49 | |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
61 |
62 |
63 |
64 |
65 |
66 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
285 |
286 |
360 |
--------------------------------------------------------------------------------
/client/src/components/notification.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ msg }}
5 |
6 |
7 |
8 |
18 |
19 |
28 |
--------------------------------------------------------------------------------
/client/src/components/sheet-list.vue:
--------------------------------------------------------------------------------
1 |
2 |
16 |
17 |
18 |
32 |
33 |
42 |
--------------------------------------------------------------------------------
/client/src/components/sheet-name.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
31 |
32 |
41 |
--------------------------------------------------------------------------------
/client/src/components/site-nav.vue:
--------------------------------------------------------------------------------
1 |
2 |
24 |
25 |
26 |
36 |
37 |
42 |
--------------------------------------------------------------------------------
/client/src/components/type-modal.vue:
--------------------------------------------------------------------------------
1 |
2 |
40 |
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 |
2 |
3 |
18 |
19 |
20 |

21 |
22 |
23 |
24 |
25 |
41 |
42 |
47 |
--------------------------------------------------------------------------------
/client/src/pages/layout.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
40 |
41 |
42 |
56 |
--------------------------------------------------------------------------------
/client/src/pages/login-callback.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Loading...
4 |
5 |
6 |
7 |
24 |
--------------------------------------------------------------------------------
/client/src/pages/logout.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Loading...
4 |
5 |
6 |
7 |
20 |
--------------------------------------------------------------------------------
/client/src/pages/sheet.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
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 |
--------------------------------------------------------------------------------