Skip to content
Open
9 changes: 9 additions & 0 deletions assets/controllers/elements/ckeditor_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ export default class extends Controller {
editor_div.classList.add(...new_classes.split(","));
}

// Automatic synchronization of source input
editor.model.document.on("change:data", () => {
editor.updateSourceElement();

// Dispatch the input event for further treatment
const event = new Event("input");
this.element.dispatchEvent(event);
});

//This return is important! Otherwise we get mysterious errors in the console
//See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
return editor;
Expand Down
250 changes: 250 additions & 0 deletions assets/controllers/elements/ipn_suggestion_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { Controller } from "@hotwired/stimulus";
import "../../css/components/autocomplete_bootstrap_theme.css";

export default class extends Controller {
static targets = ["input"];
static values = {
partId: Number,
partCategoryId: Number,
partDescription: String,
suggestions: Object,
commonSectionHeader: String, // Dynamic header for common Prefixes
partIncrementHeader: String, // Dynamic header for new possible part increment
suggestUrl: String,
};

connect() {
this.configureAutocomplete();
this.watchCategoryChanges();
this.watchDescriptionChanges();
}

templates = {
commonSectionHeader({ title, html }) {
return html`
<section class="aa-Source">
<div class="aa-SourceHeader">
<span class="aa-SourceHeaderTitle">${title}</span>
<div class="aa-SourceHeaderLine"></div>
</div>
</section>
`;
},
partIncrementHeader({ title, html }) {
return html`
<section class="aa-Source">
<div class="aa-SourceHeader">
<span class="aa-SourceHeaderTitle">${title}</span>
<div class="aa-SourceHeaderLine"></div>
</div>
</section>
`;
},
list({ html }) {
return html`
<ul class="aa-List" role="listbox"></ul>
`;
},
item({ suggestion, description, html }) {
return html`
<li class="aa-Item" role="option" data-suggestion="${suggestion}" aria-selected="false">
<div class="aa-ItemWrapper">
<div class="aa-ItemContent">
<div class="aa-ItemIcon aa-ItemIcon--noBorder">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21c4.971 0 9-4.029 9-9s-4.029-9-9-9-9 4.029-9 9 4.029 9 9 9z"></path>
</svg>
</div>
<div class="aa-ItemContentBody">
<div class="aa-ItemContentTitle">${suggestion}</div>
<div class="aa-ItemContentDescription">${description}</div>
</div>
</div>
</div>
</li>
`;
},
};

configureAutocomplete() {
const inputField = this.inputTarget;
const commonPrefixes = this.suggestionsValue.commonPrefixes || [];
const prefixesPartIncrement = this.suggestionsValue.prefixesPartIncrement || [];
const commonHeader = this.commonSectionHeaderValue;
const partIncrementHeader = this.partIncrementHeaderValue;

if (!inputField || (!commonPrefixes.length && !prefixesPartIncrement.length)) return;

// Check whether the panel should be created at the update
if (this.isPanelInitialized) {
const existingPanel = inputField.parentNode.querySelector(".aa-Panel");
if (existingPanel) {
// Only remove the panel in the update phase

existingPanel.remove();
}
}

// Create panel
const panel = document.createElement("div");
panel.classList.add("aa-Panel");
panel.style.display = "none";

// Create panel layout
const panelLayout = document.createElement("div");
panelLayout.classList.add("aa-PanelLayout", "aa-Panel--scrollable");

// Section for prefixes part increment
if (prefixesPartIncrement.length) {
const partIncrementSection = document.createElement("section");
partIncrementSection.classList.add("aa-Source");

const partIncrementHeaderHtml = this.templates.partIncrementHeader({
title: partIncrementHeader,
html: String.raw,
});
partIncrementSection.innerHTML += partIncrementHeaderHtml;

const partIncrementList = document.createElement("ul");
partIncrementList.classList.add("aa-List");
partIncrementList.setAttribute("role", "listbox");

prefixesPartIncrement.forEach((prefix) => {
const itemHTML = this.templates.item({
suggestion: prefix.title,
description: prefix.description,
html: String.raw,
});
partIncrementList.innerHTML += itemHTML;
});

partIncrementSection.appendChild(partIncrementList);
panelLayout.appendChild(partIncrementSection);
}

// Section for common prefixes
if (commonPrefixes.length) {
const commonSection = document.createElement("section");
commonSection.classList.add("aa-Source");

const commonSectionHeader = this.templates.commonSectionHeader({
title: commonHeader,
html: String.raw,
});
commonSection.innerHTML += commonSectionHeader;

const commonList = document.createElement("ul");
commonList.classList.add("aa-List");
commonList.setAttribute("role", "listbox");

commonPrefixes.forEach((prefix) => {
const itemHTML = this.templates.item({
suggestion: prefix.title,
description: prefix.description,
html: String.raw,
});
commonList.innerHTML += itemHTML;
});

commonSection.appendChild(commonList);
panelLayout.appendChild(commonSection);
}

panel.appendChild(panelLayout);
inputField.parentNode.appendChild(panel);

inputField.addEventListener("focus", () => {
panel.style.display = "block";
});

inputField.addEventListener("blur", () => {
setTimeout(() => {
panel.style.display = "none";
}, 100);
});

// Selection of an item
panelLayout.addEventListener("mousedown", (event) => {
const target = event.target.closest("li");

if (target) {
inputField.value = target.dataset.suggestion;
panel.style.display = "none";
}
});

this.isPanelInitialized = true;
};

watchCategoryChanges() {
const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]');
const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]');
this.previousCategoryId = Number(this.partCategoryIdValue);

