Repository: piccolo-orm/piccolo_admin (language: python)

Issue:
Implement TableConfig class
Currently, `create_admin` accepts a list of `Table` subclasses. This can be modified to accept either `Table` subclasses, or instances of a new class called `TableConfig`.

`TableConfig` will be a dataclass which accepts a table argument, as well as a bunch of configuration options. For example:

```python
@dataclasses.dataclass
class TableConfig:
    """
    :param list_columns:
        Used to specify which columns are shown on the list page. If None,
        all are shown.
    :param list_filters:
        Used to specify which filters are shown on the list page. If None,
        all are shown.

    """
    table: Type[Table]
    list_columns: Optional[List[Union[str, Column]] = None
    list_filters: Optional[List[Union[str, Column]] = None
    # Plus some additional options, which haven't been finalised.
    ...

app = create_admin(
    tables=[
        SomeTable,
        TableConfig(
            table=MyTable,
            list_columns=[MyTable.name],
            list_filters=[MyTable.name]
        )
    ]
)
```

It will allow more fine grained control of the how the admin looks.




Discussion / hints:
@dantownsend I'm experimenting with this and this feature could be useful. Something like this can change columns we want to display in admin and that's fine for listing,

```Python
@dataclass
class TableConfig:
    """
    :param list_columns:
        Used to specify which columns are shown on the list page. If None,
        all are shown.
    :param list_filters:
        Used to specify which filters are shown on the list page. If None,
        all are shown.
    """
    
    table: t.Type[Table]
    list_columns: t.Optional[t.List[Column]] = None
    # list_filters:  t.Optional[t.List[Column]] = None
    
    def __post_init__(self):
        """
        Display the columns in the admin UI and make shure
        that primary key is always included for crud operations
        and FK column to avoid OperationalError
        """
        if self.list_columns is None:
            self.table._meta.columns = self.table._meta.columns
        else:
            self.table._meta.columns = [
                column
                for column in itertools.chain(
                    self.table._meta.default_columns,
                    self.list_columns,
                    self.table._meta.foreign_key_columns,
                )
                if column in self.table._meta.columns
            ]
```
but it's not good for adding end editing records because the changed table (from `__post_init__`) goes into `PiccoloCRUD` and `PiccoloCRUD` create model based on columns in `TableConfig` `list_columns` and another table column is missing. Do you have some advice how to solve this or we can `show/hide` columns and filters in frontend. Thanks in advance. 
@sinisaos Yeah, it's a tricky one.

Rather than excluding the columns from the Piccolo table, it might be better to use an unmodified Piccolo table.

Then add an extra field to the `/api/tables/some_table/schema` response called `visible_columns`.

```json
{
"title": "DirectorIn",
"type": "object",
"properties": {},
"help_text": "The main director for a movie.",
"visible_columns": ["id", "name"]
}
```

The UI will then work as before - we just need a check on the listing page if the column is in `visible_columns`.

For that to work we'll have to modify `create_pydantic_model` so it accepts an `extra` argument: We could then pass in something like:

```python
create_pydantic_model(extra={'visible_columns': ['id', 'name']})
```

I thought about having an entirely new endpoint like `/api/tables/some_table/config`, but it seems unnecessary if we can manage this using the existing schema endpoint.
@dantownsend Great. Thanks. I'll try that approach.
@sinisaos The only downside with this approach is we're over fetching from the API. Imagine the table has 20 columns, and we just want to display 2 of them, then we're still fetching all 20.

I don't think it's a big deal initially.

But down the road we could modify `PiccoloCRUD`'s `get_all` method, so we can ask it for a subset of columns. Something like:

```
/api/tables/movie?columns=id,name
```

@dantownsend You're right, but anyhow we need to fetch all the columns to be able to edit and add new records. I will play with this, and if I need help or advice, I will write here.
@dantownsend Trying your approach, I got stuck. So far I have added visible columns to `pydantic.py`

```Python
def create_pydantic_model(
    ...
    deserialize_json: bool = False,
    visible_columns: t.Tuple[str] = (),
) -> t.Type[pydantic.BaseModel]

  class CustomConfig(Config):
      schema_extra = {
          "help_text": table._meta.help_text,
          "visible_columns": visible_columns, # we also can pass tuple(i._meta.name for i in table._meta.columns) to get all columns visible by default
      }
```
I also added `TableConfig` to Piccolo Admin `endpoints.py` to change list of visible columns

