Skip to content

Commit 89cab8f

Browse files
Update fields (#722)
1 parent 63f31a7 commit 89cab8f

8 files changed

Lines changed: 124 additions & 54 deletions

File tree

admin-js/src/App.js

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {
1212
FilterButton, SelectColumnsButton, TopToolbar,
1313
// Fields
1414
BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField,
15-
ReferenceOneField, TextField,
15+
ReferenceOneField, SelectField, TextField,
1616
// Inputs
17-
BooleanInput, DateInput, DateTimeInput, NumberInput, SelectInput, TextInput,
17+
BooleanInput, DateInput, DateTimeInput, NumberInput, SelectInput, TextInput, TimeInput,
1818
ReferenceInput as _ReferenceInput,
1919
// Filters
2020
email, maxLength, maxValue, minLength, minValue, regex, required,
@@ -37,22 +37,26 @@ const ReferenceInput = (props) => {
3737
/** Display a single record in a Datagrid-like view (e.g. for ReferenceField). */
3838
const DatagridSingle = (props) => (
3939
<WithRecord {...props} render={
40-
(record) => <Datagrid {...props} data={[record]} bulkActionButtons={false} hover={false} rowClick={false} setSort={false} />
40+
(record) => <Datagrid {...props} data={[record]} bulkActionButtons={false}
41+
hover={false} rowClick={false} setSort={false}
42+
sort={{field: "id", order: "DESC"}} />
4143
} />
4244
);
4345

44-
45-
const _body = document.querySelector("body");
46-
const STATE = JSON.parse(_body.dataset.state);
4746
// Create a mapping of components, so we can reference them by name later.
48-
const COMPONENTS = {
47+
const COMPONENTS = Object.freeze({
4948
Datagrid, DatagridSingle,
5049

5150
BooleanField, DateField, NumberField, ReferenceField, ReferenceManyField,
52-
ReferenceOneField, TextField,
51+
ReferenceOneField, SelectField, TextField,
5352

54-
BooleanInput, DateInput, DateTimeInput, NumberInput, ReferenceInput, TextInput};
55-
const VALIDATORS = {email, maxLength, maxValue, minLength, minValue, regex, required};
53+
BooleanInput, DateInput, DateTimeInput, NumberInput, ReferenceInput, SelectInput,
54+
TextInput, TimeInput
55+
});
56+
const VALIDATORS = Object.freeze(
57+
{email, maxLength, maxValue, minLength, minValue, regex, required});
58+
const _body = document.querySelector("body");
59+
const STATE = Object.freeze(JSON.parse(_body.dataset.state));
5660

5761
/** Make an authenticated API request and return the response object. */
5862
function apiRequest(url, options) {
@@ -139,24 +143,27 @@ function createFields(resource, name, permissions) {
139143
if (C === undefined)
140144
throw Error(`Unknown component '${state["type"]}'`);
141145

146+
const {children, ...props} = state["props"];
142147
let c;
143-
if (state["props"]["children"]) {
144-
let child_fields = createFields({"fields": state["props"]["children"],
145-
"display": Object.keys(state["props"]["children"])},
146-
name, permissions);
147-
delete state["props"]["children"];
148-
c = <C source={field} {...state["props"]}>{child_fields}</C>;
148+
if (children) {
149+
let child_fields = createFields(
150+
{"fields": children, "display": Object.keys(children)}, name, permissions);
151+
c = <C source={field} {...props}>{child_fields}</C>;
149152
} else {
150-
c = <C source={field} {...state["props"]} />;
153+
c = <C source={field} {...props} />;
151154
}
152-
if (field === "_")
155+
if (field === "_") {
153156
// Layout component, not related to a specific field.
154157
components.push(c);
155-
else
158+
} else {
159+
const withRecordProps = {
160+
"source": field, "label": props["label"], "sortable": props["sortable"],
161+
"sortBy": props["sortBy"], "sortByOrder": props["sortByOrder"]}
156162
// Show icon if user doesn't have permission to view this field (based on filters).
157-
components.push(<WithRecord source={field} label={state["props"]["label"]} render={
163+
components.push(<WithRecord {...withRecordProps} render={
158164
(record) => hasPermission(`${name}.${field}.view`, permissions, record) ? c : <VisibilityOffIcon />
159165
} />);
166+
}
160167
}
161168
return components;
162169
}

aiohttp_admin/backends/abc.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import json
33
import warnings
44
from abc import ABC, abstractmethod
5-
from datetime import date, datetime
5+
from datetime import date, datetime, time
66
from enum import Enum
77
from functools import cached_property, partial
88
from types import MappingProxyType
@@ -21,14 +21,18 @@
2121
"BooleanInput": bool,
2222
"DateInput": date,
2323
"DateTimeInput": datetime,
24-
"NumberInput": float
24+
"NumberInput": float,
25+
"TimeInput": time
2526
})
2627

2728

2829
class Encoder(json.JSONEncoder):
2930
def default(self, o: object) -> Any:
3031
if isinstance(o, date):
3132
return str(o)
33+
if isinstance(o, time):
34+
# React-admin needs a datetime to display only a time...
35+
return f"2000-01-01T{o}"
3236
if isinstance(o, Enum):
3337
return o.value
3438

@@ -95,6 +99,7 @@ class AbstractAdminResource(ABC):
9599
fields: dict[str, FieldState]
96100
inputs: dict[str, InputState]
97101
primary_key: str
102+
omit_fields: set[str]
98103

99104
def __init__(self) -> None:
100105
if "id" in self.fields and self.primary_key != "id":

aiohttp_admin/backends/sqlalchemy.py

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import operator
55
import sys
66
from collections.abc import Callable, Coroutine, Iterator, Sequence
7-
from types import MappingProxyType
7+
from types import MappingProxyType as MPT
88
from typing import Any, Literal, Optional, TypeVar, Union
99

1010
import sqlalchemy as sa
@@ -30,17 +30,54 @@
3030

3131
logger = logging.getLogger(__name__)
3232

33-
FIELD_TYPES = MappingProxyType({
34-
sa.Integer: ("NumberField", "NumberInput"),
35-
sa.Text: ("TextField", "TextInput"),
36-
sa.Float: ("NumberField", "NumberInput"),
37-
sa.Date: ("DateField", "DateInput"),
38-
sa.DateTime: ("DateField", "DateTimeInput"),
39-
sa.Boolean: ("BooleanField", "BooleanInput"),
40-
sa.String: ("TextField", "TextInput")
33+
FIELD_TYPES: MPT[type[sa.types.TypeEngine[Any]], tuple[str, str, MPT[str, bool]]] = MPT({
34+
sa.Boolean: ("BooleanField", "BooleanInput", MPT({})),
35+
sa.Date: ("DateField", "DateInput", MPT({"showDate": True, "showTime": False})),
36+
sa.DateTime: ("DateField", "DateTimeInput", MPT({"showDate": True, "showTime": True})),
37+
sa.Enum: ("SelectField", "SelectInput", MPT({})),
38+
sa.Integer: ("NumberField", "NumberInput", MPT({})),
39+
sa.Numeric: ("NumberField", "NumberInput", MPT({})),
40+
sa.String: ("TextField", "TextInput", MPT({})),
41+
sa.Time: ("DateField", "TimeInput", MPT({"showDate": False, "showTime": True})),
42+
sa.Uuid: ("TextField", "TextInput", MPT({})), # TODO: validators
43+
# TODO: Set fields for below types.
44+
# sa.sql.sqltypes._AbstractInterval: (),
45+
# sa.types._Binary: (),
46+
# sa.types.PickleType: (),
47+
48+
# sa.ARRAY: (),
49+
# sa.JSON: (),
50+
51+
# sa.dialects.postgresql.AbstractRange: (),
52+
# sa.dialects.postgresql.BIT: (),
53+
# sa.dialects.postgresql.CIDR: (),
54+
# sa.dialects.postgresql.HSTORE: (),
55+
# sa.dialects.postgresql.INET: (),
56+
# sa.dialects.postgresql.MACADDR: (),
57+
# sa.dialects.postgresql.MACADDR8: (),
58+
# sa.dialects.postgresql.MONEY: (),
59+
# sa.dialects.postgresql.OID: (),
60+
# sa.dialects.postgresql.REGCONFIG: (),
61+
# sa.dialects.postgresql.REGCLASS: (),
62+
# sa.dialects.postgresql.TSQUERY: (),
63+
# sa.dialects.postgresql.TSVECTOR: (),
64+
# sa.dialects.mysql.BIT: (),
65+
# sa.dialects.mysql.YEAR: (),
66+
# sa.dialects.oracle.ROWID: (),
67+
# sa.dialects.mssql.MONEY: (),
68+
# sa.dialects.mssql.SMALLMONEY: (),
69+
# sa.dialects.mssql.SQL_VARIANT: (),
4170
})
4271

4372

73+
def get_components(t: sa.types.TypeEngine[object]) -> tuple[str, str, dict[str, bool]]:
74+
for key, (field, inp, props) in FIELD_TYPES.items():
75+
if isinstance(t, key):
76+
return (field, inp, props.copy())
77+
78+
return ("TextField", "TextInput", {})
79+
80+
4481
def handle_errors(
4582
f: Callable[_P, Coroutine[None, None, _T]]
4683
) -> Callable[_P, Coroutine[None, None, _T]]:
@@ -129,16 +166,20 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[Declara
129166
self.name = table.name
130167
self.fields = {}
131168
self.inputs = {}
169+
self.omit_fields = set()
132170
for c in table.c.values():
133171
if c.foreign_keys:
134172
field = "ReferenceField"
135173
inp = "ReferenceInput"
136174
key = next(iter(c.foreign_keys)) # TODO: Test composite foreign keys.
137-
props: dict[str, object] = {"reference": key.column.table.name,
138-
"source": c.name, "target": key.column.name}
175+
props: dict[str, Any] = {"reference": key.column.table.name,
176+
"source": c.name, "target": key.column.name}
139177
else:
140-
field, inp = FIELD_TYPES.get(type(c.type), ("TextField", "TextInput"))
141-
props = {}
178+
field, inp, props = get_components(c.type)
179+
180+
if isinstance(c.type, sa.Enum):
181+
props["choices"] = tuple({"id": e.value, "name": e.name}
182+
for e in c.type.python_type)
142183

143184
if isinstance(c.default, sa.ColumnDefault):
144185
props["placeholder"] = c.default.arg
@@ -161,24 +202,29 @@ def __init__(self, db: AsyncEngine, model_or_table: Union[sa.Table, type[Declara
161202
local, remote = relationship.local_remote_pairs[0]
162203

163204
props = {"reference": relationship.entity.persist_selectable.name,
164-
"label": name.title(), "source": local.name, "target": remote.name}
205+
"label": name.title(), "source": local.name,
206+
"target": remote.name, "sortable": False}
165207
if local.foreign_keys:
166208
t = "ReferenceField"
209+
props["link"] = "show"
167210
elif relationship.uselist:
168211
t = "ReferenceManyField"
169212
else:
170213
t = "ReferenceOneField"
214+
props["link"] = "show"
171215

172216
children = {}
173217
for c in relationship.target.c.values():
174218
if c is remote: # Skip the foreign key
175219
continue
176-
field, inp = FIELD_TYPES.get(type(c.type), ("TextField", "TextInput"))
177-
children[c.name] = {"type": field, "props": {}}
220+
field, inp, c_props = get_components(c.type)
221+
children[c.name] = {"type": field, "props": c_props}
178222
container = "Datagrid" if t == "ReferenceManyField" else "DatagridSingle"
179-
props["children"] = {"_": {"type": container, "props": {"children": children}}}
223+
props["children"] = {"_": {"type": container, "props": {
224+
"children": children, "rowClick": "show"}}}
180225

181226
self.fields[name] = {"type": t, "props": props}
227+
self.omit_fields.add(name)
182228

183229
self._db = db
184230
self._table = table

aiohttp_admin/routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def setup_resources(admin: web.Application, schema: Schema) -> None:
2323
try:
2424
omit_fields = m.fields.keys() - r["display"]
2525
except KeyError:
26-
omit_fields = ()
26+
omit_fields = m.omit_fields
2727
else:
2828
if not all(f in m.fields for f in r["display"]):
2929
raise ValueError(f"Display includes non-existent field {r['display']}")

aiohttp_admin/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from collections.abc import Collection
2-
from typing import Any, Awaitable, Callable, Optional, Sequence, TypedDict, Union
1+
from collections.abc import Callable, Collection, Sequence
2+
from typing import Any, Awaitable, Optional, TypedDict, Union
33

44

55
class FieldState(TypedDict):

examples/simple.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818

1919

2020
class Currency(Enum):
21-
EUR = "EUR"
22-
GBP = "GBP"
23-
USD = "USD"
21+
EUR = 1
22+
GBP = 2
23+
USD = 3
2424

2525

2626
class Base(DeclarativeBase):

tests/_resources.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ def __init__(self, name: str, fields: dict[str, FieldState],
1313
self.fields = fields
1414
self.inputs = inputs
1515
self.primary_key = primary_key
16+
self.omit_fields = set()
1617
super().__init__()
1718

1819
async def get_list(self, params: GetListParams) -> tuple[list[Record], int]: # pragma: no cover # noqa: B950

tests/test_backends_sqlalchemy.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,21 @@
99
from aiohttp.test_utils import TestClient
1010
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
1111
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
12+
from sqlalchemy.sql.type_api import TypeEngine
13+
from sqlalchemy.types import TypeDecorator
1214

1315
import aiohttp_admin
1416
from _auth import check_credentials
15-
from aiohttp_admin.backends.sqlalchemy import SAResource, permission_for
17+
from aiohttp_admin.backends.sqlalchemy import FIELD_TYPES, SAResource, permission_for
1618

1719
_Login = Callable[[TestClient], Awaitable[dict[str, str]]]
1820

1921

22+
def test_no_subtypes() -> None:
23+
"""We don't want any subtypes in the lookup, as this would depend on test ordering."""
24+
assert all({TypeEngine, TypeDecorator} & set(t.__bases__) for t in FIELD_TYPES)
25+
26+
2027
def test_pk(base: type[DeclarativeBase], mock_engine: AsyncEngine) -> None:
2128
class TestModel(base): # type: ignore[misc,valid-type]
2229
__tablename__ = "dummy"
@@ -99,8 +106,9 @@ class TestOne(base): # type: ignore[misc,valid-type]
99106
"type": "ReferenceManyField",
100107
"props": {
101108
"children": {"_": {"type": "Datagrid", "props": {
102-
"children": {"id": {"type": "NumberField", "props": {}}}}}},
103-
"label": "Ones", "reference": "one", "source": "id", "target": "many_id"}}
109+
"rowClick": "show", "children": {"id": {"type": "NumberField", "props": {}}}}}},
110+
"label": "Ones", "reference": "one", "source": "id", "target": "many_id",
111+
"sortable": False}}
104112
assert "ones" not in r.inputs
105113

106114
r = SAResource(mock_engine, TestOne)
@@ -109,8 +117,9 @@ class TestOne(base): # type: ignore[misc,valid-type]
109117
"type": "ReferenceField",
110118
"props": {
111119
"children": {"_": {"type": "DatagridSingle", "props": {
112-
"children": {"foo": {"type": "NumberField", "props": {}}}}}},
113-
"label": "Many", "reference": "many", "source": "many_id", "target": "id"}}
120+
"rowClick": "show", "children": {"foo": {"type": "NumberField", "props": {}}}}}},
121+
"label": "Many", "reference": "many", "source": "many_id", "target": "id",
122+
"sortable": False, "link": "show"}}
114123
assert "many" not in r.inputs
115124

