Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<The formset prefix>"`.
4. To use buttons to add and remove formset items to and from the formset, use the attribute `data-formset-append-to-list-id="<The formset prefix>"` on the append button and `data-formset-remove-from-list-id="<The formset prefix>"` on the remove button.

### URL Format Select Field

You can use `data-url-format-selector-for` for `<select>` inputs meant to control the format of files specified in another input.

```html
<input type="url" id="my-url-input" />
<select data-url-format-selector-for="my-url-input">
<option value="markdown">Markdown</option>
<option value="plaintext">Plaintext</option>
</select>
```

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`.
Expand Down
12 changes: 7 additions & 5 deletions core/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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",
Expand All @@ -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)
Expand Down
86 changes: 86 additions & 0 deletions core/static/js/site.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down