diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..50391af --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,58 @@ +name: Publish Python Package + +on: + release: + types: [created] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 + name: Configure pip caching + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies + run: | + pip install -e '.[test]' + - name: Run tests + run: | + pytest + deploy: + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: "3.10" + - uses: actions/cache@v2 + name: Configure pip caching + with: + path: ~/.cache/pip + key: ${{ runner.os }}-publish-pip-${{ hashFiles('**/setup.py') }} + restore-keys: | + ${{ runner.os }}-publish-pip- + - name: Install dependencies + run: | + pip install setuptools wheel twine build + - name: Publish + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + python -m build + twine upload dist/* + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2ba2afc --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Test + +on: [push] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/cache@v2 + name: Configure pip caching + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies + run: | + pip install -e '.[test]' + - name: Run tests + run: | + pytest + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e61ce1d --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.venv +__pycache__/ +*.py[cod] +*$py.class +venv +.eggs +.pytest_cache +*.egg-info +.DS_Store +.vscode +dist +build diff --git a/README.md b/README.md new file mode 100644 index 0000000..0de9aae --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# datasette-hovercards + +[![PyPI](https://img.shields.io/pypi/v/datasette-hovercards.svg)](https://pypi.org/project/datasette-hovercards/) +[![Changelog](https://img.shields.io/github/v/release/simonw/datasette-hovercards?include_prereleases&label=changelog)](https://github.com/simonw/datasette-hovercards/releases) +[![Tests](https://github.com/simonw/datasette-hovercards/workflows/Test/badge.svg)](https://github.com/simonw/datasette-hovercards/actions?query=workflow%3ATest) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-hovercards/blob/main/LICENSE) + +Add preview hovercards to links in Datasette + +## Installation + +Install this plugin in the same environment as Datasette. + + $ datasette install datasette-hovercards + +## Usage + +Usage instructions go here. + +## Development + +To set up this plugin locally, first checkout the code. Then create a new virtual environment: + + cd datasette-hovercards + python3 -mvenv venv + source venv/bin/activate + +Or if you are using `pipenv`: + + pipenv shell + +Now install the dependencies and test dependencies: + + pip install -e '.[test]' + +To run the tests: + + pytest diff --git a/datasette_hovercards/__init__.py b/datasette_hovercards/__init__.py new file mode 100644 index 0000000..901c0d0 --- /dev/null +++ b/datasette_hovercards/__init__.py @@ -0,0 +1,11 @@ +from datasette import hookimpl + + +@hookimpl +def extra_js_urls(datasette, table): + if table: + return [ + datasette.urls.static_plugins( + "datasette-hovercards", "datasette-hovercards.js" + ) + ] diff --git a/datasette_hovercards/static/datasette-hovercards.js b/datasette_hovercards/static/datasette-hovercards.js new file mode 100644 index 0000000..96840d7 --- /dev/null +++ b/datasette_hovercards/static/datasette-hovercards.js @@ -0,0 +1,89 @@ +let hovercardOuterAnimation = null; +let hovercardInnerAnimation = null; +let hovercard = document.createElement("div"); +hovercard.setAttribute("id", "datasette-hovercard") +hovercard.style.width = '300px'; +hovercard.style.height = '200px'; +hovercard.style.overflow = 'auto'; +hovercard.style.backgroundColor = 'white'; +hovercard.style.border = '1px solid #ccc'; +hovercard.style.padding = '10px'; +hovercard.style.position = 'absolute'; +hovercard.style.display = 'none'; +hovercard.style.boxShadow = '1px 2px 8px 2px rgba(0,0,0,0.08)'; + +const hovercardEscape = (s) => { + return (s || "").toString() + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}; + +document.addEventListener("mouseover", async (ev) => { + const a = ev.target; + if (a.nodeName != 'A') { + return; + } + // TODO: Respect base_url and suchlike + if (a.pathname.split("/").length != 4) { + return; // Definitely not a row + } + // OK, it might be a row! Try a fetch + let row; + if (a.hovercardRowData) { + row = a.hovercardRowData; + } else { + const response = await fetch(a.pathname + ".json?_shape=array"); + if (response.status == 200) { + const data = await response.json(); + row = data[0]; + a.hovercardRowData = row; + } + } + if (row) { + // Cancel any existing animations + if (hovercardOuterAnimation) { + clearTimeout(hovercardOuterAnimation); + } + if (hovercardInnerAnimation) { + clearTimeout(hovercardInnerAnimation); + } + hovercard.style.top = (ev.pageY + 5) + 'px'; + hovercard.style.left = (ev.pageX - 15) + 'px'; + let html = ['
']; + for (const [key, value] of Object.entries(row)) { + html.push(` +
${hovercardEscape(key)}
+
+ ${hovercardEscape(value) || ' '} +
+ `); + } + html.push("
") + hovercard.innerHTML = html.join(""); + hovercard.style.display = 'block'; + hovercard.style.opacity = 100; + hovercard.style.transition = 'none'; + } +}); + +document.addEventListener("mouseout", (ev) => { + if (ev.target.id != "datasette-hovercard") { + return; + } + hovercardOuterAnimation = setTimeout(() => { + hovercard.style.transition = 'opacity 0.4s ease-in-out'; + hovercard.style.opacity = 0; + hovercardInnerAnimation = setTimeout(() => { + hovercard.style.transition = 'none'; + hovercard.style.display = "none"; + hovercard.style.opacity = 100; + }, 800) + }, 400); +}) + +document.addEventListener("DOMContentLoaded", () => { + document.body.appendChild(hovercard); +}); diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..97f7f8e --- /dev/null +++ b/setup.py @@ -0,0 +1,39 @@ +from setuptools import setup +import os + +VERSION = "0.1" + + +def get_long_description(): + with open( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), + encoding="utf8", + ) as fp: + return fp.read() + + +setup( + name="datasette-hovercards", + description="Add preview hovercards to links in Datasette", + long_description=get_long_description(), + long_description_content_type="text/markdown", + author="Simon Willison", + url="https://github.com/simonw/datasette-hovercards", + project_urls={ + "Issues": "https://github.com/simonw/datasette-hovercards/issues", + "CI": "https://github.com/simonw/datasette-hovercards/actions", + "Changelog": "https://github.com/simonw/datasette-hovercards/releases", + }, + license="Apache License, Version 2.0", + classifiers=[ + "Framework :: Datasette", + "License :: OSI Approved :: Apache Software License", + ], + version=VERSION, + packages=["datasette_hovercards"], + entry_points={"datasette": ["hovercards = datasette_hovercards"]}, + install_requires=["datasette"], + extras_require={"test": ["pytest", "pytest-asyncio"]}, + package_data={"datasette_hovercards": ["static/*"]}, + python_requires=">=3.6", +) diff --git a/tests/test_hovercards.py b/tests/test_hovercards.py new file mode 100644 index 0000000..0087cfa --- /dev/null +++ b/tests/test_hovercards.py @@ -0,0 +1,25 @@ +from datasette.app import Datasette +import pytest + + +@pytest.mark.asyncio +async def test_static_asset(): + datasette = Datasette([], memory=True) + response = await datasette.client.get( + "/-/static-plugins/datasette-hovercards/datasette-hovercards.js" + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("path,expected", (("/db/t", True), ("/db", False))) +async def test_included_on_table_page(path, expected): + datasette = Datasette([], memory=True) + db = datasette.add_memory_database("db") + if not await db.table_exists("t"): + await db.execute_write("create table t (id integer primary key)", block=True) + response = await datasette.client.get(path) + if expected: + assert "datasette-hovercards.js" in response.text + else: + assert "datasette-hovercards.js" not in response.text