diff --git a/AGENTS.md b/AGENTS.md index c7391bca..e66f5cfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This document provides key patterns and gotchas for developers and AI assistants | [SBAdminField](#sbadminfield---list-display-columns) | Defining list columns, annotations, `supporting_annotates`, admin methods, ordering with computed fields, `sbadmin_list_display_data` | | [Configuration](#configuration) | `INSTALLED_APPS`, role config, menu items, queryset restrictions, custom permissions | | [Filter Widgets](#filter-widgets) | Built-in widgets, custom filters, `filter_query_lambda` for M2M filtering | -| [Form Widgets](#form-widgets) | `SBAdminTextTagsWidget`, `Meta.widgets` initialization, required select placeholders, `SBAdminJsonEditorWidget` for schema-driven JSON | +| [Form Widgets](#form-widgets) | `SBAdminTextTagsWidget`, input prefix/suffix on text and number widgets, `Meta.widgets` initialization, required select placeholders, `SBAdminJsonEditorWidget` for schema-driven JSON | | [Admin Registration](#admin-registration) | `@admin.register` with `sb_admin_site`, `sbadmin_list_filter` vs `list_filter` | | [Selection Actions](#selection-actions-bulk-actions) | Modal forms for bulk operations, `ListActionModalView`, confirmation modals, `SBAdminCustomAction` params, per-action permissions, success/error handling | | [Row Actions](#row-actions-per-row-list-buttons) | Per-row icon buttons with `SBAdminRowAction`, `RowActionModalView`, and row-aware enablement | @@ -145,6 +145,24 @@ class ArticleAdmin(SBAdmin): | `filter_disabled` | bool | Disable filtering for this field | | `python_formatter` | callable | Format value: `(obj_id, value) -> formatted_value` | | `list_visible` | bool | Show/hide column in list | +| `tabulator_options` | TabulatorFieldOptions | Per-column Tabulator settings (width, grow, max, custom SBAdmin options) | + +### Tabulator Options (table) + +| Option | Type | Description | +|--------|------|-------------| +| `sbadminKeepDataWidth` | bool | Keep column natural width (prevent stretch) when using `fitDataFillAvailableSpace`. Best for icon/utility columns. | + +```python +from django_smartbase_admin.engine.field import SBAdminField, TabulatorFieldOptions + +sbadmin_list_display = ( + SBAdminField( + name="id", + tabulator_options=TabulatorFieldOptions(sbadminKeepDataWidth=True), + ), +) +``` ### Admin Methods (like Django admin) @@ -1078,6 +1096,37 @@ class ArticleTagNamesForm(SBAdminBaseFormInit, forms.Form): - Duplicate values are prevented client-side. - Works with dynamically-added rows in SBAdmin formsets and wizard formsets. +### Input prefix and suffix (text and number widgets) + +Pass optional `prefix` and/or `suffix` strings to `SBAdminTextInputWidget` or `SBAdminNumberWidget` (e.g. currency, units, URL stem). Omit both for a normal input. + +```python +from django import forms + +from django_smartbase_admin.admin.admin_base import SBAdminBaseForm +from django_smartbase_admin.admin.widgets import ( + SBAdminNumberWidget, + SBAdminTextInputWidget, +) + +from blog.models import Article + + +class ArticleForm(SBAdminBaseForm): + class Meta: + model = Article + fields = ("slug", "price", "discount") + widgets = { + "slug": SBAdminTextInputWidget(prefix="https://blog.example.com/"), + "price": SBAdminNumberWidget(suffix="€"), + "discount": SBAdminNumberWidget(prefix="-", suffix="%"), + } +``` + +**Key points:** +- Addons are display-only; they do not change the stored field value. +- Other widgets can support the same pattern via `SBAdminInputAffixMixin` (mix in and forward `prefix` / `suffix` in `__init__`). + ### `Meta.widgets` are initialized automatically in `SBAdminBaseForm` When a form inherits from `SBAdminBaseForm`, widgets defined in `Meta.widgets` are initialized even if the field is not re-declared on the form class. @@ -1532,7 +1581,7 @@ class ArticleAdmin(SBAdmin): title=lambda row: _("Archive %(title)s") % {"title": row.get("title", "")}, icon="Delete", view=self, - css_class=lambda row: "btn-icon btn-destructive", + css_class=lambda row: "btn btn-small btn-only-icon btn-destructive", enabled_if=lambda row: row.get("status") != "archived", ), # Mode 3: plain link. MODIFIER_OBJECT_ID is replaced with the row pk. diff --git a/pyproject.toml b/pyproject.toml index bbbd1c13..5d55afd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-smartbase-admin" -version = "1.2.0" +version = "1.3.0" description = "" authors = ["SmartBase "] readme = "README.md" diff --git a/src/django_smartbase_admin/admin/admin_base.py b/src/django_smartbase_admin/admin/admin_base.py index 97adab7e..087e2747 100644 --- a/src/django_smartbase_admin/admin/admin_base.py +++ b/src/django_smartbase_admin/admin/admin_base.py @@ -943,10 +943,11 @@ def render_change_form( ): if context.get("sbadmin_is_modal"): media = context["media"] + js_assets = [str(asset) for asset in getattr(media, "_js", [])] media_json = { - "js": list(getattr(media, "_js", [])), + "js": js_assets, "css": { - medium: list(paths) + medium: [str(path) for path in paths] for medium, paths in getattr(media, "_css", {}).items() }, } diff --git a/src/django_smartbase_admin/admin/widgets.py b/src/django_smartbase_admin/admin/widgets.py index 99498d00..716c2718 100644 --- a/src/django_smartbase_admin/admin/widgets.py +++ b/src/django_smartbase_admin/admin/widgets.py @@ -108,11 +108,41 @@ def get_context(self, name, value, attrs): return context -class SBAdminTextInputWidget(SBAdminBaseWidget, forms.TextInput): +class SBAdminInputAffixMixin: + def __init__(self, *args, prefix=None, suffix=None, **kwargs): + self.prefix = prefix + self.suffix = suffix + super().__init__(*args, **kwargs) + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context["widget"]["prefix"] = self.prefix + context["widget"]["suffix"] = self.suffix + return context + + def get_attrs_with_affix_classes(self, attrs, prefix=None, suffix=None): + attrs = dict(attrs or {}) + classes = attrs.get("class", "") + if prefix: + classes = f"{classes} rounded-l-none".strip() + if suffix: + classes = f"{classes} rounded-r-none".strip() + attrs["class"] = classes + return attrs + + +class SBAdminTextInputWidget( + SBAdminInputAffixMixin, SBAdminBaseWidget, forms.TextInput +): template_name = "sb_admin/widgets/text.html" - def __init__(self, form_field=None, attrs=None): - super().__init__(form_field, attrs={"class": "input", **(attrs or {})}) + def __init__(self, form_field=None, attrs=None, prefix=None, suffix=None): + attrs = self.get_attrs_with_affix_classes( + {"class": "input", **(attrs or {})}, + prefix=prefix, + suffix=suffix, + ) + super().__init__(form_field, attrs=attrs, prefix=prefix, suffix=suffix) class SBAdminTextTagsWidget(SBAdminBaseWidget, forms.TextInput): @@ -159,12 +189,17 @@ def __init__(self, form_field=None, attrs=None): super().__init__(form_field, attrs={"class": "input", **(attrs or {})}) -class SBAdminNumberWidget(SBAdminBaseWidget, forms.NumberInput): +class SBAdminNumberWidget(SBAdminInputAffixMixin, SBAdminBaseWidget, forms.NumberInput): class_name = "input" template_name = "sb_admin/widgets/number.html" - def __init__(self, form_field=None, attrs=None): - super().__init__(form_field, attrs={"class": self.class_name, **(attrs or {})}) + def __init__(self, form_field=None, attrs=None, prefix=None, suffix=None): + attrs = self.get_attrs_with_affix_classes( + {"class": self.class_name, **(attrs or {})}, + prefix=prefix, + suffix=suffix, + ) + super().__init__(form_field, attrs=attrs, prefix=prefix, suffix=suffix) class SBAdminCheckboxWidget(SBAdminBaseWidget, forms.CheckboxInput): diff --git a/src/django_smartbase_admin/audit/tests/test_action_processing.py b/src/django_smartbase_admin/audit/tests/test_action_processing.py index cc624858..f0f2ebab 100644 --- a/src/django_smartbase_admin/audit/tests/test_action_processing.py +++ b/src/django_smartbase_admin/audit/tests/test_action_processing.py @@ -179,7 +179,7 @@ def test_row_actions_are_processed_into_table_column_and_row_descriptors(self): "url": "/actions/PublishArticleView/7/", "title": "Publish Draft Article", "icon": "Check-correct", - "css_class": "btn btn-tiny btn-icon", + "css_class": "btn btn-small btn-only-icon", "open_in_modal": True, "is_method_action": False, "open_in_new_tab": False, @@ -197,7 +197,7 @@ def test_row_actions_are_processed_into_table_column_and_row_descriptors(self): "url": "/articles/7/", "title": "Open", "icon": "Preview-open", - "css_class": "btn btn-tiny btn-icon", + "css_class": "btn btn-small btn-only-icon", "open_in_modal": False, "is_method_action": False, "open_in_new_tab": True, diff --git a/src/django_smartbase_admin/audit/tests/test_modal_media.py b/src/django_smartbase_admin/audit/tests/test_modal_media.py new file mode 100644 index 00000000..1e295db0 --- /dev/null +++ b/src/django_smartbase_admin/audit/tests/test_modal_media.py @@ -0,0 +1,26 @@ +"""Regression tests for modal media serialization in SBAdmin change form.""" + +import json +from unittest.mock import patch + +from django.test import RequestFactory, TestCase +from js_asset import JS + +from ckeditor.widgets import CKEditorWidget + +from django_smartbase_admin.admin.admin_base import SBAdmin + + +class ModalMediaSerializationTests(TestCase): + def setUp(self): + self.request = RequestFactory().get("/") + self.admin = SBAdmin.__new__(SBAdmin) + + @patch("django.contrib.admin.options.ModelAdmin.render_change_form") + def test_modal_ckeditor_widget_media_is_json_serializable( + self, mock_render_change_form + ): + media = CKEditorWidget().media + context = {"sbadmin_is_modal": True, "media": media} + self.admin.render_change_form(self.request, context) + json.dumps(context["media_json"]) diff --git a/src/django_smartbase_admin/engine/actions.py b/src/django_smartbase_admin/engine/actions.py index 5bf32b5d..f999683b 100644 --- a/src/django_smartbase_admin/engine/actions.py +++ b/src/django_smartbase_admin/engine/actions.py @@ -115,7 +115,7 @@ class SBAdminRowAction(SBAdminCustomAction): """ target_view = None - css_class = "btn btn-tiny btn-icon" + css_class = "btn btn-small btn-only-icon" open_in_new_tab = False enabled_if = None enabled_field = None diff --git a/src/django_smartbase_admin/static/sb_admin/src/css/_components.css b/src/django_smartbase_admin/static/sb_admin/src/css/_components.css index ec47d399..8f250462 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/css/_components.css +++ b/src/django_smartbase_admin/static/sb_admin/src/css/_components.css @@ -31,13 +31,13 @@ .column-widget-columns { & > li { @apply relative px-12 py-8 flex items-center; - .checkbox.checkbox-icon:not(:checked) + label { + .checkbox.checkbox-icon:not(:checked) + :where(label, .label) { @media (pointer:fine) { opacity: 0; } } &:hover { - .checkbox.checkbox-icon + label { + .checkbox.checkbox-icon + :where(label, .label) { opacity: 1; } } diff --git a/src/django_smartbase_admin/static/sb_admin/src/css/components/_button.css b/src/django_smartbase_admin/static/sb_admin/src/css/components/_button.css index 322f5fc7..cd9484ce 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/css/components/_button.css +++ b/src/django_smartbase_admin/static/sb_admin/src/css/components/_button.css @@ -202,6 +202,16 @@ } } + .btn-only-icon { + @apply px-0 aspect-square !justify-center; + >span { + @apply hidden; + } + >svg { + @apply mx-0; + } + } + .btn-group { @apply flex; > * { diff --git a/src/django_smartbase_admin/static/sb_admin/src/css/components/_input.css b/src/django_smartbase_admin/static/sb_admin/src/css/components/_input.css index f1df65c9..cb443c5c 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/css/components/_input.css +++ b/src/django_smartbase_admin/static/sb_admin/src/css/components/_input.css @@ -72,6 +72,26 @@ @apply h-auto; } + .input-affix { + @apply flex w-full; + } + + .input-affix__field { + @apply grow min-w-0; + } + + .input-affix__addon { + @apply flex items-center px-12 border border-dark-300 bg-dark-100 text-dark-700 shadow-xs; + } + + .input-affix__prefix { + @apply border-r-0 rounded-l; + } + + .input-affix__suffix { + @apply border-l-0 rounded-r; + } + .input-file { @apply relative flex items-center; @apply border border-dark-300 border-dashed rounded bg-light; @@ -120,7 +140,7 @@ div.radio > input[type="radio"]{ @apply absolute opacity-0 top-0; z-index: -1; - & ~ label { + & ~ :where(label, .label) { @apply relative flex pl-32 text-14 min-h-20; &::before, @@ -137,7 +157,7 @@ } &:not(:disabled) { - &:hover + label { + &:hover + :where(label, .label) { &::before { @apply border-dark-400; } @@ -147,7 +167,7 @@ } } - &:focus + label { + &:focus + :where(label, .label) { &::before { @apply outline-primary; } @@ -155,7 +175,7 @@ } &:disabled { - + label { + + :where(label, .label) { cursor: not-allowed; &::before { @apply bg-light border-dark-300 shadow-none; @@ -164,20 +184,20 @@ } &:checked { - & + label { + & + :where(label, .label) { &::after { opacity: 1; } } &:not(:disabled) { - + label { + + :where(label, .label) { &::before { @apply bg-primary border-primary; } } - &:hover + label { + &:hover + :where(label, .label) { &:before { @apply bg-primary-600 border-primary-600; } @@ -192,7 +212,7 @@ input.checkbox, div.checkbox > input[type="checkbox"] { - & ~ label { + & ~ :where(label, .label) { &::before { @apply rounded; } @@ -212,7 +232,7 @@ &:checked { &:not(:disabled) { - &:hover + label { + &:hover + :where(label, .label) { &::after { background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M20.7072 7.70712L10.7072 17.7071C10.3167 18.0976 9.68349 18.0976 9.29297 17.7071L4.29297 12.7071L5.70718 11.2929L10.0001 15.5858L19.293 6.29291L20.7072 7.70712Z' fill='%23E5E7EB' fill-rule='nonzero'/%3E%3C/svg%3E%0A"); } @@ -221,7 +241,7 @@ } &:indeterminate { - & + label { + & + :where(label, .label) { &::before { @apply bg-primary border-primary; } @@ -231,7 +251,7 @@ } } &:not(:disabled) { - &:hover + label { + &:hover + :where(label, .label) { &:before { @apply bg-primary-600 border-primary-600; } @@ -241,7 +261,7 @@ } input.checkbox-delete { - + label { + + :where(label, .label) { @apply text-dark-300 transition-colors; @apply p-8 h-full w-full flex-center; @@ -256,7 +276,7 @@ } &:checked { - + label { + + :where(label, .label) { @apply text-negative; } } @@ -264,7 +284,7 @@ input.radio, div.radio > input[type="radio"] { - & ~ label { + & ~ :where(label, .label) { &::before { @apply rounded-full; } @@ -281,7 +301,7 @@ &:checked { &:not(:disabled) { - &:hover + label { + &:hover + :where(label, .label) { &::after { opacity: 1; } @@ -292,7 +312,7 @@ input.radio.radio-list, div.radio.radio-list > input[type="radio"] { - & + label { + & + :where(label, .label) { @apply px-16 py-12 flex items-center justify-between text-dark-900; &::before { @@ -309,13 +329,13 @@ } &:hover { - & + label { + & + :where(label, .label) { @apply bg-dark-100; } } &:checked { - & + label { + & + :where(label, .label) { @apply bg-dark-100; &::after { @@ -333,7 +353,7 @@ } input.checkbox.checkbox-icon { - + label { + + :where(label, .label) { @apply pl-0 text-dark-300 transition-colors; &:before, &:after { @@ -343,14 +363,14 @@ @apply text-dark-700; } } - &:not(:checked) + label { + &:not(:checked) + :where(label, .label) { > svg { &:last-child { @apply hidden; } } } - &:checked + label { + &:checked + :where(label, .label) { > svg { &:first-child { @apply hidden; @@ -385,4 +405,4 @@ } } -} \ No newline at end of file +} diff --git a/src/django_smartbase_admin/static/sb_admin/src/css/components/_toggle.css b/src/django_smartbase_admin/static/sb_admin/src/css/components/_toggle.css index a8fef7d9..7b91936d 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/css/components/_toggle.css +++ b/src/django_smartbase_admin/static/sb_admin/src/css/components/_toggle.css @@ -3,10 +3,10 @@ input[type="radio"].toggle { @apply absolute opacity-0 top-0; z-index: -1; - & ~ label { + & ~ :where(label, .label) { @apply relative flex pl-56 text-14 top-2 font-medium text-dark-900; } - & + label { + & + :where(label, .label) { @apply absolute block pl-0; @apply w-44 h-24 top-0 left-0; @apply bg-dark-200 rounded-full transition-colors; @@ -36,22 +36,22 @@ &:not(:disabled) { &:hover { - & + label { + & + :where(label, .label) { @apply bg-dark-300; } } - &:focus + label { + &:focus + :where(label, .label) { @apply outline-primary; } } &[readonly], &:disabled { - & + label { + & + :where(label, .label) { opacity: 0.5; } - & ~ label { + & ~ :where(label, .label) { cursor: default; } } @@ -59,7 +59,7 @@ &:checked { - & + label { + & + :where(label, .label) { @apply bg-primary; &::before, &::after { @@ -70,7 +70,7 @@ &:not(:disabled) { &:hover { - & + label { + & + :where(label, .label) { @apply bg-primary-600; &::after { opacity: 0.5; @@ -86,6 +86,6 @@ } } -.djn-td input[type="checkbox"].toggle + label + label { +.djn-td input[type="checkbox"].toggle + :where(label, .label) + :where(label, .label) { font-size: 0; } diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/main.js b/src/django_smartbase_admin/static/sb_admin/src/js/main.js index f6aaad85..ed541408 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/js/main.js +++ b/src/django_smartbase_admin/static/sb_admin/src/js/main.js @@ -34,6 +34,10 @@ import {setCookie, setDropdownLabel} from "./utils" import Multiselect from "./multiselect" import Radio from "./radio" +const CKEDITOR_READY_MAX_FRAMES = 120 +const PAGE_SCROLL_MARGIN_PX = 24 +const MODAL_SCROLL_MARGIN_PX = 24 + class Main { constructor() { document.body.classList.add('js-ready') @@ -78,6 +82,7 @@ class Main { this.textTags.handleDynamicallyAddedTextTags(detail.target) this.initInlines(detail.target) this.initTooltips(detail.target) + this.scheduleScrollToFirstErrorField(detail.target) }) window.htmx.on("htmx:afterSettle", (detail) => { @@ -107,6 +112,7 @@ class Main { this.initAliasName() this.handleLocationHashFromTabs() this.initCollapseEventListeners() + this.scheduleScrollToFirstErrorField(document) } isDarkMode() { @@ -188,6 +194,163 @@ class Main { }) } + hasValidationErrors(target) { + return Boolean(target.querySelector('.errorlist')) + } + + findFirstErrorAnchor(target) { + const fieldError = target.querySelector('.field.errors') + if (fieldError) { + return fieldError + } + + const inlineCellError = target.querySelector('.djn-inline-form.has-errors td[class*="field-"] .errorlist') + if (inlineCellError) { + return inlineCellError.closest('td[class*="field-"]') || inlineCellError + } + + const inlineRowError = target.querySelector('.djn-inline-form.has-errors') + if (inlineRowError) { + return inlineRowError + } + + const nonFieldError = target.querySelector('.nonfield, ul.errorlist') + if (nonFieldError) { + const wrapper = nonFieldError.closest('.field, td[class*="field-"], .djn-inline-form') + return wrapper || nonFieldError + } + + return null + } + + getFirstFocusableField(anchor) { + if (anchor.matches('input, select, textarea, button')) { + return anchor + } + return anchor.querySelector('input:not([type="hidden"]), select, textarea, button') + } + + getTargetCKEditorIds(target) { + if (!target?.querySelectorAll) { + return [] + } + return Array.from(target.querySelectorAll('textarea[data-type="ckeditortype"]')) + .map((textarea) => textarea.id) + .filter(Boolean) + } + + areTargetCKEditorsReady(editorIds) { + if (!window.CKEDITOR || !editorIds.length) { + return true + } + return editorIds.every((editorId) => window.CKEDITOR.instances[editorId]?.status === 'ready') + } + + scheduleScrollToFirstErrorField(target) { + if (!this.hasValidationErrors(target)) { + return + } + const editorIds = this.getTargetCKEditorIds(target) + if (!window.CKEDITOR || !editorIds.length) { + this.scrollToFirstErrorField(target) + return + } + let frameCount = 0 + const waitForEditors = () => { + const editorsReady = this.areTargetCKEditorsReady(editorIds) + const reachedMaxFrames = frameCount >= CKEDITOR_READY_MAX_FRAMES + if (editorsReady || reachedMaxFrames) { + this.scrollToFirstErrorField(target) + return + } + frameCount += 1 + window.requestAnimationFrame(waitForEditors) + } + window.requestAnimationFrame(waitForEditors) + } + + scrollElementIntoViewport(anchor) { + const modalBody = anchor.closest('.modal-body') + const detailActionBar = document.querySelector('.detail-view-action-bar') + const actionBarHeight = detailActionBar ? detailActionBar.getBoundingClientRect().height : 0 + const modalScrollMargin = `${MODAL_SCROLL_MARGIN_PX}px` + const pageScrollMarginTop = `${actionBarHeight + PAGE_SCROLL_MARGIN_PX}px` + const scrollMargins = modalBody + ? {scrollMarginTop: modalScrollMargin, scrollMarginBottom: modalScrollMargin} + : {scrollMarginTop: pageScrollMarginTop} + const previousScrollMargins = { + scrollMarginTop: anchor.style.scrollMarginTop, + scrollMarginBottom: anchor.style.scrollMarginBottom + } + + try { + Object.entries(scrollMargins).forEach(([propertyName, propertyValue]) => { + anchor.style[propertyName] = propertyValue + }) + anchor.scrollIntoView({behavior: 'instant', block: 'start', inline: 'nearest'}) + } finally { + anchor.style.scrollMarginTop = previousScrollMargins.scrollMarginTop + anchor.style.scrollMarginBottom = previousScrollMargins.scrollMarginBottom + } + } + + expandCollapsedAncestors(element) { + const collapsedAncestors = [] + let current = element?.parentElement + while (current) { + if (current.classList?.contains('collapse') && !current.classList.contains('show')) { + collapsedAncestors.push(current) + } + current = current.parentElement + } + const ancestorsToExpand = collapsedAncestors.reverse() + if (!ancestorsToExpand.length) { + return Promise.resolve() + } + + return ancestorsToExpand.reduce((promiseChain, collapseElement) => { + return promiseChain.then(() => { + return new Promise((resolve) => { + if (collapseElement.classList.contains('show')) { + resolve() + return + } + const collapseInstance = Collapse.getOrCreateInstance(collapseElement) + let timeoutId = null + const complete = () => { + collapseElement.removeEventListener('shown.bs.collapse', complete) + if (timeoutId) { + window.clearTimeout(timeoutId) + } + resolve() + } + collapseElement.addEventListener('shown.bs.collapse', complete, {once: true}) + timeoutId = window.setTimeout(complete, 450) + collapseInstance.show() + }) + }) + }, Promise.resolve()) + } + + scrollToFirstErrorField(target) { + target = target || document + if (!this.hasValidationErrors(target)) { + return + } + const anchor = this.findFirstErrorAnchor(target) + if (!anchor) { + return + } + const firstField = this.getFirstFocusableField(anchor) + const scrollTarget = firstField || anchor + this.expandCollapsedAncestors(scrollTarget).then(() => { + this.scrollElementIntoViewport(scrollTarget) + if (firstField) { + firstField.focus({preventScroll: true}) + } + }) + } + passwordToggleFnc(event) { const passwordToggle = event.target.closest('.js-password-toggle-show, .js-password-toggle-hide') if (passwordToggle) { diff --git a/src/django_smartbase_admin/static/sb_admin/src/js/modal_view.js b/src/django_smartbase_admin/static/sb_admin/src/js/modal_view.js index ff9ed62e..b56f0bd2 100644 --- a/src/django_smartbase_admin/static/sb_admin/src/js/modal_view.js +++ b/src/django_smartbase_admin/static/sb_admin/src/js/modal_view.js @@ -20,9 +20,6 @@ var scriptsToLoad = (media.js || []).reduce(function (acc, path) { } return acc }, []) - -console.log(scriptsToLoad) - Object.entries(media.css || {}).forEach(function ([medium, paths]) { paths.forEach(function (path) { var url = resolveUrl(path) diff --git a/src/django_smartbase_admin/templates/sb_admin/tailwind_whitelist.html b/src/django_smartbase_admin/templates/sb_admin/tailwind_whitelist.html index dd9f1604..4657684b 100644 --- a/src/django_smartbase_admin/templates/sb_admin/tailwind_whitelist.html +++ b/src/django_smartbase_admin/templates/sb_admin/tailwind_whitelist.html @@ -5,5 +5,5 @@
-
+
\ No newline at end of file diff --git a/src/django_smartbase_admin/templates/sb_admin/widgets/includes/input_affix.html b/src/django_smartbase_admin/templates/sb_admin/widgets/includes/input_affix.html new file mode 100644 index 00000000..a2327e8b --- /dev/null +++ b/src/django_smartbase_admin/templates/sb_admin/widgets/includes/input_affix.html @@ -0,0 +1,19 @@ +{% if widget.prefix or widget.suffix %} +
+ {% if widget.prefix %} +
+ {{ widget.prefix }} +
+ {% endif %} +
+ {% include "sb_admin/widgets/input.html" %} +
+ {% if widget.suffix %} +
+ {{ widget.suffix }} +
+ {% endif %} +
+{% else %} + {% include "sb_admin/widgets/input.html" %} +{% endif %} diff --git a/src/django_smartbase_admin/templates/sb_admin/widgets/number.html b/src/django_smartbase_admin/templates/sb_admin/widgets/number.html index 0268e170..df879f6c 100644 --- a/src/django_smartbase_admin/templates/sb_admin/widgets/number.html +++ b/src/django_smartbase_admin/templates/sb_admin/widgets/number.html @@ -1,3 +1,3 @@ {% include 'sb_admin/widgets/includes/field_label.html' %} -{% include "sb_admin/widgets/input.html" %} -{% include 'sb_admin/widgets/includes/help_text.html' %} \ No newline at end of file +{% include "sb_admin/widgets/includes/input_affix.html" %} +{% include 'sb_admin/widgets/includes/help_text.html' %} diff --git a/src/django_smartbase_admin/templates/sb_admin/widgets/text.html b/src/django_smartbase_admin/templates/sb_admin/widgets/text.html index 6654a6a9..df879f6c 100644 --- a/src/django_smartbase_admin/templates/sb_admin/widgets/text.html +++ b/src/django_smartbase_admin/templates/sb_admin/widgets/text.html @@ -1,3 +1,3 @@ {% include 'sb_admin/widgets/includes/field_label.html' %} -{% include "sb_admin/widgets/input.html" %} +{% include "sb_admin/widgets/includes/input_affix.html" %} {% include 'sb_admin/widgets/includes/help_text.html' %}