diff --git a/README.md b/README.md index 3ad728c..692fd36 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,20 @@ We have JavaScript support for dynamic formsets as part of a form. For an exampl 3. Render the formset's `empty_form` somewhere on the page inside an element with the attribute `data-formset-template-for-id=""`. 4. To use buttons to add and remove formset items to and from the formset, use the attribute `data-formset-append-to-list-id=""` on the append button and `data-formset-remove-from-list-id=""` on the remove button. +### URL Format Select Field + +You can use `data-url-format-selector-for` for ` + +``` + +This will change the select value when the URL in `#my-url-input` changes to something with a matching file extension. + ### Stylesheets We use the CSS reset/normalizer from Tailwind named [preflight.css](core/static/css/preflight.css) which is fairly aggressive. Otherwise, all site styles are defined in [site.css](core/static/css/site.css) with global styles at the top and page styles at the bottom. We try to use BEM syntax where appropriate; for example, we have a `.button` class with modifiers like `.button--prominent` and `.button--danger`. diff --git a/core/forms.py b/core/forms.py index 368f499..10d76e5 100644 --- a/core/forms.py +++ b/core/forms.py @@ -59,10 +59,6 @@ class SchemaRefForm(ReferenceItemForm): help_text=f"Accepted formats: {', '.join(sorted(EXPLICITLY_SUPPORTED_FILE_EXTENSIONS))}" ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - def clean_url(self): if not self.cleaned_data['url']: return None @@ -103,6 +99,10 @@ class DocumentationItemForm(ReferenceItemForm): initial='' ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['format'].widget.attrs['data-url-format-selector-for'] = self['url'].id_for_label + DocumentationItemFormsetFactory = forms.formset_factory(DocumentationItemForm, extra=0) @@ -122,7 +122,8 @@ class SchemaForm(forms.Form): choices=[('', 'Other')] + list(DocumentationItem.DocumentationItemFormat.choices), required=False, label="README format", - help_text="Markdown and Plaintext READMEs are displayed on Schemas.Pub" + help_text="Markdown and Plaintext READMEs are displayed on Schemas.Pub", + widget=forms.Select(attrs={'data-url-format-selector-for': 'id_readme_url'}) ) license_url = forms.URLField( label="License URL", @@ -132,6 +133,7 @@ class SchemaForm(forms.Form): def __init__(self, *args, schema = None, **kwargs): super().__init__(*args, **kwargs) + if schema == None: self.additional_documentation_items_formset = DocumentationItemFormsetFactory(prefix="documentation_items", *args, **kwargs) SchemaRefFormsetFactory = forms.formset_factory(SchemaRefForm, extra=1) diff --git a/core/static/js/site.js b/core/static/js/site.js index 1fcf7d0..84f5f46 100644 --- a/core/static/js/site.js +++ b/core/static/js/site.js @@ -1,6 +1,11 @@ (() => { // This needs to match the animation duration in site.css const MESSAGE_TIMEOUT_MS = 10 * 1000; + // These keys need to match the values of DocumentationItemFormat + const FORMAT_OPTION_VALUE_EXTENSIONS = { + markdown: ['md', 'markdown'], + plaintext: ['txt'], + }; /** * @param {(...args: any) => any} fn @@ -88,6 +93,68 @@ mutationObserver.observe(formsetListElement, { childList: true }); }; + /** + * Pass a `select` element containing a list of file formats + * for a paired URL field. When the URL field is changed, + * the `select` will be updated to a matching format if possible. + * + * @param {HTMLSelectElement} formatSelectElement + */ + const initializeFormatSelectElement = (formatSelectElement) => { + const triggerElementId = formatSelectElement.getAttribute( + 'data-url-format-selector-for' + ); + if (!triggerElementId) { + return; + } + const triggerElement = document.getElementById(triggerElementId); + // If the URL input already has a value (like when editing an existing entity), bail. + if (!(triggerElement instanceof HTMLInputElement) || triggerElement.value) { + return; + } + // Get the available formats and filter by the ones we know extensions for + /** @type {(keyof FORMAT_OPTION_VALUE_EXTENSIONS)[]} */ + const availableFormats = Array.from(formatSelectElement.options) + .map(({ value }) => value) + .filter( + /** @type {(value: string) => value is keyof FORMAT_OPTION_VALUE_EXTENSIONS } */ + (value) => value in FORMAT_OPTION_VALUE_EXTENSIONS + ); + // When the input element changes, we'll try to match its extension. + // If there's a match, we'll select it in the format dropdown. + const handleTriggerElementInput = () => { + try { + const url = new URL(triggerElement.value); + const matchingFormat = availableFormats.find((value) => + FORMAT_OPTION_VALUE_EXTENSIONS[value].find((extension) => + url.pathname.toLowerCase().endsWith('.' + extension) + ) + ); + if (matchingFormat) { + formatSelectElement.value = matchingFormat; + } + // eslint-disable-next-line no-unused-vars + } catch (err) { + // Fine; don't mess with the select element + } + }; + triggerElement.addEventListener('input', handleTriggerElementInput); + // If the user manually changes the format selection, + // stop trying to set it for them. + // 'change' events do *not* fire when we set the value programmatically + const handleFormatSelectElementChange = () => { + triggerElement.removeEventListener('input', handleTriggerElementInput); + formatSelectElement.removeEventListener( + 'change', + handleFormatSelectElementChange + ); + }; + formatSelectElement.addEventListener( + 'change', + handleFormatSelectElementChange + ); + }; + document.addEventListener('DOMContentLoaded', () => { Array.from(document.querySelectorAll('.js-autosubmit-input')) // If the input isn't in a form, there's nothing to submit @@ -175,9 +242,28 @@ currentFormItemCount.toString() ); formsetListElement.insertAdjacentHTML('beforeend', nextFormItemHtml); + const formsetItems = formsetListElement.querySelectorAll('.formset'); + Array.from( + formsetItems[formsetItems.length - 1].querySelectorAll( + '[data-url-format-selector-for]' + ) + ) + .filter((element) => element instanceof HTMLSelectElement) + .forEach((formatSelectElement) => { + initializeFormatSelectElement(formatSelectElement); + }); }); }); + Array.from(document.querySelectorAll('[data-url-format-selector-for]')) + .filter( + (formatSelectElement) => + formatSelectElement instanceof HTMLSelectElement + ) + .forEach((formatSelectElement) => + initializeFormatSelectElement(formatSelectElement) + ); + setTimeout(() => { Array.from(document.querySelectorAll('.messages .message')).forEach( (element) => {