if (categoryField) {
categoryField.addEventListener("change", () => {
const categoryId = Number(categoryField.value);
const description = String(descriptionField?.value ?? '');

// Check whether the category has changed compared to the previous ID
if (categoryId !== this.previousCategoryId) {
this.fetchNewSuggestions(categoryId, description);
this.previousCategoryId = categoryId;
}
});
}
}

watchDescriptionChanges() {
const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]');
const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]');
this.previousDescription = String(this.partDescriptionValue);

if (descriptionField) {
descriptionField.addEventListener("input", () => {
const categoryId = Number(categoryField.value);
const description = String(descriptionField?.value ?? '');

// Check whether the description has changed compared to the previous one
if (description !== this.previousDescription) {
this.fetchNewSuggestions(categoryId, description);
this.previousDescription = description;
}
});
}
}

fetchNewSuggestions(categoryId, description) {
const baseUrl = this.suggestUrlValue;
const partId = this.partIdValue;
const truncatedDescription = description.length > 150 ? description.substring(0, 150) : description;
const encodedDescription = this.base64EncodeUtf8(truncatedDescription);
const url = `${baseUrl}?partId=${partId}&categoryId=${categoryId}` + (description !== '' ? `&description=${encodedDescription}` : '');

fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
})
.then((response) => {
if (!response.ok) {
throw new Error(`Error when calling up the IPN-suggestions: ${response.status}`);
}
return response.json();
})
.then((data) => {
this.suggestionsValue = data;
this.configureAutocomplete();
})
.catch((error) => {
console.error("Errors when loading the new IPN-suggestions:", error);
});
};

base64EncodeUtf8(text) {
const utf8Bytes = new TextEncoder().encode(text);
return btoa(String.fromCharCode(...utf8Bytes));
};
}
12 changes: 12 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,18 @@ services:
tags:
- { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' }

App\Repository\PartRepository:
arguments:
$translator: '@translator'
tags: ['doctrine.repository_service']

App\EventSubscriber\UserSystem\PartUniqueIpnSubscriber:
tags:
- { name: doctrine.event_listener, event: onFlush, connection: default }

App\Validator\Constraints\UniquePartIpnValidator:
tags: [ 'validator.constraint_validator' ]

# We are needing this service inside a migration, where only the container is injected. So we need to define it as public, to access it from the container.
App\Services\UserSystem\PermissionPresetsHelper:
public: true
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
value should be handled as confidential data and not shared publicly.
* `SHOW_PART_IMAGE_OVERLAY`: Set to 0 to disable the part image overlay, which appears if you hover over an image in the
part image gallery
* `IPN_SUGGEST_REGEX`: A global regular expression, that part IPNs have to fullfill. Enforce your own format for your users.
* `IPN_SUGGEST_REGEX_HELP`: Define your own user help text for the Regex format specification.
* `IPN_AUTO_APPEND_SUFFIX`: When enabled, an incremental suffix will be added to the user input when entering an existing
* IPN again upon saving.
* `IPN_SUGGEST_PART_DIGITS`: Defines the fixed number of digits used as the increment at the end of an IPN (Internal Part Number).
IPN prefixes, maintained within part categories and their hierarchy, form the foundation for suggesting complete IPNs.
These suggestions become accessible during IPN input of a part. The constant specifies the digits used to calculate and assign
unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation.
* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the part’s description is used to find existing parts with the same
description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list.

### E-Mail settings (all env only)

Expand Down
Loading