├── .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 | [](https://pypi.org/project/datasette-ui-extras/)
4 | [](https://github.com/cldellow/datasette-ui-extras/releases)
5 | [](https://github.com/cldellow/datasette-ui-extras/actions?query=workflow%3ATest)
6 | [](https://codecov.io/gh/cldellow/datasette-ui-extras)
7 | [](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 | `
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 = ``;
5 |
6 | const FilterSolid = ``
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