```Python
@dataclass
class TableConfig:
    """
    :param list_columns:
        Used to specify which columns are shown on the list page. If None,
        all are shown.
    """
    
    table: t.Type[Table]
    list_columns: t.List[Column] = field(default_factory=list)

    def __post_init__(self):
        """
        Display the columns in the admin UI and make shure
        that primary key and FK column is always included
        """
        self.list_columns = [
            column
            for column in itertools.chain(
                self.table._meta.default_columns,
                self.list_columns,
                self.table._meta.foreign_key_columns,
            )
        ]
        return self.list_columns
```
I can change the `PiccoloCRUD` pydantic_model in the tables loop and print the correct schema, **but how to change only the `TableConfig` table and leave the rest tables unchanged**

```Python
# endpoints py
piccolo_crud: PiccoloCRUD = PiccoloCRUD

for table in tables:
    if isinstance(table, TableConfig):
        piccolo_crud.pydantic_model = create_pydantic_model(
            table.table,
            model_name=f"{table.table.__name__}In",
            visible_columns=tuple(
                i._meta.name for i in table.list_columns
            ),
        )
        print(piccolo_crud.pydantic_model.schema_json(indent=2))
    else:
        piccolo_crud.pydantic_model = create_pydantic_model(
            table,
            model_name=f"{table.__name__}In",
            visible_columns=tuple(
                i._meta.name for i in table._meta.columns
            ),
        )
        print(piccolo_crud.pydantic_model.schema_json(indent=2))
```
I also changed the `cellNames` function to` RowListing.vue` to list only visible columns

