Closes #11294: Markdown Preview (#11894)

* MarkdownWidget

* Change border and color of active markdown tab

* Fix template name typo

* Add render markdown endpoint

* Static assets for markdown widget

* widget style fix and unique ids based on name

* Replace SmallTextArea with SmallMarkdownWidget

* Clear innerHTML before swapping

* render markdown directly in template

* change render markdown view path

* remove small markdown widget

* Simplify rendering logic

* Use a form to clean input Markdown data

---------

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
This commit is contained in:
Aron Bergur Jóhannsson 2023-03-09 13:21:13 +00:00 committed by GitHub
parent 33286aad39
commit fa60f9d2a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 138 additions and 46 deletions

View File

@ -7,7 +7,7 @@ from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
StaticSelect, StaticSelect,
) )
@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label=_('Comments') label=_('Comments')
) )
@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label=_('Comments') label=_('Comments')
) )
@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label=_('Comments') label=_('Comments')
) )

View File

@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget, DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget
) )
__all__ = ( __all__ = (
@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label=_('Comments') label=_('Comments')
) )

View File

@ -2,6 +2,7 @@ from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *
from .misc import *
from .mixins import * from .mixins import *
from .config import * from .config import *
from .scripts import * from .scripts import *

View File

@ -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
)

View File

@ -92,4 +92,6 @@ urlpatterns = [
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'), path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'), re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
# Markdown
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
] ]

View File

@ -1,7 +1,7 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q 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.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.generic import View from django.views.generic import View
@ -10,6 +10,7 @@ from rq import Worker
from netbox.views import generic from netbox.views import generic
from utilities.htmx import is_htmx 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.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -885,3 +886,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
queryset = JobResult.objects.all() queryset = JobResult.objects.all()
filterset = filtersets.JobResultFilterSet filterset = filtersets.JobResultFilterSet
table = tables.JobResultTable 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)

View File

