├── .gitignore ├── LICENSE.md ├── README.md ├── main.js ├── menu.js ├── package.json └── public ├── index.html ├── src ├── clients │ ├── database.js │ ├── postgres.js │ └── valid-types │ │ └── postgres-types.js ├── components │ ├── alert.js │ ├── data-grid.js │ ├── dropdown.js │ ├── pagination.js │ └── tabs.js ├── index.js ├── layouts │ ├── database.js │ └── root.js ├── models │ ├── app.js │ ├── db.js │ └── table.js └── views │ ├── connect.js │ ├── table-list.js │ ├── table-options.js │ ├── table-rows.js │ └── table-schema.js ├── styles └── main.css └── test ├── clients └── postgres.js ├── components ├── data-grid.js ├── dropdown.js └── pagination.js ├── models ├── db.js └── table.js └── views └── connect.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public/dist 3 | public/src/config 4 | bin 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Tim Wisniewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dataface 2 | Desktop application to manage Postgres databases (**Work in Progress**) 3 | 4 | ## Background 5 | Have you ever wanted to open a database, create a table, add a few fields and some data? 6 | So has just about every other developer! So why is there no free, open source, multi-platform 7 | application with a modern-looking UI available? And if there were, it would probably be just 8 | for one flavor of database. 9 | 10 | Dataface aims to be just that, and hopefully for multiple flavors of database (at least Postgres 11 | and MySQL, maybe more). It won't be as fully-featured as some of the other tools out there, 12 | but it will provide the simple stuff simply. 13 | 14 | ## Technology 15 | Dataface is a JavaScript application using the 16 | [:steam_locomotive::train::train::train::train::train:](https://github.com/yoshuawuyts/choo/) 17 | [choo](https://github.com/yoshuawuyts/choo/) 18 | framework. It's almost entirely client-side, but since it needs to access databases, it's 19 | built as a cross-platform desktop application using [electron](http://electron.atom.io/). 20 | 21 | ## Development 22 | * Clone the repository and install dependencies via `npm install` 23 | * Run the electron app via `npm start` 24 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const {app, BrowserWindow, Menu} = require('electron') 2 | const menuTemplate = require('./menu')(app) 3 | 4 | let window 5 | 6 | app.on('ready', () => { 7 | window = new BrowserWindow({width: 1360, height: 800}) 8 | window.loadURL(`file://${__dirname}/public/index.html`) 9 | 10 | // Open dev tools 11 | if (process.env.NODE_ENV === 'development') window.webContents.openDevTools() 12 | 13 | window.on('closed', () => { window = null }) 14 | }) 15 | 16 | // Create default menu 17 | app.once('ready', () => { 18 | if (Menu.getApplicationMenu()) return 19 | const menu = Menu.buildFromTemplate(menuTemplate) 20 | Menu.setApplicationMenu(menu) 21 | }) 22 | 23 | app.on('window-all-closed', () => { 24 | if (process.platform !== 'darwin') app.quit() 25 | }) 26 | -------------------------------------------------------------------------------- /menu.js: -------------------------------------------------------------------------------- 1 | module.exports = (app) => { 2 | const template = [ 3 | { 4 | label: 'Edit', 5 | submenu: [ 6 | { 7 | label: 'Undo', 8 | accelerator: 'CmdOrCtrl+Z', 9 | role: 'undo' 10 | }, 11 | { 12 | label: 'Redo', 13 | accelerator: 'Shift+CmdOrCtrl+Z', 14 | role: 'redo' 15 | }, 16 | { 17 | type: 'separator' 18 | }, 19 | { 20 | label: 'Cut', 21 | accelerator: 'CmdOrCtrl+X', 22 | role: 'cut' 23 | }, 24 | { 25 | label: 'Copy', 26 | accelerator: 'CmdOrCtrl+C', 27 | role: 'copy' 28 | }, 29 | { 30 | label: 'Paste', 31 | accelerator: 'CmdOrCtrl+V', 32 | role: 'paste' 33 | }, 34 | { 35 | label: 'Select All', 36 | accelerator: 'CmdOrCtrl+A', 37 | role: 'selectall' 38 | } 39 | ] 40 | }, 41 | { 42 | label: 'View', 43 | submenu: [ 44 | { 45 | label: 'Reload', 46 | accelerator: 'CmdOrCtrl+R', 47 | click (item, focusedWindow) { 48 | if (focusedWindow) focusedWindow.reload() 49 | } 50 | }, 51 | { 52 | label: 'Toggle Full Screen', 53 | accelerator: (() => { 54 | return (process.platform === 'darwin') ? 'Ctrl+Command+F' : 'F11' 55 | })(), 56 | click (item, focusedWindow) { 57 | if (focusedWindow) focusedWindow.setFullScreen(!focusedWindow.isFullScreen()) 58 | } 59 | }, 60 | { 61 | label: 'Toggle Developer Tools', 62 | accelerator: (() => { 63 | return (process.platform === 'darwin') ? 'Alt+Command+I' : 'Ctrl+Shift+I' 64 | })(), 65 | click (item, focusedWindow) { 66 | if (focusedWindow) focusedWindow.toggleDevTools() 67 | } 68 | } 69 | ] 70 | }, 71 | { 72 | label: 'Window', 73 | role: 'window', 74 | submenu: [ 75 | { 76 | label: 'Minimize', 77 | accelerator: 'CmdOrCtrl+M', 78 | role: 'minimize' 79 | }, 80 | { 81 | label: 'Close', 82 | accelerator: 'CmdOrCtrl+W', 83 | role: 'close' 84 | } 85 | ] 86 | }, 87 | { 88 | label: 'Help', 89 | role: 'help', 90 | submenu: [ 91 | { 92 | label: 'Learn More', 93 | click () { 94 | shell.openExternal('http://electron.atom.io') 95 | } 96 | }, 97 | { 98 | label: 'Documentation', 99 | click () { 100 | shell.openExternal( 101 | `https://github.com/electron/electron/tree/v${process.versions.electron}/docs#readme` 102 | ) 103 | } 104 | }, 105 | { 106 | label: 'Community Discussions', 107 | click () { 108 | shell.openExternal('https://discuss.atom.io/c/electron') 109 | } 110 | }, 111 | { 112 | label: 'Search Issues', 113 | click () { 114 | shell.openExternal('https://github.com/electron/electron/issues') 115 | } 116 | } 117 | ] 118 | } 119 | ] 120 | 121 | if (process.platform === 'darwin') { 122 | template.unshift({ 123 | label: 'Electron', 124 | submenu: [ 125 | { 126 | label: 'About Electron', 127 | role: 'about' 128 | }, 129 | { 130 | type: 'separator' 131 | }, 132 | { 133 | label: 'Services', 134 | role: 'services', 135 | submenu: [] 136 | }, 137 | { 138 | type: 'separator' 139 | }, 140 | { 141 | label: 'Hide Electron', 142 | accelerator: 'Command+H', 143 | role: 'hide' 144 | }, 145 | { 146 | label: 'Hide Others', 147 | accelerator: 'Command+Alt+H', 148 | role: 'hideothers' 149 | }, 150 | { 151 | label: 'Show All', 152 | role: 'unhide' 153 | }, 154 | { 155 | type: 'separator' 156 | }, 157 | { 158 | label: 'Quit', 159 | accelerator: 'Command+Q', 160 | click () { app.quit() } 161 | } 162 | ] 163 | }) 164 | template[3].submenu.push( 165 | { 166 | type: 'separator' 167 | }, 168 | { 169 | label: 'Bring All to Front', 170 | role: 'front' 171 | } 172 | ) 173 | } 174 | 175 | return template 176 | } 177 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dataface", 3 | "version": "0.4.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "start": "electron .", 8 | "test": "tape public/test/*.js public/test/**/*.js | tap-spec", 9 | "build": "electron-packager ./ --out=bin --all" 10 | }, 11 | "keywords": [], 12 | "author": "timwis ", 13 | "license": "MIT", 14 | "dependencies": { 15 | "bootstrap": "^4.0.0-alpha.2", 16 | "choo": "^3.0.1", 17 | "font-awesome": "^4.6.3", 18 | "get-form-data": "^1.2.5", 19 | "knex": "^0.11.7", 20 | "lodash": "^4.13.1", 21 | "notie": "^3.2.0", 22 | "pg": "^4.5.6" 23 | }, 24 | "devDependencies": { 25 | "browserify": "^13.0.1", 26 | "electron-packager": "^7.1.0", 27 | "electron-prebuilt": "^1.1.1", 28 | "jsdom": "^9.2.1", 29 | "jsdom-global": "^2.0.0", 30 | "lite-server": "^2.2.0", 31 | "tap-spec": "^4.1.1", 32 | "tape": "^4.5.1", 33 | "watchify": "^3.7.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dataface 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/src/clients/database.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Generic database client 3 | * Like a "base class" for database clients. Some methods will 4 | * work across all clients (via Knex.js) and do not need to be 5 | * extended. Some methods do not have Knex.js support and thus 6 | * must be hard-coded for each client. 7 | */ 8 | const knex = require('knex') 9 | 10 | module.exports = class Databse { 11 | constructor () { 12 | this.connection = knex({}) 13 | } 14 | 15 | getValidTypes () { 16 | throw new Error('getValidTypes method not implemented') 17 | } 18 | 19 | getTables () { 20 | throw new Error('getTables method not implemented') 21 | } 22 | 23 | createTable (table) { 24 | return this.connection.schema.createTable(table, () => {}) 25 | } 26 | 27 | deleteTable (table) { 28 | return this.connection.schema.dropTable(table) 29 | } 30 | 31 | getSchema (table) { 32 | return this.connection(table).columnInfo() 33 | } 34 | 35 | getPrimaryKey (table) { 36 | throw new Error('getPrimaryKey method not implemented') 37 | } 38 | 39 | insertColumn (table, payload) { 40 | const bindings = Object.assign({}, payload, {table}) 41 | const sql = [` 42 | ALTER TABLE :table: 43 | ADD COLUMN :name: ${payload.type}` 44 | ] 45 | if (payload.maxLength) sql.push(`(${+payload.maxLength})`) 46 | if (payload.nullable === 'false') sql.push('NOT NULL') 47 | if (payload.defaultValue) sql.push('DEFAULT :defaultValue') 48 | // defaultValue doesn't seem to work as a binding, so this is a hacky workaround 49 | return this.connection.raw(this.connection.raw(sql.join(' '), bindings).toString()) 50 | } 51 | 52 | updateColumn (table, column, changes) { 53 | throw new Error('updateColumn method not implemented') 54 | } 55 | 56 | renameColumn (table, column, newName) { 57 | const query = this.connection.schema.table(table, (t) => { 58 | t.renameColumn(column, newName) 59 | }) 60 | return query 61 | } 62 | 63 | deleteColumn (table, column) { 64 | return this.connection.schema.table(table, (t) => { 65 | t.dropColumn(column) 66 | }) 67 | } 68 | 69 | getRows (table, limit, offset) { 70 | return this.connection.select().from(table).limit(limit).offset(offset) 71 | } 72 | 73 | getRowCount (table) { 74 | return this.connection.count().from(table) 75 | .then((results) => results.length > 0 ? results[0].count : null) 76 | } 77 | 78 | updateRow (table, payload, conditions) { 79 | return this.connection(table).where(conditions).update(payload).limit(1) 80 | } 81 | 82 | insertRow (table, payload) { 83 | return this.connection(table).insert(payload, '*') 84 | } 85 | 86 | deleteRow (table, conditions) { 87 | return this.connection(table).where(conditions).del().limit(1) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /public/src/clients/postgres.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Postgres database client 3 | * Extends the base client, database.js to provide postgres-specific 4 | * queries for certain methods. Inherits the rest. 5 | */ 6 | const knex = require('knex') 7 | 8 | const Database = require('./database') 9 | const validTypes = require('./valid-types/postgres-types') 10 | 11 | module.exports = class Postgres extends Database { 12 | constructor (config) { 13 | super(config) 14 | this.connection = knex({ client: 'pg', connection: config }) 15 | } 16 | 17 | getValidTypes () { 18 | return validTypes 19 | } 20 | 21 | getTables () { 22 | return this.connection.raw(` 23 | SELECT tablename 24 | FROM pg_catalog.pg_tables 25 | WHERE schemaname='public' 26 | ORDER BY tablename`) 27 | } 28 | 29 | getPrimaryKey (table) { 30 | return this.connection.raw(` 31 | SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS data_type 32 | FROM pg_index i 33 | JOIN pg_attribute a 34 | ON a.attrelid = i.indrelid 35 | AND a.attnum = ANY(i.indkey) 36 | WHERE i.indrelid = ?::regclass 37 | AND i.indisprimary`, table) 38 | .then((results) => results.rows.length > 0 ? results.rows[0].attname : null) 39 | } 40 | 41 | updateColumn (table, column, changes) { 42 | const alterations = [] 43 | 44 | if (changes.type) { 45 | alterations.push(`ALTER COLUMN :column: TYPE ${changes.type} ${changes.maxLength ? `(${+changes.maxLength})` : ''}`) 46 | } 47 | 48 | if (changes.defaultValue !== undefined) { 49 | if (changes.defaultValue === '') alterations.push('ALTER COLUMN :column: DROP DEFAULT') 50 | else alterations.push('ALTER COLUMN :column: SET DEFAULT :defaultValue') 51 | } 52 | 53 | if (changes.nullable === 'false') { 54 | alterations.push('ALTER COLUMN :column: SET NOT NULL') 55 | } else if (changes.nullable === 'true') { 56 | alterations.push('ALTER COLUMN :column: DROP NOT NULL') 57 | } 58 | 59 | if (alterations.length) { 60 | const sql = 'ALTER TABLE :table: ' + alterations.join(', ') 61 | const bindings = Object.assign({}, changes, { table, column }) 62 | // defaultValue doesn't seem to work as a binding, so this is a hacky workaround 63 | return this.connection.raw(this.connection.raw(sql, bindings).toString()) 64 | } else { 65 | // noop 66 | return Promise.resolve() 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /public/src/clients/valid-types/postgres-types.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | 'bigint', 3 | 'bigserial', 4 | 'bit', 5 | 'bit varying', 6 | 'bool', 7 | 'boolean', 8 | 'box', 9 | 'bytea', 10 | 'char', 11 | 'character', 12 | 'character varying', 13 | 'cidr', 14 | 'circle', 15 | 'date', 16 | 'decimal', 17 | 'double precision', 18 | 'float4', 19 | 'float8', 20 | 'inet', 21 | 'int, int4', 22 | 'int2', 23 | 'int8', 24 | 'integer', 25 | 'interval', 26 | 'json', 27 | 'line', 28 | 'lseg', 29 | 'macaddr', 30 | 'money', 31 | 'numeric', 32 | 'path', 33 | 'point', 34 | 'polygon', 35 | 'real', 36 | 'serial', 37 | 'serial2', 38 | 'serial4', 39 | 'serial8', 40 | 'smallint', 41 | 'smallserial', 42 | 'text', 43 | 'time without time zone', 44 | 'time with time zone', 45 | 'time', 46 | 'timestamp without time zone', 47 | 'timestamp with time zone', 48 | 'timestamp', 49 | 'timestamptz', 50 | 'timetz', 51 | 'tsquery', 52 | 'tsvector', 53 | 'txid_snapshot', 54 | 'uuid', 55 | 'varbit', 56 | 'varchar', 57 | 'xml' 58 | ] 59 | -------------------------------------------------------------------------------- /public/src/components/alert.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const noop = () => {} 3 | 4 | /** 5 | * Creates an alert component 6 | * @param {string} msg Message to display 7 | * @param {string} [type] Bootstrap conext class [success|warning|danger|info] default: danger 8 | * @callback [onDismiss] Function to call when alert is dismissed 9 | */ 10 | module.exports = ({ msg, type = 'danger', onDismiss = {} }) => { 11 | return html` 12 | ` 21 | } 22 | -------------------------------------------------------------------------------- /public/src/components/data-grid.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const {zipObject} = require('lodash') 3 | const noop = () => {} 4 | 5 | /** 6 | * Create an editable data grid component 7 | * @param {Object[]|string[]} columns Column names as strings or column objects 8 | * @param {Object[]} rows Row objects containing each column as a property 9 | * @param {number} selectedRowIndex Index of selected (currently editing) row in rows array 10 | * @callback [onSelectRow] Function to execute when user selects a row (index) 11 | * @callback [onUpdateRow] Function to execute when a row is updated by the user (index, payload) 12 | * @callback [onInsertRow] Function to execute when a new row is saved by the user (payload) 13 | * @callback [onDeleteRow] Function to execute when user deletes a row (index) 14 | */ 15 | module.exports = ({ columns, rows, selectedRowIndex, 16 | onSelectRow = noop, onUpdateRow = noop, onInsertRow = noop, onDeleteRow = noop }) => { 17 | const newRowIndex = rows.length // (highest index plus one - a bit hacky) 18 | const changesObserved = {} // stores any changes made to the selected row 19 | 20 | return html` 21 |
22 | 23 | 24 | ${columns.length ? html` 25 | 26 | 27 | 28 | ${columns.map((column) => html``)} 29 | 30 | ` : ''} 31 | 32 | 33 | ${rows.length ? html` 34 | ${rows.map((row, index) => selectedRowIndex === index 35 | ? editableRow(index, row) 36 | : displayRow(index, row))}` : ''} 37 | ${columns.length ? html` 38 | ${selectedRowIndex === newRowIndex 39 | ? editableRow(newRowIndex) 40 | : blankRow(newRowIndex)}` : ''} 41 | 42 | 43 |
${column.title || column.key || column}
44 |
` 45 | 46 | function editableRow (index, rowData = {}) { 47 | return html` 48 | 49 | ${saveEditButton(index)} 50 | ${deleteButton(index)} 51 | ${columns.map((column) => html` 52 | onInput(e.target, column)}>${rowData[column.key || column]}`)} 54 | ` 55 | } 56 | 57 | function displayRow (index, rowData) { 58 | return html` 59 | 60 | ${editButton(index)} 61 | ${deleteButton(index)} 62 | ${columns.map((column) => html` 63 | ${rowData[column.key || column]}`)} 64 | ` 65 | } 66 | 67 | function blankRow (index) { 68 | return html` 69 | 70 | onSelectRow(index)}> 72 | Click to add a new row 73 | 74 | ` 75 | } 76 | 77 | function onInput (el, column) { 78 | changesObserved[column.key || column] = el.innerText 79 | if (typeof column.validate === 'function') { 80 | const row = el.closest('tr') 81 | const rowData = getRowData(row) 82 | const isValid = column.validate(el.innerText, rowData) 83 | el.classList.toggle('invalid', !isValid) 84 | } 85 | } 86 | 87 | function editButton (index) { 88 | return html` 89 | { 90 | onSelectRow(index) 91 | }}>` 92 | } 93 | 94 | function saveEditButton (index) { 95 | return html` 96 | { 97 | const row = e.target.parentNode.parentNode // closest('tr') is preferable but doesn't work in jsdom 98 | const isNewRow = index >= rows.length 99 | if (isRowValid(row)) { 100 | onSelectRow(null) 101 | isNewRow ? onInsertRow(changesObserved) : onUpdateRow(index, changesObserved) 102 | } else { 103 | console.warn('Cannot save because of validation errors') 104 | } 105 | }}>` 106 | } 107 | 108 | function deleteButton (index) { 109 | return html` 110 | { 111 | const isNewRow = index >= rows.length 112 | isNewRow ? onSelectRow(null) : onDeleteRow(index) 113 | }}` 114 | } 115 | 116 | function getRowData (row) { 117 | const rowValues = Array.from(row.children).slice(2).map((child) => child.innerText) // first 2 columns are controls 118 | const columnKeys = columns.map((column) => column.key || column) 119 | return zipObject(columnKeys, rowValues) 120 | } 121 | 122 | // Would be nice to merge this with getRowData but the validation callbacks expect [{key: val}, {key: val}] 123 | function getRowCells (row) { 124 | const rowCells = Array.from(row.children).slice(2) // first 2 columns are controls 125 | const columnKeys = columns.map((column) => column.key || column) 126 | return zipObject(columnKeys, rowCells) 127 | } 128 | 129 | // Confirm every column is valid. Can't just check for .invalid classes because 130 | // user may not have typed anything in a column at all, which wouldn't have run validation 131 | function isRowValid (row) { 132 | const rowData = getRowData(row) 133 | const rowCells = getRowCells(row) 134 | const validationResults = [] 135 | 136 | return columns.filter((column) => typeof column.validate === 'function') 137 | .map((column) => { 138 | const columnKey = column.key || column 139 | const isValid = column.validate(rowData[columnKey], rowData) 140 | rowCells[columnKey].classList.toggle('invalid', !isValid) 141 | return isValid 142 | }) 143 | .every((isValid) => isValid) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /public/src/components/dropdown.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | /** 4 | * Create a element 8 | */ 9 | module.exports = ({ items = [], selected, attributes = {} }) => { 10 | return html` 11 | ` 24 | } 25 | -------------------------------------------------------------------------------- /public/src/components/pagination.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const noop = () => {} 3 | 4 | /** 5 | * Create a pagination component 6 | * @param {number} offset Current pagination offset 7 | * @param {number} limit Pagination limit 8 | * @param {number} total Number of total records 9 | * @callback [onPaginate] Function to execute when a paginate button is clicked (newOffset) 10 | */ 11 | module.exports = ({ offset, limit, total, onPaginate = noop }) => { 12 | return html` 13 | ` 19 | 20 | function arrowButton (dir) { 21 | const label = dir === 'previous' ? 'Previous' : 'Next' 22 | 23 | const newOffset = dir === 'previous' 24 | ? offset - limit 25 | : offset + limit 26 | 27 | const disabled = newOffset < 0 || newOffset >= total 28 | 29 | return html` 30 |
  • 31 | { 32 | if (!disabled) onPaginate(newOffset) 33 | e.preventDefault() 34 | }}> 35 | ${label} 36 | 37 |
  • ` 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/src/components/tabs.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | /** 4 | * Create a tabs component 5 | * @param {[Object]} items Tab item objects with key, label, and href properties 6 | * @param {string} activeKey The key from `items` to show as active 7 | */ 8 | module.exports = (items, activeKey) => { 9 | return html` 10 | ` 16 | } 17 | -------------------------------------------------------------------------------- /public/src/index.js: -------------------------------------------------------------------------------- 1 | const choo = require('choo') 2 | 3 | const layouts = { 4 | root: require('./layouts/root'), 5 | database: require('./layouts/database') 6 | } 7 | const dbLayout = (view, id) => layouts.root(layouts.database(view, id)) 8 | const views = { 9 | connect: require('./views/connect'), 10 | tableRows: require('./views/table-rows'), 11 | tableSchema: require('./views/table-schema'), 12 | tableOptions: require('./views/table-options') 13 | } 14 | 15 | const app = choo({ 16 | onError: (err, state, createSend) => { 17 | console.error(err) 18 | const send = createSend('error') 19 | send('app:alert', err) 20 | } 21 | }) 22 | 23 | app.model(require('./models/app')) 24 | app.model(require('./models/db')) 25 | app.model(require('./models/table')) 26 | 27 | app.router((route) => [ 28 | route('/', layouts.root(views.connect)), 29 | route('/tables', dbLayout(), [ 30 | route('/:name', dbLayout(views.tableRows, 'rows'), [ 31 | route('/schema', dbLayout(views.tableSchema, 'schema')), 32 | route('/options', dbLayout(views.tableOptions, 'options')) 33 | ]) 34 | ]) 35 | ]) 36 | 37 | const tree = app.start({hash: true}) 38 | document.body.appendChild(tree) 39 | -------------------------------------------------------------------------------- /public/src/layouts/database.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | const Tabs = require('../components/tabs') 4 | const tableList = require('../views/table-list') 5 | 6 | module.exports = (view, id) => (state, prev, send) => { 7 | const table = state.params.name 8 | const tabItems = [ 9 | { key: 'rows', label: 'Rows', href: `#tables/${table}` }, 10 | { key: 'schema', label: 'Schema', href: `#tables/${table}/schema` }, 11 | { key: 'options', label: 'Options', href: `#tables/${table}/options` } 12 | ] 13 | 14 | return html` 15 |
    16 |

    ${state.db.config.database}

    17 |
    18 |
    19 | ${tableList(state, prev, send)} 20 |
    21 | ${table ? html` 22 |
    23 |
    24 | ${Tabs(tabItems, id)} 25 |
    26 |
    27 | ${view(state, prev, send)} 28 |
    29 |
    ` : ''} 30 |
    31 |
    ` 32 | } 33 | -------------------------------------------------------------------------------- /public/src/layouts/root.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | const Alert = require('../components/alert') 4 | 5 | module.exports = (view) => (state, prev, send) => { 6 | const alert = state.app.alert.msg ? Alert({ 7 | msg: state.app.alert.msg, 8 | onDismiss: (e) => send('app:clearAlert') 9 | }) : '' 10 | return html` 11 |
    12 | ${alert} 13 | 24 |
    25 | ${view(state, prev, send)} 26 |
    27 |
    ` 28 | } 29 | -------------------------------------------------------------------------------- /public/src/models/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | namespace: 'app', 3 | state: { 4 | alert: {} 5 | }, 6 | reducers: { 7 | setAlert: (data, state) => { 8 | return { alert: data } 9 | }, 10 | clearAlert: (data, state) => { 11 | return { alert: {} } 12 | } 13 | }, 14 | effects: { 15 | alert: (data, state, send, done) => { 16 | const id = Math.random() // used to ensure timeout removes correct alert 17 | const duration = data.duration || 5000 18 | send('app:setAlert', { msg: data.msg, _id: id }, () => { 19 | window.setTimeout(() => { 20 | if (state.alert._id && state.alert._id === id) { 21 | send('app:clearAlert', null, done) 22 | } 23 | }, duration) 24 | }) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /public/src/models/db.js: -------------------------------------------------------------------------------- 1 | const clients = { 2 | pg: require('../clients/postgres') 3 | } 4 | 5 | const model = { 6 | namespace: 'db', 7 | state: { 8 | config: { 9 | clientType: '', 10 | host: '', 11 | user: '', 12 | password: '', 13 | database: '', 14 | ssl: false 15 | }, 16 | client: null, // stored in state because knex.js creates connection pools 17 | tables: [], 18 | fetchedTables: false, 19 | isCreatingTable: false 20 | }, 21 | reducers: { 22 | receiveConnection: (data, state) => { 23 | return Object.assign(data, { 24 | tables: [], 25 | fetchedTables: false 26 | }) 27 | }, 28 | receiveTableList: (data, state) => { 29 | return { tables: data.payload, fetchedTables: true } 30 | }, 31 | receiveNewTable: (data, state) => { 32 | const newTables = [...state.tables, data.name] 33 | return { tables: newTables } 34 | }, 35 | setCreatingTable: (data, state) => { 36 | return { isCreatingTable: data.value } 37 | }, 38 | receiveTableDeletion: (data, state) => { 39 | const newTables = [ 40 | ...state.tables.slice(0, data.index), 41 | ...state.tables.slice(data.index + 1) 42 | ] 43 | return { tables: newTables } 44 | } 45 | }, 46 | effects: { 47 | connect: (data, state, send, done) => { 48 | const Client = clients[data.payload.clientType] 49 | const client = new Client(data.payload) 50 | send('db:receiveConnection', { client, config: data.payload }, done) 51 | }, 52 | getTableList: (data, state, send, done) => { 53 | state.client.getTables() 54 | .then((response) => { 55 | const tables = response.rows.map((table) => table.tablename) 56 | send('db:receiveTableList', { payload: tables }, done) 57 | }) 58 | .catch((err) => { 59 | // Since we can't detect failure in connect, we'll do it here 60 | // and reset the connection on an error 61 | // https://github.com/tgriesser/knex/issues/1542 62 | send('db:receiveConnection', { client: null }, () => { 63 | done({ msg: 'Error fetching tables' }) 64 | }) 65 | }) 66 | }, 67 | createTable: (data, state, send, done) => { 68 | const name = data.name 69 | state.client.createTable(name) 70 | .then((response) => { 71 | send('db:receiveNewTable', {name}, done) 72 | }) 73 | .catch((err) => done({ msg: 'Error creating table' })) 74 | }, 75 | deleteTable: (data, state, send, done) => { 76 | const name = data.name 77 | const index = state.tables.findIndex((table) => table === name) 78 | state.client.deleteTable(name) 79 | .then((response) => { 80 | send('db:receiveTableDeletion', {index}, done) 81 | window.location.hash = 'tables' 82 | }) 83 | .catch((err) => done({ msg: 'Error deleting table' })) 84 | } 85 | } 86 | } 87 | 88 | // Allow specifying default connection details in a file for development 89 | if (process.env.NODE_ENV === 'development') { 90 | const initialCredentials = require('../config') 91 | model.state.config = initialCredentials 92 | model.state.client = new clients.pg(initialCredentials) // eslint-disable-line 93 | } 94 | 95 | module.exports = model 96 | -------------------------------------------------------------------------------- /public/src/models/table.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | namespace: 'table', 3 | state: { 4 | name: '', 5 | primaryKey: '', 6 | columns: [], 7 | rows: [], 8 | selectedRowIndex: null, 9 | rowCount: 0, 10 | limit: 50, 11 | offset: 0 12 | }, 13 | reducers: { 14 | receiveTable: (data, state) => { 15 | return Object.assign({}, data.payload, { selectedRowIndex: null, offset: 0 }) 16 | }, 17 | receiveRows: (data, state) => { 18 | return { rows: data.rows } 19 | }, 20 | setOffset: (data, state) => { 21 | return { offset: data.newOffset } 22 | }, 23 | setSelectedRow: (data, state) => { 24 | return { selectedRowIndex: data.index } 25 | }, 26 | receiveRowUpdate: (data, state) => { 27 | const newRows = state.rows.slice() 28 | Object.assign(newRows[data.index], data.payload) 29 | return { rows: newRows } 30 | }, 31 | receiveRowDeletion: (data, state) => { 32 | const newRows = [ 33 | ...state.rows.slice(0, data.index), 34 | ...state.rows.slice(data.index + 1) 35 | ] 36 | return { rows: newRows, rowCount: state.rowCount - 1 } 37 | }, 38 | receiveNewRow: (data, state) => { 39 | const newRows = [ ...state.rows, data.payload ] 40 | return { rows: newRows, rowCount: state.rowCount + 1 } 41 | }, 42 | receiveColumnUpdate: (data, state) => { 43 | const newColumns = state.columns.slice() 44 | Object.assign(newColumns[data.index], data.payload) 45 | return { columns: newColumns } 46 | }, 47 | receiveNewColumn: (data, state) => { 48 | const newColumns = [ ...state.columns, data.payload ] 49 | return { columns: newColumns } 50 | }, 51 | receiveColumnDeletion: (data, state) => { 52 | const newColumns = [ 53 | ...state.columns.slice(0, data.index), 54 | ...state.columns.slice(data.index + 1) 55 | ] 56 | return { columns: newColumns } 57 | } 58 | }, 59 | effects: { 60 | getTable: (data, state, send, done) => { 61 | const { client, table } = data 62 | Promise.all([ 63 | client.getSchema(table), 64 | client.getPrimaryKey(table), 65 | client.getRows(table, state.limit, 0), 66 | client.getRowCount(table) 67 | ]) 68 | .then((results) => { 69 | const [columnsResults, primaryKey, rows, rowCount] = results 70 | const payload = { 71 | primaryKey, 72 | columns: [], 73 | rows, 74 | name: table, 75 | rowCount: +rowCount 76 | } 77 | 78 | // Map columns object to an array 79 | for (let column in columnsResults) { 80 | columnsResults[column].name = column 81 | payload.columns.push(columnsResults[column]) 82 | } 83 | return payload 84 | }) 85 | .then((payload) => send('table:receiveTable', {payload}, done)) 86 | .catch((err) => { 87 | done({ msg: `Error reading table ${table}` }) 88 | window.location.hash = 'tables' // prevent infinite loop 89 | }) 90 | }, 91 | paginate: (data, state, send, done) => { 92 | const { client, table, newOffset } = data 93 | client.getRows(table, state.limit, newOffset) 94 | .then((rows) => { 95 | send('table:receiveRows', {rows}, () => { 96 | send('table:setOffset', {newOffset}, done) 97 | }) 98 | }) 99 | .catch((err) => done({ msg: `Error reading rows at offset ${newOffset}` })) 100 | }, 101 | updateRow: (data, state, send, done) => { 102 | const { client, index, payload } = data 103 | if (Object.keys(payload).length) { 104 | const row = state.rows[index] 105 | const primaryKey = state.primaryKey 106 | 107 | // If primary key exists, use it as the condition; 108 | // otherwise, use every value of row 109 | const conditions = primaryKey 110 | ? {[primaryKey]: row[primaryKey]} 111 | : row 112 | 113 | client.updateRow(state.name, payload, conditions) 114 | .then((results) => { 115 | if (results > 0) send('table:receiveRowUpdate', { index, payload }, done) 116 | }) 117 | .catch((err) => done({ msg: `Error updating row ${index}` })) 118 | } 119 | }, 120 | insertRow: (data, state, send, done) => { 121 | const { client, payload } = data 122 | if (Object.keys(payload).length) { 123 | client.insertRow(state.name, payload) 124 | .then((results) => { 125 | if (results.length > 0) send('table:receiveNewRow', { payload: results[0] }, done) 126 | }) 127 | .catch((err) => done({ msg: 'Error inserting row' })) 128 | } 129 | }, 130 | deleteRow: (data, state, send, done) => { 131 | const { client, index } = data 132 | const row = state.rows[index] 133 | const primaryKey = state.primaryKey 134 | 135 | // If primary key exists, use it as the condition; 136 | // otherwise, use every value of row 137 | const conditions = primaryKey 138 | ? {[primaryKey]: row[primaryKey]} 139 | : row 140 | 141 | client.deleteRow(state.name, conditions) 142 | .then((deletedCount) => { 143 | if (deletedCount > 0) send('table:receiveRowDeletion', {index}, done) 144 | }) 145 | .catch((err) => done({ msg: `Error deleting row ${index}` })) 146 | }, 147 | updateColumn: (data, state, send, done) => { 148 | const { client, index, payload } = data 149 | if (Object.keys(payload).length) { 150 | const column = state.columns[index].name 151 | const query = client.updateColumn(state.name, column, payload) 152 | 153 | // Column renaming should be run *after* alterations because alterations use original name 154 | if (payload.name) { 155 | query.then(() => client.renameColumn(state.name, column, payload.name)) 156 | } 157 | 158 | query.then((results) => { 159 | send('table:receiveColumnUpdate', { index, payload }, done) 160 | }) 161 | .catch((err) => done({ msg: `Error updating column ${column}` })) 162 | } 163 | }, 164 | insertColumn: (data, state, send, done) => { 165 | const { client, payload } = data 166 | if (Object.keys(payload).length) { 167 | client.insertColumn(state.name, payload) 168 | .then((results) => client.getSchema(state.name)) 169 | .then((columnsResults) => { 170 | const newColumn = columnsResults[payload.name] || {} 171 | newColumn.name = payload.name // not included by default in knex column object 172 | send('table:receiveNewColumn', { payload: newColumn }, done) 173 | }) 174 | .catch((err) => done({ msg: 'Error inserting column' })) 175 | } 176 | }, 177 | deleteColumn: (data, state, send, done) => { 178 | const { client, index } = data 179 | const column = state.columns[index].name 180 | client.deleteColumn(state.name, column) 181 | .then((results) => { 182 | send('table:receiveColumnDeletion', {index}, done) 183 | }) 184 | .catch((err) => done({ msg: `Error deleting column ${column}` })) 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /public/src/views/connect.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const getFormData = require('get-form-data') 3 | 4 | const Dropdown = require('../components/dropdown') 5 | 6 | module.exports = (state, prev, send) => { 7 | const onSubmit = (e) => { 8 | const payload = getFormData(e.target) 9 | send('db:connect', {payload}) 10 | window.location.hash = 'tables' 11 | e.preventDefault() 12 | } 13 | 14 | const config = state.db.config 15 | 16 | const dropdown = Dropdown({ 17 | items: [{ value: 'pg', label: 'Postgres' }], 18 | selected: config.clientType, 19 | attributes: { id: 'clientType', class: 'form-control' } 20 | }) 21 | 22 | return html` 23 |
    24 |

    Connect

    25 |
    26 |
    27 | 28 | ${dropdown} 29 |
    30 | 31 |
    32 | 33 | 34 |
    35 | 36 |
    37 | 38 | 39 |
    40 | 41 |
    42 | 43 | 44 |
    45 | 46 |
    47 | 48 | 49 |
    50 | 51 |
    52 | 55 |
    56 | 57 | 58 |
    59 |
    ` 60 | } 61 | -------------------------------------------------------------------------------- /public/src/views/table-list.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | 3 | module.exports = (state, prev, send) => { 4 | if (state.db.client && !state.db.fetchedTables) { 5 | send('db:getTableList') 6 | } 7 | 8 | return html` 9 |
    10 | ${state.db.tables.map(tableItem)} 11 | ${addTableItem()} 12 |
    ` 13 | 14 | function tableItem (name, index) { 15 | const isActive = state.params.name === name 16 | return html` 17 | 18 | ${name} 19 | ` 20 | } 21 | 22 | function addTableItem () { 23 | const tableName = html`` 24 | return state.db.isCreatingTable 25 | ? html` 26 | 38 | 39 | 40 | 41 | 42 | ` 43 | : html` 44 | ` 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/src/views/table-options.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const notie = require('notie') 3 | 4 | module.exports = (state, prev, send) => { 5 | const table = state.params.name 6 | return html` 7 |
    8 | 15 |
    ` 16 | } 17 | -------------------------------------------------------------------------------- /public/src/views/table-rows.js: -------------------------------------------------------------------------------- 1 | const html = require('choo/html') 2 | const notie = require('notie') 3 | 4 | const DataGrid = require('../components/data-grid') 5 | const Pagination = require('../components/pagination') 6 | 7 | module.exports = (state, prev, send) => { 8 | const table = state.params.name 9 | const client = state.db.client 10 | if (table && client && state.table.name !== table) { 11 | send('table:getTable', { client, table }) 12 | } 13 | 14 | const { columns, rows, primaryKey, selectedRowIndex, offset, limit, rowCount } = state.table 15 | const columnsObject = columns.map((column) => ({ key: column.name, editable: (column.name !== primaryKey) })) 16 | 17 | const dataGrid = DataGrid({ 18 | columns: columnsObject, 19 | rows, 20 | selectedRowIndex, 21 | onSelectRow: (index) => send('table:setSelectedRow', {index}), 22 | onUpdateRow: (index, payload) => send('table:updateRow', {client, index, payload}), 23 | onInsertRow: (payload) => send('table:insertRow', {client, payload}), 24 | onDeleteRow: (index) => { 25 | notie.confirm('Delete this row?', 'Yes, delete', 'Cancel', () => send('table:deleteRow', {client, index})) 26 | } 27 | }) 28 | 29 | const pagination = Pagination({ 30 | offset, 31 | limit, 32 | total: rowCount, 33 | onPaginate: (newOffset) => send('table:paginate', {client, table, newOffset}) 34 | }) 35 | 36 | return html` 37 |
    38 | ${dataGrid} 39 | 40 | Showing ${offset} - ${Math.min(rowCount, offset + limit)} 41 | of ${rowCount} rows 42 | 43 | ${pagination} 44 |
    ` 45 | } 46 | -------------------------------------------------------------------------------- /public/src/views/table-schema.js: -------------------------------------------------------------------------------- 1 | const notie = require('notie') 2 | 3 | const dataGrid = require('../components/data-grid') 4 | 5 | module.exports = (state, prev, send) => { 6 | const table = state.params.name 7 | const client = state.db.client 8 | if (table && client && state.table.name !== table) { 9 | send('table:getTable', { client, table }) 10 | } 11 | 12 | const validTypes = client.getValidTypes() 13 | 14 | const columns = [ 15 | { 16 | key: 'name', 17 | title: 'Name', 18 | validate: (value, row) => value.length > 0 19 | }, 20 | { 21 | key: 'type', 22 | title: 'Type', 23 | validate: (value, row) => validTypes.includes(value) 24 | }, 25 | { 26 | key: 'maxLength', 27 | title: 'Length', 28 | validate: (value, row) => value.length === 0 || (!isNaN(value) && row.type.length > 0) 29 | }, 30 | { 31 | key: 'nullable', 32 | title: 'Null', 33 | validate: (value, row) => value.length === 0 || ['true', 'false'].includes(value) 34 | }, 35 | { 36 | key: 'defaultValue', 37 | title: 'Default', 38 | validate: (value, row) => value.length > 0 || row.nullable !== 'false' 39 | } 40 | ] 41 | 42 | return dataGrid({ 43 | columns: columns, 44 | rows: state.table.columns, 45 | selectedRowIndex: state.table.selectedRowIndex, 46 | onSelectRow: (index) => send('table:setSelectedRow', {index}), 47 | onUpdateRow: (index, payload) => send('table:updateColumn', {client, index, payload}), 48 | onInsertRow: (payload) => send('table:insertColumn', {client, payload}), 49 | onDeleteRow: (index) => { 50 | const columnName = state.table.columns[index].name 51 | notie.confirm(`Delete column ${columnName}?`, 'Yes, delete', 'Cancel', () => send('table:deleteColumn', {client, index})) 52 | } 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /public/styles/main.css: -------------------------------------------------------------------------------- 1 | .data-grid { 2 | overflow-x: auto; 3 | } 4 | 5 | .invalid { 6 | background-color: #f2dede !important; 7 | } 8 | 9 | .edit-button, .delete-button { 10 | width: 23px; 11 | } 12 | 13 | .database-table { 14 | padding-top: 10px; 15 | } 16 | 17 | .global-alert { 18 | position: absolute; 19 | top: 7px; 20 | left: 10%; 21 | right: 10%; 22 | z-index: 99; 23 | } -------------------------------------------------------------------------------- /public/test/clients/postgres.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | 3 | const Client = require('../../src/clients/postgres') 4 | const client = new Client() 5 | 6 | test('postgres: create table', (t) => { 7 | t.plan(1) 8 | const query = client.createTable('users') 9 | const expected = 'create table "users" ()' 10 | t.equal(query.toString(), expected) 11 | }) 12 | 13 | test('postgres: insert column', (t) => { 14 | t.plan(1) 15 | const payload = { 16 | name: 'bio', 17 | type: 'character varying', 18 | maxLength: '24', 19 | nullable: 'false', 20 | defaultValue: 'n/a' 21 | } 22 | const query = client.insertColumn('users', payload) 23 | const expected = ` 24 | ALTER TABLE "users" 25 | ADD COLUMN "bio" character varying (24) NOT NULL DEFAULT \'n/a\'` 26 | t.equal(trimQuery(query.toString()), trimQuery(expected), 'query matches') 27 | }) 28 | 29 | test('postgres: update column', (t) => { 30 | t.plan(1) 31 | const changes = { 32 | type: 'character varying', 33 | maxLength: '24', 34 | defaultValue: '', 35 | nullable: 'true' 36 | } 37 | const query = client.updateColumn('users', 'bio', changes) 38 | const expected = ` 39 | ALTER TABLE "users" 40 | ALTER COLUMN "bio" TYPE character varying (24), 41 | ALTER COLUMN "bio" DROP DEFAULT, 42 | ALTER COLUMN "bio" DROP NOT NULL` 43 | t.equal(trimQuery(query.toString()), trimQuery(expected), 'query matches') 44 | }) 45 | 46 | test('postgres: rename column', (t) => { 47 | t.plan(1) 48 | const query = client.renameColumn('users', 'bio', 'biography') 49 | const expected = ` 50 | alter table "users" 51 | rename "bio" to "biography"` 52 | t.equal(query.toString(), trimQuery(expected), 'query matches') 53 | }) 54 | 55 | test('postgres: pagination', (t) => { 56 | t.plan(1) 57 | const query = client.getRows('users', 10, 20) 58 | const expected = 'select * from "users" limit \'10\' offset \'20\'' 59 | t.equal(query.toString(), expected, 'includes limit and offset when provided') 60 | }) 61 | 62 | function trimQuery (sql) { 63 | return sql.trim().replace(/ +(?= )/g, '').replace(/\n/g, '') 64 | } 65 | -------------------------------------------------------------------------------- /public/test/components/data-grid.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | require('jsdom-global')() 3 | 4 | const dataGrid = require('../../src/components/data-grid') 5 | 6 | test('data grid: displays rows and blank row', (t) => { 7 | t.plan(2) 8 | const columns = ['firstName', 'lastName'] 9 | const rows = [ 10 | { firstName: 'George', lastName: 'Washington' }, 11 | { firstName: 'John', lastName: 'Adams' }, 12 | { firstName: 'Thomas', lastName: 'Jefferson' } 13 | ] 14 | const tree = dataGrid({ 15 | columns, 16 | rows 17 | }) 18 | // +1 to account for blank row at end 19 | const tableRows = tree.querySelector('tbody').children 20 | t.equal(tableRows.length, rows.length + 1, 'display all rows') 21 | 22 | const expectedColSpan = columns.length + 2 23 | const blankRowColumn = tree.querySelector('tbody tr:last-child td') 24 | t.equal(+blankRowColumn.getAttribute('colspan'), expectedColSpan, 'last row spans all columns') 25 | }) 26 | 27 | test('data grid: clicking edit sets row to edit mode', (t) => { 28 | t.plan(2) 29 | const columns = ['firstName', 'lastName'] 30 | const rows = [ 31 | { firstName: 'George', lastName: 'Washington' }, 32 | { firstName: 'John', lastName: 'Adams' }, 33 | { firstName: 'Thomas', lastName: 'Jefferson' } 34 | ] 35 | const treeWithoutSelection = dataGrid({ 36 | columns, 37 | rows, 38 | onSelectRow: (index) => t.equal(index, 1, 'set selectedRowIndex to 1') 39 | }) 40 | const targetRow = treeWithoutSelection.querySelectorAll('tbody tr')[1] 41 | targetRow.querySelector('.fa-pencil').dispatchEvent(new window.Event('click')) 42 | 43 | const treeWithSelection = dataGrid({ columns, rows, selectedRowIndex: 1 }) 44 | 45 | const selectedRow = treeWithSelection.querySelectorAll('tbody tr')[1] 46 | t.ok(selectedRow.classList.contains('selected'), 'row has selected class') 47 | }) 48 | 49 | test('data grid: clicking blank row fires selected event on new row index', (t) => { 50 | t.plan(1) 51 | const columns = ['firstName', 'lastName'] 52 | const rows = [ 53 | { firstName: 'George', lastName: 'Washington' }, 54 | { firstName: 'John', lastName: 'Adams' }, 55 | { firstName: 'Thomas', lastName: 'Jefferson' } 56 | ] 57 | const tree = dataGrid({ 58 | columns, 59 | rows, 60 | onSelectRow: (index) => t.equal(index, rows.length, 'set selected row to rows.length + 1') 61 | }) 62 | 63 | const blankRow = tree.querySelector('tbody tr:last-child td') 64 | blankRow.dispatchEvent(new window.Event('click')) 65 | }) 66 | 67 | test('data grid: pre-save validation marks invalid cells', (t) => { 68 | t.plan(1) 69 | const columns = [ 70 | { key: 'name' }, 71 | { 72 | key: 'email', 73 | validate: (value, row) => '@'.indexOf(value) !== -1 // value.includes('@') is preferrable but tests fail somehow 74 | } 75 | ] 76 | const rows = [{ name: 'foo', email: 'bar' }] 77 | const tree = dataGrid({ 78 | columns, 79 | rows, 80 | selectedRowIndex: 0 81 | }) 82 | tree.querySelector('tr.selected td:first-child .fa-save').dispatchEvent(new window.MouseEvent('click', {view: window, bubbles: true, cancelable: true})) 83 | t.ok(tree.querySelector('tr.selected td:last-child').classList.contains('invalid'), 'marks email cell invalid') 84 | }) 85 | -------------------------------------------------------------------------------- /public/test/components/dropdown.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | require('jsdom-global')() 3 | 4 | const Dropdown = require('../../src/components/dropdown') 5 | 6 | test('dropdown: supports array of item strings', (t) => { 7 | t.plan(1) 8 | const tree = Dropdown({ 9 | items: ['foo', 'bar'] 10 | }) 11 | t.equal(tree.querySelectorAll('option').length, 2, 'has two