├── .coveragerc ├── .github └── workflows │ ├── publish-docs.yml │ ├── publish.yml │ ├── test-coverage.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── codecov.yml ├── cypress.config.js ├── cypress ├── e2e │ └── smoke-tests.cy.js ├── fixtures │ ├── example.json │ ├── profile.json │ └── users.json └── support │ ├── commands.js │ └── e2e.js ├── datasette_ui_extras ├── __init__.py ├── column_stats.py ├── column_stats_schema.py ├── dux_command.py ├── edit_controls.py ├── edit_row_pages.py ├── facet_patches.py ├── facets.py ├── filters.py ├── hookspecs.py ├── new_facets.py ├── omnisearch.py ├── plugin.py ├── schema_utils.py ├── static │ ├── app.css │ ├── compact-cogs.css │ ├── edit-row │ │ ├── CheckboxControl.js │ │ ├── DateControl.js │ │ ├── DropdownControl.js │ │ ├── ForeignKeyControl.js │ │ ├── JSONTagsControl.js │ │ ├── NumberControl.js │ │ ├── StringAutocompleteControl.js │ │ ├── StringControl.js │ │ ├── TextareaControl.js │ │ ├── edit-row.css │ │ └── edit-row.js │ ├── focus-search-box.js │ ├── hide-export.css │ ├── hide-filters.css │ ├── hide-filters.js │ ├── hide-table-definition.css │ ├── layout-row-page.css │ ├── layout-row-page.js │ ├── lazy-facets.css │ ├── lazy-facets.js │ ├── mobile-column-menu.css │ ├── mobile-column-menu.js │ ├── omnisearch.css │ ├── omnisearch.js │ ├── sticky-table-headers.css │ └── sticky-table-headers.js ├── templates │ ├── edit-row.html │ └── insert-row.html ├── undux_command.py ├── utils.py ├── view_row_pages.py └── yolo_command.py ├── docs ├── .dockerignore ├── .env.example ├── .eslintrc.json ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── fly.toml ├── jsconfig.json ├── markdoc │ ├── nodes.js │ └── tags.js ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public │ ├── favicon.ico │ └── fonts │ │ ├── Inter-italic.var.woff2 │ │ ├── Inter-roman.var.woff2 │ │ ├── lexend.txt │ │ └── lexend.woff2 ├── src │ ├── components │ │ ├── Button.jsx │ │ ├── Callout.jsx │ │ ├── Fence.jsx │ │ ├── Hero.jsx │ │ ├── HeroBackground.jsx │ │ ├── Icon.jsx │ │ ├── Layout.jsx │ │ ├── Logo.jsx │ │ ├── MobileNavigation.jsx │ │ ├── Navigation.jsx │ │ ├── Prose.jsx │ │ ├── QuickLinks.jsx │ │ ├── Search.jsx │ │ ├── ThemeSelector.jsx │ │ └── icons │ │ │ ├── InstallationIcon.jsx │ │ │ ├── LightbulbIcon.jsx │ │ │ ├── PluginsIcon.jsx │ │ │ ├── PresetsIcon.jsx │ │ │ ├── ThemingIcon.jsx │ │ │ └── WarningIcon.jsx │ ├── images │ │ ├── blur-cyan.png │ │ └── blur-indigo.png │ ├── pages │ │ ├── _app.jsx │ │ ├── _document.jsx │ │ ├── docs │ │ │ ├── architecture-guide.md │ │ │ ├── authn-authz-auditing.md │ │ │ ├── built-in-edit-controls.md │ │ │ ├── cli-tools.md │ │ │ ├── custom-edit-controls.md │ │ │ ├── data-entry.md │ │ │ ├── endpoints.md │ │ │ ├── form-mode.md │ │ │ ├── hooks.md │ │ │ ├── how-to-contribute.md │ │ │ ├── installation.md │ │ │ ├── metadata.md │ │ │ ├── publish-to-the-web.md │ │ │ ├── sql-schema.md │ │ │ ├── templates.md │ │ │ └── turker-mode.md │ │ └── index.md │ └── styles │ │ ├── docsearch.css │ │ ├── fonts.css │ │ ├── prism.css │ │ └── tailwind.css ├── tailwind.config.js └── yarn.lock ├── enable-fts ├── go ├── metadata.e2e.json ├── metadata.json ├── package.json ├── plugins └── image_url.py ├── pytest.ini ├── setup.py ├── tests ├── diy-meta.db.orig ├── e2e_test_host.py ├── plugins │ └── auth.py ├── test_compute_column_stats.py ├── test_schema_utils.py ├── test_ui_extras.py └── test_yolo_command.py └── yarn.lock /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Fly Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | env: 7 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 8 | 9 | jobs: 10 | deploy: 11 | name: Deploy app 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | working-directory: './docs' 18 | 19 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | cache: pip 23 | cache-dependency-path: setup.py 24 | - name: Install dependencies 25 | run: | 26 | pip install '.[test]' 27 | - name: Run tests 28 | run: | 29 | pytest 30 | deploy: 31 | runs-on: ubuntu-latest 32 | needs: [test] 33 | steps: 34 | - uses: actions/checkout@v3 35 | - name: Set up Python 36 | uses: actions/setup-python@v4 37 | with: 38 | python-version: "3.11" 39 | cache: pip 40 | cache-dependency-path: setup.py 41 | - name: Install dependencies 42 | run: | 43 | pip install setuptools wheel twine build 44 | - name: Publish 45 | env: 46 | TWINE_USERNAME: __token__ 47 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 48 | run: | 49 | python -m build 50 | twine upload dist/* 51 | 52 | -------------------------------------------------------------------------------- /.github/workflows/test-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Calculate test coverage 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out dux 18 | uses: actions/checkout@v2 19 | - name: Set up Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.9 23 | - name: Set up Node 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: '16' 27 | - name: Yarn install 28 | run: yarn 29 | - uses: actions/cache@v2 30 | name: Configure pip caching 31 | with: 32 | path: ~/.cache/pip 33 | key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} 34 | restore-keys: | 35 | ${{ runner.os }}-pip- 36 | - name: Install Python dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | python -m pip install -e .[test] 40 | python -m pip install pytest-cov 41 | - name: Run tests 42 | run: |- 43 | ls -lah 44 | cat .coveragerc 45 | pytest --cov=datasette_ui_extras --cov-config=.coveragerc --cov-report xml:coverage.xml --cov-report term 46 | ls -lah 47 | cp tests/diy-meta.db.orig diy.db 48 | coverage run --append -m tests.e2e_test_host dux diy.db 49 | coverage run --append -m tests.e2e_test_host yolo diy.db 50 | coverage run --append -m tests.e2e_test_host serve diy.db --metadata metadata.e2e.json --port 8888 & 51 | ./node_modules/.bin/wait-on http://localhost:8888 52 | ./node_modules/.bin/cypress run 53 | kill %1 54 | coverage run --append -m tests.e2e_test_host undux diy.db 55 | coverage xml -o coverage.xml 56 | - name: Upload coverage report 1 57 | uses: codecov/codecov-action@v1 58 | with: 59 | token: ${{ secrets.CODECOV_TOKEN }} 60 | file: coverage.xml 61 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | cache: pip 21 | cache-dependency-path: setup.py 22 | - name: Install dependencies 23 | run: | 24 | pip install '.[test]' 25 | - name: Run tests 26 | run: | 27 | pytest 28 | 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | venv 6 | .eggs 7 | .pytest_cache 8 | *.egg-info 9 | .DS_Store 10 | .vscode 11 | dist 12 | build 13 | *.db* 14 | node_modules 15 | cypress/screenshots/ 16 | cypress/videos/ 17 | .coverage 18 | coverage.xml 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datasette-ui-extras 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/datasette-ui-extras.svg)](https://pypi.org/project/datasette-ui-extras/) 4 | [![Changelog](https://img.shields.io/github/v/release/cldellow/datasette-ui-extras?include_prereleases&label=changelog)](https://github.com/cldellow/datasette-ui-extras/releases) 5 | [![Tests](https://github.com/cldellow/datasette-ui-extras/workflows/Test/badge.svg)](https://github.com/cldellow/datasette-ui-extras/actions?query=workflow%3ATest) 6 | [![codecov](https://codecov.io/gh/cldellow/datasette-ui-extras/branch/main/graph/badge.svg?token=QRV8VXYKTW)](https://codecov.io/gh/cldellow/datasette-ui-extras) 7 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/cldellow/datasette-ui-extras/blob/main/LICENSE) 8 | 9 | This plugin aims to be a batteries-included theme that makes Datasette more like a self-hosted Airtable or Notion. 10 | 11 | You can share read-only access, while still allowing authenticated users to edit data. 12 | 13 | ## Demo 14 | 15 | You can see a demo at https://dux.fly.dev/ 16 | 17 | ## User documentation 18 | 19 | See our documentation site at https://dux.cldellow.com 20 | 21 | ## Development 22 | 23 | To set up this plugin locally, first checkout the code. Then create a new virtual environment: 24 | 25 | cd datasette-ui-extras 26 | python3 -m venv venv 27 | source venv/bin/activate 28 | 29 | Now install the dependencies and test dependencies: 30 | 31 | pip install -e '.[test]' 32 | 33 | To run the tests: 34 | 35 | pytest 36 | 37 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | patch: off 7 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /cypress/e2e/smoke-tests.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('dux smoke tests', () => { 4 | it('can click Edit and make a change on a simple row', () => { 5 | cy.visit('http://localhost:8888/diy/badges/6') 6 | cy.contains('Edit row').click(); 7 | cy.url().should('include', '_dux_edit=1'); 8 | cy.get('.col-name input').focus().type('{selectAll}s'); 9 | 10 | // Confirm we see a dropdown result 11 | cy.contains('Scholar'); 12 | 13 | // Write a new value 14 | cy.get('.col-name input').focus().type('{selectAll}xyzzy'); 15 | cy.contains('Save').click(); 16 | }) 17 | 18 | it('can render the Add row screen', () => { 19 | cy.visit('http://localhost:8888/diy/posts/dux-insert') 20 | }) 21 | 22 | it('can render year facets', () => { 23 | cy.visit('http://localhost:8888/diy/posts?_facet_year=creation_date') 24 | cy.contains('creation_date (year)') 25 | }) 26 | 27 | it('can render year-month facets', () => { 28 | cy.visit('http://localhost:8888/diy/posts?_facet_year_month=creation_date') 29 | cy.contains('creation_date (year_month)') 30 | }) 31 | 32 | it('can render stats facets', () => { 33 | cy.visit('http://localhost:8888/diy/posts?_facet_stats=views') 34 | cy.contains('p99') 35 | }) 36 | 37 | it('can render array facets', () => { 38 | cy.visit('http://localhost:8888/diy/posts?_facet_array=tags') 39 | cy.contains('tags (array)') 40 | }) 41 | 42 | it('can filter by fkey and show label', () => { 43 | cy.visit('http://localhost:8888/diy/posts?_sort=id&owner_user_id__exact=17') 44 | cy.contains('where owner_user_id = 17 (Jeremy McGee)') 45 | }) 46 | 47 | 48 | it('can omnisearch on tables', () => { 49 | cy.visit('http://localhost:8888/diy/posts') 50 | cy.get('#_search').focus().type('discussion'); 51 | cy.contains('tags contains discussion').click(); 52 | }); 53 | 54 | it('can omnisearch on views', () => { 55 | cy.visit('http://localhost:8888/diy/questions') 56 | cy.get('#_search').focus().type('discussion'); 57 | cy.contains('tags contains discussion').click(); 58 | }); 59 | 60 | }) 61 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/fixtures/profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 8739, 3 | "name": "Jane", 4 | "email": "jane@example.com" 5 | } -------------------------------------------------------------------------------- /cypress/fixtures/users.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Leanne Graham", 5 | "username": "Bret", 6 | "email": "Sincere@april.biz", 7 | "address": { 8 | "street": "Kulas Light", 9 | "suite": "Apt. 556", 10 | "city": "Gwenborough", 11 | "zipcode": "92998-3874", 12 | "geo": { 13 | "lat": "-37.3159", 14 | "lng": "81.1496" 15 | } 16 | }, 17 | "phone": "1-770-736-8031 x56442", 18 | "website": "hildegard.org", 19 | "company": { 20 | "name": "Romaguera-Crona", 21 | "catchPhrase": "Multi-layered client-server neural-net", 22 | "bs": "harness real-time e-markets" 23 | } 24 | }, 25 | { 26 | "id": 2, 27 | "name": "Ervin Howell", 28 | "username": "Antonette", 29 | "email": "Shanna@melissa.tv", 30 | "address": { 31 | "street": "Victor Plains", 32 | "suite": "Suite 879", 33 | "city": "Wisokyburgh", 34 | "zipcode": "90566-7771", 35 | "geo": { 36 | "lat": "-43.9509", 37 | "lng": "-34.4618" 38 | } 39 | }, 40 | "phone": "010-692-6593 x09125", 41 | "website": "anastasia.net", 42 | "company": { 43 | "name": "Deckow-Crist", 44 | "catchPhrase": "Proactive didactic contingency", 45 | "bs": "synergize scalable supply-chains" 46 | } 47 | }, 48 | { 49 | "id": 3, 50 | "name": "Clementine Bauch", 51 | "username": "Samantha", 52 | "email": "Nathan@yesenia.net", 53 | "address": { 54 | "street": "Douglas Extension", 55 | "suite": "Suite 847", 56 | "city": "McKenziehaven", 57 | "zipcode": "59590-4157", 58 | "geo": { 59 | "lat": "-68.6102", 60 | "lng": "-47.0653" 61 | } 62 | }, 63 | "phone": "1-463-123-4447", 64 | "website": "ramiro.info", 65 | "company": { 66 | "name": "Romaguera-Jacobson", 67 | "catchPhrase": "Face to face bifurcated interface", 68 | "bs": "e-enable strategic applications" 69 | } 70 | }, 71 | { 72 | "id": 4, 73 | "name": "Patricia Lebsack", 74 | "username": "Karianne", 75 | "email": "Julianne.OConner@kory.org", 76 | "address": { 77 | "street": "Hoeger Mall", 78 | "suite": "Apt. 692", 79 | "city": "South Elvis", 80 | "zipcode": "53919-4257", 81 | "geo": { 82 | "lat": "29.4572", 83 | "lng": "-164.2990" 84 | } 85 | }, 86 | "phone": "493-170-9623 x156", 87 | "website": "kale.biz", 88 | "company": { 89 | "name": "Robel-Corkery", 90 | "catchPhrase": "Multi-tiered zero tolerance productivity", 91 | "bs": "transition cutting-edge web services" 92 | } 93 | }, 94 | { 95 | "id": 5, 96 | "name": "Chelsey Dietrich", 97 | "username": "Kamren", 98 | "email": "Lucio_Hettinger@annie.ca", 99 | "address": { 100 | "street": "Skiles Walks", 101 | "suite": "Suite 351", 102 | "city": "Roscoeview", 103 | "zipcode": "33263", 104 | "geo": { 105 | "lat": "-31.8129", 106 | "lng": "62.5342" 107 | } 108 | }, 109 | "phone": "(254)954-1289", 110 | "website": "demarco.info", 111 | "company": { 112 | "name": "Keebler LLC", 113 | "catchPhrase": "User-centric fault-tolerant solution", 114 | "bs": "revolutionize end-to-end systems" 115 | } 116 | }, 117 | { 118 | "id": 6, 119 | "name": "Mrs. Dennis Schulist", 120 | "username": "Leopoldo_Corkery", 121 | "email": "Karley_Dach@jasper.info", 122 | "address": { 123 | "street": "Norberto Crossing", 124 | "suite": "Apt. 950", 125 | "city": "South Christy", 126 | "zipcode": "23505-1337", 127 | "geo": { 128 | "lat": "-71.4197", 129 | "lng": "71.7478" 130 | } 131 | }, 132 | "phone": "1-477-935-8478 x6430", 133 | "website": "ola.org", 134 | "company": { 135 | "name": "Considine-Lockman", 136 | "catchPhrase": "Synchronised bottom-line interface", 137 | "bs": "e-enable innovative applications" 138 | } 139 | }, 140 | { 141 | "id": 7, 142 | "name": "Kurtis Weissnat", 143 | "username": "Elwyn.Skiles", 144 | "email": "Telly.Hoeger@billy.biz", 145 | "address": { 146 | "street": "Rex Trail", 147 | "suite": "Suite 280", 148 | "city": "Howemouth", 149 | "zipcode": "58804-1099", 150 | "geo": { 151 | "lat": "24.8918", 152 | "lng": "21.8984" 153 | } 154 | }, 155 | "phone": "210.067.6132", 156 | "website": "elvis.io", 157 | "company": { 158 | "name": "Johns Group", 159 | "catchPhrase": "Configurable multimedia task-force", 160 | "bs": "generate enterprise e-tailers" 161 | } 162 | }, 163 | { 164 | "id": 8, 165 | "name": "Nicholas Runolfsdottir V", 166 | "username": "Maxime_Nienow", 167 | "email": "Sherwood@rosamond.me", 168 | "address": { 169 | "street": "Ellsworth Summit", 170 | "suite": "Suite 729", 171 | "city": "Aliyaview", 172 | "zipcode": "45169", 173 | "geo": { 174 | "lat": "-14.3990", 175 | "lng": "-120.7677" 176 | } 177 | }, 178 | "phone": "586.493.6943 x140", 179 | "website": "jacynthe.com", 180 | "company": { 181 | "name": "Abernathy Group", 182 | "catchPhrase": "Implemented secondary concept", 183 | "bs": "e-enable extensible e-tailers" 184 | } 185 | }, 186 | { 187 | "id": 9, 188 | "name": "Glenna Reichert", 189 | "username": "Delphine", 190 | "email": "Chaim_McDermott@dana.io", 191 | "address": { 192 | "street": "Dayna Park", 193 | "suite": "Suite 449", 194 | "city": "Bartholomebury", 195 | "zipcode": "76495-3109", 196 | "geo": { 197 | "lat": "24.6463", 198 | "lng": "-168.8889" 199 | } 200 | }, 201 | "phone": "(775)976-6794 x41206", 202 | "website": "conrad.com", 203 | "company": { 204 | "name": "Yost and Sons", 205 | "catchPhrase": "Switchable contextually-based project", 206 | "bs": "aggregate real-time technologies" 207 | } 208 | }, 209 | { 210 | "id": 10, 211 | "name": "Clementina DuBuque", 212 | "username": "Moriah.Stanton", 213 | "email": "Rey.Padberg@karina.biz", 214 | "address": { 215 | "street": "Kattie Turnpike", 216 | "suite": "Suite 198", 217 | "city": "Lebsackbury", 218 | "zipcode": "31428-2261", 219 | "geo": { 220 | "lat": "-38.2386", 221 | "lng": "57.2232" 222 | } 223 | }, 224 | "phone": "024-648-3804", 225 | "website": "ambrose.net", 226 | "company": { 227 | "name": "Hoeger LLC", 228 | "catchPhrase": "Centralized empowering task-force", 229 | "bs": "target end-to-end models" 230 | } 231 | } 232 | ] -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /datasette_ui_extras/dux_command.py: -------------------------------------------------------------------------------- 1 | from datasette import hookimpl 2 | import click 3 | import sqlite3 4 | import sqlite_sqlean 5 | import json 6 | import os 7 | from .column_stats_schema import ensure_schema_and_triggers 8 | from .column_stats import ensure_empty_rows_for_db, index_next_backfill_batch, index_pending_rows 9 | 10 | @hookimpl 11 | def prepare_connection(conn, database): 12 | # Don't enable fkey checks on _internal, see https://github.com/simonw/datasette/issues/2032 13 | if database == '_internal': 14 | return 15 | 16 | old_level = conn.isolation_level 17 | try: 18 | conn.isolation_level = None 19 | conn.enable_load_extension(True) 20 | sqlite_sqlean.load(conn, 'crypto') 21 | conn.enable_load_extension(False) 22 | 23 | 24 | # Try to enable WAL and synchronous = NORMAL mode 25 | conn.execute('PRAGMA journal_mode = WAL') 26 | conn.execute('PRAGMA synchronous = NORMAL') 27 | 28 | # Foreign keys are great, databases should enforce them. 29 | conn.execute('PRAGMA foreign_keys = ON') 30 | finally: 31 | conn.isolation_level = old_level 32 | 33 | @hookimpl(specname='register_commands') 34 | def dux_command(cli): 35 | @cli.command() 36 | @click.argument( 37 | "files", type=click.Path(exists=True), nargs=-1 38 | ) 39 | def dux(files): 40 | "Add datasette-ui-extras's triggers and stats tables to the given database(s)." 41 | 42 | for file in files: 43 | dux_the_file(str(file)) 44 | 45 | def dux_the_file(file): 46 | conn = sqlite3.connect(str(file)) 47 | conn.row_factory = sqlite3.Row 48 | prepare_connection(conn, 'not-internal') 49 | 50 | ensure_schema_and_triggers(conn) 51 | ensure_empty_rows_for_db(conn) 52 | 53 | while index_next_backfill_batch(conn): 54 | pass 55 | 56 | while index_pending_rows(conn): 57 | pass 58 | 59 | conn.close() 60 | -------------------------------------------------------------------------------- /datasette_ui_extras/edit_row_pages.py: -------------------------------------------------------------------------------- 1 | from datasette.utils.asgi import Forbidden 2 | from .utils import row_edit_params 3 | 4 | def enable_yolo_edit_row_pages(): 5 | # We also enable a new template: edit-row. 6 | from datasette.views.row import RowView 7 | 8 | # We can't capture the original RowView.data function outside of this fn 9 | # due to a circular import issue, so do it here. 10 | # 11 | # In tests, this can lead to patching it twice, so remember if we've patched it. 12 | if not hasattr(RowView, 'dux_patched'): 13 | patch_RowView_data(RowView) 14 | patch_Datasette_resolve_row() 15 | 16 | 17 | def patch_RowView_data(RowView): 18 | RowView.dux_patched = True 19 | original_RowView_data = RowView.data 20 | async def patched_RowView_data(self, request, default_labels=False): 21 | data, template_data, templates = await original_RowView_data(self, request, default_labels) 22 | 23 | 24 | resolved = await self.ds.resolve_row(request) 25 | database = resolved.db.name 26 | table = resolved.table 27 | 28 | 29 | edit_mode = await row_edit_params(self.ds, request, database, table) 30 | 31 | if resolved.pks == 'dux-insert': 32 | templates = tuple(['insert-row' + x[3:] for x in templates]) 33 | elif edit_mode: 34 | templates = tuple(['edit-row' + x[3:] for x in templates]) 35 | return (data, template_data, templates) 36 | 37 | 38 | RowView.data = patched_RowView_data 39 | 40 | def patch_Datasette_resolve_row(): 41 | from datasette.app import Datasette, ResolvedRow 42 | 43 | original_Datasette_resolve_row = Datasette.resolve_row 44 | async def patched_Datasette_resolve_row(self, request): 45 | pks = request.url_vars['pks'] 46 | 47 | if pks != 'dux-insert': 48 | return await original_Datasette_resolve_row(self, request) 49 | 50 | db, table_name, _ = await self.resolve_table(request) 51 | 52 | # Determine the columns that we should show the user. 53 | # Proposal: show all columns except single-column primary keys. 54 | all_columns = list(await db.execute('SELECT "name", "type", "notnull", "dflt_value", "pk" FROM pragma_table_info(?)', [table_name])) 55 | 56 | single_column_pk = True if len([col for col in all_columns if col['pk']]) == 1 else False 57 | 58 | columns = [col for col in all_columns if not single_column_pk or not col['pk']] 59 | 60 | sql = 'SELECT ' + ', '.join([ 61 | 'NULL AS "{}"'.format(col['name']) 62 | for col in columns 63 | ]) 64 | 65 | # db, table, sql, params, pks, pk_values, row 66 | params = {} 67 | pk_values = [] 68 | row = [] 69 | return ResolvedRow(db, table_name, sql, params, pks, pk_values, row) 70 | 71 | Datasette.resolve_row = patched_Datasette_resolve_row 72 | -------------------------------------------------------------------------------- /datasette_ui_extras/facet_patches.py: -------------------------------------------------------------------------------- 1 | from datasette import facets 2 | from datasette.utils import ( 3 | escape_sqlite, 4 | path_with_added_args, 5 | path_with_removed_args, 6 | ) 7 | from datasette.database import QueryInterrupted 8 | 9 | 10 | # monkey patch until https://github.com/simonw/datasette/pull/2008 11 | # is merged 12 | async def ArrayFacet_facet_results(self): 13 | # self.configs should be a plain list of columns 14 | facet_results = [] 15 | facets_timed_out = [] 16 | 17 | facet_size = self.get_facet_size() 18 | for source_and_config in self.get_configs(): 19 | config = source_and_config["config"] 20 | source = source_and_config["source"] 21 | column = config.get("column") or config["simple"] 22 | # https://github.com/simonw/datasette/issues/448 23 | facet_sql = """ 24 | with inner as ({sql}), 25 | with_ids as (select row_number() over () as row_number, {col} as array from inner), 26 | array_items as (select row_number, each.value from json_each(with_ids.array) each, with_ids) 27 | select 28 | value as value, 29 | count(distinct row_number) as count 30 | from 31 | array_items 32 | group by 33 | value 34 | order by 35 | count(distinct row_number) desc, value limit {limit} 36 | """.format( 37 | col=escape_sqlite(column), sql=self.sql, limit=facet_size + 1 38 | ) 39 | try: 40 | facet_rows_results = await self.ds.execute( 41 | self.database, 42 | facet_sql, 43 | self.params, 44 | truncate=False, 45 | custom_time_limit=self.ds.setting("facet_time_limit_ms"), 46 | ) 47 | facet_results_values = [] 48 | facet_results.append( 49 | { 50 | "name": column, 51 | "type": self.type, 52 | "results": facet_results_values, 53 | "hideable": source != "metadata", 54 | "toggle_url": self.ds.urls.path( 55 | path_with_removed_args( 56 | self.request, {"_facet_array": column} 57 | ) 58 | ), 59 | "truncated": len(facet_rows_results) > facet_size, 60 | } 61 | ) 62 | facet_rows = facet_rows_results.rows[:facet_size] 63 | pairs = self.get_querystring_pairs() 64 | for row in facet_rows: 65 | value = str(row["value"]) 66 | selected = (f"{column}__arraycontains", value) in pairs 67 | if selected: 68 | toggle_path = path_with_removed_args( 69 | self.request, {f"{column}__arraycontains": value} 70 | ) 71 | else: 72 | toggle_path = path_with_added_args( 73 | self.request, {f"{column}__arraycontains": value} 74 | ) 75 | facet_results_values.append( 76 | { 77 | "value": value, 78 | "label": value, 79 | "count": row["count"], 80 | "toggle_url": self.ds.absolute_url( 81 | self.request, toggle_path 82 | ), 83 | "selected": selected, 84 | } 85 | ) 86 | except QueryInterrupted: 87 | facets_timed_out.append(column) 88 | 89 | return facet_results, facets_timed_out 90 | 91 | 92 | facets.ArrayFacet.facet_results = ArrayFacet_facet_results 93 | -------------------------------------------------------------------------------- /datasette_ui_extras/filters.py: -------------------------------------------------------------------------------- 1 | from datasette import hookimpl 2 | from datasette.filters import Filters, FilterArguments 3 | def enable_yolo_arraycontains_filter(): 4 | for fltr in Filters._filters: 5 | if fltr.key == 'arraycontains': 6 | fltr.sql_template = '"{c}" like \'%"\' || :{p} || \'"%\'' 7 | if fltr.key == 'arraynotcontains': 8 | fltr.sql_template = '"{c}" not like \'%"\' || :{p} || \'"%\'' 9 | 10 | def patched_Filter_lookups(self): 11 | """Yields (lookup, display, no_argument) pairs""" 12 | yield 'exact', '=', False 13 | yield 'not', '!=', False 14 | for filter in self._filters: 15 | yield filter.key, filter.display, filter.no_argument 16 | 17 | def enable_yolo_exact_filter(): 18 | Filters._filters = [x for x in Filters._filters if x.key != 'exact' and x.key != 'not'] 19 | Filters._filters_by_key = {f.key: f for f in Filters._filters} 20 | 21 | Filters.lookups = patched_Filter_lookups 22 | 23 | pass 24 | 25 | async def yolo_filters_from_request(request, database, table, datasette): 26 | # Borrowed from https://github.com/simonw/datasette/blob/0b4a28691468b5c758df74fa1d72a823813c96bf/datasette/views/table.py#L347-L354 27 | filter_args = [] 28 | for key in request.args: 29 | if not (key.startswith("_") and "__" not in key): 30 | for v in request.args.getlist(key): 31 | if not '__' in key: 32 | key += '__exact' 33 | 34 | if key.endswith('__exact') or key.endswith('__not'): 35 | filter_args.append((key, v)) 36 | 37 | if not filter_args: 38 | return 39 | 40 | wheres = [] 41 | params = {} 42 | descs = [] 43 | 44 | param_index = 0 45 | for k, v in filter_args: 46 | op = 'exact' 47 | if k.endswith('__exact'): 48 | k = k[0:-7] 49 | elif k.endswith('__not'): 50 | op = 'not' 51 | k = k[0:-5] 52 | 53 | op_human = '=' if op == 'exact' else '!=' 54 | wheres.append('"{c}" {op} :dux{param_index}'.format( 55 | c = k, 56 | op = op_human, 57 | param_index = param_index 58 | )) 59 | params['dux{}'.format(param_index)] = v 60 | 61 | human_desc_format = "{c} {op} {v}" if v.isdigit() else '{c} {op} "{v}"' 62 | 63 | human_desc = human_desc_format.format(c = k, op = op_human, v = v) 64 | 65 | # Is table.c an fkey to a pkey? If yes, fetch its label. 66 | expanded = await datasette.expand_foreign_keys(database, table, k, [v]) 67 | if expanded: 68 | human_desc += ' ({})'.format(list(expanded.values())[0]) 69 | descs.append(human_desc) 70 | param_index += 1 71 | return FilterArguments( 72 | wheres, 73 | params, 74 | descs 75 | ) 76 | -------------------------------------------------------------------------------- /datasette_ui_extras/hookspecs.py: -------------------------------------------------------------------------------- 1 | from pluggy import HookimplMarker 2 | from pluggy import HookspecMarker 3 | 4 | hookspec = HookspecMarker("datasette_ui_extras") 5 | hookimpl = HookimplMarker("datasette_ui_extras") 6 | 7 | # Conceptually, this is a firstresult=True hook. However, we support async 8 | # awaitables, so we need to get all the results and await them. 9 | @hookspec() 10 | def edit_control(datasette, database, table, column, row, value, metadata): 11 | """Return the name of the edit control class to use, and optionally some configuration.""" 12 | 13 | # This is also a firstresult=True hook. 14 | @hookspec() 15 | def redirect_after_edit(datasette, database, table, action, pk): 16 | """Return the URL to redirect the user to after editing a row.""" 17 | -------------------------------------------------------------------------------- /datasette_ui_extras/omnisearch.py: -------------------------------------------------------------------------------- 1 | from .column_stats import autosuggest_column 2 | from .utils import get_editable_columns 3 | from datasette.utils import path_from_row_pks 4 | 5 | def dateish(column): 6 | min = column['min'] 7 | max = column['max'] 8 | 9 | if not isinstance(min, str) or not isinstance(max, str): 10 | return False 11 | 12 | if min >= '1900-01-01' and max <= '9999-12-31': 13 | return True 14 | 15 | return False 16 | 17 | async def omnisearch(datasette, db, table, q): 18 | if not q: 19 | return [] 20 | 21 | known_columns = [r[0] for r in list(await db.execute("SELECT name FROM pragma_table_info(?)", [table]))] 22 | 23 | editable_columns = await get_editable_columns(datasette, db.name, table) 24 | base_table = table 25 | for info in editable_columns.values(): 26 | if info['base_table'] != table: 27 | base_table = info['base_table'] 28 | 29 | ok_columns = (datasette.plugin_config('datasette-ui-extras', db.name, table) or {}).get('omnisearch-columns', None) 30 | 31 | # TODO: dates 32 | 33 | banned_columns = {} 34 | 35 | # Do we have a title column? Search for entries based on that. 36 | label_column = await db.label_column_for_table(base_table) 37 | 38 | row_results = [] 39 | if label_column and label_column in known_columns and (not ok_columns or label_column in ok_columns): 40 | banned_columns[label_column] = True 41 | def get_results(conn): 42 | return suggest_row_results(datasette, conn, db.name, base_table, table, label_column, q) 43 | row_results = await db.execute_fn(get_results) 44 | 45 | # We only support single-column fkeys, so filter on max(seq) 46 | fkey_columns = list(await db.execute('select "table", "from", "to" from pragma_foreign_key_list(:table) where id in (select id from pragma_foreign_key_list(:table) group by 1 having max(seq) = 0)', { 'table': base_table})) 47 | 48 | fkey_results = [] 49 | for other_table, my_column, other_column in fkey_columns: 50 | if not my_column in known_columns: 51 | continue 52 | 53 | if ok_columns and not my_column in ok_columns: 54 | continue 55 | 56 | label_column = await db.label_column_for_table(other_table) 57 | if not label_column: 58 | continue 59 | 60 | banned_columns[my_column] = True 61 | 62 | def get_results(conn): 63 | return suggest_fkey_results(datasette, conn, db.name, base_table, table, my_column, other_table, other_column, label_column, q) 64 | fkey_results = fkey_results + list(await db.execute_fn(get_results))[0:3] 65 | 66 | all_columns = list(await db.execute('select di.name, dcs.* from dux_column_stats dcs join dux_ids di on di.id = dcs.column_id where table_id = (select id from dux_ids where name = ?)', [base_table])) 67 | string_results = [] 68 | for column in all_columns: 69 | if not column['name'] in known_columns: 70 | continue 71 | 72 | if ok_columns and not column['name'] in ok_columns: 73 | continue 74 | 75 | if column['name'] in banned_columns: 76 | continue 77 | # column__exact={}, column__contains={} 78 | if column['json_arrays'] + column['nulls'] == column['count']: 79 | #print('json array: {}'.format(column['name'])) 80 | banned_columns[column['name']] = True 81 | def get_results(conn): 82 | return suggest_string_results(datasette, conn, db.name, base_table, table, column['name'], q, 'contains', column['name'] + '__contains={}') 83 | string_results = string_results + list(await db.execute_fn(get_results))[0:3] 84 | 85 | elif (column['texts'] + column['nulls']) * 1.1 >= column['count'] and column['texts_newline'] == 0 and not dateish(column): 86 | # print('simple text: {}'.format(column['name'])) 87 | banned_columns[column['name']] = True 88 | def get_results(conn): 89 | return suggest_string_results(datasette, conn, db.name, base_table, table, column['name'], q, 'is', column['name'] + '__exact={}') 90 | string_results = string_results + list(await db.execute_fn(get_results))[0:3] 91 | 92 | 93 | return row_results + fkey_results + string_results 94 | 95 | def suggest_string_results(datasette, conn, db, table, link_table, column, q, verb, template): 96 | hits = autosuggest_column(conn, table, column, q) 97 | 98 | rv = [] 99 | for hit in hits[0:3]: 100 | rv.append({ 101 | 'value': '{} {} {}'.format(column, verb, hit['value']), 102 | 'url': '{}?{}'.format(datasette.urls.table(db, link_table), template.format(hit['value'])) 103 | }) 104 | 105 | return rv 106 | 107 | def suggest_fkey_results(datasette, conn, db, table, link_table, my_column, other_table, other_column, other_label_column, q): 108 | hits = autosuggest_column(conn, other_table, other_label_column, q) 109 | 110 | rv = [] 111 | for hit in hits[0:3]: 112 | rv.append({ 113 | 'value': '{} is {}'.format(my_column, hit['value']), 114 | 'url': '{}?{}={}'.format(datasette.urls.table(db, link_table), my_column, hit['pks'][0][other_column]) 115 | }) 116 | 117 | return rv 118 | 119 | def suggest_row_results(datasette, conn, db, table, link_table, column, q): 120 | # TODO: when table != link_table, we should filter the results to 121 | # confirm that their pks are in link_table 122 | # 123 | # If we don't, we may give nonsense results, and leak data that the 124 | # user is not meant to have access to. 125 | # 126 | # There's not a _good_ solution here. Because it's a view, we can't 127 | # compute membership using triggers, so the worst case performance 128 | # to do the filter could be quite bad. 129 | # 130 | # We may want to forbid table != link_table? :( 131 | hits = autosuggest_column(conn, table, column, q) 132 | 133 | rv = [] 134 | for hit in hits[0:3]: 135 | # if this happens, it means we've deleted the last examplar from the dux_column_stats_values 136 | # table. My gut is that that won't happen very often, so let's fail loudly so 137 | # we can investigate if it does. 138 | if not hit['pks']: 139 | raise Exception('omnisearch failed to find pks for a hit, q={} hit={}'.format(q, hit)) 140 | 141 | pks = hit['pks'][0] 142 | link_id = path_from_row_pks(pks, list(pks.keys()), 'rowid' in pks) 143 | rv.append({ 144 | 'value': '...' + hit['value'], 145 | 'url': '{}/{}'.format(datasette.urls.table(db, link_table), link_id) 146 | }) 147 | 148 | return rv 149 | -------------------------------------------------------------------------------- /datasette_ui_extras/plugin.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import pluggy 3 | import sys 4 | from . import hookspecs 5 | 6 | DEFAULT_PLUGINS = ( 7 | "datasette_ui_extras.edit_controls", 8 | ) 9 | 10 | pm = pluggy.PluginManager("datasette_ui_extras") 11 | pm.add_hookspecs(hookspecs) 12 | 13 | if not hasattr(sys, "_called_from_test"): 14 | # Only load plugins if not running tests 15 | pm.load_setuptools_entrypoints("datasette_ui_extras") 16 | 17 | # Load default plugins 18 | for plugin in DEFAULT_PLUGINS: 19 | mod = importlib.import_module(plugin) 20 | pm.register(mod, plugin) 21 | -------------------------------------------------------------------------------- /datasette_ui_extras/schema_utils.py: -------------------------------------------------------------------------------- 1 | from sqlglot import parse_one, exp 2 | 3 | def get_column_choices_from_check_constraints(sql): 4 | if not isinstance(sql, str): 5 | raise Exception('expected sql to be str but was {}'.format(sql)) 6 | 7 | try: 8 | parsed = parse_one(sql) 9 | except: 10 | # They might have used something unparseable by sqlglot, fail gracefully. 11 | return {} 12 | 13 | # Returns a map from column name to permitted values, if 14 | # table defn has column defn of the form 15 | # x check (x in (...)) 16 | rv = {} 17 | checks = list(parsed.find_all(exp.CheckColumnConstraint, exp.Check)) 18 | 19 | for check in checks: 20 | if not isinstance(check.this, exp.In): 21 | continue 22 | 23 | in_ = check.this 24 | exprs = in_.expressions 25 | column = in_.this.this.this 26 | ok = [] 27 | 28 | # Are all of the exprs literals? 29 | all_literal = True 30 | for expr in exprs: 31 | if not isinstance(expr, exp.Literal): 32 | all_literal = False 33 | break 34 | 35 | if expr.is_string: 36 | ok.append(expr.this) 37 | else: 38 | ok.append(int(expr.this)) 39 | 40 | 41 | if not all_literal: 42 | continue 43 | 44 | if ok: 45 | rv[column] = ok 46 | 47 | return rv 48 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/app.css: -------------------------------------------------------------------------------- 1 | .search-row { 2 | margin-bottom: 0.6em; 3 | } 4 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/compact-cogs.css: -------------------------------------------------------------------------------- 1 | body.table .rows-and-columns thead th { 2 | position: relative; 3 | } 4 | 5 | body.table .rows-and-columns thead th a { 6 | margin-right: 4px; 7 | } 8 | 9 | body.table thead th svg.dropdown-menu-icon { 10 | position: absolute; 11 | display: none; 12 | top: 4px; 13 | right: 4px; 14 | } 15 | 16 | /* make the first one visible to hint that they're there */ 17 | body.table thead th:first-child svg.dropdown-menu-icon { 18 | display: inline-block; 19 | } 20 | 21 | body.table .rows-and-columns thead:hover svg.dropdown-menu-icon { 22 | display: inline-block; 23 | } 24 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/CheckboxControl.js: -------------------------------------------------------------------------------- 1 | window.CheckboxControl = class CheckboxControl { 2 | constructor(initialValue, config) { 3 | this.config = config; 4 | this.value_ = initialValue; 5 | this.el = null; 6 | this.dirty = false; 7 | } 8 | 9 | // Return a DOM element that will be shown to the user to edit this column's value 10 | createControl() { 11 | this.el = document.createElement('input'); 12 | this.el.type = 'checkbox'; 13 | this.el.checked = this.value_ !== '0' && this.value !== 0; 14 | this.el.addEventListener('change', () => { 15 | this.dirty = true; 16 | this.value_ = this.el.checked ? 1 : 0; 17 | }); 18 | 19 | return this.el; 20 | } 21 | 22 | get value() { 23 | return this.value_; 24 | } 25 | }; 26 | 27 | 28 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/DateControl.js: -------------------------------------------------------------------------------- 1 | window.DateControl = (function () { 2 | const pad = (x) => { 3 | const rv = String(x); 4 | if (rv.length < 2) 5 | return '0' + rv; 6 | 7 | return rv; 8 | } 9 | 10 | const strftime = (format, date, utc) => { 11 | return format.replace(/%[a-z]+\b/gi, (needle) => { 12 | // We support a very stripped down set of strftime formatters! 13 | if (needle == '%Y') 14 | return utc ? date.getUTCFullYear() : date.getFullYear(); 15 | if (needle == '%m') 16 | return pad((utc ? date.getUTCMonth() : date.getMonth()) + 1); 17 | if (needle == '%d') 18 | return pad(utc ? date.getUTCDate() : date.getDate()); 19 | if (needle == '%H') 20 | return pad(utc ? date.getUTCHours() : date.getHours()); 21 | if (needle == '%M') 22 | return pad(utc ? date.getUTCMinutes() : date.getMinutes()); 23 | 24 | return needle; 25 | }); 26 | }; 27 | 28 | const extract = (date) => { 29 | { 30 | const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(date); 31 | 32 | if (m) { 33 | return { 34 | year: Number(m[1]), 35 | month: Number(m[2]), 36 | day: Number(m[3]), 37 | hour: 0, 38 | minute: 0, 39 | } 40 | } 41 | } 42 | 43 | { 44 | const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[T ]([0-9]{2}):([0-9]{2})[:.0-9]+Z?$/.exec(date); 45 | 46 | if (m) { 47 | return { 48 | year: Number(m[1]), 49 | month: Number(m[2]), 50 | day: Number(m[3]), 51 | hour: Number(m[4]), 52 | minute: Number(m[5]), 53 | } 54 | } 55 | } 56 | 57 | const d = new Date(); 58 | 59 | return { 60 | year: d.getFullYear(), 61 | month: d.getMonth() + 1, 62 | day: d.getDate(), 63 | hour: d.getHours(), 64 | minute: d.getMinutes(), 65 | } 66 | } 67 | 68 | const USLocale = { 69 | days: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], 70 | daysShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], 71 | daysMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], 72 | months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], 73 | monthsShort: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], 74 | today: 'Today', 75 | clear: 'Clear', 76 | dateFormat: 'yyyy-MM-dd', 77 | timeFormat: 'hh:mm aa', 78 | firstDay: 0 79 | }; 80 | 81 | return class DateControl { 82 | constructor(initialValue, config) { 83 | this.config = config; 84 | this.initialValue = initialValue; 85 | this.dirty = false; 86 | } 87 | 88 | // Return a DOM element that will be shown to the user to edit this column's value 89 | createControl() { 90 | const dateInput = document.createElement('input'); 91 | dateInput.type = 'text'; 92 | dateInput.classList.add('dux-date-picker'); 93 | dateInput.readOnly = true; 94 | 95 | if (this.config.nullable) { 96 | this.el = document.createElement('div'); 97 | this.el.classList.add('dux-nullable-date-picker'); 98 | 99 | const notSetInput = document.createElement('input'); 100 | notSetInput.type = 'radio'; 101 | notSetInput.name = this.config.column; 102 | notSetInput.id = notSetInput.name + '-null'; 103 | notSetInput.value = 'null'; 104 | if (this.initialValue == null) notSetInput.checked = true; 105 | this.notSetInput = notSetInput; 106 | notSetInput.addEventListener('change', () => this.dirty = true); 107 | this.el.appendChild(notSetInput); 108 | 109 | const notSetLabel = document.createElement('label'); 110 | notSetLabel.innerText = 'Not set'; 111 | notSetLabel.htmlFor = this.config.column + '-null'; 112 | this.el.appendChild(notSetLabel); 113 | 114 | const setInput = document.createElement('input'); 115 | setInput.type = 'radio'; 116 | setInput.name = this.config.column; 117 | setInput.value = 'set'; 118 | setInput.addEventListener('change', () => this.dirty = true); 119 | this.el.appendChild(setInput); 120 | if (this.initialValue !== null) setInput.checked = true; 121 | 122 | dateInput.addEventListener('click', () => { 123 | if (!setInput.checked) { 124 | setInput.checked = true; 125 | this.dirty = true; 126 | } 127 | }); 128 | 129 | this.el.appendChild(dateInput); 130 | 131 | } else { 132 | this.el = dateInput; 133 | } 134 | 135 | const { year, month, day, hour, minute } = extract(this.initialValue || ''); 136 | const t = this.config.t ? 'T' : ' '; 137 | const z = this.config.utc ? 'Z' : ''; 138 | const precision = this.config.precision; 139 | 140 | const date = this.config.utc ? new Date(Date.UTC(year, month - 1, day, hour, minute)) : new Date(year, month - 1, day, hour, minute); 141 | const format = (date) => { 142 | if (precision === 'date') 143 | // Date is special; ignore UTC flag 144 | return strftime('%Y-%m-%d', date, false); 145 | 146 | if (precision === 'millis') 147 | return strftime(`%Y-%m-%d${t}%H:%M:00.000${z}`, date, this.config.utc); 148 | 149 | if (precision === 'secs') 150 | return strftime(`%Y-%m-%d${t}%H:%M:00${z}`, date, this.config.utc); 151 | 152 | throw new Error(`unexpected precision: ${precision}`); 153 | }; 154 | 155 | this.value_ = format(date); 156 | 157 | // In the insert-row case, default the initial value to a non-null value if 158 | // the column is not nullable 159 | if (!this.config.nullable && this.initialValue === null) { 160 | this.initialValue = this.value_; 161 | } 162 | 163 | return [ 164 | this.el, 165 | () => { 166 | new AirDatepicker(dateInput, { 167 | locale: USLocale, 168 | selectedDates: date, 169 | timepicker: precision !== 'date', 170 | onSelect: ({date}) => { 171 | this.dirty = true; 172 | this.value_ = format(date); 173 | 174 | } 175 | }); 176 | } 177 | ] 178 | } 179 | 180 | get value() { 181 | if (this.dirty) { 182 | if (this.notSetInput && this.notSetInput.checked) 183 | return null; 184 | 185 | return this.value_; 186 | } 187 | 188 | return this.initialValue; 189 | } 190 | } 191 | })(); 192 | 193 | 194 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/DropdownControl.js: -------------------------------------------------------------------------------- 1 | window.DropdownControl = class DropdownControl { 2 | constructor(initialValue, config) { 3 | this.config = config; 4 | this.initialValue = initialValue; 5 | this.el = null; 6 | this.dirty = false; 7 | 8 | // In the insert-row case, default the initial value to a non-null value if 9 | // the column is not nullable 10 | if (!this.config.nullable && this.initialValue === null) { 11 | this.initialValue = this.config.choices[0].value; 12 | } 13 | } 14 | 15 | // Return a DOM element that will be shown to the user to edit this column's value 16 | createControl() { 17 | this.el = document.createElement('select'); 18 | this.el.value = this.initialValue; 19 | 20 | 21 | const choices = []; 22 | if (this.config.nullable) { 23 | choices.push({value: null, label: 'Not set'}); 24 | } 25 | 26 | choices.push(...this.config.choices); 27 | for (const choice of choices) { 28 | const opt = document.createElement('option'); 29 | opt.value = JSON.stringify(choice.value); 30 | opt.innerText = choice.label; 31 | 32 | if (this.initialValue === choice.value) 33 | opt.selected = true; 34 | 35 | this.el.appendChild(opt); 36 | } 37 | 38 | this.el.addEventListener('change', () => this.dirty = true); 39 | 40 | return this.el; 41 | } 42 | 43 | get value() { 44 | return this.dirty ? JSON.parse(this.el.value) : this.initialValue; 45 | } 46 | }; 47 | 48 | 49 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/ForeignKeyControl.js: -------------------------------------------------------------------------------- 1 | window.ForeignKeyControl = class ForeignKeyControl { 2 | constructor(initialValue, config) { 3 | this.config = config; 4 | this.initialValue = initialValue; 5 | this.value_ = initialValue; 6 | this.dirty = false; 7 | } 8 | 9 | createControl() { 10 | const autosuggest = document.createElement('input'); 11 | autosuggest.classList.add('dux-fkey-picker'); 12 | this.el = autosuggest; 13 | 14 | autosuggest.type = 'text'; 15 | 16 | if (this.config.initialLabel) 17 | autosuggest.value = this.config.initialLabel; 18 | 19 | let setInput; 20 | if (this.config.nullable) { 21 | this.el = document.createElement('div'); 22 | this.el.classList.add('dux-nullable-fkey-picker'); 23 | 24 | const notSetInput = document.createElement('input'); 25 | notSetInput.type = 'radio'; 26 | notSetInput.name = this.config.column; 27 | notSetInput.id = notSetInput.name + '-null'; 28 | notSetInput.value = 'null'; 29 | if (this.initialValue == null) notSetInput.checked = true; 30 | this.notSetInput = notSetInput; 31 | notSetInput.addEventListener('change', () => this.dirty = true); 32 | this.el.appendChild(notSetInput); 33 | 34 | const notSetLabel = document.createElement('label'); 35 | notSetLabel.innerText = 'Not set'; 36 | notSetLabel.htmlFor = this.config.column + '-null'; 37 | this.el.appendChild(notSetLabel); 38 | 39 | setInput = document.createElement('input'); 40 | setInput.type = 'radio'; 41 | setInput.name = this.config.column; 42 | setInput.value = 'set'; 43 | setInput.addEventListener('change', () => this.dirty = true); 44 | this.el.appendChild(setInput); 45 | if (this.initialValue !== null) setInput.checked = true; 46 | 47 | this.el.appendChild(autosuggest); 48 | } 49 | 50 | return [ 51 | this.el, 52 | () => { 53 | const awesomplete = new Awesomplete(autosuggest, { 54 | minChars: 0, 55 | filter: () => { // We will provide a list that is already filtered ... 56 | return true; 57 | }, 58 | sort: false, // ... and sorted. 59 | list: [] 60 | }); 61 | 62 | autosuggest.addEventListener('input', async (e) => { 63 | const rv = await fetch(this.config.otherAutosuggestColumnUrl + '?' + new URLSearchParams({ 64 | column: this.config.labelColumn, 65 | q: e.target.value, 66 | })); 67 | const json = await rv.json(); 68 | 69 | const values = []; 70 | for (const row of json) { 71 | for (const pk of row.pks) { 72 | values.push({ 73 | label: row.value, 74 | value: pk 75 | }); 76 | } 77 | } 78 | awesomplete.list = values; 79 | awesomplete.evaluate(); 80 | }); 81 | 82 | autosuggest.addEventListener('awesomplete-selectcomplete', (e) => { 83 | console.log(e); 84 | 85 | const pkeys = Object.values(e.text.value); 86 | if (pkeys.length !== 1) { 87 | alert(`datasette-ui-extras: expected a single pkey, but got ${JSON.stringify(e.text.value)}`); 88 | return; 89 | } 90 | 91 | this.value_ = pkeys[0]; 92 | this.dirty = true; 93 | autosuggest.value = e.text.label; 94 | if (setInput) 95 | setInput.checked = true; 96 | 97 | }); 98 | 99 | 100 | } 101 | ] 102 | } 103 | 104 | get value() { 105 | if (this.dirty) { 106 | if (this.notSetInput && this.notSetInput.checked) 107 | return null; 108 | 109 | return this.value_; 110 | } 111 | 112 | return this.initialValue; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/JSONTagsControl.js: -------------------------------------------------------------------------------- 1 | window.JSONTagsControl = class JSONTagsControl { 2 | constructor(initialValue, config) { 3 | this.autosuggestColumnUrl = config.autosuggestColumnUrl; 4 | this.column = config.column; 5 | this.initialValue = initialValue; 6 | this._value = JSON.parse(this.initialValue || '[]'); 7 | this.el = null; 8 | this.dirty = false; 9 | } 10 | 11 | // Return a DOM element that will be shown to the user to edit this column's value 12 | createControl() { 13 | this.div = document.createElement('div'); 14 | this.div.classList.add('dux-json-tags'); 15 | this.ul = document.createElement('ul'); 16 | this.div.appendChild(this.ul); 17 | 18 | const input = document.createElement('input'); 19 | input.type = 'text'; 20 | this.div.appendChild(input); 21 | input.addEventListener('change', () => this.dirty = true); 22 | 23 | const syncChanges = () => { 24 | const parser = new DOMParser(); 25 | this.ul.replaceChildren(); 26 | let i = 0; 27 | for (const v of this._value) { 28 | const li = document.createElement('li'); 29 | 30 | const x = parser.parseFromString( 31 | ` 32 | 33 | 34 | `, 'image/svg+xml').documentElement; 35 | const captured = i; 36 | i++; 37 | x.addEventListener('click', () => { 38 | this.dirty = true; 39 | this._value.splice(captured, 1); 40 | syncChanges(); 41 | }); 42 | 43 | li.appendChild(x); 44 | const span = document.createElement('span'); 45 | span.innerText = v; 46 | li.appendChild(span); 47 | this.ul.appendChild(li); 48 | } 49 | } 50 | 51 | syncChanges(); 52 | 53 | return [ 54 | this.div, 55 | () => { 56 | const awesomplete = new Awesomplete(input, { 57 | minChars: 0, 58 | filter: () => { // We will provide a list that is already filtered ... 59 | return true; 60 | }, 61 | sort: false, // ... and sorted. 62 | list: [] 63 | }); 64 | 65 | input.addEventListener('awesomplete-selectcomplete', (e) => { 66 | const choice = e.text.value; 67 | this.dirty = true; 68 | this._value.push(choice); 69 | syncChanges(); 70 | input.value = ''; 71 | }); 72 | 73 | input.addEventListener('keyup', async (e) => { 74 | if (e.key === 'Enter') { 75 | if (e.target.value) { 76 | this.dirty = true; 77 | this._value.push(e.target.value); 78 | syncChanges(); 79 | input.value = ''; 80 | return; 81 | } 82 | } 83 | }) 84 | 85 | input.addEventListener('input', async (e) => { 86 | const rv = await fetch(this.autosuggestColumnUrl + '?' + new URLSearchParams({ 87 | column: this.column, 88 | q: e.target.value, 89 | })); 90 | const json = await rv.json(); 91 | 92 | const values = json.map(x => x.value); 93 | 94 | let exists = false; 95 | for (const entry of json) 96 | if (entry.value === e.target.value) 97 | exists = true; 98 | 99 | awesomplete.list = [ 100 | ...(exists ? [] : [e.target.value]), 101 | ...json.map(x => x.value) 102 | ]; 103 | awesomplete.evaluate(); 104 | }); 105 | 106 | } 107 | ] 108 | } 109 | 110 | get value() { 111 | return this.dirty ? JSON.stringify(this._value) : this.initialValue; 112 | } 113 | }; 114 | 115 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/NumberControl.js: -------------------------------------------------------------------------------- 1 | window.NumberControl = class NumberControl { 2 | constructor(initialValue, config) { 3 | this.initialValue = initialValue; 4 | this.el = null; 5 | this.dirty = false; 6 | } 7 | 8 | // Return a DOM element that will be shown to the user to edit this column's value 9 | createControl() { 10 | this.el = document.createElement('input'); 11 | this.el.type = 'text'; 12 | this.el.value = this.initialValue; 13 | this.el.addEventListener('change', () => this.dirty = true); 14 | 15 | return this.el; 16 | } 17 | 18 | get value() { 19 | if (!this.dirty) 20 | return this.initialValue; 21 | 22 | if (this.el.value === '') 23 | return null; 24 | 25 | return Number(this.el.value); 26 | } 27 | }; 28 | 29 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/StringAutocompleteControl.js: -------------------------------------------------------------------------------- 1 | window.StringAutocompleteControl = class StringAutocompleteControl { 2 | constructor(initialValue, config) { 3 | this.autosuggestColumnUrl = config.autosuggestColumnUrl; 4 | this.column = config.column; 5 | this.initialValue = initialValue; 6 | this.el = null; 7 | this.dirty = false; 8 | } 9 | 10 | // Return a DOM element that will be shown to the user to edit this column's value 11 | createControl() { 12 | this.el = document.createElement('input'); 13 | this.el.type = 'text'; 14 | this.el.value = this.initialValue; 15 | 16 | this.el.addEventListener('change', () => this.dirty = true); 17 | 18 | return [ 19 | this.el, 20 | () => { 21 | const awesomplete = new Awesomplete(this.el, { 22 | minChars: 0, 23 | filter: () => { // We will provide a list that is already filtered ... 24 | return true; 25 | }, 26 | sort: false, // ... and sorted. 27 | list: [] 28 | }); 29 | 30 | this.el.addEventListener('input', async (e) => { 31 | const rv = await fetch(this.autosuggestColumnUrl + '?' + new URLSearchParams({ 32 | column: this.column, 33 | q: e.target.value, 34 | })); 35 | const json = await rv.json(); 36 | 37 | const values = json.map(x => x.value); 38 | awesomplete.list = json.map(x => x.value); 39 | awesomplete.evaluate(); 40 | }); 41 | 42 | } 43 | ] 44 | } 45 | 46 | get value() { 47 | return this.dirty ? this.el.value : this.initialValue; 48 | } 49 | }; 50 | 51 | 52 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/StringControl.js: -------------------------------------------------------------------------------- 1 | window.StringControl = class StringControl { 2 | constructor(initialValue, config) { 3 | this.initialValue = initialValue; 4 | this.el = null; 5 | this.dirty = false; 6 | } 7 | 8 | // Return a DOM element that will be shown to the user to edit this column's value 9 | createControl() { 10 | this.el = document.createElement('input'); 11 | this.el.value = this.initialValue; 12 | this.el.addEventListener('change', () => this.dirty = true); 13 | 14 | return this.el; 15 | } 16 | 17 | get value() { 18 | return this.dirty ? this.el.value : this.initialValue; 19 | } 20 | }; 21 | 22 | 23 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/TextareaControl.js: -------------------------------------------------------------------------------- 1 | window.TextareaControl = class TextareaControl { 2 | constructor(initialValue, config) { 3 | this.initialValue = initialValue; 4 | this.el = null; 5 | this.dirty = false; 6 | } 7 | 8 | // Return a DOM element that will be shown to the user to edit this column's value 9 | createControl() { 10 | this.el = document.createElement('textarea'); 11 | this.el.rows = 5; 12 | this.el.value = this.initialValue; 13 | this.el.addEventListener('change', () => this.dirty = true); 14 | 15 | return this.el; 16 | } 17 | 18 | get value() { 19 | return this.dirty ? this.el.value : this.initialValue; 20 | } 21 | }; 22 | 23 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/edit-row/edit-row.css: -------------------------------------------------------------------------------- 1 | /* Try to hide the Other links section */ 2 | body.edit-row h2, body.edit-row h2 + ul { 3 | display: none; 4 | } 5 | 6 | body.row .table-wrapper { 7 | overflow-x: initial; 8 | } 9 | 10 | body.row .awesomplete { 11 | width: 100%; 12 | } 13 | 14 | body.row .awesomplete li { 15 | list-style-type: none; 16 | } 17 | 18 | body.row .awesomplete mark { 19 | background: initial; 20 | } 21 | 22 | body.row .btn-row-actions { 23 | display: inline-block; 24 | margin-right: 1em; 25 | margin-bottom: 1em; 26 | } 27 | 28 | body.row .btn-delete { 29 | background-color: #ff3333; 30 | } 31 | 32 | body.row input[type=text], body.row textarea, body.row select { 33 | padding: 6px 12px; 34 | box-sizing: content-box; 35 | font-size: 16px; 36 | font-weight: 400; 37 | line-height: 1.5; 38 | color: #212529; 39 | background-color: #fff; 40 | background-clip: padding-box; 41 | border: 1px solid #ced4da; 42 | appearance: none; 43 | border-radius: 4px; 44 | transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out; 45 | width: 80%; 46 | } 47 | 48 | body.row input.dux-date-picker { 49 | width: 20em; 50 | } 51 | 52 | body.row input.dux-fkey-picker { 53 | width: 20em; 54 | } 55 | 56 | body.row form .dux-nullable-date-picker label, body.row form .dux-nullable-fkey-picker label { 57 | font-weight: normal; 58 | width: auto; 59 | color: initial; 60 | } 61 | 62 | body.row .dux-nullable-fkey-picker .awesomplete { 63 | display: inline; 64 | } 65 | 66 | body.row input[type="checkbox"] { 67 | appearance: auto; 68 | width: 24px; 69 | height: 24px; 70 | } 71 | 72 | body.row input[type="radio"] { 73 | appearance: auto; 74 | width: initial; 75 | height: initial; 76 | } 77 | 78 | body.row input[type='text']:focus, body.row textarea:focus, body.row select:focus { 79 | color: #212529; 80 | background-color: #fff; 81 | border-color: #86b7fe; 82 | outline: 0; 83 | box-shadow: 0 0 0 0.25rem rgb(13 110 253 / 25%); 84 | } 85 | 86 | body.row select { 87 | appearance: auto; 88 | } 89 | 90 | .dux-json-tags li { 91 | list-style-type: none; 92 | line-height: 24px; 93 | } 94 | 95 | .dux-json-tags li span { 96 | vertical-align: top; 97 | } 98 | 99 | .dux-remove-me { 100 | display: inline-block; 101 | cursor: pointer; 102 | width: 20px; 103 | height: 20px; 104 | margin-right: 0.25em; 105 | margin-bottom: 0.5em; 106 | } 107 | 108 | body.table .form-insert-row { 109 | display: inline-block; 110 | margin-left: 0.5em; 111 | } 112 | 113 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/focus-search-box.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | function focusSearchBox() { 3 | const box = document.querySelector('.search-row #_search'); 4 | 5 | if (!box) 6 | return; 7 | 8 | const maybeFocus = (e) => { 9 | const tagName = (e.target || {}).tagName; 10 | 11 | if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') 12 | return; 13 | 14 | if (e.key !== '/') 15 | return; 16 | 17 | box.focus(); 18 | box.select(); 19 | e.preventDefault(); 20 | return false; 21 | }; 22 | 23 | document.addEventListener('keydown', maybeFocus); 24 | } 25 | 26 | addEventListener('DOMContentLoaded', focusSearchBox); 27 | })(); 28 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/hide-export.css: -------------------------------------------------------------------------------- 1 | #export { 2 | display: none; 3 | } 4 | 5 | #export:target { 6 | display: block; 7 | } 8 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/hide-filters.css: -------------------------------------------------------------------------------- 1 | body.table .filter-row { 2 | display: none; 3 | } 4 | 5 | body.table.dux-show-filters .filter-row { 6 | display: block; 7 | } 8 | 9 | body.table .filter-solid { 10 | display: none; 11 | } 12 | 13 | body.table.dux-show-filters .filter-outline { 14 | display: none; 15 | } 16 | 17 | body.table.dux-show-filters .filter-solid { 18 | display: block; 19 | } 20 | 21 | body.table form.filters + p:has(a[title^="select"]) { 22 | display: none; 23 | } 24 | 25 | body.table.dux-show-filters form.filters + p:has(a[title^="select"]) { 26 | display: block; 27 | } 28 | 29 | .dux-toggle-show-filters, .dux-toggle-show-filters:hover { 30 | filter: grayscale(100%); 31 | text-decoration: none !important; 32 | display: inline-block; 33 | width: 22px; 34 | height: 22px; 35 | line-height: 22px; 36 | vertical-align: middle; 37 | } 38 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/hide-filters.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | const FilterOutline = ` 3 | 4 | `; 5 | 6 | const FilterSolid = ` 7 | 8 | ` 9 | 10 | function applyDuxShowFiltersParameter() { 11 | const a = document.querySelector('.dux-toggle-show-filters'); 12 | const me = new URL(window.location.href); 13 | if (me.searchParams.get('_dux_show_filters') !== null) { 14 | document.body.classList.add('dux-show-filters'); 15 | me.searchParams.delete('_dux_show_filters'); 16 | } else { 17 | me.searchParams.set('_dux_show_filters', '1'); 18 | document.body.classList.remove('dux-show-filters'); 19 | } 20 | a.href = me.toString(); 21 | } 22 | 23 | function initialize() { 24 | // Only run on the table page 25 | if (!document.body.classList.contains('table')) 26 | return; 27 | 28 | const me = new URL(window.location.href); 29 | 30 | // Add a link that toggles the _dux_show_filters parameter 31 | const h3 = document.querySelector('h3'); 32 | if (!h3) 33 | return; 34 | 35 | const a = document.createElement('a'); 36 | a.innerHTML = `${FilterSolid}${FilterOutline}`; 37 | a.classList.add('dux-toggle-show-filters'); 38 | h3.append(a); 39 | applyDuxShowFiltersParameter(); 40 | 41 | a.addEventListener('click', (e) => { 42 | e.preventDefault(); 43 | window.history.replaceState(null, '', a.href); 44 | applyDuxShowFiltersParameter(); 45 | return false; 46 | }); 47 | } 48 | addEventListener('DOMContentLoaded', initialize); 49 | })(); 50 | 51 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/hide-table-definition.css: -------------------------------------------------------------------------------- 1 | body.table pre.wrapped-sql { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/layout-row-page.css: -------------------------------------------------------------------------------- 1 | /* Make the row page render the row vertically by assuming the CSS 2 | * for narrow screens. 3 | */ 4 | @media only screen { 5 | body.row .small-screen-only { 6 | display: initial; 7 | } 8 | body.row .select-wrapper.small-screen-only { 9 | display: inline-block; 10 | } 11 | 12 | body.row form.sql textarea { 13 | width: 95%; 14 | } 15 | /* Force table to not be like tables anymore */ 16 | body.row table.rows-and-columns, 17 | body.row .rows-and-columns thead, 18 | body.row .rows-and-columns tbody, 19 | body.row .rows-and-columns th, 20 | body.row .rows-and-columns td, 21 | body.row .rows-and-columns tr { 22 | display: block; 23 | } 24 | 25 | /* Hide table headers (but not display: none;, for accessibility) */ 26 | body.row .rows-and-columns thead tr { 27 | position: absolute; 28 | top: -9999px; 29 | left: -9999px; 30 | } 31 | 32 | body.row .rows-and-columns tr { 33 | border: 1px solid #ccc; 34 | margin-bottom: 1em; 35 | border-radius: 10px; 36 | background-color: white; 37 | padding: 0.2rem; 38 | } 39 | 40 | body.row .rows-and-columns td { 41 | /* Behave like a "row" */ 42 | border: none; 43 | border-bottom: 1px solid #eee; 44 | padding: 0; 45 | padding-left: 10%; 46 | } 47 | 48 | body.row .rows-and-columns td:before { 49 | display: block; 50 | color: black; 51 | margin-left: -10%; 52 | font-size: 0.8em; 53 | } 54 | 55 | body.row .select-wrapper { 56 | width: 100px; 57 | } 58 | body.row .select-wrapper.filter-op { 59 | width: 60px; 60 | } 61 | body.row .filters input.filter-value { 62 | width: 140px; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /datasette_ui_extras/static/layout-row-page.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | function initialize() { 3 | // Only run on the row page 4 | if (!document.body.classList.contains('row')) 5 | return; 6 | 7 | // Rewrite any media queries in the