@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField, add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField, StaticSelect, DynamicModelMultipleChoiceField
) )
__all__ = ( __all__ = (
@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions';
import { initReslug } from './reslug'; import { initReslug } from './reslug';
import { initSelectAll } from './selectAll'; import { initSelectAll } from './selectAll';
import { initSelectMultiple } from './selectMultiple'; import { initSelectMultiple } from './selectMultiple';
import { initMarkdownPreviews } from './markdownPreview';
export function initButtons(): void { export function initButtons(): void {
for (const func of [ for (const func of [
@ -13,6 +14,7 @@ export function initButtons(): void {
initSelectAll, initSelectAll,
initSelectMultiple, initSelectMultiple,
initMoveButtons, initMoveButtons,
initMarkdownPreviews,
]) { ]) {
func(); func();
} }

View File

@ -0,0 +1,45 @@
import { isTruthy } from 'src/util';
/**
* interface for htmx configRequest event
*/
declare global {
interface HTMLElementEventMap {
'htmx:configRequest': CustomEvent<{
parameters: Record<string, string>;
headers: Record<string, string>;
}>;
}
}
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<HTMLDivElement>('.markdown-widget')) {
initMarkdownPreview(markdownWidget);
}
}

View File

@ -236,12 +236,12 @@ table {
} }
th.asc > a::after { th.asc > a::after {
content: "\f0140"; content: '\f0140';
font-family: 'Material Design Icons'; font-family: 'Material Design Icons';
} }
th.desc > a::after { th.desc > a::after {
content: "\f0143"; content: '\f0143';
font-family: 'Material Design Icons'; font-family: 'Material Design Icons';
} }
@ -419,7 +419,7 @@ 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 // Overrides input-group styles and adds transition effects
.quicksearch { .quicksearch {
input[type="search"] { input[type='search'] {
border-radius: $border-radius !important; border-radius: $border-radius !important;
} }
@ -998,9 +998,24 @@ div.card-overlay {
padding: 8px; 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 // Preformatted text blocks
td pre { td pre {
margin-bottom: 0 margin-bottom: 0;
} }
pre.block { pre.block {
padding: $spacer; padding: $spacer;

View File

@ -2,7 +2,7 @@ from django import forms
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import * from tenancy.models import *
from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea from utilities.forms import CommentField, DynamicModelChoiceField
__all__ = ( __all__ = (
'ContactBulkEditForm', 'ContactBulkEditForm',
@ -106,7 +106,6 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )

View File

@ -27,7 +27,7 @@ class CommentField(forms.CharField):
""" """
A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. 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""" help_text = f"""
<i class="mdi mdi-information-outline"></i> <i class="mdi mdi-information-outline"></i>
<a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1"> <a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1">

View File

@ -16,6 +16,7 @@ __all__ = (
'ColorSelect', 'ColorSelect',
'DatePicker', 'DatePicker',
'DateTimePicker', 'DateTimePicker',
'MarkdownWidget',
'NumericArrayField', 'NumericArrayField',
'SelectDurationWidget', 'SelectDurationWidget',
'SelectSpeedWidget', 'SelectSpeedWidget',
@ -116,6 +117,10 @@ class SelectDurationWidget(forms.NumberInput):
template_name = 'widgets/select_duration.html' template_name = 'widgets/select_duration.html'
class MarkdownWidget(forms.Textarea):
template_name = 'widgets/markdown_input.html'
class NumericArrayField(SimpleArrayField): class NumericArrayField(SimpleArrayField):
def clean(self, value): def clean(self, value):

View File

@ -6,7 +6,7 @@
{# Render the field label, except for: #} {# Render the field label, except for: #}
{# 1. Checkboxes (label appears to the right of the field #} {# 1. Checkboxes (label appears to the right of the field #}
{# 2. Textareas with no label set (will expand across entire row) #} {# 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 %} {% else %}
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}"> <label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ label }} {{ label }}

View File

@ -0,0 +1,22 @@
<div class="border rounded markdown-widget">
<ul class="nav nav-tabs px-3 pt-2 rounded-top border-0">
<li class="nav-item" role="presentation">
<button class="nav-link active " id="{{ widget.name }}-input-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-input" type="button" role="tab" aria-controls="{{ widget.name }}-input" aria-selected="true">
Write
</button>
</li>
<li class="nav-item" role="presentation">
<button hx-target="#{{ widget.name }}-preview" hx-swap="innerHTML" hx-post="{% url 'extras:render_markdown' %}" class="nav-link preview-button" id="{{ widget.name }}-markdown-preview-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-markdown-preview" type="button" role="tab" aria-controls="{{ widget.name }}-markdown-preview" aria-selected="false">
Preview
</button>
</li>
</ul>
<div class="tab-content bg-body rounded-bottom border-top">
<div class="tab-pane show active" id="{{ widget.name }}-input" role="tabpanel" aria-labelledby="{{ widget.name }}-input-tab">
{% include "django/forms/widgets/textarea.html" %}
</div>
<div class="tab-pane show" id="{{ widget.name }}-markdown-preview" role="tabpanel" aria-labelledby="{{ widget.name }}-markdown-preview-tab">
<div id="{{ widget.name }}-preview" class="preview px-3 py-2">Testing</div>
</div>
</div>
</div>

View File

@ -9,7 +9,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField, add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect DynamicModelMultipleChoiceField, StaticSelect
) )
from virtualization.choices import * from virtualization.choices import *
from virtualization.models import * from virtualization.models import *
@ -90,7 +90,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label=_('Comments') label=_('Comments')
) )
@ -163,7 +162,6 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label=_('Comments') label=_('Comments')
) )

View File

@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices
from ipam.models import VLAN from ipam.models import VLAN
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField
from wireless.choices import * from wireless.choices import *
from wireless.constants import SSID_MAX_LENGTH from wireless.constants import SSID_MAX_LENGTH
from wireless.models import * from wireless.models import *
@ -74,7 +74,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )
@ -119,7 +118,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=SmallTextarea,
label='Comments' label='Comments'
) )