diff --git a/docs/api_reference/model_view.md b/docs/api_reference/model_view.md
index 6df8c210..4a46657f 100644
--- a/docs/api_reference/model_view.md
+++ b/docs/api_reference/model_view.md
@@ -58,6 +58,7 @@
- form_create_rules
- form_edit_rules
- column_type_formatters
+ - column_type_formatters_detail
- non_link_related_fields
- list_query
- count_query
diff --git a/docs/configurations.md b/docs/configurations.md
index 1f7d0534..4d66aa34 100644
--- a/docs/configurations.md
+++ b/docs/configurations.md
@@ -268,7 +268,7 @@ The filter UI provides a dropdown for operation selection and a text input for t
- **AllUniqueStringValuesFilter/StaticValuesFilter/ForeignKeyFilter**: Shows all possible values as links (good for columns with few unique values)
- **OperationColumnFilter**: Provides operation dropdown + text input (good for columns with many possible values or numeric/date operations)
-
+
Choose OperationColumnFilter when you want users to type custom search terms with operation flexibility, and AllUniqueStringValuesFilter when you want to show all available options as clickable links.
@@ -319,8 +319,6 @@ The pagination options in the list page can be configured. The available options
There are a few options which apply to both List and Detail pages. They include:
- `column_labels`: A mapping of column labels, used to map column names to new names in all places.
-- `column_type_formatters`: A mapping of type keys and callable values to format in all places.
- For example you can add custom date formatter to be used in both list and detail pages.
- `save_as`: A boolean to enable "save as new" option when editing an object.
- `save_as_continue`: A boolean to control the redirect URL if `save_as` is enabled.
@@ -336,6 +334,60 @@ There are a few options which apply to both List and Detail pages. They include:
save_as = True
```
+## Type formatters
+
+You can create formatters for data types without specifying field names in both `column_formatters` and `column_formatters_detail`. The following options are suitable for this:
+
+- `column_type_formatters`: Mapping type keys to callable values for formatting on list pages.
+- `column_type_formatters_detail`: A mapping of type keys and callable values to format in details pages.
+
+!!! example
+ ```python
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters = {
+ type(None): lambda x: 'Empty',
+ str: lambda x: x[:10]
+ }
+ column_type_formatters_detail = {
+ type(None): lambda x: 'Null',
+ str: lambda x: x.title()
+ }
+ ```
+
+!!! tip
+
+ If `column_type_formatters_detail` is not explicitly specified, the `column_type_formatters` mapping is used for the detail page.
+
+??? example "Example with build-in formatters"
+
+ ```python
+ import enum
+ import datetime
+ import uuid
+
+ from enum import StrEnum
+ # from sqladmin._types import StrEnum # for python <3.11
+ from sqladmin.formatters import (
+ str_enum_formatter,
+ datetime_formatter,
+ copy_to_clipboard_formatter,
+ )
+
+
+ custom_column_type_formatters_detail = ModelView.column_type_formatters_detail.copy()
+ custom_column_type_formatters_detail.update(
+ {
+ StrEnum: str_enum_formatter,
+ datetime.datetime: datetime_formatter,
+ uuid.UUID: copy_to_clipboard_formatter,
+ }
+ )
+
+
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters_detail = custom_column_type_formatters_detail
+ ```
+
## Form options
SQLAdmin allows customizing how forms work with your models.
@@ -393,13 +445,11 @@ The export options can be set per model and includes the following options:
## Pretty CSV Export
- `ModelView.use_pretty_export`: Default value is `False`
-Enables exporting CSV files with user-friendly column labels and formatted cell values
-matching the UI list view.
-When enabled, exports utilize the `column_formatters` and `column_labels` defined in the admin view,
-improving readability and ensuring consistency between the UI and exported data.
+Enables exporting CSV files with user-friendly column labels and formatted cell values matching the UI list view.
+When enabled, exports utilize the `column_formatters` and `column_labels` defined in the admin view,
+improving readability and ensuring consistency between the UI and exported data.
-Custom cell formatting can be implemented in the ModelView class by overriding the async method
-`custom_export_cell`, otherwise basic cell formatting is used by default.
+Custom cell formatting can be implemented in the ModelView class by overriding the async method custom_export_cell`, otherwise basic cell formatting is used by default.
Example of usage:
```python
@@ -408,10 +458,10 @@ class ExamResultAdmin(ModelView, model=ExamResult):
column_list = ["score", "instructors", "course.title", "course.instructors", "created_at"]
column_labels = {
- "score": "Score",
- "instructors": "Exam Instructors",
- "course.title": "Course Title",
- "course.instructors": "Course Instructors",
+ "score": "Score",
+ "instructors": "Exam Instructors",
+ "course.title": "Course Title",
+ "course.instructors": "Course Instructors",
"created_at": "Exam Time",
}
column_formatters = {
@@ -435,8 +485,6 @@ class ExamResultAdmin(ModelView, model=ExamResult):
return None
```
-
-
## Import options
SQLAdmin supports importing data from a UTF-8 CSV file on the list page.
@@ -632,7 +680,6 @@ The available options for `action` are:
- `add_in_detail`: A boolean indicating if this action should be available in detail page.
- `confirmation_message`: A string message that if defined, will open a modal to ask for confirmation before calling the action method.
-
### Toast Notifications
You can display toast notifications after a custom action completes using
diff --git a/sqladmin/_types.py b/sqladmin/_types.py
index 8a1224b5..663ac005 100644
--- a/sqladmin/_types.py
+++ b/sqladmin/_types.py
@@ -1,14 +1,21 @@
+import sys
+from enum import Enum
from typing import (
Any,
+ AnyStr,
Callable,
+ Dict,
+ Iterable,
List,
Protocol,
Tuple,
+ Type,
TypeVar,
Union,
runtime_checkable,
)
+from markupsafe import Markup
from sqlalchemy.engine import Engine
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
from sqlalchemy.orm import (
@@ -19,6 +26,15 @@
)
from sqlalchemy.sql.expression import Select
from starlette.requests import Request
+from typing_extensions import TypeAlias
+
+if sys.version_info < (3, 11):
+
+ class StrEnum(str, Enum):
+ __str__ = str.__str__
+ __repr__ = Enum.__repr__
+else:
+ from enum import StrEnum as StrEnum # noqa: F401
MODEL_PROPERTY = Union[ColumnProperty, RelationshipProperty]
ENGINE_TYPE = Union[Engine, AsyncEngine]
@@ -79,3 +95,8 @@ async def get_filtered_query(
ColumnFilter = Union[SimpleColumnFilter, OperationColumnFilter]
+
+BASE_FORMATTERS_TYPE: TypeAlias = Dict[
+ Type[Any],
+ Callable[[Any], Union[Markup, Iterable[Markup], AnyStr, Iterable[AnyStr]]],
+]
diff --git a/sqladmin/formatters.py b/sqladmin/formatters.py
index 7c00ad57..adda9c7c 100644
--- a/sqladmin/formatters.py
+++ b/sqladmin/formatters.py
@@ -1,20 +1,82 @@
+import datetime
from typing import Any
from markupsafe import Markup
+from sqladmin._types import BASE_FORMATTERS_TYPE, StrEnum
-def empty_formatter(value: Any) -> str:
+
+def empty_formatter(value: Any) -> Markup:
"""Return empty string for `None` value"""
- return ""
+
+ return Markup("") # nosec
def bool_formatter(value: bool) -> Markup:
"""Return check icon if value is `True` or X otherwise."""
+
icon_class = "fa-check text-success" if value else "fa-times text-danger"
- return Markup("").format(icon_class)
+ return Markup("").format(icon_class) # nosec
+
+
+def str_enum_formatter(value: StrEnum) -> Markup:
+ """Return badge for value and list of available StrEnum values in tooltip."""
+
+ title = ""
+ if hasattr(value, "_member_names_") and len(value._member_names_) > 0:
+ title = f'title="Available values: {", ".join(value._member_names_)}">'
+
+ return Markup(
+ f'"
+ ) # nosec
+
+
+def datetime_formatter(value: datetime.datetime) -> Markup:
+ """Return badge for easy viewing of datetime."""
+
+ return Markup(
+ f""
+ f' '
+ f"{value.strftime('%d %B %Y %H:%M:%S')}"
+ f""
+ ) # nosec
+
+
+def copy_to_clipboard_formatter(value: Any) -> Markup:
+ """Return value with copy to clipboard button and alert."""
+
+ escaped_value = Markup.escape(str(value))
+
+ return Markup(
+ f'
'
+ f'
{escaped_value}
'
+ f""
+ f'
Copied!
'
+ f"
"
+ ) # nosec
-BASE_FORMATTERS = {
+BASE_FORMATTERS: BASE_FORMATTERS_TYPE = {
type(None): empty_formatter,
bool: bool_formatter,
}
diff --git a/sqladmin/models.py b/sqladmin/models.py
index a325bfaf..8c62773c 100644
--- a/sqladmin/models.py
+++ b/sqladmin/models.py
@@ -29,6 +29,7 @@
from sqlalchemy import Column, String, asc, cast, desc, false, func, inspect, or_
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.orm import selectinload
+from sqlalchemy.orm.collections import InstrumentedList, InstrumentedSet
from sqlalchemy.orm.exc import DetachedInstanceError
from sqlalchemy.sql.elements import ClauseElement
from sqlalchemy.sql.expression import Select, select
@@ -42,11 +43,13 @@
from sqladmin._queries import Query
from sqladmin._types import (
_UNSET,
+ BASE_FORMATTERS_TYPE,
MODEL_ATTR,
SESSION_MAKER,
ColumnFilter,
OperationColumnFilter,
SimpleColumnFilter,
+ StrEnum,
)
from sqladmin.ajax import create_ajax_loader
from sqladmin.exceptions import InvalidModelError
@@ -732,7 +735,7 @@ class UserAdmin(ModelView, model=User):
```
"""
- column_type_formatters: ClassVar[Dict[Type, Callable]] = BASE_FORMATTERS
+ column_type_formatters: ClassVar[BASE_FORMATTERS_TYPE] = BASE_FORMATTERS
"""Dictionary of value type formatters to be used in the list view.
By default, two types are formatted:
@@ -750,6 +753,24 @@ class UserAdmin(ModelView, model=User):
```
"""
+ column_type_formatters_detail: ClassVar[BASE_FORMATTERS_TYPE] = BASE_FORMATTERS
+ """Dictionary of value type formatters to be used in the details view.
+
+ By default, two types are formatted:
+
+ - None will be displayed as an empty string
+ - bool will be displayed as a checkmark if it is True otherwise as an X.
+
+ If you don't like the default behavior and don't want any type formatters applied,
+ just override this property with an empty dictionary:
+
+ ???+ example
+ ```python
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters_detail = dict()
+ ```
+ """
+
non_link_related_fields: ClassVar[List[MODEL_ATTR]] = []
"""Relationship fields that should be rendered as plain text instead of links.
@@ -840,6 +861,18 @@ def __init__(self) -> None:
self._custom_actions_in_detail: Dict[str, str] = {}
self._custom_actions_confirmation: Dict[str, str] = {}
+ self._column_type_formatters = self.column_type_formatters.copy()
+ if (
+ self.column_type_formatters != BASE_FORMATTERS
+ and self.column_type_formatters_detail == BASE_FORMATTERS
+ ):
+ # If you want to apply filters for types on all pages
+ self._column_type_formatters_detail = self.column_type_formatters.copy()
+ else:
+ self._column_type_formatters_detail = (
+ self.column_type_formatters_detail.copy()
+ )
+
def _run_arbitrary_query_sync(self, stmt: ClauseElement) -> Any:
with self.session_maker(expire_on_commit=False) as session:
result = session.execute(stmt)
@@ -908,10 +941,47 @@ def _get_default_sort(self) -> List[Tuple[str, bool]]:
return [(pk.name, False) for pk in self.pk_columns]
def _default_formatter(self, value: Any) -> Any:
- if type(value) in self.column_type_formatters:
- formatter = self.column_type_formatters[type(value)]
+ value_class = type(value)
+
+ if value_class in self._column_type_formatters:
+ formatter = self._column_type_formatters[value_class]
return formatter(value)
+ elif value_class is InstrumentedList:
+ return [self._default_formatter(item) for item in value]
+
+ elif value_class is InstrumentedSet:
+ return {self._default_formatter(item) for item in value}
+
+ if hasattr(value, "__class__") and hasattr(value.__class__, "__bases__"):
+ parents = value.__class__.__bases__
+ for parent_class in parents:
+ if parent_class in self._column_type_formatters:
+ formatter = self._column_type_formatters[parent_class]
+ return formatter(value)
+
+ return value
+
+ def _default_formatter_detail(self, value: Any) -> Any:
+ value_class = type(value)
+
+ if value_class in self._column_type_formatters_detail:
+ formatter = self._column_type_formatters_detail[value_class]
+ return formatter(value)
+
+ elif value_class is InstrumentedList:
+ return [self._default_formatter_detail(item) for item in value]
+
+ elif value_class is InstrumentedSet:
+ return {self._default_formatter_detail(item) for item in value}
+
+ if hasattr(value, "__class__") and hasattr(value.__class__, "__bases__"):
+ parents = value.__class__.__bases__
+ for parent_class in parents:
+ if parent_class in self._column_type_formatters_detail:
+ formatter = self._column_type_formatters_detail[parent_class]
+ return formatter(value)
+
return value
def _formatter_accepts_request(self, formatter: Callable[..., Any]) -> bool:
@@ -1137,7 +1207,7 @@ async def get_prop_value(self, obj: Any, prop: str) -> Any:
except DetachedInstanceError:
obj = await self._lazyload_prop(obj, part)
- if obj and isinstance(obj, Enum):
+ if obj and isinstance(obj, Enum) and not isinstance(obj, StrEnum):
obj = obj.name
return obj
@@ -1188,7 +1258,7 @@ async def get_detail_value(
self._detail_formatter_accepts_request.get(prop, False),
)
if formatter
- else self._default_formatter(value)
+ else self._default_formatter_detail(value)
)
return value, formatted_value
diff --git a/sqladmin/statics/js/main.js b/sqladmin/statics/js/main.js
index a7f9559a..c6361352 100644
--- a/sqladmin/statics/js/main.js
+++ b/sqladmin/statics/js/main.js
@@ -539,8 +539,28 @@ $(':input[data-role="select2-tags"]').each(function () {
}
});
+function copyToClipboard(element, value) {
+ navigator.clipboard.writeText(value)
+ .then(() => {
+ const alertElement = element.nextElementSibling;
+ if (
+ alertElement &&
+ alertElement.classList.contains('alert') &&
+ alertElement.classList.contains('alert-primary')
+ ) {
+ alertElement.classList.remove('fade');
+ setTimeout(() => {
+ alertElement.classList.add('fade');
+ }, 2000);
+ }
+ })
+ .catch(err => {
+ console.error('Failed to copy text: ', err);
+ });
+}
+
// Automatically resize textarea on input events.
-$('.autoresize-textarea').each(function() {
+$('.autoresize-textarea').each(function () {
const $textarea = $(this);
if (!$textarea) return;
@@ -549,7 +569,7 @@ $('.autoresize-textarea').each(function() {
$textarea.css('height', $textarea[0].scrollHeight + 'px');
};
- $textarea.on('input propertychange', function() {
+ $textarea.on('input propertychange', function () {
updateTextareaHeight();
});
@@ -558,7 +578,7 @@ $('.autoresize-textarea').each(function() {
// Displays the number of characters in the text field.
-$('.chars-count-label').each(function() {
+$('.chars-count-label').each(function () {
const $charsCountLabel = $(this);
if (!$charsCountLabel) return;
@@ -575,7 +595,7 @@ $('.chars-count-label').each(function() {
$charsCountLabel.toggleClass('warning', $maxLength > 0 && $currentLength >= $maxLength);
};
- $textarea.on('input propertychange', function() {
+ $textarea.on('input propertychange', function () {
updateCharsCountLabel();
});
diff --git a/tests/test_formatters.py b/tests/test_formatters.py
new file mode 100644
index 00000000..1f2e25bf
--- /dev/null
+++ b/tests/test_formatters.py
@@ -0,0 +1,292 @@
+import datetime
+import enum
+import uuid
+from typing import Generator
+
+import pytest
+from markupsafe import Markup
+from sqlalchemy import Boolean, Column, DateTime, Enum, ForeignKey, Integer, String
+from sqlalchemy.orm import declarative_base, relationship, sessionmaker
+from starlette.applications import Starlette
+from starlette.testclient import TestClient
+
+from sqladmin import Admin, ModelView
+from sqladmin._types import StrEnum
+from sqladmin.formatters import (
+ BASE_FORMATTERS,
+ copy_to_clipboard_formatter,
+ datetime_formatter,
+ str_enum_formatter,
+)
+from tests.common import sync_engine as engine
+
+# Try to import UUID type for SQLAlchemy 2.0+
+try:
+ from sqlalchemy import Uuid
+
+ HAS_UUID_SUPPORT = True
+except ImportError:
+ HAS_UUID_SUPPORT = False
+ Uuid = None
+
+pytestmark = pytest.mark.anyio
+
+Base = declarative_base() # type: ignore
+session_maker = sessionmaker(bind=engine)
+
+app = Starlette()
+admin = Admin(app=app, session_maker=session_maker)
+
+
+@pytest.fixture(autouse=True)
+def prepare_database() -> Generator[None, None, None]:
+ Base.metadata.create_all(engine)
+ yield
+ Base.metadata.drop_all(engine)
+
+
+@pytest.fixture
+def client() -> Generator[TestClient, None, None]:
+ with TestClient(app=app, base_url="http://testserver") as c:
+ yield c
+
+
+class Status(enum.Enum):
+ ACTIVE = "ACTIVE"
+ DEACTIVE = "DEACTIVE"
+
+
+class Role(StrEnum):
+ ADMIN = "ADMIN"
+ USER = "USER"
+
+
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True)
+ name = Column(String)
+ role = Column(Enum(Role))
+ registered_at = Column(DateTime)
+
+ profile = relationship("Profile", back_populates="user", uselist=False)
+
+ if HAS_UUID_SUPPORT:
+ user_uuid = Column(Uuid, nullable=True)
+
+
+class Profile(Base):
+ __tablename__ = "profiles"
+
+ id = Column(Integer, primary_key=True)
+ is_active = Column(Boolean)
+ role = Column(Enum(Role))
+ status = Column(Enum(Status))
+ user_id = Column(Integer, ForeignKey("users.id"), unique=True)
+
+ user = relationship("User", back_populates="profile")
+
+
+NOW = datetime.datetime.now()
+VALID_DATETIME_HTML = (
+ f""
+ f' '
+ f"{NOW.strftime('%d %B %Y %H:%M:%S')}"
+ f""
+)
+
+VALID_STR_ENUM_HTML = Markup(
+ "ADMIN'
+)
+
+
+async def test_column_formatters_different_details_and_list() -> None:
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update(
+ {str: lambda x: Markup(x[:16] + "...") if len(x) > 16 else Markup(x)}
+ )
+
+ custom_column_type_formatters_detail = BASE_FORMATTERS.copy()
+ custom_column_type_formatters_detail.update({str: lambda x: Markup(x)})
+
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters = custom_column_type_formatters
+ column_type_formatters_detail = custom_column_type_formatters_detail
+
+ user = User(name="very " * 10 + "long string")
+
+ assert await UserAdmin().get_detail_value(user, "name") == (
+ user.name,
+ Markup(user.name),
+ )
+
+ assert await UserAdmin().get_list_value(user, "name") == (
+ user.name,
+ Markup("very very very v..."),
+ )
+
+ if HAS_UUID_SUPPORT:
+ user_uuid = uuid.uuid4()
+ user.user_uuid = user_uuid
+ assert await UserAdmin().get_list_value(user, "user_uuid") == (
+ user.user_uuid,
+ user_uuid,
+ )
+
+
+async def test_column_formatters_list_with_inheritance_type() -> None:
+ class CustomStr(str): ...
+
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update(
+ {CustomStr: lambda x: Markup(x[:16] + "...") if len(x) > 16 else Markup(x)}
+ )
+
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters = custom_column_type_formatters
+
+ user = User(name="very " * 10 + "long string")
+
+ assert await UserAdmin().get_detail_value(user, "name") == (
+ user.name,
+ Markup(user.name),
+ )
+
+
+async def test_column_formatters_list_with_inheritance_type_and_parent() -> None:
+ class CustomStr(str): ...
+
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update(
+ {
+ str: lambda x: Markup(x + " str class"),
+ CustomStr: lambda x: Markup(x + " CustomStr class"),
+ }
+ )
+
+ class UserAdmin(ModelView, model=User):
+ column_type_formatters = custom_column_type_formatters
+
+ user = User(name="Max")
+ user_1 = User(name=CustomStr("Max"))
+
+ assert await UserAdmin().get_list_value(user, "name") == (
+ user.name,
+ Markup("Max str class"),
+ )
+
+ assert await UserAdmin().get_list_value(user_1, "name") == (
+ user_1.name,
+ Markup("Max CustomStr class"),
+ )
+
+
+async def test_column_formatters_str_enum() -> None:
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update({StrEnum: str_enum_formatter})
+
+ class ProfileAdmin(ModelView, model=Profile):
+ column_type_formatters = custom_column_type_formatters
+
+ user = User()
+ profile = Profile(user=user, role=Role.ADMIN, status=None, is_active=True)
+
+ assert await ProfileAdmin().get_detail_value(profile, "role") == (
+ Role.ADMIN,
+ VALID_STR_ENUM_HTML,
+ )
+
+ assert await ProfileAdmin().get_detail_value(profile, "status") == (
+ None,
+ Markup(""),
+ )
+
+
+async def test_column_formatters_list_page_str_enum(client: TestClient) -> None:
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update({StrEnum: str_enum_formatter})
+
+ class ProfileAdmin(ModelView, model=Profile):
+ column_list = [Profile.id, Profile.role]
+
+ column_type_formatters = custom_column_type_formatters
+
+ user = User()
+ profile = Profile(user=user, role=Role.ADMIN, is_active=True)
+
+ session = session_maker()
+ session.add_all([user, profile])
+ session.commit()
+
+ admin.add_model_view(ProfileAdmin)
+ response = client.get("/admin/profile/list")
+
+ assert VALID_STR_ENUM_HTML in response.text
+
+
+async def test_column_formatters_details_page_str_enum(client: TestClient) -> None:
+ custom_column_type_formatters = BASE_FORMATTERS.copy()
+ custom_column_type_formatters.update({StrEnum: str_enum_formatter})
+
+ class ProfileAdmin(ModelView, model=Profile):
+ column_details_list = [Profile.id, Profile.role]
+
+ column_type_formatters = custom_column_type_formatters
+
+ user = User()
+ profile = Profile(user=user, role=Role.ADMIN, is_active=True)
+
+ session = session_maker()
+ session.add_all([user, profile])
+ session.commit()
+
+ admin.add_model_view(ProfileAdmin)
+ response = client.get("/admin/profile/details/1")
+
+ assert VALID_STR_ENUM_HTML in response.text
+
+
+async def test_column_formatters_details_datetime(client: TestClient) -> None:
+ custom_column_type_formatters = ModelView.column_type_formatters_detail.copy()
+ custom_column_type_formatters.update(
+ {
+ datetime.datetime: datetime_formatter,
+ str: copy_to_clipboard_formatter,
+ }
+ )
+
+ class UserAdmin(ModelView, model=User):
+ column_details_list = [User.id, User.registered_at]
+
+ column_type_formatters_detail = custom_column_type_formatters
+
+ user = User(registered_at=NOW)
+
+ session = session_maker()
+ session.add(user)
+ session.commit()
+
+ admin.add_model_view(UserAdmin)
+ response = client.get("/admin/user/details/1")
+
+ assert VALID_DATETIME_HTML in response.text
+
+
+async def test_copy_to_clipboard_formatter_escapes_value() -> None:
+ formatted = copy_to_clipboard_formatter('"')
+
+ assert 'data-copy-value=""<script>alert(1)</script>"' in formatted
+ assert "<script>alert(1)</script>" in formatted