Skip to content

Commit 8a3dd54

Browse files
Allow using a custom JS module for advanced customisation (#726)
1 parent faaf1ac commit 8a3dd54

7 files changed

Lines changed: 71 additions & 25 deletions

File tree

admin-js/src/App.js

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,33 @@ const DatagridSingle = (props) => (
4444
);
4545

4646
// Create a mapping of components, so we can reference them by name later.
47-
const COMPONENTS = Object.freeze({
47+
const COMPONENTS = {
4848
Datagrid, DatagridSingle,
4949

5050
BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField,
5151
ReferenceOneField, SelectField, TextField,
5252

5353
BooleanInput, DateInput, DateTimeInput, NumberInput, ReferenceInput, SelectInput,
5454
TextInput, TimeInput
55-
});
56-
const VALIDATORS = Object.freeze(
57-
{email, maxLength, maxValue, minLength, minValue, regex, required});
55+
};
56+
const USER_FUNCS = {};
57+
const VALIDATORS = {email, maxLength, maxValue, minLength, minValue, regex, required};
5858
const _body = document.querySelector("body");
5959
const STATE = Object.freeze(JSON.parse(_body.dataset.state));
6060

61+
let MODULE_LOADER;
62+
if (STATE["js_module"]) {
63+
// The inline comment skips the webpack import() and allows us to use the native
64+
// browser's import() function. Needed to dynamically import a module.
65+
MODULE_LOADER = import(/* webpackIgnore: true */ STATE["js_module"]).then((mod) => {
66+
Object.assign(COMPONENTS, mod.components);
67+
Object.assign(VALIDATORS, mod.validators);
68+
Object.assign(USER_FUNCS, mod.funcs);
69+
});
70+
} else {
71+
MODULE_LOADER = Promise.resolve();
72+
}
73+
6174
/** Make an authenticated API request and return the response object. */
6275
function apiRequest(url, options) {
6376
const headers = new Headers({
@@ -386,4 +399,4 @@ const App = () => (
386399
</Admin>
387400
);
388401

389-
export default App;
402+
export {App, MODULE_LOADER};

admin-js/src/index.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import React from "react";
22
import ReactDOM from "react-dom/client";
3-
import App from "./App";
3+
import {App, MODULE_LOADER} from "./App";
44

55
const root = ReactDOM.createRoot(document.getElementById("root"));
6-
root.render(
7-
<React.StrictMode>
8-
<App />
9-
</React.StrictMode>
10-
);
6+
MODULE_LOADER.then(() => {
7+
root.render(
8+
<React.StrictMode>
9+
<App />
10+
</React.StrictMode>
11+
);
12+
});

aiohttp_admin/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def value(r: web.RouteDef) -> tuple[str, str]:
7676
admin.on_startup.append(on_startup)
7777
admin["check_credentials"] = schema["security"]["check_credentials"]
7878
admin["identity_callback"] = schema["security"].get("identity_callback")
79-
admin["state"] = {"view": schema.get("view", {})}
79+
admin["state"] = {"view": schema.get("view", {}), "js_module": schema.get("js_module")}
8080

8181
max_age = schema["security"].get("max_age")
8282
secure = schema["security"].get("secure", True)

aiohttp_admin/routes.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
from . import views
99
from .types import Schema
1010

11-
_VALIDATORS = ("email", "maxLength", "maxValue", "minLength", "minValue", "regex", "required")
12-
1311

1412
def setup_resources(admin: web.Application, schema: Schema) -> None:
1513
admin["resources"] = []
@@ -37,8 +35,6 @@ def setup_resources(admin: web.Application, schema: Schema) -> None:
3735
inputs = copy.deepcopy(m.inputs)
3836

3937
for name, validators in r.get("validators", {}).items():
40-
if not all(v[0] in _VALIDATORS for v in validators):
41-
raise ValueError(f"First value in validators must be one of {_VALIDATORS}")
4238
inputs[name] = inputs[name].copy()
4339
inputs[name]["validators"] = tuple(inputs[name]["validators"]) + tuple(validators)
4440

aiohttp_admin/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ class Resource(_Resource):
8484

8585
class _Schema(TypedDict, total=False):
8686
view: _ViewSchema
87+
js_module: str
8788

8889

8990
class Schema(_Schema):
@@ -105,3 +106,4 @@ class State(TypedDict):
105106
resources: dict[str, _ResourceState]
106107
urls: dict[str, str]
107108
view: _ViewSchema
109+
js_module: Optional[str]

examples/validators.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
import aiohttp_admin
1212
from aiohttp_admin.backends.sqlalchemy import SAResource
1313

14+
JS = """
15+
const odd = () => (value, allValues) => {
16+
if (value % 2 === 0)
17+
return "Votes must be an odd number";
18+
return undefined;
19+
};
20+
21+
export const validators = {odd};
22+
"""
23+
1424

1525
class Base(DeclarativeBase):
1626
"""Base model."""
@@ -26,13 +36,18 @@ class User(Base):
2636
votes: Mapped[int] = mapped_column()
2737

2838
__table_args__ = (sa.CheckConstraint(sa.func.char_length(username) >= 3),
29-
sa.CheckConstraint(votes >= 1), sa.CheckConstraint(votes < 5))
39+
sa.CheckConstraint(votes >= 1), sa.CheckConstraint(votes < 6),
40+
sa.CheckConstraint(votes % 2 == 1))
3041

3142

3243
async def check_credentials(username: str, password: str) -> bool:
3344
return username == "admin" and password == "admin"
3445

3546

47+
async def serve_js(request: web.Request) -> web.Response:
48+
return web.Response(text=JS, content_type="text/javascript")
49+
50+
3651
async def create_app() -> web.Application:
3752
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
3853
session = async_sessionmaker(engine, expire_on_commit=False)
@@ -41,10 +56,11 @@ async def create_app() -> web.Application:
4156
async with engine.begin() as conn:
4257
await conn.run_sync(Base.metadata.create_all)
4358
async with session.begin() as sess:
44-
sess.add(User(username="Foo", votes=4))
59+
sess.add(User(username="Foo", votes=5))
4560
sess.add(User(username="Spam", votes=1, note="Second user"))
4661

4762
app = web.Application()
63+
app.router.add_get("/js", serve_js, name="js")
4864

4965
# This is the setup required for aiohttp-admin.
5066
schema: aiohttp_admin.Schema = {
@@ -54,7 +70,12 @@ async def create_app() -> web.Application:
5470
},
5571
"resources": ({"model": SAResource(engine, User),
5672
"validators": {User.username.name: (("regex", r"^[A-Z][a-z]+$"),),
57-
User.email.name: (("email",),)}},)
73+
User.email.name: (("email",),),
74+
# Custom validator from our JS module.
75+
# Min/Max validators are automatically included.
76+
User.votes.name: (("odd",),)}},),
77+
# Use our JS module to include our custom validator.
78+
"js_module": str(app.router["js"].url_for())
5879
}
5980
aiohttp_admin.setup(app, schema)
6081