116125

@@ -133,8 +142,9 @@ class TestB(base): # type: ignore[misc,valid-type]
133142
"type": "ReferenceOneField",
134143
"props": {
135144
"children": {"_": {"type": "DatagridSingle", "props": {
136-
"children": {"id": {"type": "NumberField", "props": {}}}}}},
137-
"label": "Other", "reference": "test_b", "source": "id", "target": "a_id"}}
145+
"rowClick": "show", "children": {"id": {"type": "NumberField", "props": {}}}}}},
146+
"label": "Other", "reference": "test_b", "source": "id", "target": "a_id",
147+
"sortable": False, "link": "show"}}
138148
assert "other" not in r.inputs
139149

140150
r = SAResource(mock_engine, TestB)
@@ -143,8 +153,9 @@ class TestB(base): # type: ignore[misc,valid-type]
143153
"type": "ReferenceField",
144154
"props": {
145155
"children": {"_": {"type": "DatagridSingle", "props": {
146-
"children": {"str": {"type": "TextField", "props": {}}}}}},
147-
"label": "Linked", "reference": "test_a", "source": "a_id", "target": "id"}}
156+
"rowClick": "show", "children": {"str": {"type": "TextField", "props": {}}}}}},
157+
"label": "Linked", "reference": "test_a", "source": "a_id", "target": "id",
158+
"sortable": False, "link": "show"}}
148159
assert "linked" not in r.inputs
149160

150161

0 commit comments

Comments
 (0)