From 797883dac82b73744c2c24fc8eb6a9214ac28161 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Thu, 8 Jan 2026 15:19:16 +0100 Subject: [PATCH] I can display datagrid content --- src/myfasthtml/assets/myfasthtml.css | 341 +++++++++++++++++++- src/myfasthtml/controls/DataGrid.py | 285 +++++++++++++++- src/myfasthtml/controls/DataGridsManager.py | 2 +- src/myfasthtml/controls/helpers.py | 2 +- src/myfasthtml/core/constants.py | 4 + src/myfasthtml/core/dbmanager.py | 6 +- src/myfasthtml/core/utils.py | 33 ++ tests/core/test_utils.py | 10 +- 8 files changed, 665 insertions(+), 18 deletions(-) diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css index e0a1a89..6f515eb 100644 --- a/src/myfasthtml/assets/myfasthtml.css +++ b/src/myfasthtml/assets/myfasthtml.css @@ -823,4 +823,343 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} \ No newline at end of file +} + + +/* ********************************************* */ +/* ************* Datagrid Component ************ */ +/* ********************************************* */ +input:focus { + outline: none; +} + +.dt2-drag-drop { + display: none; + position: absolute; + top: 100%; + z-index: var(--datagrid-drag-drop-zindex); + width: 100px; + border: 1px solid var(--color-base-300); + border-radius: 10px; + padding: 10px; + box-shadow: 0 0 40px rgba(0, 0, 0, 0.3); + background: var(--color-base-100); + box-sizing: border-box; + overflow-x: auto; + pointer-events: none; /* Prevent interfering with mouse events */ + +} + +.dt2-main { + height: 100%; + position: relative; +} + +.dt2-sidebar { + opacity: 0; /* Default to invisible */ + visibility: hidden; /* Prevent interaction when invisible */ + transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */ + position: absolute; + top: 0; + right: 0; + width: 75%; + max-height: 710px; + overflow-y: auto; + background-color: var(--color-base-100); + z-index: var(--datagrid-sidebar-zindex); + box-shadow: -5px 0 15px rgba(0, 0, 0, 0.5); /* Stronger shadow */ + border-radius: 10px; +} + +.dt2-sidebar.active { + opacity: 1; + visibility: visible; + transition: opacity 0.3s ease; +} + +.dt2-container { + position: relative; +} + +.dt2-scrollbars { + position: absolute; + top: 24px; + bottom: 0px; + left: 0; + right: 0; + pointer-events: none; /* Ensures parents don't intercept pointer events */ + z-index: var(--datagrid-scrollbars-zindex); +} + +/* Scrollbar Wrappers common attributes*/ +.dt2-scrollbars-vertical-wrapper, +.dt2-scrollbars-horizontal-wrapper { + position: absolute; + background-color: var(--color-base-200); + opacity: 1; + transition: opacity 0.2s ease-in-out; /* Smooth fade in/out */ + pointer-events: auto; /* Allow interaction */ +} + +/* Scrollbar Wrappers */ +.dt2-scrollbars-vertical-wrapper { + left: auto; + right: 3px; + top: 3px; + bottom: 3px; + width: 8px; +} + +.dt2-scrollbars-horizontal-wrapper { + left: 3px; + right: 3px; + top: auto; + bottom: -12px; + height: 8px; +} + +/* Scrollbars */ +.dt2-scrollbars-vertical, +.dt2-scrollbars-horizontal { + background-color: var(--color-base-300); + border-radius: 3px; + pointer-events: auto; /* Allow interaction with the scrollbar */ + cursor: pointer; + position: absolute; + border-radius: 3px; /* Rounded corners */ + pointer-events: auto; /* Enable interaction */ + cursor: pointer; +} + + +/* Vertical Scrollbar */ +.dt2-scrollbars-vertical { + left: 0; + right: 0; + top: auto; + bottom: auto; + width: 100%; /* Fits inside its wrapper */ +} + +/* Horizontal Scrollbar */ +.dt2-scrollbars-horizontal { + left: auto; + right: auto; + top: 0; + bottom: 0; + height: 100%; /* Fits inside its wrapper */ +} + +/* Scrollbar hover effects */ +.dt2-scrollbars-vertical:hover, +.dt2-scrollbars-horizontal:hover, +.dt2-scrollbars-vertical.dt2-dragging, +.dt2-scrollbars-horizontal.dt2-dragging { + background-color: var(--color-base-content); +} + +.dt2-table { + --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000); + --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000); + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: 10px; + overflow: hidden; +} + +.dt2-table:focus { + outline: none; +} + +.dt2-header, +.dt2-footer { + background-color: var(--color-base-200); + border-radius: 10px 10px 0 0; + min-width: max-content; +} + +.dt2-body { + overflow: hidden; /* You can change this to auto if horizontal scrolling is required */ + font-size: 14px; + min-width: max-content; +} + +.dt2-row { + display: flex; + width: 100%; + height: 22px; +} + +.dt2-cell { + display: flex; + align-items: center; + justify-content: flex-start; + padding: 2px 8px; + position: relative; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 100px; + flex-grow: 0; + flex-shrink: 1; + box-sizing: border-box; /* to include the borders in the computations */ + border-bottom: 1px solid var(--color-border); + user-select: none; +} + +.dt2-cell-content-text { + text-align: inherit; + width: 100%; + padding-right: 10px; +} + +.dt2-cell-content-checkbox { + display: flex; + width: 100%; + justify-content: center; /* Horizontally center the icon */ + align-items: center; /* Vertically center the icon */ +} + +.dt2-cell-content-number { + text-align: right; + width: 100%; + padding-right: 10px; +} + +.dt2-footer-cell { + cursor: pointer +} + +.dt2-footer-menu { + position: absolute; + display: None; + z-index: var(--datagrid-menu-zindex); + border: 1px solid oklch(var(--b3)); + box-sizing: border-box; + width: 80px; + background-color: var(--color-base-100); /* Add background color */ + opacity: 1; /* Ensure full opacity */ +} + +.dt2-footer-menu.show { + display: block; +} + +.dt2-footer-menu-item { + padding: 0 8px; + border-radius: 4px; + background-color: var(--color-base-100); /* Add background color */ +} + +.dt2-footer-menu-item:hover { + background: color-mix(in oklab, var(--color-base-100, var(--color-base-200)), #000 7%); + cursor: pointer +} + +.dt2-resize-handle { + position: absolute; + right: 0; + top: 0; + width: 8px; + height: 100%; + cursor: col-resize; +} + +.dt2-resize-handle::after { + content: ''; /* This is required */ + position: absolute; /* Position as needed */ + z-index: var(--datagrid-resize-zindex); + display: block; /* Makes it a block element */ + width: 3px; + height: 60%; + top: calc(50% - 60% * 0.5); + background-color: var(--color-resize); +} + +.dt2-header-hidden { + width: 5px; + background: var(--color-neutral-content); + border-bottom: 1px solid var(--color-border); + cursor: pointer; +} + +.dt2-col-hidden { + width: 5px; + border-bottom: 1px solid var(--color-border); +} + +.dt2-highlight-1 { + color: var(--color-accent); +} + +.dt2-item-handle { + background-image: radial-gradient(var(--color-primary-content) 40%, transparent 0); + background-repeat: repeat; + background-size: 4px 4px; + cursor: grab; + display: inline-block; + height: 16px; + margin: auto; + position: relative; + top: 1px; + width: 12px; +} + +/* **************************************************************************** */ +/* COLUMNS SETTINGS */ +/* **************************************************************************** */ + +.dt2-cs-header { + background-color: var(--color-base-200); + min-width: max-content; +} + +.dt2-cs-columns { + display: grid; + grid-template-columns: 20px 1fr 0.5fr 0.5fr 0.5fr 0.5fr; +} + +.dt2-cs-body input { + outline: none; + border-color: transparent; + box-shadow: none; +} + +.dt2-cs-body input[type="checkbox"], +.dt2-cs-body input.checkbox { + outline: initial; + border-color: var(--color-border); +} + + +.dt2-cs-cell { + padding: 0 6px 0 6px; + margin: auto; +} + +.dt2-cs-checkbox-cell { + margin: auto; +} + +.dt2-cs-number-cell { + padding: 0 6px 0 6px; + text-align: right; +} + +.dt2-cs-select-cell { + padding: 0 6px; + margin: 3px 0; +} + +.dt2-cs-body input:hover { + border: 1px solid #ccc; /* Provide a subtle border on focus */ +} + + +.dt2-views-container-select { + width: 170px; +} + +.dt2-views-container-create { + width: 300px; +} diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 71b2cf3..0a25d79 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -1,18 +1,22 @@ +import html from typing import Optional -from fastcore.basics import NotStr -from fasthtml.components import Div +import pandas as pd +from fasthtml.components import * from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState +from myfasthtml.controls.helpers import mk +from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.instances import MultipleInstance +from myfasthtml.core.utils import make_safe_id class DatagridState(DbObject): - def __init__(self, owner): - super().__init__(owner, name=f"{owner.get_full_id()}-state") + def __init__(self, owner, save_state): + super().__init__(owner, name=f"{owner.get_full_id()}-state", save_state=save_state) with self.initializing(): self.sidebar_visible: bool = False self.selected_view: str = None @@ -25,12 +29,15 @@ class DatagridState(DbObject): self.filtered: dict = {} self.edition: DatagridEditionState = DatagridEditionState() self.selection: DatagridSelectionState = DatagridSelectionState() - self.ne_data = None + self.ne_df = None + + self.ns_fast_access = None + self.ns_total_rows = None class DatagridSettings(DbObject): - def __init__(self, owner): - super().__init__(owner, name=f"{owner.get_full_id()}-settings") + def __init__(self, owner, save_state): + super().__init__(owner, name=f"{owner.get_full_id()}-settings", save_state=save_state) with self.initializing(): self.file_name: Optional[str] = None self.selected_sheet_name: Optional[str] = None @@ -46,19 +53,271 @@ class Commands(BaseCommands): class DataGrid(MultipleInstance): - def __init__(self, parent, settings=None, _id=None): + def __init__(self, parent, settings=None, save_state=False, _id=None): super().__init__(parent, _id=_id) - self._settings = DatagridSettings(self) - self._state = DatagridState(self) + self._settings = settings or DatagridSettings(self, save_state=save_state) + self._state = DatagridState(self, save_state=save_state) self.commands = Commands(self) + self.init_from_dataframe(self._state.ne_df) + + @property + def _df(self): + return self._state.ne_df def init_from_dataframe(self, df): - self._state.ne_data = df + + def _get_column_type(dtype): + if pd.api.types.is_integer_dtype(dtype): + return ColumnType.Number + elif pd.api.types.is_float_dtype(dtype): + return ColumnType.Number + elif pd.api.types.is_bool_dtype(dtype): + return ColumnType.Bool + elif pd.api.types.is_datetime64_any_dtype(dtype): + return ColumnType.Datetime + else: + return ColumnType.Text # Default to Text if no match + + def _init_columns(_df): + columns = [DataGridColumnState(make_safe_id(col_id), + col_index, + col_id, + _get_column_type(self._df[make_safe_id(col_id)].dtype)) + for col_index, col_id in enumerate(_df.columns)] + if self._state.row_index: + columns.insert(0, DataGridColumnState(make_safe_id(ROW_INDEX_ID), -1, " ", ColumnType.RowIndex)) + + return columns + + def _init_fast_access(_df): + """ + Generates a fast-access dictionary for a DataFrame. + + This method converts the columns of the provided DataFrame into NumPy arrays + and stores them as values in a dictionary, using the column names as keys. + This allows for efficient access to the data stored in the DataFrame. + + Args: + _df (DataFrame): The input pandas DataFrame whose columns are to be converted + into a dictionary of NumPy arrays. + + Returns: + dict: A dictionary where the keys are the column names of the input DataFrame + and the values are the corresponding column values as NumPy arrays. + """ + if _df is None: + return {} + + res = {col: _df[col].to_numpy() for col in _df.columns} + res[ROW_INDEX_ID] = _df.index.to_numpy() + return res + + if df is not None: + self._state.ne_df = df + self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed + self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index] + self._state.columns = _init_columns(df) # use df not self._df to keep the original title + self._state.ns_fast_access = _init_fast_access(self._df) + self._state.ns_total_rows = len(self._df) if self._df is not None else 0 + + return self + + def mk_headers(self): + def _mk_header_name(col_def: DataGridColumnState): + return Div( + mk.label(col_def.title, name="dt2-header-title"), + cls="flex truncate cursor-default", + ) + + def _mk_header(col_def: DataGridColumnState): + return Div( + _mk_header_name(col_def), + Div(cls="dt2-resize-handle"), + style=f"width:{col_def.width}px;", + data_col=col_def.col_id, + data_tooltip=col_def.title, + cls="dt2-cell dt2-resizable flex", + ) + + header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden" + return Div( + *[_mk_header(col_def) for col_def in self._state.columns], + cls=header_class, + id=f"th_{self._id}" + ) + + def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState): + + def mk_bool(_value): + return Div(mk.icon(icon_checked if _value else icon_unchecked, can_select=False), + cls="dt2-cell-content-checkbox") + + def mk_text(_value): + return mk.label(_value, cls="dt2-cell-content-text") + + def mk_number(_value): + return mk.label(_value, cls="dt2-cell-content-number") + + def process_cell_content(_value): + value_str = html.escape(str(_value)) + + if FILTER_INPUT_CID not in self._state.filtered or ( + keyword := self._state.filtered[FILTER_INPUT_CID]) is None: + return value_str + + index = value_str.lower().find(keyword.lower()) + if index < 0: + return value_str + + len_keyword = len(keyword) + res = [Span(value_str[:index])] if index > 0 else [] + res += [Span(value_str[index:index + len_keyword], cls="dt2-highlight-1")] + res += [Span(value_str[index + len_keyword:])] if len(value_str) > len_keyword else [] + return tuple(res) + + column_type = col_def.type + value = self._state.ns_fast_access[col_def.col_id][row_index] + + if column_type == ColumnType.Bool: + content = mk_bool(value) + elif column_type == ColumnType.Number: + content = mk_number(process_cell_content(value)) + elif column_type == ColumnType.RowIndex: + content = mk_number(row_index) + else: + content = mk_text(process_cell_content(value)) + + return content + + def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState): + if not col_def.usable: + return None + + if not col_def.visible: + return Div(cls="dt2-col-hidden") + + content = self.mk_body_cell_content(col_pos, row_index, col_def) + + return Div(content, + data_col=col_def.col_id, + style=f"width:{col_def.width}px;", + cls="dt2-cell") + + def mk_body_content_page(self, page_index: int): + df = self._df # self._get_filtered_df() + start = page_index * DATAGRID_PAGE_SIZE + end = start + DATAGRID_PAGE_SIZE + if self._state.ns_total_rows > end: + last_row = df.index[end - 1] + else: + last_row = None + + rows = [Div( + *[self.mk_body_cell(col_pos, row_index, col_def) for col_pos, col_def in enumerate(self._state.columns)], + cls="dt2-row", + data_row=f"{row_index}", + id=f"tr_{self._id}-{row_index}", + ) for row_index in df.index[start:end]] + + return rows + + def mk_body(self): + return Div( + *self.mk_body_content_page(0), + cls="dt2-body", + id=f"tb_{self._id}", + ) + + def mk_footers(self): + return Div( + *[Div( + *[self.mk_aggregation_cell(col_def, row_index, footer) for col_def in self._state.columns], + id=f"tf_{self._id}", + data_row=f"{row_index}", + cls="dt2-row dt2-row-footer", + ) for row_index, footer in enumerate(self._state.footers)], + cls="dt2-footer", + id=f"tf_{self._id}" + ) + + def mk_table(self): + return Div( + self.mk_headers(), + self.mk_body(), + self.mk_footers() + ) + + def mk_aggregation_cell(self, col_def, row_index: int, footer_conf, oob=False): + """ + Generates a footer cell for a data table based on the provided column definition, + row index, footer configuration, and optional out-of-bound setting. This method + applies appropriate aggregation functions, determines visibility, and structures + the cell's elements accordingly. + + :param col_def: Details of the column state, including its usability, visibility, + and column ID, which are necessary to determine how the footer + cell should be created. + :type col_def: DataGridColumnState + :param row_index: The specific index of the footer row where this cell will be + added. This parameter is used to uniquely identify the cell + within the footer. + :type row_index: int + :param footer_conf: Configuration for the footer that contains mapping of column + IDs to their corresponding aggregation functions. This is + critical for calculating aggregated values for the cell content. + :type footer_conf: DataGridFooterConf + :param oob: A boolean flag indicating whether the configuration involves any + out-of-bound parameters that must be handled specifically. This + parameter is optional and defaults to False. + :type oob: bool + :return: Returns an instance of `Div`, containing the visually structured footer + cell content, including the calculated aggregation if applicable. If + the column is not usable, it returns None. For non-visible columns, it + returns a hidden cell `Div`. The aggregation value is displayed for valid + aggregations. If none is applicable or the configuration is invalid, + appropriate default content or styling is applied. + :rtype: Div | None + """ + if not col_def.usable: + return None + + if not col_def.visible: + return Div(cls="dt2-col-hidden") + + if col_def.col_id in footer_conf.conf: + agg_function = footer_conf.conf[col_def.col_id] + if agg_function == FooterAggregation.Sum.value: + value = self._df[col_def.col_id].sum() + elif agg_function == FooterAggregation.Min.value: + value = self._df[col_def.col_id].min() + elif agg_function == FooterAggregation.Max.value: + value = self._df[col_def.col_id].max() + elif agg_function == FooterAggregation.Mean.value: + value = self._df[col_def.col_id].mean() + elif agg_function == FooterAggregation.Count.value: + value = self._df[col_def.col_id].count() + else: + value = "** Invalid aggregation function **" + else: + value = None + + return Div(mk_ellipsis(value, cls="dt2-cell-content-number"), + data_col=col_def.col_id, + style=f"width:{col_def.width}px;", + cls="dt2-cell dt2-footer-cell", + id=f"tf_{self._id}-{col_def.col_id}-{row_index}", + hx_swap_oob='true' if oob else None, + ) def render(self): - html = self._state.ne_data.to_html(index=False) if self._state.ne_data is not None else "Content lost !" + if self._state.ne_df is None: + return Div("No data to display !") + return Div( - NotStr(html), + Div( + self.mk_table(), + # Script(f"bindDatagrid('{self._id}', false);"), + ), id=self._id ) diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 9633fee..8cb3ec0 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -91,7 +91,7 @@ class DataGridsManager(MultipleInstance): def open_from_excel(self, tab_id, file_upload: FileUpload): excel_content = file_upload.get_content() df = pd.read_excel(excel_content, file_upload.get_sheet_name()) - dg = DataGrid(self._tabs_manager) + dg = DataGrid(self._tabs_manager, save_state=True) dg.init_from_dataframe(df) document = DocumentDefinition( document_id=str(uuid.uuid4()), diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index e3c982b..eff9651 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -95,7 +95,7 @@ class mk: command: Command | CommandTemplate = None, binding: Binding = None, **kwargs): - merged_cls = merge_classes("flex", cls, kwargs) + merged_cls = merge_classes("flex truncate", cls, kwargs) icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None text_part = Span(text, cls=f"text-{size}") return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding) diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py index c6c2dec..f87b623 100644 --- a/src/myfasthtml/core/constants.py +++ b/src/myfasthtml/core/constants.py @@ -4,6 +4,10 @@ DEFAULT_COLUMN_WIDTH = 100 ROUTE_ROOT = "/myfasthtml" +# Datagrid +ROW_INDEX_ID = "__row_index__" +DATAGRID_PAGE_SIZE = 1000 +FILTER_INPUT_CID = "__filter_input__" class Routes: Commands = "/commands" diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index 6c656f4..7734432 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -37,10 +37,11 @@ class DbObject: _initializing = False _forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"} - def __init__(self, owner: BaseInstance, name=None, db_manager=None): + def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True): self._owner = owner self._name = name or owner.get_full_id() self._db_manager = db_manager or DbManager(self._owner) + self._save_state = save_state self._finalize_initialization() @@ -75,6 +76,9 @@ class DbObject: self._save_self() def _save_self(self): + if not self._save_state: + return + props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_") and not k.startswith("ns")} if props: diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index 64bb40c..cc52526 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -284,6 +284,39 @@ def flatten(*args): res.append(arg) return res + +def make_html_id(s: str | None) -> str | None: + """ + Creates a valid html id + :param s: + :return: + """ + if s is None: + return None + + s = str(s).strip() + # Replace spaces and special characters with hyphens or remove them + s = re.sub(r'[^a-zA-Z0-9_-]', '-', s) + + # Ensure the ID starts with a letter or underscore + if not re.match(r'^[a-zA-Z_]', s): + s = 'id_' + s # Add a prefix if it doesn't + + # Collapse multiple consecutive hyphens into one + s = re.sub(r'-+', '-', s) + + # Replace trailing hyphens with underscores + s = re.sub(r'-+$', '_', s) + + return s + +def make_safe_id(s: str | None): + if s is None: + return None + + res = re.sub('-', '_', make_html_id(s)) # replace '-' by '_' + return res.lower() # no uppercase + @utils_rt(Routes.Commands) def post(session, c_id: str, client_response: dict = None): """ diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index af2d6ef..ac5db08 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,6 +1,6 @@ import pytest -from myfasthtml.core.utils import flatten +from myfasthtml.core.utils import flatten, make_html_id @pytest.mark.parametrize("input_args,expected,test_description", [ @@ -56,3 +56,11 @@ def test_i_can_flatten(input_args, expected, test_description): """Test that flatten correctly handles various nested structures and arguments.""" result = flatten(*input_args) assert result == expected, f"Failed for test case: {test_description}" + +@pytest.mark.parametrize("string, expected", [ + ("My Example String!", "My-Example-String_"), + ("123 Bad ID", "id_123-Bad-ID"), + (None, None) +]) +def test_i_can_have_valid_html_id(string, expected): + assert make_html_id(string) == expected