diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index e1fe6338d..0449f8e99 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, ) @@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) @@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) @@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 38fa55738..bd466ca48 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget, + DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget ) __all__ = ( @@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label=_('Comments') ) diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index af0f7cf43..0825c9ca7 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -2,6 +2,7 @@ from .model_forms import * from .filtersets import * from .bulk_edit import * from .bulk_import import * +from .misc import * from .mixins import * from .config import * from .scripts import * diff --git a/netbox/extras/forms/misc.py b/netbox/extras/forms/misc.py new file mode 100644 index 000000000..b52338e76 --- /dev/null +++ b/netbox/extras/forms/misc.py @@ -0,0 +1,14 @@ +from django import forms + +__all__ = ( + 'RenderMarkdownForm', +) + + +class RenderMarkdownForm(forms.Form): + """ + Provides basic validation for markup to be rendered. + """ + text = forms.CharField( + required=False + ) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f41a45f5a..304e5b9ea 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -92,4 +92,6 @@ urlpatterns = [ path('scripts/results//', views.ScriptResultView.as_view(), name='script_result'), re_path(r'^scripts/(?P.([^.]+)).(?P.(.+))/', views.ScriptView.as_view(), name='script'), + # Markdown + path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown") ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2d2608ae8..91d3b5c58 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,7 +1,7 @@ from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q -from django.http import Http404, HttpResponseForbidden +from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.generic import View @@ -10,6 +10,7 @@ from rq import Worker from netbox.views import generic from utilities.htmx import is_htmx +from utilities.templatetags.builtins.filters import render_markdown from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from . import filtersets, forms, tables @@ -885,3 +886,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView): queryset = JobResult.objects.all() filterset = filtersets.JobResultFilterSet table = tables.JobResultTable + + +# +# Markdown +# + +class RenderMarkdownView(View): + + def post(self, request): + form = forms.RenderMarkdownForm(request.POST) + if not form.is_valid(): + HttpResponseBadRequest() + rendered = render_markdown(form.cleaned_data['text']) + + return HttpResponse(rendered) diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index d0af43975..63352698b 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField, - SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField, + StaticSelect, DynamicModelMultipleChoiceField ) __all__ = ( @@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) @@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index 8b6b37a4c..74ab785a5 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 27e804c30..f12291e44 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index f0220c050..5d7f7342b 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index ed6e12747..f430604f9 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index dd1ec47c9..753576bc3 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index e677ff599..fe2ccaaef 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; import { initSelectMultiple } from './selectMultiple'; +import { initMarkdownPreviews } from './markdownPreview'; export function initButtons(): void { for (const func of [ @@ -13,6 +14,7 @@ export function initButtons(): void { initSelectAll, initSelectMultiple, initMoveButtons, + initMarkdownPreviews, ]) { func(); } diff --git a/netbox/project-static/src/buttons/markdownPreview.ts b/netbox/project-static/src/buttons/markdownPreview.ts new file mode 100644 index 000000000..224b2beab --- /dev/null +++ b/netbox/project-static/src/buttons/markdownPreview.ts @@ -0,0 +1,45 @@ +import { isTruthy } from 'src/util'; + +/** + * interface for htmx configRequest event + */ +declare global { + interface HTMLElementEventMap { + 'htmx:configRequest': CustomEvent<{ + parameters: Record; + headers: Record; + }>; + } +} + +function initMarkdownPreview(markdownWidget: HTMLDivElement) { + const previewButton = markdownWidget.querySelector('button.preview-button') as HTMLButtonElement; + const textarea = markdownWidget.querySelector('textarea') as HTMLTextAreaElement; + const preview = markdownWidget.querySelector('div.preview') as HTMLDivElement; + + /** + * Make sure the textarea has style attribute height + * So that it can be copied over to preview div. + */ + if (!isTruthy(textarea.style.height)) { + const { height } = textarea.getBoundingClientRect(); + textarea.style.height = `${height}px`; + } + + /** + * Add the value of the textarea to the body of the htmx request + * and copy the height of text are to the preview div + */ + previewButton.addEventListener('htmx:configRequest', e => { + e.detail.parameters = { text: textarea.value || '' }; + e.detail.headers['X-CSRFToken'] = window.CSRF_TOKEN; + preview.style.minHeight = textarea.style.height; + preview.innerHTML = ''; + }); +} + +export function initMarkdownPreviews(): void { + for (const markdownWidget of document.querySelectorAll('.markdown-widget')) { + initMarkdownPreview(markdownWidget); + } +} diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index e486bc7db..37f6c21c4 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -236,12 +236,12 @@ table { } th.asc > a::after { - content: "\f0140"; + content: '\f0140'; font-family: 'Material Design Icons'; } th.desc > a::after { - content: "\f0143"; + content: '\f0143'; font-family: 'Material Design Icons'; } @@ -416,18 +416,18 @@ nav.search { } } -// Styles for the quicksearch and its clear button; +// Styles for the quicksearch and its clear button; // Overrides input-group styles and adds transition effects .quicksearch { - input[type="search"] { - border-radius: $border-radius !important; + input[type='search'] { + border-radius: $border-radius !important; } button { margin-left: -32px !important; z-index: 100 !important; outline: none !important; - border-radius: $border-radius !important; + border-radius: $border-radius !important; transition: visibility 0s, opacity 0.2s linear; } @@ -998,9 +998,24 @@ div.card-overlay { padding: 8px; } +/* Markdown widget */ +.markdown-widget { + .nav-link { + border-bottom: 0; + + &.active { + background-color: var(--nbx-body-bg); + } + } + + .nav-tabs { + background-color: var(--nbx-pre-bg); + } +} + // Preformatted text blocks td pre { - margin-bottom: 0 + margin-bottom: 0; } pre.block { padding: $spacer; diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 183a8e851..ab882fe7e 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import * -from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea +from utilities.forms import CommentField, DynamicModelChoiceField __all__ = ( 'ContactBulkEditForm', @@ -106,7 +106,6 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm): required=False ) comments = CommentField( - widget=SmallTextarea, label='Comments' ) diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index bb6c3f73b..ee9543452 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -27,7 +27,7 @@ class CommentField(forms.CharField): """ A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. """ - widget = forms.Textarea + widget = widgets.MarkdownWidget help_text = f""" diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 1802306f1..bd828bb8f 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -16,6 +16,7 @@ __all__ = ( 'ColorSelect', 'DatePicker', 'DateTimePicker', + 'MarkdownWidget', 'NumericArrayField', 'SelectDurationWidget', 'SelectSpeedWidget', @@ -116,6 +117,10 @@ class SelectDurationWidget(forms.NumberInput): template_name = 'widgets/select_duration.html' +class MarkdownWidget(forms.Textarea): + template_name = 'widgets/markdown_input.html' + + class NumericArrayField(SimpleArrayField): def clean(self, value): diff --git a/netbox/utilities/templates/form_helpers/render_field.html b/netbox/utilities/templates/form_helpers/render_field.html index ec9ceb09a..85c04df92 100644 --- a/netbox/utilities/templates/form_helpers/render_field.html +++ b/netbox/utilities/templates/form_helpers/render_field.html @@ -6,7 +6,7 @@ {# Render the field label, except for: #} {# 1. Checkboxes (label appears to the right of the field #} {# 2. Textareas with no label set (will expand across entire row) #} - {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' and not label %} + {% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %} {% else %}