diff --git a/src/sdg/js/components/abstract/utils.js b/src/sdg/js/components/abstract/utils.js new file mode 100644 index 000000000..938bc22e9 --- /dev/null +++ b/src/sdg/js/components/abstract/utils.js @@ -0,0 +1,32 @@ +/** + * Removes any text enclosed in parentheses from the input string, including the parentheses themselves, + * and trims any leading or trailing whitespace from the final result. + * + * @param {string} str - The input string from which to remove text within parentheses. + * @returns {string} - The modified string with all text within parentheses removed. + */ +export function stripParensText(str) { + // Uses a regular expression to find text within parentheses and remove it. + // \s* matches any surrounding whitespace. + // \(.*?\) matches any text within parentheses (non-greedy). + return str.replace(/\s*\(.*?\)\s*/g, "").trim(); +} + +/** + * Creates a debounced function that delays invoking the provided function + * until after a specified delay has elapsed since the last time it was invoked. + * + * @param {Function} func - The function to debounce. + * @param {number} delay - The number of milliseconds to delay. + * @returns {Function} - A new debounced function that, when invoked, will + * delay the execution of the original function by the specified delay time. + */ +export function debounce(func, delay) { + let inDebounce; + return function () { + const context = this; + const args = arguments; + clearTimeout(inDebounce); + inDebounce = setTimeout(() => func.apply(context, args), delay); + }; +} diff --git a/src/sdg/js/components/bevoegde_organisaties.js b/src/sdg/js/components/bevoegde_organisaties.js deleted file mode 100644 index 7ea9e6fae..000000000 --- a/src/sdg/js/components/bevoegde_organisaties.js +++ /dev/null @@ -1,37 +0,0 @@ -const forms = document.querySelectorAll('#bevoegde_organisaties_form .form__subforms--form'); - - -class BevoegdeOrganisatiesForm { - - setUpDynamicCheckbox() { - // Show field if checkbox is checked - if (this.checkbox) { - this.checkbox.addEventListener('change', () => { - if (this.checkbox.checked) { - this.formGroup.classList.remove('form__group--hidden'); - } else { - this.formGroup.classList.add('form__group--hidden'); - } - }); - } - } - - - constructor(node) { - this.node = node; - this.nameField = this.node.querySelector('[id$="naam"]'); - this.checkbox = this.node.querySelector('[id$="staat_niet_in_de_lijst"]'); - this.formGroup = this.nameField.parentElement; - if (this.nameField.value) { - this.formGroup.classList.remove('form__group--hidden'); - } - this.setUpDynamicCheckbox(); - } - -} - -if (forms) { - [...forms].forEach(form => new BevoegdeOrganisatiesForm(form)); -} - -export {BevoegdeOrganisatiesForm}; diff --git a/src/sdg/js/components/choices.js b/src/sdg/js/components/choices.js index b78845b16..e54689bd1 100644 --- a/src/sdg/js/components/choices.js +++ b/src/sdg/js/components/choices.js @@ -1,12 +1,7 @@ -import Choices from 'choices.js' - export const CHOICES_CONFIG = { - loadingText: 'Laden...', - noResultsText: 'Geen resultaten gevonden', - noChoicesText: 'Geen keuzes om uit te kiezen', - itemSelectText: 'Druk om te kiezen', - allowHTML: false - }; - -const fields = document.querySelectorAll(".choices"); -[...fields].forEach(element => new Choices(element, CHOICES_CONFIG)); + loadingText: "Laden...", + noResultsText: "Geen resultaten gevonden", + noChoicesText: "Geen keuzes om uit te kiezen", + itemSelectText: "Druk om te kiezen", + allowHTML: false, +}; diff --git a/src/sdg/js/components/dynamic_title.js b/src/sdg/js/components/dynamic_title.js new file mode 100644 index 000000000..574951163 --- /dev/null +++ b/src/sdg/js/components/dynamic_title.js @@ -0,0 +1,76 @@ +import { debounce } from "./abstract/utils"; +import { Component } from "./abstract/component"; + +const DYNAMIC_TITLES = document.querySelectorAll(".dynamic_title"); +const DEBOUNCE_DELAY = 300; + +export class DynamicTitle extends Component { + /** + * Constructor method. + * @param {HTMLElement} node + */ + constructor(node) { + super(node); + this.targetClassname = this.node.dataset.dynamicTitleTarget; + this.targetElement = this.getDynamicTitleTarget(); + } + /** + * Binds events to callbacks. + * Use this to define EventListeners, MutationObservers etc. + */ + bindEvents() { + super.bindEvents(); + + this.node.addEventListener( + "input", + debounce(this.handleChange.bind(this), DEBOUNCE_DELAY) + ); + + /** + * Event listener for when the input value is programmatically changed. + * This must be used in conjunction with node.dispatch(new CustomEvent()) + * to notify that the input value has been updated. + * + * @example + * Dispatching the event: + * this.node.dispatchEvent(new CustomEvent("forced_input", { detail: { forcedValue: value } })); + */ + this.node.addEventListener( + "forced_input", + this.handleChange.bind(this) + ); + } + + /** + * Handles the change event. + * @param {InputEvent} event + */ + handleChange(event) { + const title = event.target.value; + if (!this.targetElement) return; + if (!title) this.setValue(""); // Remove dynamic + else this.setValue(title.trim()); + } + + /** + * Set the value on the target element. + * @param {string} value + */ + setValue(value) { + this.targetElement.textContent = value; + } + + /** + * Get the dynamic title element. + * @returns {HTMLElement | undefined} + */ + getDynamicTitleTarget() { + if (!this.targetClassname) return undefined; + + const titleElement = document.querySelector(this.targetClassname); + if (!titleElement) return undefined; + return titleElement; + } +} + +[...DYNAMIC_TITLES].forEach((node) => new DynamicTitle(node)); diff --git a/src/sdg/js/components/formset.js b/src/sdg/js/components/formset.js deleted file mode 100644 index b6bc8dde1..000000000 --- a/src/sdg/js/components/formset.js +++ /dev/null @@ -1,145 +0,0 @@ -import toArray from 'arrayify'; -import BEM from 'bem.js'; -import {initializeDynamicWidget} from './dynamic_array'; -import Choices from 'choices.js' -import {CHOICES_CONFIG} from './choices'; -import {BevoegdeOrganisatiesForm} from "./bevoegde_organisaties"; - - -const BLOCK_FORMSET = 'formset'; -const DYNAMIC = 'dynamic'; - -const ELEMENT_BODY = 'body'; -const ELEMENT_TEMPLATE = 'template'; -const ELEMENT_ADD = 'add'; -const ELEMENT_REMOVE = 'remove'; -const ELEMENT_TITLE = 'title'; -const ELEMENT_CONTAINER = 'container'; - -const FORMSET = BEM.getBEMNode(BLOCK_FORMSET); -const FORMSET_BODY = BEM.getBEMNode(BLOCK_FORMSET, ELEMENT_BODY); -const TEMPLATE = BEM.getBEMNode(BLOCK_FORMSET, ELEMENT_TEMPLATE); -const ADD = BEM.getBEMNode(BLOCK_FORMSET, ELEMENT_ADD); - -const MATCH_FORM_IDS = /(id|for|name)="(.+?)"/g; -const PREFIX_PLACEHOLDER = '__prefix__'; - - -/** - * Formset class - * Contains logic for the add member form - * @class - */ -class Formset { - /** - * Constructor method - * Gets called when class get instantiated - */ - constructor() { - if (ADD) { - this.setUpAddForm(); - this.setUpRemoveForm(); - } - } - - /** - * Binds ADD click to this.addForm() - */ - setUpAddForm() { - ADD.addEventListener('click', (e) => { - e.preventDefault(); - this.addForm(); - }); - } - - /** - * Binds REMOVE click to this.removeForm() - */ - setUpRemoveForm() { - toArray(FORMSET_BODY.children).forEach(el => { - this.setUpRemoveFormForElement(el) - }); - } - - /** - * Binds REMOVE click to this.removeForm() - */ - setUpRemoveFormForElement(el) { - const removeButton = BEM.getChildBEMNode(el, BLOCK_FORMSET, ELEMENT_REMOVE); - if (removeButton != null) { - removeButton.addEventListener('click', (e) => { - e.preventDefault(); - this.removeForm(e); - }); - } - } - - /** - * Creates a new form based on TEMPLATE - * Updates the id's of the form to unique values - * Applies styling to fake elements (checkboxes, radio buttons, datepicker) - */ - addForm() { - // Creates a new form based on TEMPLATE - let template = document.importNode(TEMPLATE.content, true); - - FORMSET_BODY.appendChild(template); - let form = FORMSET_BODY.children[FORMSET_BODY.children.length - 1]; - - // Updates the id's of the form to unique values - form.innerHTML = form.innerHTML.replace(MATCH_FORM_IDS, this.updateMatchedId.bind(this, form)); - const formsetTitle = BEM.getChildBEMNode(form, BLOCK_FORMSET, ELEMENT_TITLE); - formsetTitle.innerHTML = formsetTitle.innerHTML.replace( - PREFIX_PLACEHOLDER, this.getFormIndex.bind(this, form)() + 1 - ); - - let index = FORMSET_BODY.children.length; - FORMSET.querySelector('[name="form-TOTAL_FORMS"]').value = index; - this.setUpRemoveFormForElement(form); - - // TODO: Refactor below this line (re-adding listeners to any dynamic elements) - const dynamicElements = BEM.getChildBEMNodes(form, DYNAMIC, ELEMENT_CONTAINER); - if (dynamicElements) { - [...dynamicElements].forEach(element => { - initializeDynamicWidget(element) - }); - } - - const choiceElements = form.querySelectorAll('.choices'); - [...choiceElements].forEach(element => { new Choices(element, CHOICES_CONFIG)}); - - if (form.closest('#bevoegde_organisaties_form')) { - new BevoegdeOrganisatiesForm(form); - } - } - - getFormIndex(form) { - return toArray(FORMSET_BODY.children).indexOf(form); - } - - /** - * Callback function for replacing ids in form - * Replaces PREFIX_PLACEHOLDER with index of form - * @param {HTMLFormElement} form The form we're replacing id's for - * @param {string} match The string matching MATCH_FORM_IDS - * @param {string} attr (Capturing group) the matched attribute - * @param {string} id (Capturing group) the value of the attr - * @returns {string} An html attribute/value pair with PREFIX_PLACEHOLDER replaced - */ - updateMatchedId(form, match, attr, id) { // jshint unused:false - const index = this.getFormIndex(form); - id = id.replace(PREFIX_PLACEHOLDER, index); - return `${attr}="${id}"`; - } - - /** - * Removes the selected form - */ - removeForm(e) { - const form = e.target.parentNode.parentNode; - form.classList.add("hidden"); - form.querySelector(`[name="form-${this.getFormIndex(form)}-DELETE"]`).checked = true; - } -} - -new Formset(); diff --git a/src/sdg/js/components/formset/abstract/field_utils.js b/src/sdg/js/components/formset/abstract/field_utils.js new file mode 100644 index 000000000..b2f27dfdf --- /dev/null +++ b/src/sdg/js/components/formset/abstract/field_utils.js @@ -0,0 +1,74 @@ +/** + * Simple class for implementing field components (`input[type='text']`) within the formset form component. + * @abstract + */ +export class TextField { + /** + * Construct the NumberField + * @param {HTMLInputElement} node + */ + constructor(node) { + this.node = node; + + /** @type {string} */ + this.value = node.value; + } + + /** + * Set the value of the totalForms field + * @param {string} value + */ + setValue(value) { + this.node.value = value; + } +} + +/** + * Simple class for implementing field components (`input[type='number']`) within the formset form component. + * @abstract + */ +export class NumberField { + /** + * Construct the NumberField + * @param {HTMLInputElement} node + */ + constructor(node) { + this.node = node; + + /** @type {number} */ + this.value = parseInt(node.value); + } + + /** + * Set the value of the totalForms field + * @param {number} value + */ + setValue(value) { + this.node.value = value; + } +} + +/** + * Simple class for implementing field components (`input[type='checkbox']`) within the formset form component. + * @abstract + */ +export class CheckboxField { + /** + * Construct the CheckboxField + * @param {HTMLInputElement} node + */ + constructor(node) { + this.node = node; + + /** @type {boolean} */ + this.checked = parseInt(node.checked); + } + + /** + * Set the checked value + * @param {boolean} checked + */ + setChecked(checked) { + this.node.checked = checked; + } +} diff --git a/src/sdg/js/components/formset/abstract/form_component.js b/src/sdg/js/components/formset/abstract/form_component.js new file mode 100644 index 000000000..b3b51eaed --- /dev/null +++ b/src/sdg/js/components/formset/abstract/form_component.js @@ -0,0 +1,278 @@ +import { Component } from "../../abstract/component"; +import { CheckboxField, NumberField, TextField } from "./field_utils"; + +/** + * Base class for implementing components within the formset form component. + * @abstract + */ +export class FormComponent extends Component { + /** + * Binds events to callbacks. + * Use this to define EventListeners, MutationObservers etc. + */ + bindEvents() { + this.node.addEventListener("click", this.onClick.bind(this)); + } + + /** + * Gets called when this.node gets clicked. + * @param {MouseEvent} event + */ + onClick(event) {} + + /** + * Get the formset body element from the form. + * @param {HTMLElement} [child] + * @returns {HTMLElement} + */ + getFormBody(child = undefined) { + return this._getForm(child).querySelector(".formset__body"); + } + + /** + * Get all `.formset__form:not(.hidden)` elements. + * @param {HTMLElement} [child] + * @returns {number} + */ + getTotalFormsCount(child = undefined) { + return this.getFormBody(child).querySelectorAll(".formset__form") + .length; + } + + /** + * Get all (visible) `.formset__form:not(.hidden)` elements. + * @param {HTMLElement} [child] + * @returns {NodeListOf} + */ + getVisibileFormsetForms(child = undefined) { + return this.getFormBody(child).querySelectorAll( + ".formset__form:not(.hidden)" + ); + } + + /** + * Get the formset body element from the form. + * @param {HTMLElement} [child] + * @returns {HTMLElement} + */ + getFormsetForm(child = undefined) { + return this._getParent("formset__form", child); + } + + /** + * Get the imported node of the template content. + * @returns {globalThis.DocumentFragment} + */ + getImportedTemplate() { + const template = this._getTemplate(); + return document.importNode(template.content, true); + } + + /** + * Get the order field of a `.formset__form` form. + * @param {HTMLElement} form element with the class `.formset__form` + * @returns {NumberField | undefined} + */ + getFormOrderField(form) { + const field = form.querySelector(".order_field"); + if (!field) return undefined; + return new NumberField(field); + } + + /** + * Get the field that stores the id of a `.formset__form` form. + * @param {HTMLElement} form + * @returns {NumberField | undefined} + */ + getFormIdField(form) { + const field = form.querySelector(`[name="${form.dataset.prefix}-id"]`); + if (!field) return undefined; + return new NumberField(field); + } + + /** + * Get the field that stores a inputable name. + * @returns {TextField | undefined} + */ + getFormNameField() { + const field = this.node.querySelector('input[name$="naam"]'); + if (!field) return undefined; + return new TextField(field); + } + + /** + * Get the field that stores a inputable name. + * @returns {TextField | undefined} + */ + getFormNotInListCheckox() { + const field = this.node.querySelector( + 'input[name$="staat_niet_in_de_lijst"]' + ); + if (!field) return undefined; + return new CheckboxField(field); + } + + /** + * Get the field that stores if the form should be removed on submit of a `.formset__form` form. + * @param {HTMLElement} form + * @returns {CheckboxField | undefined} + */ + getFormRemoveField(form) { + const field = form.querySelector( + `[name="${form.dataset.prefix}-DELETE"]` + ); + if (!field) return undefined; + return new CheckboxField(field); + } + + /** + * Get the field that keeps track of the total amount of forms. + * @returns {NumberField | undefined} + */ + getFormsetTotalFormsField() { + const field = document.querySelector('[name="form-TOTAL_FORMS"]'); + if (!field) return undefined; + return new NumberField(field); + } + + getDynamicTitleComponent() { + return this.node.querySelector(".dynamic_title"); + } + + getCurrentOrderText(form) { + return form.querySelector(".formset__current-order"); + } + + /** + * Get the next visible element (recursive). + * @param {HTMLElement} currentElement + * @returns {HTMLElement | undefined} The next element without the class `.hidden`. + */ + getNextVisibleElement(currentElement) { + const nextElement = currentElement.nextElementSibling; + if (!nextElement) return undefined; + + if (nextElement.classList.contains("hidden")) + return this.getNextVisibleElement(nextElement); + + return nextElement; + } + + /** + * Get the previous visible element (recursive). + * @param {HTMLElement} currentElement + * @returns {HTMLElement | undefined} The previous element without the class `.hidden`. + */ + getPreviousVisibleElement(currentElement) { + const previousElement = currentElement.previousElementSibling; + if (!previousElement) return undefined; + + if (previousElement.classList.contains("hidden")) + return this.getPreviousVisibleElement(previousElement); + + return previousElement; + } + + /** + * Get the lowest availible form prefix value + * @returns {number} lowest unused form prefix value + */ + getLowestAvailiblePrefix() { + return Array.from(this.getVisibileFormsetForms(this.node)) + .map((form) => + parseInt(form.dataset.prefix.replace("form-", ""), 10) + ) + .filter((num) => !isNaN(num)) + .sort((a, b) => a - b) + .reduce((prev, cur) => { + if (prev === cur) prev++; + return prev; + }, 0); + } + + /** + * Get an array containing data about the order. + * @returns {Array<{ + * orderField: NumberField | undefined, + * counter0: number, + * counter: number, + * orderText: HTMLElement, + * }>} + */ + getSubformOrderInfo() { + const subforms = this.getVisibileFormsetForms(this.node); + + return Array.from(subforms).map((form, index) => ({ + orderField: this.getFormOrderField(form), + counter0: index, + counter: index + 1, + orderText: form.querySelector(".formset__current-order"), + })); + } + + /** + * Get an array containing data about order and the order elements. + * @returns {Array<{ + * isFirst: boolean, + * isLast: boolean, + * incElement: HTMLElement, + * decElement: HTMLElement, + * }>} + */ + getSubformOrderElementInfo() { + const subforms = this.getVisibileFormsetForms(this.node); + + return Array.from(subforms).map((cur, index) => ({ + isFirst: index == 0, + isLast: index == subforms.length - 1, + incElement: cur.querySelector(".order_increment"), + decElement: cur.querySelector(".order_decrement"), + })); + } + + /** + * Get the form element from the current `scope` + * @param {HTMLElement} [child] + * @returns {HTMLElement} + * @private + */ + _getForm(child = undefined) { + return this._getParent("form", child); + } + + /** + * Get the `.formset__template` element. + * @returns {HTMLTemplateElement} + * @private + */ + _getTemplate() { + return document.querySelector(".formset__template"); + } + + /** + * Returns a parent based on className. + * @param {string} className An item in the parents classList. + * @param {HTMLElement} [child=this.node] + * @return {HTMLElement} + * @private + */ + _getParent(className, child = this.node) { + let iteratedNode = child.parentElement; + let i = 1; + + if (child.classList.contains(className)) return child; + + while (!iteratedNode || !iteratedNode.classList.contains(className)) { + iteratedNode = iteratedNode.parentElement; + i++; + + if (!iteratedNode || i > 100) { + throw new Error( + `Maximum recursion depth exceeded while localizing parent with className ${className} for ${child}.` + ); + } + } + + return iteratedNode; + } +} diff --git a/src/sdg/js/components/formset/abstract/formset_form_component.js b/src/sdg/js/components/formset/abstract/formset_form_component.js new file mode 100644 index 000000000..57585ea0f --- /dev/null +++ b/src/sdg/js/components/formset/abstract/formset_form_component.js @@ -0,0 +1,40 @@ +import { FormComponent } from "./form_component"; + +/** + * Base class for implementing components within a formset update form. + * @abstract + */ +export class FormsetFormComponent extends FormComponent { + /** + * Options object. + * Use this to specify the options of the component. + * @type {Object} + */ + static options = { + observe: true, + }; + + /** + * Gets called after the first render cycle. + * Use this to sync state with DOM on initial mount. + */ + onMount() { + super.onMount(); + } + + /** + * Gets called when MutationObservers detects a mutation. + * Only when `options.observe` is set to `true`. + * Use this to sync state with DOM updates. + * @param {MutationRecord} mutationRecord + */ + onMutation(mutationRecord) { + super.onMutation(mutationRecord); + + const disabled = Boolean( + mutationRecord.target[mutationRecord.attributeName] + ); + + this.setState({ disabled: disabled }); + } +} diff --git a/src/sdg/js/components/formset/add_formset_form.js b/src/sdg/js/components/formset/add_formset_form.js new file mode 100644 index 000000000..1de41f958 --- /dev/null +++ b/src/sdg/js/components/formset/add_formset_form.js @@ -0,0 +1,125 @@ +import { initializeDynamicWidget } from "../dynamic_array"; +import { FormsetFormComponent } from "./abstract/formset_form_component"; +import { RemoveFormsetForm } from "./remove_formset_form"; +import { DecrementOrder } from "./decrement_order"; +import { IncrementOrder } from "./increment_order"; +import { createTooltips } from "../tooltip"; +import { Toggle } from "../toggle"; +import { DynamicTitle } from "../dynamic_title"; +import { DynamicCheckbox } from "./dynamic_checkbox"; + +/** @type {HTMLDivElement} */ +const ADD_FORM_ELEMENT = document.querySelector(".formset__button--add"); +const PREFIX_PLACEHOLDER = "__prefix__"; + +class AddFormsetForm extends FormsetFormComponent { + /** + * Gets called when this.node gets clicked. + * @param {MouseEvent} event + */ + onClick(event) { + event.preventDefault(); + + // Create and append a new location form. + this.createForm(); + } + + /** + * Create a new `.formset__form` from the template and append this form. + */ + createForm() { + const importedTemplate = this.getImportedTemplate(); + this.appendForm(importedTemplate); + } + + /** + * Append the template fragment and initialize the form. + * @param {globalThis.DocumentFragment} importedTemplate The imported template element + */ + appendForm(importedTemplate) { + // Append the form + const formBody = this.getFormBody(); + formBody.appendChild(importedTemplate); + + // Get the appended form and initialize it. + const createdForm = formBody.children[formBody.children.length - 1]; + this.initializeForm(createdForm); + } + + /** + * Initialize the new `.formset__form` and rebind the events. + * @param {HTMLElement} createdForm Element of the created form + */ + initializeForm(createdForm) { + // Replace the content and rebind the events + this.replaceContent(createdForm); + this.rebindEvents(createdForm); + } + + /** + * Replace the content inside the appended form. + * @param {HTMLElement} createdForm The appended form. + */ + replaceContent(createdForm) { + // Get the lowest unused prefix; + const newPrefix = this.getLowestAvailiblePrefix(); + + // Replace the prefix_placeholder in the form classses. + createdForm.classList.forEach((cls) => { + if (cls.includes(PREFIX_PLACEHOLDER)) { + createdForm.classList.remove(cls); + createdForm.classList.add( + cls.replace(PREFIX_PLACEHOLDER, newPrefix) + ); + } + }); + + // Replace the prefix_placeholder in the rest of the html. + createdForm.innerHTML = createdForm.innerHTML.replaceAll( + PREFIX_PLACEHOLDER, + newPrefix + ); + + // Change the order text next indicating the row. + this.getCurrentOrderText(createdForm).textContent = newPrefix + 1; + + // Set the form prefix on the form element. + createdForm.dataset.prefix = `form-${newPrefix}`; + } + + /** + * Custom bindEvents function to activate logic on the + * Manually rebind all classes to the elements. + * @param {HTMLElement} form + */ + rebindEvents(form) { + createTooltips(); + + const toggle = form.querySelector(".bem-toggle"); + if (toggle) new Toggle(toggle); + + const dynamicTitle = form.querySelector(".dynamic_title"); + if (dynamicTitle) new DynamicTitle(dynamicTitle); + + const removeButton = form.querySelector(".formset__remove"); + if (removeButton) new RemoveFormsetForm(removeButton); + + const incrementElement = form.querySelector(".order_increment"); + if (incrementElement) new IncrementOrder(incrementElement); + + const decrementElement = form.querySelector(".order_decrement"); + if (decrementElement) new DecrementOrder(decrementElement); + + const dynamicArrays = form.querySelectorAll(".dynamic__container"); + if (dynamicArrays) + dynamicArrays.forEach((node) => initializeDynamicWidget(node)); + + console.log(form.closest("#bevoegde_organisaties_form")); + if (form.closest("#bevoegde_organisaties_form")) { + new DynamicCheckbox(form); + } + } +} + +// Start! +if (ADD_FORM_ELEMENT) new AddFormsetForm(ADD_FORM_ELEMENT); diff --git a/src/sdg/js/components/formset/decrement_order.js b/src/sdg/js/components/formset/decrement_order.js new file mode 100644 index 000000000..3767225c9 --- /dev/null +++ b/src/sdg/js/components/formset/decrement_order.js @@ -0,0 +1,31 @@ +import { FormsetFormComponent } from "./abstract/formset_form_component"; + +const DECREMENT_ELEMENTS = document.querySelectorAll(".order_decrement"); + +export class DecrementOrder extends FormsetFormComponent { + /** + * Gets called when this.node gets clicked. + * @param {MouseEvent} event + */ + onClick(event) { + event.preventDefault(); + event.stopPropagation(); + this.swapUp(); + } + + /** + * Swap the previous and current element in the DOM. + * All the states should be controlled in the formset_container render function. + */ + swapUp() { + const currentElement = this.getFormsetForm(this.node); + const previousElement = this.getPreviousVisibleElement(currentElement); + const parent = currentElement.parentElement; + + if (previousElement) { + parent.insertBefore(currentElement, previousElement); + } + } +} + +[...DECREMENT_ELEMENTS].forEach((node) => new DecrementOrder(node)); diff --git a/src/sdg/js/components/formset/dynamic_checkbox.js b/src/sdg/js/components/formset/dynamic_checkbox.js new file mode 100644 index 000000000..3d6192ec0 --- /dev/null +++ b/src/sdg/js/components/formset/dynamic_checkbox.js @@ -0,0 +1,159 @@ +import Choices, { Item } from "choices.js"; +import { FormsetFormComponent } from "./abstract/formset_form_component"; +import { CHOICES_CONFIG } from "../choices"; +import { stripParensText } from "../abstract/utils"; + +const DYNAMIC_CHECKBOXES = document.querySelectorAll( + ".formset__form:not(.formset__form--preview)" +); + +const CHOICE_PLACEHOLDER_ID = 1; + +export class DynamicCheckbox extends FormsetFormComponent { + constructor(node) { + super(node); + + console.log(node.closest("#bevoegde_organisaties_form")); + if (node.closest("#bevoegde_organisaties_form")) { + this.shouldMount = true; + } else this.shouldMount = false; + } + /** + * Gets called before the first render cycle. + */ + onMount() { + super.onMount(); + + if (!this.shouldMount) return; + + // Create choices + this.choices = new Choices( + this.node.querySelector(".choices"), + CHOICES_CONFIG + ); + + this.handleMount(); + } + + /** + * Binds events to callbacks. + * Use this to define EventListeners, MutationObservers etc. + */ + bindEvents() { + super.bindEvents(); + + if (!this.shouldMount) return; + + this.getFormNotInListCheckox().node.addEventListener( + "change", + this.onChange.bind(this) + ); + + this.selectElement = this.choices.passedElement.element; + + if (this.selectElement) + this.selectElement.addEventListener( + "change", + this.handleSelect.bind(this) + ); + } + + /** + * Gets called when the input event is fired. + * @param {Event} event + */ + onChange(event) { + this.handleChange(event.target.checked); + } + + handleMount() { + const placeholderSelected = this.isPlaceholderSelected(); + if (placeholderSelected) { + this.showDynamicTitle(); + this.getFormNameField().node.removeAttribute("disabled"); + } else { + const strippedLabel = stripParensText( + this.choices.getValue().label + ); + this.setValue(strippedLabel); + this.hideDynamicTitle(); + this.getFormNameField().node.setAttribute("disabled", true); + } + } + + /** + * Gets called onMount and when the select value changes. + */ + handleSelect() { + const placeholderSelected = this.isPlaceholderSelected(); + if (placeholderSelected) { + this.setValue(""); + this.showDynamicTitle(); + this.getFormNameField().node.removeAttribute("disabled"); + } else { + const strippedLabel = stripParensText( + this.choices.getValue().label + ); + this.setValue(strippedLabel); + this.hideDynamicTitle(); + this.getFormNameField().node.setAttribute("disabled", true); + } + } + + /** + * Handler for the change event, executed by the checkbox. + * @param {boolean} checked + */ + handleChange(checked) { + checked ? this.showDynamicTitle() : this.hideDynamicTitle(); + } + + /** + * Hide the dynamic title and force the checkbox value. + */ + hideDynamicTitle() { + this.getDynamicTitleComponent().classList.add("animated--hidden"); + this.getFormNotInListCheckox().setChecked(false); + } + + /** + * Show the dynamic title and force the checkbox value. + */ + showDynamicTitle() { + this.getDynamicTitleComponent().classList.remove("animated--hidden"); + this.getFormNotInListCheckox().setChecked(true); + } + + /** + * Set the value for the dynamic title and the name field. + * @param {string} value + */ + setValue(value) { + // this.changeDynamicTitle(value); + this.getFormNameField().setValue(value); + + // Dispatch input event so that dynamic_title can listen to it using the default input function. + const inputEvent = new CustomEvent("forced_input", { + bubbles: true, + detail: { + forcedValue: value, + }, + }); + this.getFormNameField().node.dispatchEvent(inputEvent); + } + + /** + * Return a boolean indicating if the current selected item has the choiceId of the placeholder. + * @returns {boolean} + */ + isPlaceholderSelected() { + /** @type {Item} */ + const value = this.choices.getValue(); + return value.choiceId == CHOICE_PLACEHOLDER_ID; + } +} + +// Start! +[...DYNAMIC_CHECKBOXES].forEach((node) => new DynamicCheckbox(node)); + +// r. 159 diff --git a/src/sdg/js/components/formset/formset_container.js b/src/sdg/js/components/formset/formset_container.js new file mode 100644 index 000000000..1279d905e --- /dev/null +++ b/src/sdg/js/components/formset/formset_container.js @@ -0,0 +1,73 @@ +import { FormsetFormComponent } from "./abstract/formset_form_component"; + +const FORMSET = document.querySelectorAll(".formset"); + +export class OrderContainer extends FormsetFormComponent { + /** + * Gets called after the first render cycle. + * Use this to sync state with DOM after first render. + */ + onMount() { + super.onMount(); + this.renderForm(); + } + + /** + * Render the form and the correct meta data + */ + renderForm() { + this.renderOrder(); + this.renderOrderFieldButtonDisabled(); + + this.setFormMetadata(); + } + + renderOrder() { + // Set the correct order, new forms have automatically the correct order. + this.getSubformOrderInfo().forEach( + ({ orderText, orderField, counter, counter0 }) => { + // Render orderText + if (orderText.textContent !== counter) + orderText.textContent = counter; + + // Update the order field + if (orderField && orderField.value !== counter0) + orderField.setValue(counter0); + } + ); + } + + /** + * Render the disabled state of the order field buttons. + */ + renderOrderFieldButtonDisabled() { + this.getSubformOrderElementInfo().forEach( + ({ isFirst, isLast, incElement, decElement }) => { + if (!incElement && !decElement) return; + isFirst + ? decElement.setAttribute("disabled", true) + : decElement.removeAttribute("disabled"); + isLast + ? incElement.setAttribute("disabled", true) + : incElement.removeAttribute("disabled"); + } + ); + } + + /** + * Set the form metadata. + * - Total forms. + */ + setFormMetadata() { + // Update the total forms field. + this.getFormsetTotalFormsField().setValue(this.getTotalFormsCount()); + } + + render(state) { + super.render(state); + this.renderForm(); + } +} + +// Start! +[...FORMSET].forEach((node) => new OrderContainer(node)); diff --git a/src/sdg/js/components/formset/increment_order.js b/src/sdg/js/components/formset/increment_order.js new file mode 100644 index 000000000..331417dfa --- /dev/null +++ b/src/sdg/js/components/formset/increment_order.js @@ -0,0 +1,30 @@ +import { FormsetFormComponent } from "./abstract/formset_form_component"; + +const INCREMENT_ELEMENTS = document.querySelectorAll(".order_increment"); + +export class IncrementOrder extends FormsetFormComponent { + /** + * Gets called when this.node gets clicked. + * @param {MouseEvent} event + */ + onClick(event) { + event.preventDefault(); + event.stopPropagation(); + this.swapDown(); + } + + /** + * Swap the current and next element in the DOM. + * All the states should be controlled in the formset_container render function. + */ + swapDown() { + const currentElement = this.getFormsetForm(this.node); + const nextElement = this.getNextVisibleElement(currentElement); + + if (nextElement) { + return nextElement.after(currentElement); + } + } +} + +[...INCREMENT_ELEMENTS].forEach((node) => new IncrementOrder(node)); diff --git a/src/sdg/js/components/formset/index.js b/src/sdg/js/components/formset/index.js new file mode 100644 index 000000000..5e66122c8 --- /dev/null +++ b/src/sdg/js/components/formset/index.js @@ -0,0 +1,6 @@ +import "./decrement_order"; +import "./increment_order"; +import "./add_formset_form"; +import "./remove_formset_form"; +import "./formset_container"; +import "./dynamic_checkbox"; diff --git a/src/sdg/js/components/formset/remove_formset_form.js b/src/sdg/js/components/formset/remove_formset_form.js new file mode 100644 index 000000000..bf414bec1 --- /dev/null +++ b/src/sdg/js/components/formset/remove_formset_form.js @@ -0,0 +1,34 @@ +import { FormsetFormComponent } from "./abstract/formset_form_component"; + +const REMOVE_ELEMENTS = document.querySelectorAll(".formset__button--remove"); + +export class RemoveFormsetForm extends FormsetFormComponent { + /** + * Gets called when this.node gets clicked. + * @param {MouseEvent} event + */ + onClick(event) { + event.preventDefault(); + this.removeForm(); + } + + /** + * Remove or hide the formset form. + */ + removeForm() { + const form = this.getFormsetForm(this.node); + const idField = this.getFormIdField(form); + // Check if the form existed or not. + if (idField && idField.value) { + // Hide form and check the checkbox that removes the form + form.classList.add("hidden"); + this.getFormRemoveField(form).setChecked(true); + } else { + // Remove the form if it was never saved. + form.remove(); + } + } +} + +// Start +[...REMOVE_ELEMENTS].forEach((node) => new RemoveFormsetForm(node)); diff --git a/src/sdg/js/components/index.js b/src/sdg/js/components/index.js index eca34f581..99cafa58b 100644 --- a/src/sdg/js/components/index.js +++ b/src/sdg/js/components/index.js @@ -2,12 +2,12 @@ import "./form"; import "./toggle"; import "./notifications"; -import "./formset"; import "./calendar"; import "./markdown"; import "./dynamic_array"; import "./generic"; import "./tooltip"; import "./choices"; -import "./bevoegde_organisaties"; import "./prevent_submit_twice"; +import "./formset/"; +import "./dynamic_title"; diff --git a/src/sdg/js/components/toggle.js b/src/sdg/js/components/toggle.js index 98450d5fc..f3c686f7e 100644 --- a/src/sdg/js/components/toggle.js +++ b/src/sdg/js/components/toggle.js @@ -1,12 +1,11 @@ -import BEM from 'bem.js'; +import BEM from "bem.js"; /** @const {string} */ -export const BLOCK_TOGGLE = 'bem-toggle'; +export const BLOCK_TOGGLE = "bem-toggle"; /** @const {NodeList} */ const TOGGLES = BEM.getBEMNodes(BLOCK_TOGGLE); - /** * Class for generic toggles. * @@ -20,7 +19,7 @@ const TOGGLES = BEM.getBEMNodes(BLOCK_TOGGLE); * Toggle may have data-toggle-ignore set to tag names to not listen to for events. * @class */ -class Toggle { +export class Toggle { /** * Constructor method. * @param {HTMLElement} node @@ -33,7 +32,9 @@ class Toggle { this.toggleModifier = this.node.dataset.toggleModifier; /** @type {(boolean|undefined)} */ - this.toggleMobileState = this.node.dataset.toggleMobileState ? this.node.dataset.toggleMobileState.toUpperCase() === 'TRUE' : undefined; + this.toggleMobileState = this.node.dataset.toggleMobileState + ? this.node.dataset.toggleMobileState.toUpperCase() === "TRUE" + : undefined; this.restoreState(); this.bindEvents(); @@ -43,7 +44,7 @@ class Toggle { * Binds events to callbacks. */ bindEvents() { - this.node.addEventListener('click', this.onClick.bind(this)); + this.node.addEventListener("click", this.onClick.bind(this)); } /** @@ -58,26 +59,26 @@ class Toggle { * @param {MouseEvent} e */ onClick(e) { - let toggleLinkMode = this.node.dataset.toggleLinkMode || 'normal'; + let toggleLinkMode = this.node.dataset.toggleLinkMode || "normal"; - if (toggleLinkMode === 'normal') { - if (!e.target.href || e.target.href === '#') { + if (toggleLinkMode === "normal") { + if (!e.target.href || e.target.href === "#") { e.preventDefault(); } - } else if (toggleLinkMode === 'positive') { + } else if (toggleLinkMode === "positive") { if (!e.target.href || !this.getState()) { e.preventDefault(); } - } else if (toggleLinkMode === 'negative') { + } else if (toggleLinkMode === "negative") { if (!e.target.href || this.getState()) { e.preventDefault(); } - } else if (toggleLinkMode === 'prevent') { + } else if (toggleLinkMode === "prevent") { e.preventDefault(); } - let ignore = this.node.dataset.toggleIgnore || ''; - ignore = ignore.split(',').map(n => n.trim().toUpperCase()); + let ignore = this.node.dataset.toggleIgnore || ""; + ignore = ignore.split(",").map((n) => n.trim().toUpperCase()); if (ignore.indexOf(e.target.tagName) > -1) { return; @@ -107,11 +108,15 @@ class Toggle { */ toggle(exp = undefined) { let targets = this.getTargets(); - targets.forEach(target => BEM.toggleModifier(target, this.toggleModifier, exp)); + targets.forEach((target) => + BEM.toggleModifier(target, this.toggleModifier, exp) + ); this.getExclusive() - .filter(exclusive => targets.indexOf(exclusive) === -1) - .forEach(exclusive => BEM.removeModifier(exclusive, this.toggleModifier)); + .filter((exclusive) => targets.indexOf(exclusive) === -1) + .forEach((exclusive) => + BEM.removeModifier(exclusive, this.toggleModifier) + ); } /** @@ -142,7 +147,7 @@ class Toggle { * @returns {*} */ getExclusive() { - let selector = this.node.dataset.toggleExclusive || ''; + let selector = this.node.dataset.toggleExclusive || ""; return this.getRelated(selector); } @@ -153,9 +158,16 @@ class Toggle { */ getRelated(selector) { let targets = []; - selector.split(',') - .filter(selector => selector.length) - .forEach(selector => targets = [...targets, ...document.querySelectorAll(selector)]); + selector + .split(",") + .filter((selector) => selector.length) + .forEach( + (selector) => + (targets = [ + ...targets, + ...document.querySelectorAll(selector), + ]) + ); return targets; } @@ -167,7 +179,7 @@ class Toggle { let id = this.node.id; let value = this.getState(); - if (typeof value !== 'boolean') { + if (typeof value !== "boolean") { return; } @@ -176,7 +188,7 @@ class Toggle { try { localStorage.setItem(key, value); } catch (e) { - console.warn(this, 'Unable to save state to localstorage'); + console.warn(this, "Unable to save state to localstorage"); } } } @@ -185,7 +197,10 @@ class Toggle { * Restores state from localstorage. */ restoreState() { - if (this.toggleMobileState !== undefined && matchMedia('(max-width: 767px)').matches) { + if ( + this.toggleMobileState !== undefined && + matchMedia("(max-width: 767px)").matches + ) { this.toggle(this.toggleMobileState); return; } @@ -196,12 +211,11 @@ class Toggle { let key = `ToggleButton#${id}.modifierApplied`; try { let value = localStorage.getItem(key) || false; - this.toggle(value.toUpperCase() === 'TRUE'); - } catch (e) { - } + this.toggle(value.toUpperCase() === "TRUE"); + } catch (e) {} } } } // Start! -[...TOGGLES].forEach(node => new Toggle(node)); +[...TOGGLES].forEach((node) => new Toggle(node)); diff --git a/src/sdg/js/components/tooltip.js b/src/sdg/js/components/tooltip.js index ea48775d9..33f1e4aa1 100644 --- a/src/sdg/js/components/tooltip.js +++ b/src/sdg/js/components/tooltip.js @@ -1,9 +1,7 @@ -import {dom} from '@fortawesome/fontawesome-svg-core' +import { dom } from "@fortawesome/fontawesome-svg-core"; import tippy from "tippy.js"; - class InfoTooltip { - constructor(node) { this.node = node; const text = this.node.firstChild.textContent; @@ -21,7 +19,13 @@ class InfoTooltip { const iconsDoneRendering = () => { const infoIcons = document.querySelectorAll("svg.fa-circle-info"); - [...infoIcons].forEach(icon => new InfoTooltip(icon)); + [...infoIcons].forEach((icon) => new InfoTooltip(icon)); }; -dom.i2svg({callback: iconsDoneRendering}); +// Exportable function that allows re-rendering new appended tooltips. +export function createTooltips() { + dom.i2svg({ callback: iconsDoneRendering }); +} + +// Create tooltips the first render +createTooltips(); diff --git a/src/sdg/organisaties/forms.py b/src/sdg/organisaties/forms.py index 10ba67500..6ffb2cb5e 100644 --- a/src/sdg/organisaties/forms.py +++ b/src/sdg/organisaties/forms.py @@ -52,6 +52,7 @@ class Meta: model = Locatie fields = ( "naam", + "order", "straat", "nummer", "postcode", diff --git a/src/sdg/organisaties/migrations/0033_auto_20241022_1421.py b/src/sdg/organisaties/migrations/0033_auto_20241022_1421.py new file mode 100644 index 000000000..fdd251a71 --- /dev/null +++ b/src/sdg/organisaties/migrations/0033_auto_20241022_1421.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.23 on 2024-10-22 14:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organisaties", "0032_alter_links"), + ] + + operations = [ + migrations.AlterModelOptions( + name="lokatie", + options={ + "ordering": ("order", "naam"), + "verbose_name": "locatie", + "verbose_name_plural": "locaties", + }, + ), + migrations.AddField( + model_name="lokatie", + name="order", + field=models.PositiveIntegerField(default=0, verbose_name="order"), + ), + ] diff --git a/src/sdg/organisaties/models.py b/src/sdg/organisaties/models.py index 28c3bb6ca..40347362f 100644 --- a/src/sdg/organisaties/models.py +++ b/src/sdg/organisaties/models.py @@ -169,6 +169,7 @@ class Lokatie(models.Model): related_name="locaties", ) + order = models.PositiveIntegerField("order", default=0) naam = models.CharField( _("naam"), max_length=100, @@ -284,7 +285,7 @@ class Meta: "naam", "lokale_overheid", ) - ordering = ("naam",) + ordering = ("order", "naam") def __str__(self): return self.naam diff --git a/src/sdg/scss/components/_button.scss b/src/sdg/scss/components/_button.scss index 4a19d96b1..ae3a5444a 100644 --- a/src/sdg/scss/components/_button.scss +++ b/src/sdg/scss/components/_button.scss @@ -13,6 +13,7 @@ --spacing: 10px; --padding-h: 20px; --padding-v: 20px; + --default-font-size: 13.3333px; background-color: var(--main-color); color: var(--label-color); @@ -24,6 +25,7 @@ transition: transform 150ms, box-shadow 100ms; border: 0; cursor: pointer; + font-size: var(--default-font-size); &[disabled] { --label-color: #{$color_grey_dark}; @@ -66,6 +68,14 @@ --spacing: 0; } + &--medium { + --shadow-size: 1px; + --shadow-size-hover: 2px; + --padding-h: .5rem; + --padding-v: .5rem; + height: min-content; + } + &.button--light { --main-color: #{$color_secondary_light}; --shadow-color: #{$color_secondary-dark}; diff --git a/src/sdg/scss/components/_form-actions.scss b/src/sdg/scss/components/_form-actions.scss deleted file mode 100644 index b57c8021e..000000000 --- a/src/sdg/scss/components/_form-actions.scss +++ /dev/null @@ -1,24 +0,0 @@ -.form-actions { - display: grid; - grid-template-columns: 1fr; - margin: 24px 0 48px 0; - - .form-actions__title { - color: #333; - margin-bottom: 24px; - } - - .form-actions__options { - display: flex; - flex-direction: column; - align-items: flex-start; - - label { - margin-right: 20px; - - &:last-child { - margin-right: 0; - } - } - } -} diff --git a/src/sdg/scss/components/_form.scss b/src/sdg/scss/components/_form.scss index 4d7ce63ed..1ccf432e3 100644 --- a/src/sdg/scss/components/_form.scss +++ b/src/sdg/scss/components/_form.scss @@ -1,4 +1,4 @@ -@import 'colors'; +@import "colors"; .form { $form-gap: $grid-margin-2; @@ -11,11 +11,11 @@ padding: $form-gap 0; display: grid; grid-template-areas: "hdr" - "bdy"; + "bdy"; grid-template-rows: auto auto; gap: $form-label-row-gap; width: 100%; - + &--inline { padding: 12px 0; grid-template-areas: "hdr bdy"; @@ -67,6 +67,7 @@ padding: .75rem; vertical-align: top; width: 100%; + padding: 12px; // Input states &[disabled], @@ -80,6 +81,12 @@ color: transparent; } + &:focus-visible { + // Use CK-editor focus state as default focus state. + box-shadow: var(--ck-inner-shadow), 0 0; + --field-border-color: var(--ck-color-focus-border); + } + // Modifiers &--preview { --field-border-color: #{$color_grey_light}; @@ -93,8 +100,8 @@ } &--wrapper { - padding: 0; - border: 0; + padding: 0 !important; + border: 0 !important; } // Nested elements @@ -196,6 +203,19 @@ visibility: hidden; } + & &__order { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + + .svg-inline--fa[aria-hidden="true"] { + visibility: visible; + display: block; + } + } + + &__group { display: flex; flex-direction: column; @@ -225,6 +245,10 @@ &__label { margin-bottom: 8px; font-size: 16px; + + &--no-spacing { + margin-bottom: 0; + } } &__block { @@ -236,53 +260,45 @@ } &-group { - border: 1px solid $color_secondary; - background-color: $color_secondary_lightest; - border-radius: 15px; display: grid; grid-template-columns: 1fr 1fr; - margin-bottom: 24px; - + & > div { padding: 24px; + + &:only-child { + padding-top: 0; + } } } } + &__special-group { + &.form__table { + border: none; + } + + .form__help-text { + display: none; + } + } + &__table { display: grid; grid-template-columns: 95px 1fr; grid-gap: 8px; align-items: center; - - &:not(.form__special-group) { - border-left: 1px solid $color_secondary; - } - - .form__special-group { - .form__help-text { - display: none; - } - } + border-left: 1px solid $color_secondary; &-header { text-transform: uppercase; font-weight: bold; - &-left { - text-transform: uppercase; - font-weight: bold; - margin-bottom: 16px; - min-height: 18px; - } + &--left { + min-height: 18px; + margin-bottom: 16px; } - } - - &__subtitle { - padding: 16px 16px; - font-size: 16px; - display: flex; - justify-content: space-between; + } } &__forms { @@ -290,37 +306,10 @@ padding: 8px 0; } - &__subforms { - margin-bottom: 32px; - color: $color_secondary_darkest; - - .form__help-text { - color: $color_secondary_darker; - } - - .formset__remove { - text-decoration: none; - } - } - &__help-text { margin-top: 4px; font-size: 14px; - color: #666; - } - - &__add-subform { - color: #0B71A1; - cursor: pointer; - margin-bottom: 42px; - margin-left: 16px; - } - - &__buttons { - - &-link { - margin-left: 16px; - } + color: $color_secondary_darker; } &__checkbox { @@ -329,7 +318,6 @@ &--invisible { opacity: 0; } - } .errorlist { @@ -352,4 +340,4 @@ textarea { resize: none; } -} \ No newline at end of file +} diff --git a/src/sdg/scss/components/_formset.scss b/src/sdg/scss/components/_formset.scss new file mode 100644 index 000000000..af0239518 --- /dev/null +++ b/src/sdg/scss/components/_formset.scss @@ -0,0 +1,159 @@ +.formset { + --text-color: #{$color_secondary_darkest}; + --border-color: #{$color_secondary}; + --background-color: #{$color_secondary_lightest}; + --x-spacing: 1.5rem; + --y-spacing: 1.5rem; + --border-radius: 15px; + --margin-bottom: 32px; + --text-gap: .75rem; + --font-size: 1rem; + + margin-bottom: var(--margin-bottom); + color: var(--text-color); + + &__button { + font-size: 13.3333px; + } + + &__form{ + margin-bottom: var(--margin-bottom); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background-color: var(--background-color); + width: 100%; + + &--hide-overflow { + overflow: hidden; + } + + // Show error indication + &:has(.errorlist) { + --border-color: #{$color_red}; + .formset__indication--error { + display: block; + visibility: visible; + } + } + + // Hide error indication + .formset__indication--error { + display: none; + visibility: hidden; + } + + &-title { + font-size: 16px; + margin-right: auto; + + } + + // Header component. + &-header { + display: flex; + gap: var(--text-gap); + align-items: center; + padding: var(--y-spacing) var(--x-spacing); + + &--small { + padding-bottom: calc(var(--y-spacing) / 2); + } + + &.bem-toggle { + cursor: pointer; + } + } + + // Body collapsed + &--hidden &-body{ + visibility: hidden; + max-height: 0; + opacity: 0; + } + + // Body default + &-body{ + transition: all 300ms ease-in-out; + max-height: 1600px; + box-sizing: border-box; + display: block; + border-top: 1px solid var(--border-color); + opacity: 1; + } + } + + &__header { + display: flex; + gap: var(--x-spacing); + align-items: center; + padding: var(--y-spacing) var(--x-spacing); + justify-content: space-between; + } + + + &__wrapper { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background-color: var(--background-color); + overflow: hidden; + width: 100%; + + } + + &__label { + font-size: 16px; + } + + &__footer{ + padding: 24px; + border-top: 1px solid var(--border-color); + } + + &__remove:hover { + text-decoration: underline; + } + + // Formset - toggle indication (works with bem-toggle.js) + &__toggle-indication.svg-inline--fa[aria-hidden=true] { + visibility: visible; + display: block; + transition: all 300ms ease-in-out; + } + + &__form--hidden &__toggle-indication { + transform: rotate(0deg); + } + + &__form:not(&__form--hidden) &__toggle-indication { + transform: rotate(180deg); + } + + &__button { + cursor: pointer; + border: none; + font-size: 1rem; + padding: 0; + background-color: transparent; + appearance: none; + + &--add { + color: #0b71a1; + margin-bottom: 42px; + margin-left: 16px; + margin-bottom: 10px; + } + + &--remove { + color: $color_red; + } + + &:hover { + text-decoration: underline; + } + } + + // Remove the dynamic title '(' & ')' if there is no dyanmic title target availible + &__form-name:has([class*="dynamic_title_target"]:empty){ + display: none; + } +} \ No newline at end of file diff --git a/src/sdg/scss/components/_index.scss b/src/sdg/scss/components/_index.scss index e41a6574a..c5df433e5 100644 --- a/src/sdg/scss/components/_index.scss +++ b/src/sdg/scss/components/_index.scss @@ -10,7 +10,6 @@ @import 'button'; @import 'notifications'; @import 'revision-list'; -@import 'form-actions'; @import 'markdown'; @import 'divider'; @import 'cards'; @@ -36,3 +35,4 @@ @import 'publications'; @import 'nav'; @import 'user-dropdown'; +@import 'formset'; diff --git a/src/sdg/scss/screen.scss b/src/sdg/scss/screen.scss index c829d4738..2d8a6f346 100644 --- a/src/sdg/scss/screen.scss +++ b/src/sdg/scss/screen.scss @@ -15,7 +15,7 @@ html, body { scrollbar-gutter: stable; } -input, textarea { +input, textarea, button { color: #212121; font-family: 'Source Sans Pro', sans-serif; } @@ -38,6 +38,17 @@ blockquote { display: none !important; } +.animated { + transition: 300ms all ease-in-out; + opacity: 1; + visibility: visible; + + &--hidden { + opacity: 0; + visibility: hidden; + } +} + .text-center { text-align: center; } diff --git a/src/sdg/templates/account/login.html b/src/sdg/templates/account/login.html index ca9f97a68..c3b6a5e88 100644 --- a/src/sdg/templates/account/login.html +++ b/src/sdg/templates/account/login.html @@ -23,9 +23,9 @@

{% trans "Aanmelden" %}

{% field form.password %} {% checkbox form.remember %} -
- - Wachtwoord vergeten? +
diff --git a/src/sdg/templates/forms/location_form.html b/src/sdg/templates/forms/location_form.html new file mode 100644 index 000000000..0e527ac8b --- /dev/null +++ b/src/sdg/templates/forms/location_form.html @@ -0,0 +1,63 @@ + +{% load utils i18n %} + +
+
+ + +

+ {% trans "Locatie" %} + {{initialOrder}} + ({{subform.naam.value}}) +

+ +

+ {% trans 'Locatie bevat fouten'%} + +

+ + {% order_field subform.order %} +
+ +
+ {% for hidden in subform.hidden_fields %} + {{ hidden }} + {% endfor %} + {{ subform.DELETE|addclass:"hidden" }} + +
+
+
{% trans "Locatie" %}
+
+ {% field subform.naam %} +
+ {% field subform.straat %} + {% field subform.nummer %} +
+ {% field subform.postcode %} + {% field subform.plaats %} +
+ {% field subform.land %} + {% field subform.openingstijden_opmerking %} +
+
+
Dag
+
Openingstijden
+ {% table_grid_field subform.maandag %} + {% table_grid_field subform.dinsdag %} + {% table_grid_field subform.woensdag %} + {% table_grid_field subform.donderdag %} + {% table_grid_field subform.vrijdag %} + {% table_grid_field subform.zaterdag %} + {% table_grid_field subform.zondag %} +
+
+
+ +
+
+
+ diff --git a/src/sdg/templates/forms/order_field.html b/src/sdg/templates/forms/order_field.html new file mode 100644 index 000000000..7b7aae3f1 --- /dev/null +++ b/src/sdg/templates/forms/order_field.html @@ -0,0 +1,16 @@ +{% load utils i18n %} + +
+ + + {{field|addclass:'order_field hidden'}} + +
+ + +
+
\ No newline at end of file diff --git a/src/sdg/templates/forms/organization_form.html b/src/sdg/templates/forms/organization_form.html new file mode 100644 index 000000000..df5f1a9ce --- /dev/null +++ b/src/sdg/templates/forms/organization_form.html @@ -0,0 +1,44 @@ +{% load static i18n utils l10n %} + + +
+
+ + +

+ {% trans "Organisatie" %} + {{initialOrder}} + ({{subform.naam.value|default:""}}) +

+ +

+ {% trans 'Organisatie bevat fouten'%} + +

+
+ +
+ {{ subform.non_field_errors }} + {% for hidden in subform.hidden_fields %} + {{ hidden }} + {% endfor %} + {{ subform.DELETE|addclass:"hidden" }} + +
+
+ {% choices_field subform.organisatie %} + {% checkbox subform.staat_niet_in_de_lijst %} +
+
+ {% field subform.naam %} +
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/src/sdg/templates/organisaties/_prefix/locatie_formset.html b/src/sdg/templates/organisaties/_prefix/locatie_formset.html index ed386d689..461f75abb 100644 --- a/src/sdg/templates/organisaties/_prefix/locatie_formset.html +++ b/src/sdg/templates/organisaties/_prefix/locatie_formset.html @@ -1,37 +1,57 @@ {% load utils %} {% load i18n %} -
-

- {% trans "Locatie" %} __prefix__ - - - {% trans "Locatie verwijderen" %} - -

-
-
-
{% trans "Locatie" %}
- {% field form.empty_form.naam %} - {% field form.empty_form.straat %} - {% field form.empty_form.nummer %} -
- {% field form.empty_form.postcode %} - {% field form.empty_form.plaats %} +
+
+ + +

+ {% trans "Locatie" %} + __prefix__ + () +

+ +

+ {% trans 'Nieuw'%} + +

+ + {% order_field form.empty_form.order %} +
+ +
+
+
+
{% trans "Locatie" %}
+
+ {% field form.empty_form.naam %} +
+ {% field form.empty_form.straat %} + {% field form.empty_form.nummer %} +
+ {% field form.empty_form.postcode %} + {% field form.empty_form.plaats %} +
+ {% field form.empty_form.land %} + {% field form.empty_form.openingstijden_opmerking %} +
+
+
Dag
+
Openingstijden
+ {% table_grid_field form.empty_form.maandag %} + {% table_grid_field form.empty_form.dinsdag %} + {% table_grid_field form.empty_form.woensdag %} + {% table_grid_field form.empty_form.donderdag %} + {% table_grid_field form.empty_form.vrijdag %} + {% table_grid_field form.empty_form.zaterdag %} + {% table_grid_field form.empty_form.zondag %}
- {% field form.empty_form.land %} - {% field form.empty_form.openingstijden_opmerking %} -
-
-
Dag
-
Openingstijden
- {% table_grid_field form.empty_form.maandag %} - {% table_grid_field form.empty_form.dinsdag %} - {% table_grid_field form.empty_form.woensdag %} - {% table_grid_field form.empty_form.donderdag %} - {% table_grid_field form.empty_form.vrijdag %} - {% table_grid_field form.empty_form.zaterdag %} - {% table_grid_field form.empty_form.zondag %}
+
+ +
-
+ diff --git a/src/sdg/templates/organisaties/_prefix/organisatie_formset.html b/src/sdg/templates/organisaties/_prefix/organisatie_formset.html index eeaebae49..928ec45d2 100644 --- a/src/sdg/templates/organisaties/_prefix/organisatie_formset.html +++ b/src/sdg/templates/organisaties/_prefix/organisatie_formset.html @@ -1,27 +1,37 @@ {% load utils %} {% load i18n %} -
- {% for hidden in form.empty_form.hidden_fields %} - {{ hidden }} - {% endfor %} - {{ form.empty_form.DELETE|addclass:"hidden" }} -

- {% trans "Organisatie" %} __prefix__ - - - {% trans "Organisatie verwijderen" %} - -

-
-
-
{% trans "Organisatie" %}
- {% choices_field form.empty_form.organisatie %} - {% checkbox form.empty_form.staat_niet_in_de_lijst %} -
-
-
- {% field form.empty_form.naam hidden=True %} +
+
+ + +

+ {% trans "Organisatie" %} + __prefix__ + () +

+ +

+ {% trans 'Nieuw'%} + +

+
+ +
+
+
+ {% choices_field form.empty_form.organisatie %} + {% checkbox form.empty_form.staat_niet_in_de_lijst %} +
+
+ {% field form.empty_form.naam %} +
+
+ +
-
+ \ No newline at end of file diff --git a/src/sdg/templates/organisaties/bevoegde_organisaties.html b/src/sdg/templates/organisaties/bevoegde_organisaties.html index e565fe4a7..6f24871c9 100644 --- a/src/sdg/templates/organisaties/bevoegde_organisaties.html +++ b/src/sdg/templates/organisaties/bevoegde_organisaties.html @@ -12,71 +12,52 @@
{% csrf_token %} -
+
{{ form.management_form }}
{% for subform in form %} {% if subform.instance.organisatie == lokaleoverheid.organisatie %} -
{# No form__subforms class to prevent JS-bindings #} + +
{{ subform.non_field_errors }} + {% for hidden in subform.hidden_fields %} {{ hidden }} {% endfor %} -

- {% trans "Organisatie" %} {{ forloop.counter }} -

+ +
+

+ {% trans "Organisatie" %} + {{ forloop.counter }} + ({{ subform.instance.organisatie }}) +

+
+
-

{{ subform.instance.organisatie }}

{% else %} -
- {{ subform.non_field_errors }} - {% for hidden in subform.hidden_fields %} - {{ hidden }} - {% endfor %} - {{ subform.DELETE|addclass:"hidden" }} -

- {% trans "Organisatie" %} {{ forloop.counter }} - - - {% trans "Organisatie verwijderen" %} - -

-
-
-
{% trans "Organisatie" %}
- {% choices_field subform.organisatie %} - {% checkbox subform.staat_niet_in_de_lijst %} -
-
-
- {% field subform.naam hidden=True %} -
-
-
+ {% organization_form subform initialOrder=forloop.counter %} {% endif %} {% endfor %}
-
+
+
-
- +
+ + + {% trans 'Annuleren' %} +
diff --git a/src/sdg/templates/organisaties/invitation/create.html b/src/sdg/templates/organisaties/invitation/create.html index 9b1402b0e..0b7c995f5 100644 --- a/src/sdg/templates/organisaties/invitation/create.html +++ b/src/sdg/templates/organisaties/invitation/create.html @@ -36,8 +36,8 @@
-
- +
+ {% trans 'Annuleren' %} diff --git a/src/sdg/templates/organisaties/locaties.html b/src/sdg/templates/organisaties/locaties.html index a84a8b180..4560be998 100644 --- a/src/sdg/templates/organisaties/locaties.html +++ b/src/sdg/templates/organisaties/locaties.html @@ -11,65 +11,26 @@ {% block inner_content %}
{% csrf_token %} -
+
{{ form.management_form }}
{% for subform in form %} -
- {% for hidden in subform.hidden_fields %} - {{ hidden }} - {% endfor %} - {{ subform.DELETE|addclass:"hidden" }} -

- {% trans "Locatie" %} {{ forloop.counter }} - - - {% trans "Locatie verwijderen" %} - -

-
-
-
{% trans "Locatie" %}
- {% field subform.naam %} - {% field subform.straat %} - {% field subform.nummer %} -
- {% field subform.postcode %} - {% field subform.plaats %} -
- {% field subform.land %} - {% field subform.openingstijden_opmerking %} -
-
-
Dag
-
Openingstijden
- {% table_grid_field subform.maandag %} - {% table_grid_field subform.dinsdag %} - {% table_grid_field subform.woensdag %} - {% table_grid_field subform.donderdag %} - {% table_grid_field subform.vrijdag %} - {% table_grid_field subform.zaterdag %} - {% table_grid_field subform.zondag %} -
-
-
+ {% location_form subform initialOrder=forloop.counter %} {% endfor %}
-
+
+
-
- +
+ + + {% trans 'Annuleren' %} +
diff --git a/src/sdg/utils/templatetags/utils.py b/src/sdg/utils/templatetags/utils.py index bf54c53f6..3d015587a 100644 --- a/src/sdg/utils/templatetags/utils.py +++ b/src/sdg/utils/templatetags/utils.py @@ -110,6 +110,32 @@ def choices_field(field, **kwargs): return {**kwargs, "field": field} +@register.inclusion_tag("forms/order_field.html", takes_context=True) +def order_field(context, field=None, **kwargs): + return {**kwargs, "context": context, "field": field} + + +@register.inclusion_tag("forms/location_form.html", takes_context=True) +def location_form(context, subform, initialOrder, **kwargs): + print(subform) + return { + "context": context, + "subform": subform, + "initialOrder": initialOrder, + **kwargs, + } + + +@register.inclusion_tag("forms/organization_form.html", takes_context=True) +def organization_form(context, subform, initialOrder, **kwargs): + return { + "context": context, + "subform": subform, + "initialOrder": initialOrder, + **kwargs, + } + + @register.inclusion_tag("forms/table_grid_field.html") def table_grid_field(field, **kwargs): return {**kwargs, "field": field}