tests/test_admin.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ def test_path() -> None:
1919
assert str(admin.router["index"].url_for()) == "/another/admin"
2020

2121

22+
def test_js_module() -> None:
23+
app = web.Application()
24+
schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials},
25+
"resources": (), "js_module": "/custom_js.js"}
26+
admin = aiohttp_admin.setup(app, schema)
27+
28+
assert admin["state"]["js_module"] == "/custom_js.js"
29+
30+
31+
def test_no_js_module() -> None:
32+
app = web.Application()
33+
schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials},
34+
"resources": ()}
35+
admin = aiohttp_admin.setup(app, schema)
36+
37+
assert admin["state"]["js_module"] is None
38+
39+
2240
def test_validators() -> None:
2341
dummy = DummyResource(
2442
"dummy", {"id": {"type": "NumberField", "props": {}}},
@@ -34,12 +52,6 @@ def test_validators() -> None:
3452
assert validators == (("required",), ("minValue", "3"))
3553
assert ("minValue", "3") not in dummy.inputs["id"]["validators"]
3654

37-
# Invalid validator
38-
schema = {"security": {"check_credentials": check_credentials},
39-
"resources": ({"model": dummy, "validators": {"id": (("bad", 3),)}},)}
40-
with pytest.raises(ValueError, match="validators must be one of"):
41-
aiohttp_admin.setup(app, schema)
42-
4355

4456
def test_re() -> None:
4557
test_re = DummyResource("testre", {"id": {"type": "NumberField", "props": {}},

0 commit comments

Comments
 (0)