```Javascript
cellNames() {
    const keys = []
    for (const key in this.rows[0]) {
        if (!key.endsWith("_readable")) {
            keys.push(key)
        }
    }
    const columnsDifference = keys.filter((column) =>
        this.schema.visible_columns.includes(column)
    )
    return columnsDifference
  },
```
Sorry for long comment.
@dantownsend Ideally change for `TableConfig` will happen [here](https://github.com/piccolo-orm/piccolo_api/blob/830a4549907b70f55536884ac7b181b0f1feef28/piccolo_api/crud/endpoints.py#L156#L162), but I don’t know how. Something like this

```Python
@property
def pydantic_model(self) -> t.Type[pydantic.BaseModel]:
    """
    Useful for serialising inbound data from POST and PUT requests.
    """
    if something:
        visible_columns = visible_columns=tuple(
                i._meta.name for i in table.list_columns
            ),
    else:
        visible_columns=tuple(
                i._meta.name for i in table._meta.columns
            ),
    return create_pydantic_model(
        self.table, model_name=f"{self.table.__name__}In",
        visible_columns=visible_columns,
    )
```
Another approach could be to add `visible_columns` in `TableMetaclass` and then user could add visible columns in table definition. Something like this. 

```Python
class Director(Table, help_text="The main director for a movie."):
    name = Varchar(length=300, null=False)
    gender = Varchar(length=1, choices=Gender)

    @classmethod
    def get_readable(cls):
        return Readable(template="%s", columns=[cls.name])

    @classmethod
    def set_visible(cls):
        return [cls.name] # some like this and then we can pass it to pydantic.py visible columns
```
Then we dont need TableConfig    

@sinisaos I like the idea of the `TableConfig` because in the future we could allow the user to customise all sorts of things (e.g. overriding the name which appears in the UI).

When `tables` are passed to `create_admin`, I think they should all be converted to `TableConfig`. It will make the code cleaner.

```python
# in AdminRouter.__init__

self.tables = [table if isinstance(TableConfig) else TableConfig(table=table) for table in tables)
```

You should be able to simplify `TableConfig` so it's just this:

```python
@dataclass
class TableConfig:
    """
    :param list_columns:
        Used to specify which columns are shown on the list page. If None,
        all are shown.
    """
    
    table: t.Type[Table]
    list_columns: t.List[Column] = field(default_factory=list)

    def __post_init__(self):
        """
        Display the columns in the admin UI and make shure
        that primary key and FK column is always included
        """
       if not self.list_columns:
           self.list_columns = self.table._meta.columns

```
That should simplify things.
@dantownsend Thanks. I'll try that during the day.
@dantownsend In order for your example to work I had to change

```Python
def create_pydantic_model(
    ...
    deserialize_json: bool = False,
    visible_columns: t.Tuple[str] = (),
) -> t.Type[pydantic.BaseModel]

  class CustomConfig(Config):
      schema_extra = {
          "help_text": table._meta.help_text,
          "visible_columns": tuple(
                i._meta.name for i in table._meta.columns
            ),
      }
```
to pass all columns to `visible_columns` but the unfortunately problem is stay the same. I don't able to change `visible_columns` for table with columns in `list_columns`. The only way I was able to change the schema is to change the `pydantic_model` property object in the tables loop,

```Python
# endpoints py
piccolo_crud: PiccoloCRUD = PiccoloCRUD

for table in tables:
    if isinstance(table, TableConfig):
        piccolo_crud.pydantic_model = create_pydantic_model(
            table.table,
            model_name=f"{table.table.__name__}In",
            visible_columns=tuple(
                i._meta.name for i in table.list_columns
            ),
        )
        # print(piccolo_crud.pydantic_model.schema_json(indent=2))
    else:
        piccolo_crud.pydantic_model = create_pydantic_model(
            table,
            model_name=f"{table.__name__}In",
            visible_columns=tuple(
                i._meta.name for i in table._meta.columns
            ),
        )
        # print(piccolo_crud.pydantic_model.schema_json(indent=2))
```
 but it's a loop and only the last result from the loop is returned, and all the tables change to the last result schema, which is not good.
@sinisaos I think this might be the issue:

```python
def create_pydantic_model(
    ...
    deserialize_json: bool = False,
    visible_columns: t.Tuple[str] = (),
) -> t.Type[pydantic.BaseModel]

  class CustomConfig(Config):
      schema_extra = {
          "help_text": table._meta.help_text,
          # You should have to do this - just use the visible_columns value passed into the function.
          "visible_columns": tuple(
                i._meta.name for i in table._meta.columns
            ),
      }
```

It can just be:

```python
def create_pydantic_model(
    ...
    deserialize_json: bool = False,
    visible_columns: t.Tuple[str] = (),
) -> t.Type[pydantic.BaseModel]

  class CustomConfig(Config):
      schema_extra = {
          "help_text": table._meta.help_text,
          "visible_columns":  visible_columns
      }
```

Does that help?
@dantownsend Unfortunately no. Without this `visible_columns` 

```Python
class CustomConfig(Config):
      schema_extra = {
          "help_text": table._meta.help_text,
          # You should have to do this - just use the visible_columns value passed into the function.
          "visible_columns": tuple(
                i._meta.name for i in table._meta.columns
            ),
      }
```
is empty. We need to do that or add visible columns in here.

```Python
# in PiccoloCRUD class in piccolo_api.endpoints.crud
@property
def pydantic_model(self) -> t.Type[pydantic.BaseModel]:
    """
    Useful for serialising inbound data from POST and PUT requests.
    """
    return create_pydantic_model(
        self.table,
        model_name=f"{self.table.__name__}In",
        visible_columns=tuple(
            i._meta.name for i in self.table._meta.columns
        ),
    )
```
I think if we manage somehow (I don't know how) to pass TableConfig `list_columns` to `pydantic_model` will solve the problem because we can pass the table to `PiccoloCRUD`, and `PiccoloCRUD` will create a schema with the correct `visible_columns`. Something like this:

```Python
@property
def pydantic_model(self) -> t.Type[pydantic.BaseModel]:
    """
    Useful for serialising inbound data from POST and PUT requests.
    """
    if (some condition to get TableConfig visible_columns) :
        visible_columns = visible_columns=tuple(
                i._meta.name for i in table.list_columns
            ),
    else:
        visible_columns=tuple(
                i._meta.name for i in table._meta.columns
            ),
    return create_pydantic_model(
        self.table, model_name=f"{self.table.__name__}In",
        visible_columns=visible_columns,
    )
```

@sinisaos I'll have to look at it in detail - it sounds trickier than I originally thought.
@dantownsend I know you didn't imagine it that way, but if there's no easy workaround for `TableConfig`, we can always put `visible_colums` in `TableMeta` (just like help_text or tags) to display in the UI

```Python
@dataclass
class TableMeta:
    ...
    tags: t.List[str] = field(default_factory=list)
    help_text: t.Optional[str] = None
    visible_columns: t.Optional[t.List[str]] = None
    ...

class Table(metaclass=TableMetaclass):
    _meta = TableMeta()

    def __init_subclass__(
        ...
        tags: t.List[str] = [],
        help_text: t.Optional[str] = None,
        visible_columns: t.Optional[t.List[str]] = None,
    ):
    ...
    cls._meta = TableMeta(
        ...
        tags=tags,
        help_text=help_text,
        visible_columns=visible_columns,
        _db=db,
    )
```
then in `pydantic.py` we add

```Python
...
visible_columns = tuple(
    [
        [i._meta.name for i in table._meta.columns]
        if table._meta.visible_columns is None
        else table._meta.visible_columns
    ][0]
)

class CustomConfig(Config):
   schema_extra = {
       "help_text": table._meta.help_text,
       "visible_columns": visible_columns,
   }
...
```
and then in Piccolo Admin `RowListing.vue`

```Javascript
cellNames() {
  const keys = []
  for (const key in this.rows[0]) {
      if (!key.endsWith("_readable")) {
          keys.push(key)
      }
  }
  const columnsDifference = keys.filter((column) =>
      this.schema.visible_columns.includes(column)
  )
  return columnsDifference
},
```
To use that we just add visible columns in Table construction and everything works great.

```Python
class Director(
    Table,
    help_text="The main director for a movie.",
    visible_columns=["id", "name"],
):
```
@sinisaos That's a nice idea.

My main reason I didn't want to add extra attributes to the Table class, is it increases the likelihood of a clash with a column name. 

Adding them to the Table Metaclass avoids this. However, it could be problematic because lets say you do this:

```python
class MyTable(Table, visible_columns=[MyTable.id, MyTable.name]):
    ...
```

Would that cause an error? Because MyTable isn't fully defined yet.

@dantownsend Thanks. This is just an idea if we fail to solve the problem with `TableConfig`.
> My main reason I didn't want to add extra attributes to the Table class, is it increases the likelihood of a clash with a column name.

If I correctly understand, I think we are safe here because it is just a string and if we enter the column name to `visible_columns` incorrectly it will not be visible in the UI and that's it. 

> ```python
> class MyTable(Table, visible_columns=[MyTable.id, MyTable.name]):
>     ...
> ```
I have to try it and see if there is a error.








> ```python
> class MyTable(Table, visible_columns=[MyTable.id, MyTable.name]):
>     ...
> ```

This doesn't work because NameError is raised. We need to stick to a list of strings. Please can you leave a comment here if you can't solve the `TableConfig` problem. Then I can do PR to Piccolo and Piccolo Admin to make this changes work. Cheers.
@dantownsend Just for reminder. Have you had success in implementing `TableConfig`? If not, with your approval I can make PR for Piccolo to add visible columns to the `table.py` as this could help someone who has a lot of columns and doesn’t want to display them all in Piccolo Admin.
@sinisaos I had a look. I have a potential solution - it effects the Piccolo, Piccolo API, and Piccolo Admin repos ...

I'll push them up so you can take a look.
@sinisaos These are the relevant PRs:

https://github.com/piccolo-orm/piccolo/pull/313
https://github.com/piccolo-orm/piccolo_api/pull/96
https://github.com/piccolo-orm/piccolo_admin/pull/101

What do you think of them as a solution?
@dantownsend It looks great, but first I want to give it a try and then report how it works.
@sinisaos Thanks, if you could give them a go that would be great.
This is mostly implemented by https://github.com/piccolo-orm/piccolo_admin/pull/102

However, ``TableConfig`` can be further improved, so will keep this issue open for now. For example - with ``list_filters`` as specified in the task description.
@dantownsend I will add possibility to change list of columns in filter. It's basically the same as visible columns with different modification on frontend. If I'm not able to do PR today, I will do it no later than tomorrow.
@sinisaos You're right - the implementation should be very similar. Thanks a lot 👍 

Fix patch:
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 @@
         </a>
 
         <form ref="form" v-on:submit.prevent="submitForm($event)">
-            <RowFormSearch v-bind:isFilter="true" v-bind:schema="schema" />
+            <RowFormSearch v-bind:schema="schema" />
             <button>Apply</button>
         </form>
         <button v-on:click.prevent="clearFilters">Clear filters</button>
@@ -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 @@
 <template>
     <div>
-        <div
-            v-bind:key="property.title"
-            v-for="(property, keyName) in schema.properties"
-        >
-            <template v-if="property.extra.foreign_key">
-                <label>
-                    {{ property.title }}
-                    <span class="required" v-if="isRequired(keyName)">*</span>
+        <template v-for="(property, keyName) in schema.properties">
+            <div
+                v-bind:key="property.title"
+                v-if="schema.visible_filter_names.includes(keyName)"
+            >
+                <template v-if="property.extra.foreign_key">
+                    <label>
+                        {{ property.title }}
+                    </label>
+                    <KeySearch
+                        v-bind:fieldName="property.title.toLowerCase()"
+                        v-bind:key="getValue(property.title)"
+                        v-bind:readable="getValue(property.title)"
+                        v-bind:rowID="getKeySelectID(property.title)"
+                        v-bind:tableName="property.extra.to"
+                    />
+                </template>
+                <template v-else-if="property.format !== 'json'">
+                    <label>
+                        {{ property.title }}
+                    </label>
 
-                    <router-link
-                        :to="{
-                            name: 'addRow',
-                            params: { tableName: property.extra.to },
-                        }"
-                        class="add"
-                        target="_blank"
-                        v-if="!isFilter"
-                    >
-                        <font-awesome-icon icon="plus" />
-                    </router-link>
-
-                    <router-link
-                        :to="{
-                            name: 'editRow',
-                            params: {
-                                tableName: property.extra.to,
-                                rowID: getKeySelectID(property.title),
-                            },
-                        }"
-                        class="add"
-                        target="_blank"
-                        v-if="!isFilter"
-                    >
-                        <font-awesome-icon icon="edit" />
-                    </router-link>
-                </label>
-                <KeySearch
-                    v-bind:fieldName="property.title.toLowerCase()"
-                    v-bind:key="getValue(property.title)"
-                    v-bind:tableName="property.extra.to"
-                    v-bind:rowID="getKeySelectID(property.title)"
-                    v-bind:readable="getValue(property.title)"
-                />
-            </template>
-            <template v-else>
-                <label>
-                    {{ property.title }}
-                    <span class="required" v-if="isRequired(keyName)">*</span>
-                </label>
-
-                <InputField
-                    v-bind:format="property.format"
-                    v-bind:isFilter="isFilter"
-                    v-bind:isNullable="property.nullable"
-                    v-bind:key="property.title"
-                    v-bind:required="isRequired(keyName)"
-                    v-bind:title="property.title"
-                    v-bind:type="property.type || property.anyOf[0].type"
-                    v-bind:value="getValue(property.title)"
-                    v-bind:choices="property.extra.choices"
-                />
-            </template>
-        </div>
+                    <InputField
+                        v-bind:choices="property.extra.choices"
+                        v-bind:format="property.format"
+                        v-bind:isFilter="true"
+                        v-bind:isNullable="property.nullable"
+                        v-bind:key="property.title"
+                        v-bind:required="isRequired(keyName)"
+                        v-bind:title="property.title"
+                        v-bind:type="property.type || property.anyOf[0].type"
+                        v-bind:value="getValue(property.title)"
+                    />
+                </template>
+            </div>
+        </template>
     </div>
 </template>
 
@@ -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({
     },
 })
 </script>
-
-<style scoped lang="less">
-.add {
-    float: right;
-}
-
-span.required {
-    opacity: 0.5;
-    padding-left: 0.05rem;
-}
-</style>
\ 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(


Test patch:
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"},
         )
-