mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-03 04:35:31 +00:00
Resolve "Add an option to enter Markdown for the paragraph element"
This commit is contained in:
parent
5aa4260435
commit
124fb9aa23
17 changed files with 250 additions and 54 deletions
backend
src/baserow/contrib/builder
tests/baserow/contrib/builder
web-frontend/modules
|
@ -451,12 +451,13 @@ class TextElementType(ElementType):
|
|||
|
||||
type = "text"
|
||||
model_class = TextElement
|
||||
serializer_field_names = ["value", "alignment"]
|
||||
allowed_fields = ["value", "alignment"]
|
||||
serializer_field_names = ["value", "alignment", "format"]
|
||||
allowed_fields = ["value", "alignment", "format"]
|
||||
|
||||
class SerializedDict(ElementDict):
|
||||
value: BaserowFormula
|
||||
alignment: str
|
||||
format: str
|
||||
|
||||
def get_pytest_params(self, pytest_data_fixture):
|
||||
return {
|
||||
|
@ -466,6 +467,7 @@ class TextElementType(ElementType):
|
|||
"Asperiores corporis perspiciatis nam harum veritatis. "
|
||||
"Impedit qui maxime aut illo quod ea molestias.'",
|
||||
"alignment": "left",
|
||||
"format": TextElement.TEXT_FORMATS.PLAIN,
|
||||
}
|
||||
|
||||
@property
|
||||
|
@ -479,6 +481,11 @@ class TextElementType(ElementType):
|
|||
allow_blank=True,
|
||||
default="",
|
||||
),
|
||||
"format": serializers.ChoiceField(
|
||||
choices=TextElement.TEXT_FORMATS.choices,
|
||||
default=TextElement.TEXT_FORMATS.PLAIN,
|
||||
help_text=TextElement._meta.get_field("format").help_text,
|
||||
),
|
||||
}
|
||||
|
||||
def import_serialized(self, page, serialized_values, id_mapping):
|
||||
|
|
|
@ -355,12 +355,22 @@ class TextElement(Element):
|
|||
A simple blob of text.
|
||||
"""
|
||||
|
||||
class TEXT_FORMATS(models.TextChoices):
|
||||
PLAIN = "plain"
|
||||
MARKDOWN = "markdown"
|
||||
|
||||
value = FormulaField(default="")
|
||||
alignment = models.CharField(
|
||||
choices=HorizontalAlignments.choices,
|
||||
max_length=10,
|
||||
default=HorizontalAlignments.LEFT,
|
||||
)
|
||||
format = models.CharField(
|
||||
choices=TEXT_FORMATS.choices,
|
||||
help_text="The format of the text",
|
||||
max_length=10,
|
||||
default=TEXT_FORMATS.PLAIN,
|
||||
)
|
||||
|
||||
|
||||
class LinkElement(Element):
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 4.0.10 on 2024-01-17 14:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("builder", "0004_image_element_sizing_styles"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="textelement",
|
||||
name="format",
|
||||
field=models.CharField(
|
||||
choices=[("plain", "Plain"), ("markdown", "Markdown")],
|
||||
default="plain",
|
||||
help_text="The format of the text",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -228,6 +228,7 @@ def test_builder_application_export(data_fixture):
|
|||
"style_background": "none",
|
||||
"value": element2.value,
|
||||
"alignment": "left",
|
||||
"format": TextElement.TEXT_FORMATS.PLAIN,
|
||||
},
|
||||
{
|
||||
"id": element_container.id,
|
||||
|
@ -277,6 +278,7 @@ def test_builder_application_export(data_fixture):
|
|||
"order": str(element_inside_container.order),
|
||||
"value": element_inside_container.value,
|
||||
"alignment": "left",
|
||||
"format": TextElement.TEXT_FORMATS.PLAIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -8,11 +8,9 @@
|
|||
<component
|
||||
:is="`h${element.level}`"
|
||||
class="heading-element__heading"
|
||||
:class="{ [`ab-heading--h${element.level}`]: true }"
|
||||
:style="{
|
||||
'--color': resolveColor(element.font_color, headingColorVariables),
|
||||
'--font-size': `${
|
||||
builder.theme[`heading_${element.level}_font_size`]
|
||||
}px`,
|
||||
}"
|
||||
>
|
||||
{{ resolvedValue || $t('headingElement.noValue') }}
|
||||
|
|
|
@ -6,12 +6,28 @@
|
|||
[`element--alignment-horizontal-${element.alignment}`]: true,
|
||||
}"
|
||||
>
|
||||
<p v-for="paragraph in paragraphs" :key="paragraph.id" class="ab-paragraph">
|
||||
{{ paragraph.content }}
|
||||
</p>
|
||||
<p v-if="!paragraphs.length" class="ab-paragraph">
|
||||
{{ $t('textElement.noValue') }}
|
||||
</p>
|
||||
<template v-if="element.format === TEXT_FORMAT_TYPES.MARKDOWN">
|
||||
<MarkdownIt
|
||||
v-if="resolvedValue"
|
||||
class="ab-paragraph"
|
||||
:content="resolvedValue"
|
||||
:rules="rules"
|
||||
@click.native="onClick"
|
||||
></MarkdownIt>
|
||||
<p v-else class="ab-paragraph">{{ $t('textElement.noValue') }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p
|
||||
v-for="paragraph in paragraphs"
|
||||
:key="paragraph.id"
|
||||
class="ab-paragraph"
|
||||
>
|
||||
{{ paragraph.content }}
|
||||
</p>
|
||||
<p v-if="!paragraphs.length" class="ab-paragraph">
|
||||
{{ $t('textElement.noValue') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -19,6 +35,8 @@
|
|||
import element from '@baserow/modules/builder/mixins/element'
|
||||
import { generateHash } from '@baserow/modules/core/utils/hashing'
|
||||
import { ensureString } from '@baserow/modules/core/utils/validator'
|
||||
import { TEXT_FORMAT_TYPES } from '@baserow/modules/builder/enums'
|
||||
import { prefixInternalResolvedUrl } from '@baserow/modules/builder/utils/urlResolution'
|
||||
|
||||
/**
|
||||
* @typedef Text
|
||||
|
@ -34,6 +52,7 @@ export default {
|
|||
* @type {Object}
|
||||
* @property {Array.<Text>} value - A list of paragraphs
|
||||
* @property {string} alignment - The alignment of the element on the page
|
||||
* @property {string} format - The format of the text
|
||||
*/
|
||||
element: {
|
||||
type: Object,
|
||||
|
@ -58,6 +77,77 @@ export default {
|
|||
id: generateHash(line + index),
|
||||
}))
|
||||
},
|
||||
TEXT_FORMAT_TYPES() {
|
||||
return TEXT_FORMAT_TYPES
|
||||
},
|
||||
// Custom rules to pass down to `MarkdownIt` element.
|
||||
// The goal is to make the styling of the rendered markdown content
|
||||
// consistent with the rest of the application builder CSS classes
|
||||
rules() {
|
||||
return {
|
||||
heading_open: (tokens, idx, options, env, renderer) => {
|
||||
const level = tokens[idx].markup.length
|
||||
tokens[idx].attrJoin('class', `ab-heading--h${level}`)
|
||||
return renderer.renderToken(tokens, idx, options)
|
||||
},
|
||||
link_open: (tokens, idx, options, env, renderer) => {
|
||||
const url = prefixInternalResolvedUrl(
|
||||
tokens[idx].attrGet('href'),
|
||||
this.builder,
|
||||
'custom',
|
||||
this.mode
|
||||
)
|
||||
tokens[idx].attrSet('href', url)
|
||||
tokens[idx].attrJoin('class', 'link-element__link')
|
||||
return renderer.renderToken(tokens, idx, options)
|
||||
},
|
||||
image: (tokens, idx, options, env, renderer) => {
|
||||
tokens[idx].attrJoin('class', 'image_element__img')
|
||||
return renderer.renderToken(tokens, idx, options)
|
||||
},
|
||||
paragraph_open: (tokens, idx, options, env, renderer) => {
|
||||
tokens[idx].attrJoin('class', 'ab-paragraph')
|
||||
return renderer.renderToken(tokens, idx, options)
|
||||
},
|
||||
table_open: (tokens, idx, options, env, renderer) => {
|
||||
tokens[idx].attrJoin('class', 'baserow-table')
|
||||
return renderer.renderToken(tokens, idx, options)
|
||||
},
|
||||
tr_open: (tokens, idx, options, env, renderer) => {
|
||||
// Only apply this styling to the first row present in table header.
|
||||
if (idx > 0 && tokens[idx - 1].type === 'thead_open') {
|
||||
tokens[idx].attrJoin('class', 'baserow-table__header-row')
|
||||
} else {
|
||||
tokens[idx].attrJoin('class', 'baserow-table__row')
|
||||
}
|
||||
return renderer.renderToken(tokens, idx, options)
|
||||
},
|
||||
th_open: (tokens, idx, options, env, renderer) => {
|
||||
tokens[idx].attrJoin('class', 'baserow-table__header-cell')
|
||||
return renderer.renderToken(tokens, idx, options)
|
||||
},
|
||||
td_open: (tokens, idx, options, env, renderer) => {
|
||||
tokens[idx].attrJoin('class', 'baserow-table__cell')
|
||||
return renderer.renderToken(tokens, idx, options)
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClick(event) {
|
||||
if (this.mode === 'editing') {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (event.target.classList.contains('link-element__link')) {
|
||||
const url = event.target.getAttribute('href')
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
event.preventDefault()
|
||||
this.$router.push(url)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -9,13 +9,24 @@
|
|||
<FormElement class="control">
|
||||
<HorizontalAlignmentsSelector v-model="values.alignment" />
|
||||
</FormElement>
|
||||
<FormGroup :label="$t('textElementForm.textFormatTypeLabel')">
|
||||
<RadioButton v-model="values.format" :value="TEXT_FORMAT_TYPES.PLAIN">
|
||||
{{ $t('textElementForm.textFormatTypePlain') }}
|
||||
</RadioButton>
|
||||
<RadioButton v-model="values.format" :value="TEXT_FORMAT_TYPES.MARKDOWN">
|
||||
{{ $t('textElementForm.textFormatTypeMarkdown') }}
|
||||
</RadioButton>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ApplicationBuilderFormulaInputGroup from '@baserow/modules/builder/components/ApplicationBuilderFormulaInputGroup'
|
||||
import elementForm from '@baserow/modules/builder/mixins/elementForm'
|
||||
import { HORIZONTAL_ALIGNMENTS } from '@baserow/modules/builder/enums'
|
||||
import {
|
||||
HORIZONTAL_ALIGNMENTS,
|
||||
TEXT_FORMAT_TYPES,
|
||||
} from '@baserow/modules/builder/enums'
|
||||
import HorizontalAlignmentsSelector from '@baserow/modules/builder/components/elements/components/forms/general/settings/HorizontalAlignmentsSelector.vue'
|
||||
|
||||
export default {
|
||||
|
@ -30,8 +41,14 @@ export default {
|
|||
values: {
|
||||
value: '',
|
||||
alignment: HORIZONTAL_ALIGNMENTS.LEFT.value,
|
||||
format: TEXT_FORMAT_TYPES.PLAIN,
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
TEXT_FORMAT_TYPES() {
|
||||
return TEXT_FORMAT_TYPES
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,19 +1,20 @@
|
|||
<template>
|
||||
<div>
|
||||
<ThemeProvider>
|
||||
<PageElement
|
||||
v-for="element in elements"
|
||||
:key="element.id"
|
||||
:element="element"
|
||||
:mode="mode"
|
||||
/>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import PageElement from '@baserow/modules/builder/components/page/PageElement'
|
||||
import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider.vue'
|
||||
|
||||
export default {
|
||||
components: { PageElement },
|
||||
components: { ThemeProvider, PageElement },
|
||||
inject: ['builder', 'mode'],
|
||||
props: {
|
||||
page: {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div
|
||||
<ThemeProvider
|
||||
class="page-preview__wrapper"
|
||||
@click.self="actionSelectElement({ element: null })"
|
||||
>
|
||||
|
@ -32,7 +32,7 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -42,10 +42,12 @@ import { notifyIf } from '@baserow/modules/core/utils/error'
|
|||
import PreviewNavigationBar from '@baserow/modules/builder/components/page/PreviewNavigationBar'
|
||||
import { PLACEMENTS } from '@baserow/modules/builder/enums'
|
||||
import AddElementModal from '@baserow/modules/builder/components/elements/AddElementModal.vue'
|
||||
import ThemeProvider from '@baserow/modules/builder/components/theme/ThemeProvider.vue'
|
||||
|
||||
export default {
|
||||
name: 'PagePreview',
|
||||
components: {
|
||||
ThemeProvider,
|
||||
AddElementModal,
|
||||
ElementPreview,
|
||||
PreviewNavigationBar,
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<div :style="style">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ThemeProvider',
|
||||
inject: ['builder'],
|
||||
computed: {
|
||||
style() {
|
||||
const colors = {
|
||||
'--primary-color': this.builder.theme.primary_color,
|
||||
'--secondary-color': this.builder.theme.secondary_color,
|
||||
}
|
||||
const headings = Array.from([1, 2, 3, 4, 5, 6]).reduce(
|
||||
(headings, level) => ({
|
||||
[`--heading-h${level}--font-size`]: `${
|
||||
this.builder.theme[`heading_${level}_font_size`]
|
||||
}px`,
|
||||
[`--heading-h${level}--color`]:
|
||||
this.builder.theme[`heading_${level}_color`],
|
||||
...headings,
|
||||
}),
|
||||
{}
|
||||
)
|
||||
return { ...colors, ...headings }
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -20,6 +20,11 @@ export const PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS = {
|
|||
text: ensureNonEmptyString,
|
||||
}
|
||||
|
||||
export const TEXT_FORMAT_TYPES = {
|
||||
PLAIN: 'plain',
|
||||
MARKDOWN: 'markdown',
|
||||
}
|
||||
|
||||
export const IMAGE_SOURCE_TYPES = {
|
||||
UPLOAD: 'upload',
|
||||
URL: 'url',
|
||||
|
|
|
@ -143,7 +143,10 @@
|
|||
"textElementForm": {
|
||||
"textTitle": "Text",
|
||||
"textPlaceholder": "Enter text...",
|
||||
"textError": "The value is invalid."
|
||||
"textError": "The value is invalid.",
|
||||
"textFormatTypeLabel": "Format",
|
||||
"textFormatTypePlain": "Plain text",
|
||||
"textFormatTypeMarkdown": "Markdown"
|
||||
},
|
||||
"imageElement": {
|
||||
"emptyState": "No alt text defined..."
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
@import 'dropdown';
|
||||
@import 'field_form_context';
|
||||
@import 'tooltip';
|
||||
@import 'markdown';
|
||||
@import 'rating';
|
||||
@import 'progress';
|
||||
@import 'fields/boolean';
|
||||
|
|
|
@ -131,6 +131,25 @@
|
|||
|
||||
.api-docs__content {
|
||||
color: $color-neutral-900;
|
||||
|
||||
&.markdown code {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
padding: 3px 6px;
|
||||
background-color: $color-neutral-100;
|
||||
@include rounded($rounded);
|
||||
}
|
||||
|
||||
&.markdown p {
|
||||
color: $color-neutral-900;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
line-height: 140%;
|
||||
}
|
||||
|
||||
&.markdown ul {
|
||||
padding-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.api-docs__heading-wrapper {
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
margin: 0;
|
||||
text-align: left;
|
||||
color: var(--color, $black);
|
||||
font-size: var(--font-size, 30px);
|
||||
|
||||
.element--alignment-horizontal-center & {
|
||||
text-align: center;
|
||||
|
@ -13,29 +12,33 @@
|
|||
}
|
||||
}
|
||||
|
||||
// These tag specific selectors will be removed once the heading element has it's own
|
||||
// custom styling.
|
||||
h1.heading-element__heading {
|
||||
font-size: var(--font-size, 30px);
|
||||
.ab-heading--h1 {
|
||||
color: var(--heading-h1--color, $black);
|
||||
font-size: var(--heading-h1--font-size, 30px);
|
||||
}
|
||||
|
||||
h2.heading-element__heading {
|
||||
font-size: var(--font-size, 26px);
|
||||
.ab-heading--h2 {
|
||||
color: var(--heading-h2--color, $black);
|
||||
font-size: var(--heading-h2--font-size, 26px);
|
||||
}
|
||||
|
||||
h3.heading-element__heading {
|
||||
font-size: var(--font-size, 22px);
|
||||
.ab-heading--h3 {
|
||||
color: var(--heading-h3--color, $black);
|
||||
font-size: var(--heading-h3--font-size, 22px);
|
||||
}
|
||||
|
||||
h4.heading-element__heading {
|
||||
font-size: var(--font-size, 18px);
|
||||
.ab-heading--h4 {
|
||||
color: var(--heading-h4--color, $black);
|
||||
font-size: var(--heading-h4--font-size, 18px);
|
||||
}
|
||||
|
||||
h5.heading-element__heading {
|
||||
font-size: var(--font-size, 14px);
|
||||
.ab-heading--h5 {
|
||||
color: var(--heading-h5--color, $black);
|
||||
font-size: var(--heading-h5--font-size, 14px);
|
||||
}
|
||||
|
||||
h6.heading-element__heading {
|
||||
font-size: var(--font-size, 14px);
|
||||
.ab-heading--h6 {
|
||||
color: var(--heading-h6--color, $black);
|
||||
font-size: var(--heading-h6--font-size, 14px);
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
.markdown {
|
||||
& code {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
padding: 3px 6px;
|
||||
background-color: $color-neutral-100;
|
||||
@include rounded($rounded);
|
||||
}
|
||||
|
||||
& p {
|
||||
color: $color-neutral-900;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
line-height: 140%;
|
||||
}
|
||||
|
||||
& ul {
|
||||
padding-left: 1em;
|
||||
}
|
||||
}
|
|
@ -16,6 +16,11 @@ export default {
|
|||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
rules: {
|
||||
required: false,
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -38,6 +43,7 @@ export default {
|
|||
async created() {
|
||||
const Markdown = (await import('markdown-it')).default
|
||||
this.md = new Markdown()
|
||||
this.md.renderer.rules = { ...this.md.renderer.rules, ...this.rules }
|
||||
this.htmlContent = this.md.render(this.content)
|
||||
},
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue