diff --git a/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md b/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md new file mode 100644 index 000000000..9dd83ac20 --- /dev/null +++ b/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md @@ -0,0 +1,78 @@ +# How To Extend a `djangocms-___` Plugin + +These example codes extend the [`djangocms-link` plugin](https://github.com/django-cms/djangocms-link/tree/3.0.0/djangocms_link). + +`.../models.py`: + +```python +from djangocms_link.models import AbstractLink + +from taccsite_cms.contrib.helpers import clean_for_abstract_link + +class Taccsite______(AbstractLink): + """ + Components > "Article List" Model + https://confluence.tacc.utexas.edu/x/OIAjCQ + """ + # ... + + + + # Parent + + link_is_optional = True # or False + + class Meta: + abstract = False + + # Validate + def clean(self): + clean_for_abstract_link(__class__, self) +``` + +`.../cms_plugins.py`: + +```python +from djangocms_link.cms_plugins import LinkPlugin + +from .models import ______Preview + +class ______Plugin(LinkPlugin): + # ... + render_template = 'static_article_preview.html' + def get_render_template(self, context, instance, placeholder): + return self.render_template + + fieldsets = [ + # ... + (_('Link'), { + 'fields': ( + # 'name', # to use LinkPlugin "Display name" + ('external_link', 'internal_link'), + ('anchor', 'target'), + ) + }), + ] + + # Render + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + request = context['request'] + + context.update({ + 'link_url': instance.get_link(), + 'link_target': instance.target + # 'link_text': instance.name, # to use LinkPlugin "Display name" + }) + return context +``` + +`.../templates/______.py`: + +```handlebars + + + {{ link_text }} + +``` diff --git a/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md b/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md new file mode 100644 index 000000000..59680c7d0 --- /dev/null +++ b/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md @@ -0,0 +1,62 @@ +# How To Override `ValidationError()` from Parent Model + +Intercept Error(s): + +```python +from django.core.exceptions import ValidationError + +from djangocms_Xxx.models import AbstractXxx + +from taccsite_cms.contrib.helpers import ( + get_indices_that_start_with +) + +class OurModelThatUsesXxx(AbstractXxx): + # Validate + def clean(self): + # Bypass irrelevant parent validation + try: + super().clean() + except ValidationError as err: + # Intercept single-field errors + if hasattr(err, 'error_list'): + for i in range(len(err.error_list)): + # SEE: "Find Error(s)" + # ... + # Skip error + del err.error_list[i] + # Replace error + # SEE: https://docs.djangoproject.com/en/2.2/ref/forms/validation/#raising-validationerror + + # Intercept multi-field errors + if hasattr(err, 'error_dict'): + for field, errors in err.message_dict.items(): + # SEE: "Find Error(s)" + # ... + # Skip error + del err.error_dict[field] + # Replace error + # SEE: https://docs.djangoproject.com/en/2.2/ref/forms/validation/#raising-validationerror + + if err.messages: + raise err +``` + +Handle Error(s): + +```python +# SEE: "Find Error(s)" +# ... + + # Catch known static error + if 'Known static error string' in error: + # ... + + # Catch known dynamic error + indices_to_catch = get_indices_that_start_with( + 'Known dynamic error string that starts with same text', + errors + ) + for i in indices_to_catch: + # ... +``` diff --git a/taccsite_cms/contrib/helpers.py b/taccsite_cms/contrib/helpers.py index c91d11d58..0e6fa468f 100644 --- a/taccsite_cms/contrib/helpers.py +++ b/taccsite_cms/contrib/helpers.py @@ -29,6 +29,22 @@ def concat_classnames(classes): # GH-93, GH-142, GH-133: Upcoming functions here (ease merge conflict, maybe) +# Get list of indicies of items that start with text +# SEE: https://stackoverflow.com/a/67393343/11817077 +def get_indices_that_start_with(text, list): + """ + Get a list of indices of list elements that starts with given text + :rtype: list + """ + return [i for i in range(len(list)) if list[i].startswith(text)] + + +# Tweak validation on Django CMS `AbstractLink` for TACC + +from cms.models.pluginmodel import CMSPlugin + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ @@ -65,3 +81,11 @@ def clean(self): if len(err.messages): raise err + +# Get name of field from a given model + +# SEE: https://stackoverflow.com/a/14498938/11817077 +def get_model_field_name(model, field_name): + model_field_name = model._meta.get_field(field_name).verbose_name.title() + + return model_field_name diff --git a/taccsite_cms/contrib/taccsite_callout/__init__.py b/taccsite_cms/contrib/taccsite_callout/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/taccsite_cms/contrib/taccsite_callout/cms_plugins.py b/taccsite_cms/contrib/taccsite_callout/cms_plugins.py new file mode 100644 index 000000000..5d1ff0971 --- /dev/null +++ b/taccsite_cms/contrib/taccsite_callout/cms_plugins.py @@ -0,0 +1,108 @@ +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool +from django.utils.translation import gettext_lazy as _ + +from djangocms_link.cms_plugins import LinkPlugin + +from taccsite_cms.contrib.helpers import ( + concat_classnames, + get_model_field_name +) +from taccsite_cms.contrib.constants import TEXT_FOR_NESTED_PLUGIN_CONTENT_SWAP + +from .models import TaccsiteCallout + + + + +# Constants + +RESIZE_FIGURE_FIELD_NAME = get_model_field_name(TaccsiteCallout, 'resize_figure_to_fit') + + + +# Plugin + +@plugin_pool.register_plugin +class TaccsiteCalloutPlugin(LinkPlugin): + """ + Components > "Callout" Plugin + https://confluence.tacc.utexas.edu/x/EiIFDg + """ + module = 'TACC Site' + model = TaccsiteCallout + name = _('Callout') + render_template = 'callout.html' + def get_render_template(self, context, instance, placeholder): + return self.render_template + + cache = True + text_enabled = False + allow_children = True + # GH-91: Enable this limitation + # parent_classes = [ + # 'SectionPlugin' + # ] + child_classes = [ + 'PicturePlugin' + ] + max_children = 1 + + fieldsets = [ + (None, { + 'fields': ( + 'title', 'description', + ), + }), + (_('Link'), { + 'fields': ( + ('external_link', 'internal_link'), + ('anchor', 'target'), + ) + }), + (_('Image'), { + 'classes': ('collapse',), + 'description': TEXT_FOR_NESTED_PLUGIN_CONTENT_SWAP.format( + element='an image', + plugin_name='Image' + ) + '\ +
\ + If image disappears while editing, then reload the page to reload the image.', + 'fields': (), + }), + (_('Advanced settings'), { + 'classes': ('collapse',), + 'description': 'Only use the "' + RESIZE_FIGURE_FIELD_NAME + '" in emergencies. It is preferable to resize the image. When the "Advanced settings" field "' + RESIZE_FIGURE_FIELD_NAME + '" is checked, the image may disappear after saving this plugin (because of a JavaScript race condition). Using a server-side solution would eliminate this caveat.', + 'fields': ( + 'resize_figure_to_fit', + 'attributes', + ) + }), + ] + + # Render + + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + request = context['request'] + has_child_plugin = {} + + # To identify child plugins + for plugin_instance in instance.child_plugin_instances: + if (type(plugin_instance).__name__ == 'Picture'): + has_child_plugin['image'] = True + context.update({ 'image_plugin': plugin_instance }) + + classes = concat_classnames([ + 'c-callout', + 'c-callout--has-figure' if has_child_plugin.get('image') else '', + 'c-callout--is-link' if instance.get_link() else '', + instance.attributes.get('class'), + ]) + instance.attributes['class'] = classes + + context.update({ + 'link_url': instance.get_link(), + 'link_target': instance.target + }) + return context diff --git a/taccsite_cms/contrib/taccsite_callout/migrations/0001_initial.py b/taccsite_cms/contrib/taccsite_callout/migrations/0001_initial.py new file mode 100644 index 000000000..6da3754da --- /dev/null +++ b/taccsite_cms/contrib/taccsite_callout/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 2.2.16 on 2021-08-24 13:03 + +from django.db import migrations, models +import django.db.models.deletion +import djangocms_attributes_field.fields +import djangocms_link.validators +import filer.fields.file + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('filer', '0012_file_mime_type'), + ('cms', '0022_auto_20180620_1551'), + ] + + operations = [ + migrations.CreateModel( + name='TaccsiteCallout', + fields=[ + ('cmsplugin_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='taccsite_callout_taccsitecallout', serialize=False, to='cms.CMSPlugin')), + ('title', models.CharField(blank=True, help_text='A heading for the callout.', max_length=100, verbose_name='Title')), + ('description', models.CharField(blank=True, help_text='A paragraph for the callout.', max_length=200, verbose_name='Description')), + ('resize_figure_to_fit', models.BooleanField(default=True, help_text='Make image shorter or taller to match the height of text beside it.', verbose_name='Resize any image to fit')), + ('attributes', djangocms_attributes_field.fields.AttributesField(default=dict)), + ('anchor', models.CharField(blank=True, help_text='Appends the value only after the internal or external link. Do not include a preceding "#" symbol.', max_length=255, verbose_name='Anchor')), + ('external_link', models.CharField(blank=True, help_text='Provide a link to an external source.', max_length=2040, validators=[djangocms_link.validators.IntranetURLValidator(intranet_host_re=None)], verbose_name='External link')), + ('file_link', filer.fields.file.FilerFileField(blank=True, help_text='If provided links a file from the filer app.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='filer.File', verbose_name='File link')), + ('internal_link', models.ForeignKey(blank=True, help_text='If provided, overrides the external link.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='cms.Page', verbose_name='Internal link')), + ('mailto', models.EmailField(blank=True, max_length=255, verbose_name='Email address')), + ('name', models.CharField(blank=True, max_length=255, verbose_name='Display name')), + ('phone', models.CharField(blank=True, max_length=255, verbose_name='Phone')), + ('target', models.CharField(blank=True, choices=[('_blank', 'Open in new window'), ('_self', 'Open in same window'), ('_parent', 'Delegate to parent'), ('_top', 'Delegate to top')], max_length=255, verbose_name='Target')), + ('template', models.CharField(choices=[('default', 'Default')], default='default', max_length=255, verbose_name='Template')), + ], + options={ + 'abstract': False, + }, + bases=('cms.cmsplugin',), + ), + ] diff --git a/taccsite_cms/contrib/taccsite_callout/migrations/0002_auto_20210928_1717.py b/taccsite_cms/contrib/taccsite_callout/migrations/0002_auto_20210928_1717.py new file mode 100644 index 000000000..37f20fffe --- /dev/null +++ b/taccsite_cms/contrib/taccsite_callout/migrations/0002_auto_20210928_1717.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.16 on 2021-09-28 22:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('taccsite_callout', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='taccsitecallout', + name='description', + field=models.CharField(help_text='A paragraph for the callout.', max_length=200, verbose_name='Description'), + ), + migrations.AlterField( + model_name='taccsitecallout', + name='resize_figure_to_fit', + field=models.BooleanField(default=False, help_text='Make image shorter or taller to match the height of text beside it (as it would be without the image).', verbose_name='Resize any image to fit'), + ), + migrations.AlterField( + model_name='taccsitecallout', + name='title', + field=models.CharField(help_text='A heading for the callout.', max_length=100, verbose_name='Title'), + ), + ] diff --git a/taccsite_cms/contrib/taccsite_callout/migrations/__init__.py b/taccsite_cms/contrib/taccsite_callout/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/taccsite_cms/contrib/taccsite_callout/models.py b/taccsite_cms/contrib/taccsite_callout/models.py new file mode 100644 index 000000000..86bb60ba1 --- /dev/null +++ b/taccsite_cms/contrib/taccsite_callout/models.py @@ -0,0 +1,51 @@ +from cms.models.pluginmodel import CMSPlugin + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from djangocms_link.models import AbstractLink +from djangocms_attributes_field import fields + +from taccsite_cms.contrib.helpers import clean_for_abstract_link + +class TaccsiteCallout(AbstractLink): + """ + Components > "Callout" Model + """ + title = models.CharField( + verbose_name=_('Title'), + help_text=_('A heading for the callout.'), + blank=False, + max_length=100, + ) + description = models.CharField( + verbose_name=_('Description'), + help_text=_('A paragraph for the callout.'), + blank=False, + max_length=200, + ) + + resize_figure_to_fit = models.BooleanField( + verbose_name=_('Resize any image to fit'), + help_text=_('Make image shorter or taller to match the height of text beside it (as it would be without the image).'), + blank=False, + default=False + ) + + attributes = fields.AttributesField() + + def get_short_description(self): + return self.title + + + + # Parent + + link_is_optional = False + + class Meta: + abstract = False + + # Validate + def clean(self): + clean_for_abstract_link(__class__, self) diff --git a/taccsite_cms/contrib/taccsite_callout/templates/callout.html b/taccsite_cms/contrib/taccsite_callout/templates/callout.html new file mode 100644 index 000000000..93d4e5d79 --- /dev/null +++ b/taccsite_cms/contrib/taccsite_callout/templates/callout.html @@ -0,0 +1,52 @@ +{% load cms_tags static %} + +{% if link_url %} + +{% else %} + +{% endif %} + +{% if instance.resize_figure_to_fit %} + +{% endif %} diff --git a/taccsite_cms/settings.py b/taccsite_cms/settings.py index 88cec3939..3c162dbde 100644 --- a/taccsite_cms/settings.py +++ b/taccsite_cms/settings.py @@ -300,6 +300,8 @@ def gettext(s): return s 'taccsite_cms', 'taccsite_cms.contrib.bootstrap4_djangocms_link', 'taccsite_cms.contrib.bootstrap4_djangocms_picture', + # FP-1231: Convert our CMS plugins to stand-alone apps + 'taccsite_cms.contrib.taccsite_callout', 'taccsite_cms.contrib.taccsite_sample', 'taccsite_cms.contrib.taccsite_system_specs', 'taccsite_cms.contrib.taccsite_system_monitor', diff --git a/taccsite_cms/static/site_cms/css/src/_imports/components/c-callout.css b/taccsite_cms/static/site_cms/css/src/_imports/components/c-callout.css new file mode 100644 index 000000000..c933e898b --- /dev/null +++ b/taccsite_cms/static/site_cms/css/src/_imports/components/c-callout.css @@ -0,0 +1,150 @@ +/* +Callout + +Interrupt or end Sections with a "call to action". + +Markup: + + +

+ Quick Start Guide +

+

+ Explore the Frontera User Guide to learn how to get started, see what Frontera offers, and get answers to your questions. +

+
...
+
+ + + +Styleguide Components.Callout +*/ +@import url("_imports/tools/media-queries.css"); +@import url("_imports/tools/x-article-link.css"); + + + + + +/* Layout */ + +.c-callout { + display: grid; +} +.c-callout--has-figure { + /* See @media queries */ +} +.c-callout:not(.c-callout--has-figure) { + grid-template-areas: + 'title' + 'desc'; +} +.c-callout__figure { grid-area: figure; } +.c-callout__title { grid-area: title; } +.c-callout__desc { grid-area: desc; } + +@media only screen and (--narrow-and-above) { + .c-callout { + padding: 20px; + text-align: left; + } + .c-callout--has-figure { + grid-template-rows: auto 1fr; + grid-template-columns: auto 1fr; + grid-template-areas: + 'figure title' + 'figure desc'; + + column-gap: 40px; + } + .c-callout__figure { margin: 0; /* override Bootstrap */ } +} + +@media only screen and (--narrow-and-below) { + .c-callout { + padding: 20px 40px; + text-align: center; + } + .c-callout--has-figure { + grid-template-areas: + 'figure' + 'title' + 'desc'; + + column-gap: 0px; + } + .c-callout__figure { margin: 0 0 20px; /* override Bootstrap */ } + + /* To "disable" image resize */ + .c-callout__figure[data-transform], + .c-callout__figure[data-transform] img { + height: unset !important; /* overwrite inline style tag (via JavaScript) */ + } +} + +/* To align figure to exact center */ +.c-callout__figure { + place-self: center; +} +/* To fit (expected) figure content to available horizontal space */ +.c-callout__figure img { + max-width: 100%; +} + +/* To force wrap before full width (to match design) */ +/* NOTE: 1200px window, design has 15px but this was 7px (wraps the same) */ +/* FAQ: Differ from design so narrow screen wrap limit is not noticeable */ +.c-callout__desc { padding-right: 1%; } + + + + + +/* Style */ + +.c-callout { + /* HELP: Should this color change if it is in a dark `o-section`? */ + /* SEE: https://confluence.tacc.utexas.edu/x/DyUFDg */ + background-color: var(--global-color-link-on-light--normal); +} + +.c-callout__title { + padding-bottom: 12px; + margin-bottom: 12px; + border-bottom: 1px solid var(--global-color-primary--xx-light); + + font-size: var(--global-font-size--x-large); + font-weight: var(--medium); + color: var(--global-color-primary--xx-light); /* override `html-elements` */ +} +.c-callout__desc { + font-size: var(--global-font-size--large); + font-weight: var(--medium); + color: var(--global-color-primary--xx-light); +} + + + + + +/* Link */ + +.c-callout--is-link:hover { + text-decoration: none; +} + +/* To expand link to cover its container */ +.c-callout--is-link::before { @extend %x-article-link-stretch; content: ''; } + +/* To give feedback on link hover and click */ +.c-callout--is-link:hover::before, +.c-callout--is-link:active::before { outline-offset: 1rem; } +.c-callout--is-link:hover::before { @extend %x-article-link-hover; } +.c-callout--is-link:active::before { @extend %x-article-link-active; } diff --git a/taccsite_cms/static/site_cms/css/src/_imports/tools/x-article-link.css b/taccsite_cms/static/site_cms/css/src/_imports/tools/x-article-link.css index 85a1f2039..3720f2113 100644 --- a/taccsite_cms/static/site_cms/css/src/_imports/tools/x-article-link.css +++ b/taccsite_cms/static/site_cms/css/src/_imports/tools/x-article-link.css @@ -7,13 +7,14 @@ Styles that allow visible link hover for article lists. %x-article-link-stretch--gapless - Make link box fix gapless layout %x-article-link-hover - Give link a hover state %x-article-link-hover--gapless - Make link hover state fix gapless layout +%x-article-link-active - Give link an active (click, enter) state Styleguide Tools.ExtendsAndMixins.ArticleLink */ /* WARNING: A link ancestor must have its `position` set (not to static) */ -/* Expand link to cover container */ +/* To expand link to cover container */ .x-article-link-stretch, %x-article-link-stretch { position: absolute; @@ -33,7 +34,7 @@ Styleguide Tools.ExtendsAndMixins.ArticleLink left: -15px; } -/* Give link state (pseudo-class) feedback */ +/* To give link state (pseudo-class) feedback */ .x-article-link-hover, %x-article-link-hover { outline: 1px solid var(--global-color-accent--normal); @@ -44,3 +45,8 @@ Styleguide Tools.ExtendsAndMixins.ArticleLink %x-article-link-hover--gapless { outline-offset: 0; } + +/* To give link active state feedback */ +%x-article-link-active { + outline: 1px dotted var(--global-color-accent--normal); +} diff --git a/taccsite_cms/static/site_cms/css/src/site.css b/taccsite_cms/static/site_cms/css/src/site.css index 03103175e..c9318e035 100644 --- a/taccsite_cms/static/site_cms/css/src/site.css +++ b/taccsite_cms/static/site_cms/css/src/site.css @@ -27,6 +27,7 @@ /* COMPONENTS */ /* GH-302: HELP: How should all of these become individually built files? */ /* GH-302: FAQ: Individually built stylesheets could be loaded explicitely. */ +@import url("_imports/components/c-callout.css"); @import url("_imports/components/c-data-list.css"); @import url("_imports/components/c-footer.css"); @import url("_imports/components/c-see-all-link.css"); diff --git a/taccsite_cms/static/site_cms/js/modules/elementTransformer.js b/taccsite_cms/static/site_cms/js/modules/elementTransformer.js new file mode 100644 index 000000000..c5897d094 --- /dev/null +++ b/taccsite_cms/static/site_cms/js/modules/elementTransformer.js @@ -0,0 +1,100 @@ +/** + * Transform elements (when CSS cannot) + * + * - Manipulates attributes within existing markup. + * - Transformations are NOT dynamically updated after initial load. + * + * This is a back-up solution. Try using CSS to solve the problem first. + * @module elementTransformer + */ + +/** Resize first child element to match parent height (track state in markup) */ +export class SizeContentToFit { + + + + // FAQ: Offers clarity of intent and consistency with state attr. + // NOTE: Could faciliate programmatic transforms (like as a plugin option) + /** A suggested selector to match the container */ + static containerSelector = '[data-transform="size-content-to-fit"]'; + + + + /** + * Initialize and resize + * @param {HTMLElement} container - The direct parent of the content to resize + */ + constructor (container) { + /** The `HTMLElement` containing the content to resize */ + this.container = container; + /** The `HTMLElement` to resize */ + this.content = container.querySelector(':scope > *'); + + // GH-320: Test whether `this.content` was in the DOM at runtime + // FAQ: Use `cloneNode` to NOT watch element reference that is updated later + // console.log({ + // container: this.container.cloneNode(true), + // content: this.content.cloneNode(true) + // }); + + this.resizeContent(); + } + + + + /** Mark transformation as in the given state */ + setState(state) { + this.container.dataset.transformState = state; + } + + /** Mark transformation as NOT in the given state */ + removeState(state) { + // NOTE: Multiple states are not supported, so there is no use for `state` + this.container.dataset.transformState = null; + } + + /** Whether transformation is in the given state */ + isState(state) { + return (this.container.dataset.transformState === state); + } + + /** Whether to resize the content */ + shouldResizeContent() { + if (this.container.getAttribute('hidden') !== null) { + this.content.style.offsetHeight = '0'; + this.container.removeAttribute('hidden'); + } + } + + + /** Resize the content */ + resizeContent() { + /* To prevent natural height of content from increasing container height */ + /* FAQ: Script will set wrong height if content is taller than is desired */ + if (this.container.getAttribute('hidden') !== null) { + this.content.style.height = '0'; + this.container.removeAttribute('hidden'); + } + + /* To inform observers that this transformation is active */ + this.setState('resizing-content'); + + /* To make container (and its content) the same height as a root element */ + /* FAQ: With tall content… container height = excessive content height */ + /* FAQ: With hidden content… container height = desired content height */ + this.container.style.height = '100%'; + this.content.style.height = this.container.offsetHeight + 'px'; + this.container.style.height = null; + + /* To clean up mess (only if it appears to be the mess of this script) */ + if (this.isState('resizing-content')) { + this.removeState('resizing-content'); + if (this.container.getAttribute('style') === '') { + this.container.removeAttribute('style'); + } + } + + /* To inform observers that this module is done */ + this.setState('complete'); + } +}