Skip to content

Commit cbf9a5d

Browse files
Configurable columns (#687)
1 parent 1dc16f0 commit cbf9a5d

File tree

8 files changed

+115
-31
lines changed

8 files changed

+115
-31
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ per-file-ignores =
1616
examples/*:I900,S105
1717

1818
# flake8-import-order
19-
application-import-names = aiohttp_admin, _auth, _auth_helpers, _models
19+
application-import-names = aiohttp_admin, _auth, _auth_helpers, _models, _resources
2020
import-order-style = pycharm
2121

2222
# flake8-quotes

admin-js/src/App.js

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
2-
Admin, Create, Datagrid, Edit, EditButton, List, HttpError, Resource, SimpleForm,
2+
Admin, Create, Datagrid, DatagridConfigurable, Edit, EditButton, List, HttpError, Resource, SimpleForm,
3+
SelectColumnsButton, CreateButton, FilterButton, ExportButton, TopToolbar,
4+
AppBar, InspectorButton, Layout, TitlePortal,
35
BulkDeleteButton, BulkExportButton, BulkUpdateButton,
46
SimpleShowLayout, Show,
57
AutocompleteInput,
@@ -106,11 +108,10 @@ const authProvider = {
106108
};
107109

108110

109-
function createFields(resource, name, permissions, display_only=false) {
111+
function createFields(resource, name, permissions) {
110112
let components = [];
111113
for (const [field, state] of Object.entries(resource["fields"])) {
112-
if ((display_only && !resource["display"].includes(field))
113-
|| !hasPermission(`${name}.${field}.view`, permissions))
114+
if (!hasPermission(`${name}.${field}.view`, permissions))
114115
continue;
115116

116117
const C = COMPONENTS[state["type"]];
@@ -128,7 +129,7 @@ function createFields(resource, name, permissions, display_only=false) {
128129
c = <C source={field} {...state["props"]} />;
129130
}
130131
// Show icon if user doesn't have permission to view this field (based on filters).
131-
components.push(<WithRecord label={state["props"]["label"] || field} render={
132+
components.push(<WithRecord source={field} label={state["props"]["label"] || field} render={
132133
(record) => hasPermission(`${name}.${field}.view`, permissions, record) ? c : <VisibilityOffIcon />
133134
} />);
134135
}
@@ -164,7 +165,7 @@ function createInputs(resource, name, perm_type, permissions) {
164165
const c = <C source={field} {...state["props"]} />;
165166
if (perm_type === "edit")
166167
// Don't render if filters disallow editing this field.
167-
components.push(<WithRecord render={
168+
components.push(<WithRecord source={field} render={
168169
(record) => hasPermission(`${name}.${field}.${perm_type}`, permissions, record) && c
169170
} />);
170171
else
@@ -191,6 +192,14 @@ function createBulkUpdates(resource, name, permissions) {
191192
}
192193

193194
const AiohttpList = (resource, name, permissions) => {
195+
const ListActions = () => (
196+
<TopToolbar>
197+
<SelectColumnsButton />
198+
<FilterButton />
199+
{hasPermission(`${name}.add`, permissions) && <CreateButton />}
200+
<ExportButton />
201+
</TopToolbar>
202+
);
194203
const BulkActionButtons = () => (
195204
<>
196205
{hasPermission(`${name}.edit`, permissions) && createBulkUpdates(resource, name, permissions)}
@@ -200,11 +209,11 @@ const AiohttpList = (resource, name, permissions) => {
200209
);
201210

202211
return (
203-
<List filters={createInputs(resource, name, "view", permissions)}>
204-
<Datagrid rowClick="show" bulkActionButtons={<BulkActionButtons />}>
205-
{createFields(resource, name, permissions, true)}
206-
<WithRecord render={(record) => hasPermission(`${name}.edit`, permissions, record) && <EditButton />} />
207-
</Datagrid>
212+
<List actions={<ListActions />} filters={createInputs(resource, name, "view", permissions)}>
213+
<DatagridConfigurable omit={resource["list_omit"]} rowClick="show" bulkActionButtons={<BulkActionButtons />}>
214+
{createFields(resource, name, permissions)}
215+
<WithRecord label="[Edit]" render={(record) => hasPermission(`${name}.edit`, permissions, record) && <EditButton />} />
216+
</DatagridConfigurable>
208217
</List>
209218
);
210219
}
@@ -303,8 +312,16 @@ function createResources(resources, permissions) {
303312
return components;
304313
}
305314

315+
const AiohttpAppBar = () => (
316+
<AppBar>
317+
<TitlePortal />
318+
<InspectorButton />
319+
</AppBar>
320+
);
321+
306322
const App = () => (
307-
<Admin dataProvider={dataProvider} authProvider={authProvider} title={STATE["view"]["name"]} disableTelemetry requireAuth>
323+
<Admin dataProvider={dataProvider} authProvider={authProvider} title={STATE["view"]["name"]}
324+
layout={(props) => <Layout {...props} appBar={AiohttpAppBar} />} disableTelemetry requireAuth>
308325
{permissions => createResources(STATE["resources"], permissions)}
309326
</Admin>
310327
);

aiohttp_admin/routes.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,20 @@ def setup_resources(admin: web.Application, schema: Schema) -> None:
1818
admin.router.add_routes(m.routes)
1919

2020
try:
21-
display_fields = r["display"]
21+
omit_fields = m.fields.keys() - r["display"]
2222
except KeyError:
23-
display_fields = list(m.fields.keys())
23+
omit_fields = ()
2424
else:
25-
if not all(f in m.fields for f in display_fields):
26-
raise ValueError(f"Display includes non-existent field {display_fields}")
25+
if not all(f in m.fields for f in r["display"]):
26+
raise ValueError(f"Display includes non-existent field {r['display']}")
2727

2828
repr_field = r.get("repr", m.primary_key)
2929

3030
for k, v in m.inputs.items():
31-
if k in display_fields:
31+
if k not in omit_fields:
3232
v["props"]["alwaysOn"] = "alwaysOn" # Always display filter
3333

34-
state = {"fields": m.fields, "inputs": m.inputs, "display": display_fields,
34+
state = {"fields": m.fields, "inputs": m.inputs, "list_omit": tuple(omit_fields),
3535
"repr": repr_field, "label": r.get("label"), "icon": r.get("icon"),
3636
"bulk_update": r.get("bulk_update", {})}
3737
admin["state"]["resources"][m.name] = state

aiohttp_admin/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class _ViewSchema(TypedDict, total=False):
5353

5454

5555
class _Resource(TypedDict, total=False):
56-
# List of field names that should be shown in the list view.
56+
# List of field names that should be shown in the list view by default.
5757
display: Sequence[str]
5858
# Display label in admin.
5959
label: str

examples/permissions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def create_app() -> web.Application:
6868
"secure": False
6969
},
7070
"resources": (
71-
{"model": SAResource(engine, Simple),
71+
{"model": SAResource(engine, Simple), "display": ("id", "num", "optional_num"),
7272
"bulk_update": {"Set to 7": {"optional_num": 7}}},
7373
{"model": SAResource(engine, SimpleParent)}
7474
)

tests/_resources.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from typing import Union
2+
3+
from aiohttp_admin.backends.abc import (
4+
AbstractAdminResource, CreateParams, DeleteManyParams, DeleteParams, GetListParams,
5+
GetManyParams, GetOneParams, Record, UpdateManyParams, UpdateParams)
6+
from aiohttp_admin.types import FieldState, InputState
7+
8+
9+
class DummyResource(AbstractAdminResource):
10+
def __init__(self, name: str, fields: dict[str, FieldState],
11+
inputs: dict[str, InputState], primary_key: str):
12+
self.name = name
13+
self.fields = fields
14+
self.inputs = inputs
15+
self.primary_key = primary_key
16+
super().__init__()
17+
18+
async def get_list(self, params: GetListParams) -> tuple[list[Record], int]: # pragma: no cover # noqa: B950
19+
raise NotImplementedError()
20+
21+
async def get_one(self, params: GetOneParams) -> Record: # pragma: no cover
22+
raise NotImplementedError()
23+
24+
async def get_many(self, params: GetManyParams) -> list[Record]: # pragma: no cover
25+
raise NotImplementedError()
26+
27+
async def update(self, params: UpdateParams) -> Record: # pragma: no cover
28+
raise NotImplementedError()
29+
30+
async def update_many(self, params: UpdateManyParams) -> list[Union[int, str]]: # pragma: no cover # noqa: B950
31+
raise NotImplementedError()
32+
33+
async def create(self, params: CreateParams) -> Record: # pragma: no cover
34+
raise NotImplementedError()
35+
36+
async def delete(self, params: DeleteParams) -> Record: # pragma: no cover
37+
raise NotImplementedError()
38+
39+
async def delete_many(self, params: DeleteManyParams) -> list[Union[int, str]]: # pragma: no cover # noqa: B950
40+
raise NotImplementedError()

tests/test_admin.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1+
import pytest
12
from aiohttp import web
2-
from sqlalchemy.ext.asyncio import AsyncEngine
3-
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
43

54
import aiohttp_admin
65
from _auth import check_credentials
7-
from aiohttp_admin.backends.sqlalchemy import SAResource
6+
from _resources import DummyResource
87

98

109
def test_path() -> None:
@@ -20,15 +19,13 @@ def test_path() -> None:
2019
assert str(admin.router["index"].url_for()) == "/another/admin"
2120

2221

23-
def test_re(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
24-
class TestRE(base): # type: ignore[misc,valid-type]
25-
__tablename__ = "testre"
26-
id: Mapped[int] = mapped_column(primary_key=True)
27-
value: Mapped[str]
22+
def test_re() -> None:
23+
test_re = DummyResource("testre", {"id": {"type": "NumberField", "props": {}},
24+
"value": {"type": "TextField", "props": {}}}, {}, "id")
2825

2926
app = web.Application()
3027
schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials},
31-
"resources": ({"model": SAResource(mock_engine, TestRE)},)}
28+
"resources": ({"model": test_re},)}
3229
admin = aiohttp_admin.setup(app, schema)
3330
r = admin["permission_re"]
3431

@@ -53,3 +50,33 @@ class TestRE(base): # type: ignore[misc,valid-type]
5350
assert r.fullmatch("admin.testre.value.*|value=unquoted") is None
5451
assert r.fullmatch("~admin.testre.edit|id=5") is None
5552
assert r.fullmatch('~admin.testre.value.delete|value="1"') is None
53+
54+
55+
def test_display() -> None:
56+
app = web.Application()
57+
model = DummyResource(
58+
"test",
59+
{"id": {"type": "TextField", "props": {}}, "foo": {"type": "TextField", "props": {}}},
60+
{"id": {"type": "TextInput", "props": {}, "show_create": False},
61+
"foo": {"type": "TextInput", "props": {}, "show_create": True}},
62+
"id")
63+
schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials},
64+
"resources": ({"model": model, "display": ("foo",)},)}
65+
66+
admin = aiohttp_admin.setup(app, schema)
67+
68+
test_state = admin["state"]["resources"]["test"]
69+
assert test_state["list_omit"] == ("id",)
70+
assert test_state["inputs"]["id"]["props"] == {}
71+
assert test_state["inputs"]["foo"]["props"] == {"alwaysOn": "alwaysOn"}
72+
73+
74+
def test_display_invalid() -> None:
75+
app = web.Application()
76+
model = DummyResource("test", {"id": {"type": "TextField", "props": {}},
77+
"foo": {"type": "TextField", "props": {}}}, {}, "id")
78+
schema: aiohttp_admin.Schema = {"security": {"check_credentials": check_credentials},
79+
"resources": ({"model": model, "display": ("bar",)},)}
80+
81+
with pytest.raises(ValueError, match=r"Display includes non-existent field \('bar',\)"):
82+
aiohttp_admin.setup(app, schema)

tests/test_views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ async def test_admin_view(admin_client: TestClient) -> None:
2828
state = json.loads(m.group(1))
2929

3030
r = state["resources"]["dummy"]
31-
assert r["display"] == ["id"]
31+
assert r["list_omit"] == []
3232
assert r["fields"] == {"id": {"type": "NumberField", "props": {"alwaysOn": "alwaysOn"}}}
3333
assert r["inputs"] == {"id": {"type": "NumberInput", "props": {"alwaysOn": "alwaysOn"},
3434
"show_create": False}}

0 commit comments

Comments
 (0)