From e692794911c0a46d86aaf75c3a4b00d794588aee Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 12 Jan 2026 15:06:32 -0600 Subject: [PATCH 1/4] Set up readme format to auto-select based on readme url --- core/forms.py | 4 +++- core/static/js/site.js | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/core/forms.py b/core/forms.py index 368f499..fd33b4e 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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..6b3fa3d 100644 --- a/core/static/js/site.js +++ b/core/static/js/site.js @@ -1,6 +1,10 @@ (() => { // This needs to match the animation duration in site.css const MESSAGE_TIMEOUT_MS = 10 * 1000; + const FORMAT_OPTION_VALUE_EXTENSIONS = { + markdown: ['md', 'markdown'], + plaintext: ['txt'], + }; /** * @param {(...args: any) => any} fn @@ -178,6 +182,52 @@ }); }); + Array.from(document.querySelectorAll('[data-url-format-selector-for]')) + .filter( + (formatSelectElement) => + formatSelectElement instanceof HTMLSelectElement + ) + .forEach((formatSelectElement) => { + const triggerElementId = formatSelectElement.getAttribute( + 'data-url-format-selector-for' + ); + if (!triggerElementId) { + return; + } + const triggerElement = document.getElementById(triggerElementId); + if (!(triggerElement instanceof HTMLInputElement)) { + 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. + */ + triggerElement.addEventListener('change', () => { + 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('blur', () => {}); + }); + setTimeout(() => { Array.from(document.querySelectorAll('.messages .message')).forEach( (element) => { From 48c26ee43a8fcdd9402d349bb3ae058b4ffdbeca Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 12 Jan 2026 17:07:52 -0600 Subject: [PATCH 2/4] Disable format selection on user change and make sure it works for documentation items --- core/forms.py | 8 +-- core/static/js/site.js | 119 +++++++++++++++++++++++++++-------------- 2 files changed, 83 insertions(+), 44 deletions(-) diff --git a/core/forms.py b/core/forms.py index fd33b4e..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) diff --git a/core/static/js/site.js b/core/static/js/site.js index 6b3fa3d..9c46f02 100644 --- a/core/static/js/site.js +++ b/core/static/js/site.js @@ -1,6 +1,7 @@ (() => { // 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'], @@ -92,6 +93,71 @@ 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 (!(triggerElement instanceof HTMLInputElement)) { + 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 @@ -179,6 +245,16 @@ 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); + }); }); }); @@ -187,46 +263,9 @@ (formatSelectElement) => formatSelectElement instanceof HTMLSelectElement ) - .forEach((formatSelectElement) => { - const triggerElementId = formatSelectElement.getAttribute( - 'data-url-format-selector-for' - ); - if (!triggerElementId) { - return; - } - const triggerElement = document.getElementById(triggerElementId); - if (!(triggerElement instanceof HTMLInputElement)) { - 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. - */ - triggerElement.addEventListener('change', () => { - 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('blur', () => {}); - }); + .forEach((formatSelectElement) => + initializeFormatSelectElement(formatSelectElement) + ); setTimeout(() => { Array.from(document.querySelectorAll('.messages .message')).forEach( From 75c257664539d3998bd423fc06a8e62697c9e552 Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 12 Jan 2026 17:13:46 -0600 Subject: [PATCH 3/4] Don't change a URL format for a URL inptu that initialized with a value --- core/static/js/site.js | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/core/static/js/site.js b/core/static/js/site.js index 9c46f02..84f5f46 100644 --- a/core/static/js/site.js +++ b/core/static/js/site.js @@ -108,7 +108,8 @@ return; } const triggerElement = document.getElementById(triggerElementId); - if (!(triggerElement instanceof HTMLInputElement)) { + // 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 @@ -119,10 +120,8 @@ /** @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. - */ + // 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); @@ -140,11 +139,9 @@ } }; 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 - */ + // 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( From 90b8b12e0359a454a91837260c39527b1a8db89b Mon Sep 17 00:00:00 2001 From: alexbainter Date: Mon, 12 Jan 2026 17:18:26 -0600 Subject: [PATCH 4/4] Add documentation --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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`.