diff --git a/admin_ui/src/components/RowFilter.vue b/admin_ui/src/components/RowFilter.vue index acc2ad2e..79a0662d 100644 --- a/admin_ui/src/components/RowFilter.vue +++ b/admin_ui/src/components/RowFilter.vue @@ -7,7 +7,7 @@
- + @@ -31,9 +31,6 @@ export default Vue.extend({ schema() { return this.$store.state.schema }, - tableName() { - return this.$store.state.currentTableName - }, }, methods: { closeSideBar() { diff --git a/admin_ui/src/components/RowFormSearch.vue b/admin_ui/src/components/RowFormSearch.vue index 595b0642..6967f602 100644 --- a/admin_ui/src/components/RowFormSearch.vue +++ b/admin_ui/src/components/RowFormSearch.vue @@ -1,68 +1,41 @@ @@ -76,20 +49,11 @@ export default Vue.extend({ props: { row: Object, schema: Object, - isFilter: { - type: Boolean, - default: false, - }, }, components: { InputField, KeySearch, }, - data() { - return { - keySelectIDs: {}, - } - }, methods: { getValue(propertyTitle: string) { let value = this.row @@ -109,14 +73,3 @@ export default Vue.extend({ }, }) - - \ No newline at end of file diff --git a/admin_ui/src/main.less b/admin_ui/src/main.less index 26214c14..e0da49a6 100644 --- a/admin_ui/src/main.less +++ b/admin_ui/src/main.less @@ -1,4 +1,4 @@ -@import "./vars.less"; +@import './vars.less'; html { height: 100%; @@ -10,7 +10,7 @@ body { min-height: 100%; margin: 0; position: relative; - font-family: "Avenir", Helvetica, Arial, sans-serif; + font-family: 'Avenir', Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -165,15 +165,20 @@ body { min-height: 100%; } +h1 { + font-size: 1.6rem; + font-weight: bold; +} + label { display: block; padding-bottom: 0.2rem; padding-top: 0.8rem; } -input[type="text"], -input[type="number"], -input[type="password"], +input[type='text'], +input[type='number'], +input[type='password'], select, textarea { box-sizing: border-box; @@ -182,9 +187,9 @@ textarea { margin-bottom: 0.5rem; } -input[type="text"], -input[type="number"], -input[type="password"], +input[type='text'], +input[type='number'], +input[type='password'], button, select, textarea { @@ -200,7 +205,7 @@ textarea { select { appearance: none; -webkit-appearance: none; - background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23007CB2%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E"); + background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23007CB2%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'); background-repeat: no-repeat, repeat; background-position: right 0.7em top 50%, 0 0; background-size: 0.65em auto, 100%; @@ -241,4 +246,3 @@ a { svg { padding-right: 0.3rem; } - diff --git a/docs/source/table_config/images/with_table_config.jpg b/docs/source/table_config/images/with_visible_columns.jpg similarity index 100% rename from docs/source/table_config/images/with_table_config.jpg rename to docs/source/table_config/images/with_visible_columns.jpg diff --git a/docs/source/table_config/images/with_visible_filters.jpg b/docs/source/table_config/images/with_visible_filters.jpg new file mode 100644 index 00000000..49429e27 Binary files /dev/null and b/docs/source/table_config/images/with_visible_filters.jpg differ diff --git a/docs/source/table_config/images/without_table_config.jpg b/docs/source/table_config/images/without_visible_columns.jpg similarity index 100% rename from docs/source/table_config/images/without_table_config.jpg rename to docs/source/table_config/images/without_visible_columns.jpg diff --git a/docs/source/table_config/images/without_visible_filters.jpg b/docs/source/table_config/images/without_visible_filters.jpg new file mode 100644 index 00000000..9f63d820 Binary files /dev/null and b/docs/source/table_config/images/without_visible_filters.jpg differ diff --git a/docs/source/table_config/index.rst b/docs/source/table_config/index.rst index 46113424..60d75da7 100644 --- a/docs/source/table_config/index.rst +++ b/docs/source/table_config/index.rst @@ -13,8 +13,16 @@ Alternatively, you can pass in ``TableConfig`` instances (or mix and match them). By passing in a ``TableConfig`` you have extra control over how the UI behaves -for that table. For example, we can set which columns are visible in the list -view: +for that table. This is particularly useful when you have a ``Table`` with lots +of columns. + +In the future, ``TableConfig`` will be extended to allow finer grained control +over the UI. + +visible_columns +--------------- + +We can set which columns are visible in the list view: .. code-block:: python @@ -26,17 +34,36 @@ view: Here is the UI when just passing in a ``Table``: -.. image:: ./images/without_table_config.jpg +.. image:: ./images/without_visible_columns.jpg -Here is the UI when just passing in a ``TableConfig`` instance instead (less +Here is the UI when just passing in a ``TableConfig`` instance instead (fewer columns are visible): -.. image:: ./images/with_table_config.jpg +.. image:: ./images/with_visible_columns.jpg -This is really useful when you have a ``Table`` with lots of columns. +visible_filters +--------------- -In the future, ``TableConfig`` will be extended to allow finer grained control -over the UI. +We can set which columns are visible in the filter sidebar: + +.. code-block:: python + + from piccolo_admin.endpoints import TableConfig + + movie_config = TableConfig(Movie, visible_filters=[ + Movie.name, Movie.rating, Movie.director, + ]) + + create_admin(Director, movie_config) + +Here is the UI when just passing in a ``Table``: + +.. image:: ./images/without_visible_filters.jpg + +Here is the UI when just passing in a ``TableConfig`` instance instead (fewer +filters are visible in the sidebar): + +.. image:: ./images/with_visible_filters.jpg Source ------ diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py index 320d41be..38070950 100644 --- a/piccolo_admin/endpoints.py +++ b/piccolo_admin/endpoints.py @@ -66,12 +66,21 @@ class TableConfig: You can specify this instead of ``visible_columns``, in which case all of the ``Table`` columns except the ones specified will be shown in the list view. + :param visible_filters: + If specified, only these columns will be shown in the filter sidebar + of the UI. This is useful when you have a lot of columns. + :param exclude_visible_filters: + You can specify this instead of ``visible_filters``, in which case all + of the ``Table`` columns except the ones specified will be shown in the + filter sidebar. """ table_class: t.Type[Table] visible_columns: t.Optional[t.List[Column]] = None exclude_visible_columns: t.Optional[t.List[Column]] = None + visible_filters: t.Optional[t.List[Column]] = None + exclude_visible_filters: t.Optional[t.List[Column]] = None def __post_init__(self): if self.visible_columns and self.exclude_visible_columns: @@ -80,23 +89,47 @@ def __post_init__(self): "``exclude_visible_columns``." ) - def get_visible_columns(self) -> t.List[Column]: - if self.visible_columns and not self.exclude_visible_columns: - return self.visible_columns + if self.visible_filters and self.exclude_visible_filters: + raise ValueError( + "Only specify ``visible_filters`` or " + "``exclude_visible_filters``." + ) + + def _get_columns( + self, + include_columns: t.Optional[t.List[Column]], + exclude_columns: t.Optional[t.List[Column]], + all_columns: t.List[Column], + ) -> t.List[Column]: + if include_columns and not exclude_columns: + return include_columns - if self.exclude_visible_columns and not self.visible_columns: - column_names = (i._meta.name for i in self.exclude_visible_columns) - return [ - i - for i in self.table_class._meta.columns - if i._meta.name not in column_names - ] + if exclude_columns and not include_columns: + column_names = (i._meta.name for i in exclude_columns) + return [i for i in all_columns if i._meta.name not in column_names] - return self.table_class._meta.columns + return all_columns + + def get_visible_columns(self) -> t.List[Column]: + return self._get_columns( + include_columns=self.visible_columns, + exclude_columns=self.exclude_visible_columns, + all_columns=self.table_class._meta.columns, + ) def get_visible_column_names(self) -> t.Tuple[str, ...]: return tuple(i._meta.name for i in self.get_visible_columns()) + def get_visible_filters(self) -> t.List[Column]: + return self._get_columns( + include_columns=self.visible_filters, + exclude_columns=self.exclude_visible_filters, + all_columns=self.table_class._meta.columns, + ) + + def get_visible_filter_names(self) -> t.Tuple[str, ...]: + return tuple(i._meta.name for i in self.get_visible_filters()) + @dataclass class FormConfig: @@ -148,7 +181,8 @@ def my_endpoint(request: Request, data: MyModel): name: str pydantic_model: t.Type[BaseModel] endpoint: t.Callable[ - [Request, pydantic.BaseModel], t.Union[str, None, t.Coroutine], + [Request, pydantic.BaseModel], + t.Union[str, None, t.Coroutine], ] description: t.Optional[str] = None @@ -226,6 +260,7 @@ def __init__( for table_config in table_configs: table_class = table_config.table_class visible_column_names = table_config.get_visible_column_names() + visible_filter_names = table_config.get_visible_filter_names() FastAPIWrapper( root_url=f"/tables/{table_class._meta.tablename}/", fastapi_app=api_app, @@ -234,7 +269,8 @@ def __init__( read_only=read_only, page_size=page_size, schema_extra={ - "visible_column_names": visible_column_names + "visible_column_names": visible_column_names, + "visible_filter_names": visible_filter_names, }, ), fastapi_kwargs=FastAPIKwargs( @@ -368,7 +404,8 @@ async def get_root(self, request: Request) -> HTMLResponse: def get_user(self, request: Request) -> UserResponseModel: return UserResponseModel( - username=request.user.display_name, user_id=request.user.user_id, + username=request.user.display_name, + user_id=request.user.user_id, ) ########################################################################### @@ -393,7 +430,9 @@ def get_single_form(self, form_slug: str) -> FormConfigResponseModel: raise HTTPException(status_code=404, detail="No such form found") else: return FormConfigResponseModel( - name=form.name, slug=form.slug, description=form.description, + name=form.name, + slug=form.slug, + description=form.description, ) def get_single_form_schema(self, form_slug: str) -> t.Dict[str, t.Any]: diff --git a/piccolo_admin/example.py b/piccolo_admin/example.py index 60dfba7d..b30a2d41 100644 --- a/piccolo_admin/example.py +++ b/piccolo_admin/example.py @@ -185,11 +185,20 @@ async def booking_endpoint(request, data): Movie.rating, Movie.director, ], + visible_filters=[ + Movie.name, + Movie.rating, + Movie.director, + ], ) director_config = TableConfig( table_class=Director, - visible_columns=[Director._meta.primary_key, Director.name], + visible_columns=[ + Director._meta.primary_key, + Director.name, + Director.gender, + ], ) APP = create_admin( diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 973ef27c..d05220a8 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1,7 +1,13 @@ from unittest import TestCase from piccolo.apps.user.tables import BaseUser -from piccolo.columns.column_types import ForeignKey, Text, Timestamp, Varchar +from piccolo.columns.column_types import ( + ForeignKey, + Integer, + Text, + Timestamp, + Varchar, +) from piccolo.table import Table from piccolo_api.session_auth.tables import SessionsBase from starlette.testclient import TestClient @@ -33,6 +39,7 @@ class Post(Table): name = Varchar(length=100) content = Text() created = Timestamp() + rating = Integer() class TestTableConfig(TestCase): @@ -42,7 +49,8 @@ def test_visible_columns(self): visible_columns=[Post._meta.primary_key, Post.name], ) self.assertEqual( - post_table.get_visible_column_names(), ("id", "name"), + post_table.get_visible_column_names(), + ("id", "name"), ) def test_exclude_visible_columns(self): @@ -52,7 +60,7 @@ def test_exclude_visible_columns(self): ) self.assertEqual( tuple(i._meta.name for i in post_table.get_visible_columns()), - ("content", "created"), + ("content", "created", "rating"), ) def test_visible_exclude_columns_error(self): @@ -64,6 +72,35 @@ def test_visible_exclude_columns_error(self): ) post_table.get_visible_columns() + def test_visible_filters(self): + post_table = TableConfig( + table_class=Post, + visible_filters=[Post.name, Post.rating], + ) + self.assertEqual( + post_table.get_visible_filter_names(), + ("name", "rating"), + ) + + def test_exclude_visible_filters(self): + post_table = TableConfig( + table_class=Post, + exclude_visible_filters=[Post._meta.primary_key, Post.name], + ) + self.assertEqual( + tuple(i._meta.name for i in post_table.get_visible_filters()), + ("content", "created", "rating"), + ) + + def test_visible_filters_error(self): + with self.assertRaises(ValueError): + post_table = TableConfig( + table_class=Post, + visible_filters=[Post.name], + exclude_visible_filters=[Post.name, Post.rating], + ) + post_table.get_visible_filters() + class TestAdminRouter(TestCase): def test_get_meta(self): @@ -120,7 +157,9 @@ def test_forms(self): # Login payload = dict(csrftoken=csrftoken, **self.credentials) client.post( - "/auth/login/", json=payload, headers={"X-CSRFToken": csrftoken}, + "/auth/login/", + json=payload, + headers={"X-CSRFToken": csrftoken}, ) ####################################################################### @@ -193,7 +232,9 @@ def test_post_form_success(self): # Login payload = dict(csrftoken=csrftoken, **self.credentials) client.post( - "/auth/login/", json=payload, headers={"X-CSRFToken": csrftoken}, + "/auth/login/", + json=payload, + headers={"X-CSRFToken": csrftoken}, ) ####################################################################### # Post a form @@ -241,7 +282,9 @@ def test_post_form_fail(self): # Login payload = dict(csrftoken=csrftoken, **self.credentials) client.post( - "/auth/login/", json=payload, headers={"X-CSRFToken": csrftoken}, + "/auth/login/", + json=payload, + headers={"X-CSRFToken": csrftoken}, ) ####################################################################### # Post a form with errors @@ -288,7 +331,9 @@ def test_tables(self): # Login payload = dict(csrftoken=csrftoken, **self.credentials) client.post( - "/auth/login/", json=payload, headers={"X-CSRFToken": csrftoken}, + "/auth/login/", + json=payload, + headers={"X-CSRFToken": csrftoken}, ) ####################################################################### @@ -297,7 +342,8 @@ def test_tables(self): response = client.get("/api/tables/") self.assertEqual(response.status_code, 200) self.assertEqual( - response.json(), ["movie", "director", "studio"], + response.json(), + ["movie", "director", "studio"], ) def test_get_user(self): @@ -313,7 +359,9 @@ def test_get_user(self): # Login payload = dict(csrftoken=csrftoken, **self.credentials) client.post( - "/auth/login/", json=payload, headers={"X-CSRFToken": csrftoken}, + "/auth/login/", + json=payload, + headers={"X-CSRFToken": csrftoken}, ) ####################################################################### @@ -322,6 +370,6 @@ def test_get_user(self): response = client.get("/api/user/") self.assertEqual(response.status_code, 200) self.assertEqual( - response.json(), {"username": "Bob", "user_id": "1"}, + response.json(), + {"username": "Bob", "user_id": "1"}, ) -