From 57f2b30a4c66a8885733bc2f449a6e56c5a7237a Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:37:45 -0700 Subject: [PATCH 1/6] fix(api): strip leading # from filament color codes The color_hex and multi_color_hexes validators normalized a local copy of the value (uppercasing it and removing a leading "#") in order to validate it, but then returned the caller's original string. A "#"-prefixed 8-digit color such as "#FF000000" was therefore stored with 9 characters, which no longer fits the String(8) color_hex column, so the write failed and every subsequent read of that filament returned a 500. Both validators now return the value with the "#" removed, matching the un-prefixed form the rest of the codebase and the frontend already assume. Adds tests/test_color_hex_validation.py covering both validators for the prefixed and un-prefixed, 6- and 8-digit forms. --- spoolman/api/v1/filament.py | 7 +++-- tests/test_color_hex_validation.py | 46 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/test_color_hex_validation.py diff --git a/spoolman/api/v1/filament.py b/spoolman/api/v1/filament.py index 71fa70b3..2f3bf701 100644 --- a/spoolman/api/v1/filament.py +++ b/spoolman/api/v1/filament.py @@ -142,7 +142,7 @@ class FilamentParameters(BaseModel): if len(clr) not in (6, 8): raise ValueError("Color code must be 6 or 8 characters long.") - return v + return v.removeprefix("#") @field_validator("multi_color_hexes") @classmethod @@ -150,6 +150,7 @@ class FilamentParameters(BaseModel): """Validate the multi_color_hexes field.""" if not v: return None + colors = [] for clr_raw in v.split(","): clr = clr_raw.upper() clr = clr.removeprefix("#") @@ -161,7 +162,9 @@ class FilamentParameters(BaseModel): if len(clr) not in (6, 8): raise ValueError("Color code must be 6 or 8 characters long.") - return v + colors.append(clr_raw.removeprefix("#")) + + return ",".join(colors) @model_validator(mode="after") # type: ignore[] def validate(self) -> "FilamentParameters": diff --git a/tests/test_color_hex_validation.py b/tests/test_color_hex_validation.py new file mode 100644 index 00000000..8c8f3bdb --- /dev/null +++ b/tests/test_color_hex_validation.py @@ -0,0 +1,46 @@ +"""Tests for the filament color code validators. + +The validators normalized a copy of the value (stripping a leading ``#``) but returned the +original string, so a ``#``-prefixed 8-digit color was stored with 9 characters and no longer +fit the ``String(8)`` column, breaking every later read of that filament. +""" + +from typing import Any + +import pytest + +from spoolman.api.v1.filament import FilamentParameters + + +def _params(**kwargs: Any) -> FilamentParameters: # noqa: ANN401 + return FilamentParameters(density=1.24, diameter=1.75, **kwargs) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("FF0000", "FF0000"), + ("#FF0000", "FF0000"), + ("FF000000", "FF000000"), + ("#FF000000", "FF000000"), + ], +) +def test_color_hex_strips_leading_hash(value: str, expected: str): + assert _params(color_hex=value).color_hex == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("FF0000,00FF00", "FF0000,00FF00"), + ("#FF0000,#00FF00", "FF0000,00FF00"), + ("#FF000000,00FF0000", "FF000000,00FF0000"), + ], +) +def test_multi_color_hexes_strips_leading_hash(value: str, expected: str): + params = _params(multi_color_hexes=value, multi_color_direction="coaxial") + assert params.multi_color_hexes == expected + + +def test_color_hex_fits_the_database_column(): + assert len(_params(color_hex="#FF000000").color_hex) <= 8 From 20462e86e240542db43d571faa21913d9c40abc5 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Sun, 2 Aug 2026 08:21:00 +0200 Subject: [PATCH 2/6] Translations update from Hosted Weblate (#1004) * Translated using Weblate (Russian) Currently translated at 74.4% (344 of 462 strings) Translation: Spoolman/Spoolman Web UI v2 Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/ru/ * Translated using Weblate (Russian) Currently translated at 76.0% (353 of 464 strings) Translation: Spoolman/Spoolman Web UI v2 Translate-URL: https://hosted.weblate.org/projects/spoolman/spoolman-web-ui-v2/ru/ --------- Co-authored-by: Dr_Perry_Coke --- client_v2/locales/ru/common.json | 234 ++++++++++++++++++++++++++++++- 1 file changed, 233 insertions(+), 1 deletion(-) diff --git a/client_v2/locales/ru/common.json b/client_v2/locales/ru/common.json index 3d59de27..13538b63 100644 --- a/client_v2/locales/ru/common.json +++ b/client_v2/locales/ru/common.json @@ -1,17 +1,99 @@ { + "add.addAndNew": "Добавить и новое", + "add.addN_few": "Добавить {{count}} катушки", + "add.addN_many": "Добавить {{count}} катушек", + "add.addN_one": "Добавить {{count}} катушку", + "add.adding": "Добавление...", + "add.advanced": "Расширенные характеристики", + "add.advancedNote": "плотность, диаметр, температуры (заполняются автоматически из материала)", + "add.change": "изменить", + "add.count": "Количество", + "add.createNew": "Создать новый филамент", + "add.createNewSub": "нет в вашем каталоге или {{name}}", + "add.dbUnavailable": "{{name}} недоступен", + "add.duplicate": "клонировать", + "add.duplicateNote": "Характеристики, производитель и цена были скопированы. Задайте ему новое название и цвет.", + "add.duplicateRename": "Название все еще как у оригинала — переименуйте его, чтобы различать их.", + "add.duplicateTitle": "Новый филамент · копия «{{name}}", + "add.filamentNamePlaceholder": "например, PolyTerra Matte Sage", + "add.fill.full": "Полная", + "add.fillLevel": "Уровень заполнения", + "add.importNote": "Этот филамент будет добавлен в ваш каталог при добавлении катушек.", + "add.inCatalog": "в каталоге", + "add.locationHint": "Где физически хранится катушка — полка, коробка или принтер. Оставьте пустым, если они хранятся в одном месте.", + "add.locationPlaceholder": "например: Полка А, Сухой бокс 2, Принтер 1", + "add.manufacturerPlaceholder": "например, Polymaker", + "add.nameHint": "Укажите цвет — именно по нему можно различать филаменты.", + "add.newFilament": "новый филамент", + "add.newFilamentTitle": "Новый филамент", + "add.noCatalog": "В каталоге нет филаментов", + "add.noManufacturer": "Нет производителя", + "add.pickColor": "Выбрать цвет", + "add.searchPlaceholder": "Поиск по каталогу и {{name}}...", + "add.searching": "Поиск...", + "add.spoolWeightPreset.cardboard": "Картонная {{weight}} г", + "add.spoolWeightPreset.plastic": "Пластиковая {{weight}} г", + "add.spoolWeightPresetsLead": "Начните с типичной катушки:", + "add.step2": "Шаг 2 · детали катушки", + "add.summary_few": "{{count}} катушки", + "add.summary_many": "{{count}} катушек", + "add.summary_one": "{{count}} катушка", + "add.typeToSearchAll": "Введите для поиска по тысячам филаментов", + "add.typeToSearchMatches": "Введите для поиска — нет совпадений", + "add.useExisting": "использовать существующий", + "add.vendorHint.existing": "Используется существующий производитель «{{name}}", + "add.vendorHint.new": "Будет создан новый производитель «{{name}}", + "add.vendorHint.optional": "Необязательно — оставьте пустым, если производитель не нужен", + "add.yourCatalog": "Ваш каталог", "buttons.archive": "В архив", "buttons.cancel": "Отмена", "buttons.clear": "Очистить", + "buttons.close": "Закрыть", "buttons.delete": "Удалить", "buttons.edit": "Редактировать", "buttons.filter": "Фильтр", "buttons.save": "Сохранить", "buttons.showArchived": "Показать архивные", "buttons.unArchive": "Из архива", + "common.decrement": "Уменьшить", + "common.increment": "Увеличить", + "common.remove": "Удалить", + "common.search": "Поиск", + "dashboard.addFieldHint": "Добавьте текстовое поле или поле выбора для катушек в Настройках, чтобы группировать и по нему", + "dashboard.dashboard": "Панель", + "dashboard.deleteGroup": "Удалить группу", + "dashboard.dragHint": "Перетащите катушку на другую карточку, чтобы изменить её {{field}}, или внутри карточки для изменения порядка", + "dashboard.dropHere": "перетащите катушки сюда", + "dashboard.emptyHelp": "На этой странице катушки группируются по выбранному вами полю. Добавьте катушки, чтобы начать!", + "dashboard.errorEmptyName": "Имя не может быть пустым", + "dashboard.errorExists": "Группа с таким именем уже существует", + "dashboard.errorRename": "Не удалось переименовать группу", + "dashboard.lastUsed": "Последнее использование {{time}} назад", + "dashboard.newGroup": "Новая группа", + "dashboard.newGroupName": "Новая группа {{n}}", + "dashboard.reorderGroup": "Перетащите для изменения порядка группы", + "dashboard.spoolCount_few": "\"{{count}} катушки", + "dashboard.spoolCount_many": "\"{{count}} катушек", + "dashboard.spoolCount_one": "\"{{count}} катушка", + "dashboard.unassigned": "Не назначено", + "datetime.done": "Готово", + "datetime.hour": "Час", + "datetime.minute": "Минута", + "datetime.nextMonth": "Следующий месяц", + "datetime.now": "Сейчас", + "datetime.pick": "Выберите дату и время", + "datetime.prevMonth": "Предыдущий месяц", + "datetime.time": "Время", + "documentTitle.dashboard.list": "Панель | Spoolman", + "documentTitle.labels.list": "Этикетки | Spoolman", + "documentTitle.library.list": "Библиотека | Spoolman", "documentTitle.locations.list": "Местоположения | Spoolman", + "documentTitle.settings.list": "Настройки | Spoolman", + "filament.fields.addColor": "Добавить цвет", "filament.fields.articleNumber": "Артикул", "filament.fields.coaxial": "Коэкструдированный", "filament.fields.colorHex": "Цвет", + "filament.fields.colorType": "Тип цвета", "filament.fields.comment": "Комментарий", "filament.fields.density": "Плотность", "filament.fields.diameter": "Диаметр", @@ -23,6 +105,7 @@ "filament.fields.name": "Имя", "filament.fields.price": "Цена", "filament.fields.registered": "Добавлен", + "filament.fields.removeColor": "Удалить цвет", "filament.fields.settingsBedTemp": "Температура стола", "filament.fields.settingsExtruderTemp": "Температура экструдера", "filament.fields.singleColor": "Одноцветный", @@ -34,8 +117,157 @@ "filament.fieldsHelp.spoolWeight": "Вес пустой катушки. Используется для определения измеренного веса катушки.", "filament.fieldsHelp.weight": "Вес филамента на полной катушке (вес нетто). Сюда не следует включать вес самой катушки, а только филамента. Это то, что обычно пишут на упаковке.", "filament.filament": "Филаменты", + "footer.documentation": "Документация", + "footer.reportIssue": "Сообщить о проблеме", "help.help": "Помощь", + "inspector.addSpoolsOfThis": "Добавить катушки этого филамента", + "inspector.adjustFailed": "Не удалось изменить вес.", + "inspector.adjustHelp": "Положительное значение расходует филамент, отрицательное — добавляет.", + "inspector.adjustWeight": "Изменить вес", + "inspector.apply": "Применить", + "inspector.applying": "Применение...", + "inspector.colorHex": "Шестнадцатеричный код цвета", + "inspector.consumeAmount": "Количество для списания:", + "inspector.delete.deleting": "Удаление...", + "inspector.delete.errorGone": "Он уже был удален.", + "inspector.delete.errorSpoolsExist": "Невозможно удалить этот филамент — его используют катушки.", + "inspector.delete.errorUnknown": "Не удалось удалить. Подробности см. в логах сервера.", + "inspector.delete.filament": "Удалить филамент", + "inspector.delete.filamentBlockedHint": "Сначала удалите эти катушки. Архивирования недостаточно.", + "inspector.delete.filamentBlockedTitle": "Этот филамент не может быть удален", + "inspector.delete.filamentBlocked_few": "{{count}} катушки все еще использует «{{name}}», включая архивные.", + "inspector.delete.filamentBlocked_many": "{{count}} катушек все еще использует «{{name}}», включая архивные.", + "inspector.delete.filamentBlocked_one": "{{count}} катушка все еще использует «{{name}}», включая архивные.", + "inspector.delete.filamentBody": "«{{name}}» будет удален из вашего каталога. Это действие нельзя отменить.", + "inspector.delete.filamentDone": "Филамент удален.", + "inspector.delete.filamentTitle": "Удалить этот филамент?", + "inspector.delete.spool": "Удалить катушку", + "inspector.delete.spoolBody": "Катушка #{{id}} «{{name}}» будет удалена вместе с историей использования. Это действие нельзя отменить.", + "inspector.delete.spoolDone": "Катушка удалена.", + "inspector.delete.spoolRemaining": "На ней еще осталось {{weight}} филамента. Архивируйте её, если вы просто хотите убрать её с глаз.", + "inspector.delete.spoolTitle": "Удалить эту катушку?", + "inspector.delete.vendor": "Удалить производителя", + "inspector.delete.vendorBody": "«{{name}}» будет удален. Это действие нельзя отменить.", + "inspector.delete.vendorDone": "Производитель удален.", + "inspector.delete.vendorOrphans_few": "Его {{count}} филамента сохранено, но остался без производителя.", + "inspector.delete.vendorOrphans_many": "Его {{count}} филаментов сохранено, но остался без производителя.", + "inspector.delete.vendorOrphans_one": "Его {{count}} филамент сохранен, но остался без производителя.", + "inspector.delete.vendorTitle": "Удалить этого производителя?", + "inspector.duplicateFilament": "Клонировать", + "inspector.empty": "Выберите катушку, филамент или производителя из списка, чтобы просмотреть подробную информацию здесь.", + "inspector.enterValidLength": "Введите корректную длину.", + "inspector.enterValidWeight": "Введите корректный вес.", + "inspector.filamentDefault": "{{price}} · по умолчанию для филамента", + "inspector.loading": "Загрузка...", + "inspector.manageExtraFields": "Управление полями", + "inspector.measuredHelp": "Общий вес катушки + оставшийся филамент по показаниям весов.", + "inspector.multi": "мульти ×{{count}}", + "inspector.newGross": "Новый брутто-вес:", + "inspector.noExtraFields": "Пока нет — добавьте собственные поля для отслеживания того, чего нет в Spoolman.", + "inspector.notFound": "Элемент не найден. Возможно, он был удален.", + "inspector.ofRemaining": "осталось {{weight}} · {{length}} м", + "inspector.openFilament": "Открыть филамент", + "inspector.openLink": "Открыть {{url}} в новой вкладке", + "inspector.openManufacturer": "Открыть производителя", + "inspector.specs": "Характеристики — редактирование на месте", + "inspector.spoolsCount": "Катушки · {{count}}", + "inspector.spoolsTotalRemaining": "всего {{weight}}", + "inspector.used": "использовано", + "inspector.vendorSub_few": "{{count}} филамента", + "inspector.vendorSub_many": "{{count}} филаментов", + "inspector.vendorSub_one": "{{count}} филамент", "kofi": "Поддержите меня на Ko-fi", + "labels.addHead": "Добавить", + "labels.addQr": "QR-код", + "labels.addRect": "Прямоугольник", + "labels.addSwatch": "Образец цвета", + "labels.addText": "Текст / поле", + "labels.align": "Выравнивание", + "labels.alignCenter": "По центру", + "labels.alignLeft": "По левому краю", + "labels.alignRight": "По правому краю", + "labels.blank": "У вас пока нет дизайнов этикеток.", + "labels.bold": "Жирный", + "labels.canvasHint": "Перетащите для перемещения · нажмите для выбора · маркеры изменения размера.", + "labels.centerLogo": "Центрировать логотип Spoolman", + "labels.chooseField": "Выберите поле...", + "labels.clearFill": "Без заливки (только контур)", + "labels.copies": "Копии", + "labels.cornerRadiusMm": "Радиус скругления", + "labels.createFirst": "Создайте свою первую этикетку", + "labels.cutGuides": "Линии реза", + "labels.deleteConfirm": "Удалить этот дизайн этикетки?", + "labels.deleteElement": "Удалить элемент", + "labels.designName": "Название дизайна", + "labels.designerDesc": "\"Создавайте QR-этикетки с любыми полями катушки, филамента или производителя, а затем печатайте их на листах или термопринтере. Дизайны сохраняются на сервере.", + "labels.designerTitle": "Дизайнер этикеток", + "labels.dpi": "Разрешение", + "labels.dpiHint": "Соответствуйте параметрам принтера, чтобы мелкие этикетки были четкими: 203 dpi для большинства термопринтеров, 300 или более для лазерных и струйных.", + "labels.duplicate": "Дублировать", + "labels.fields.bedTemp": "Температура стола", + "labels.fields.diameterMm": "Диаметр (мм)", + "labels.fields.groupVendor": "Производитель", + "labels.fields.initialG": "Начальный (г)", + "labels.fields.netWeightG": "Вес нетто (г)", + "labels.fields.remainingG": "Остаток (г)", + "labels.filamentsSelected_few": "{{count}} филамента выбрано", + "labels.filamentsSelected_many": "{{count}} филаментов выбрано", + "labels.filamentsSelected_one": "{{count}} филамент выбран", + "labels.fill": "Заливка", + "labels.fitWidth": "Подгонять ширину под столбцы", + "labels.fitWidthHint": "Установите ширину этикетки так, чтобы выбранное количество столбцов заполняло страницу.", + "labels.fontMm": "Шрифт", + "labels.gridInfo": "{{cols}} × {{rows}} = {{perPage}} этикеток / страница", + "labels.hMm": "В", + "labels.heightMm": "Высота", + "labels.hintNowrap": "Текст остается на одной строке; всё, что выходит за пределы ширины, обрежется.", + "labels.hintSwatch": "Заполняется цветом(-ами) филамента катушки во время печати.", + "labels.hintWrap": "Оберните поле, чтобы скрыть его, если оно пустое:", + "labels.imageDesc": "Сохраняет по одному PNG на выбранную катушку вместо печати, размер каждого {{w}}×{{h}} мм.", + "labels.imageZipNote": "Более {{threshold}} этикеток доставляются в виде одного ZIP-архива.", + "labels.insertField": "Вставить поле", + "labels.kind.qr": "QR-код", + "labels.kind.rect": "Прямоугольник", + "labels.kind.swatch": "Образец цвета", + "labels.kind.text": "Текст", + "labels.labelSize": "Размер этикетки", + "labels.labelType": "Тип этикетки", + "labels.layout": "Макет", + "labels.modeImage": "Файлы изображений", + "labels.modeLabel": "Принтер этикеток", + "labels.modeSheet": "Лист (плитка)", + "labels.new": "Новый", + "labels.newText": "Новый текст", + "labels.noDesignsOption": "Пока нет дизайнов", + "labels.noFilaments": "Нет филаментов.", + "labels.noSpools": "Нет катушек.", + "labels.onePerLabel": "Одна страница {{w}}×{{h}} мм на этикетку.", + "labels.preparing": "Подготовка...", + "labels.preview": "Предпросмотр", + "labels.previewWith": "Предпросмотр с", + "labels.printN_few": "Печать {{count}} этикетки", + "labels.printN_many": "Печать {{count}} этикеток", + "labels.printN_one": "Печать {{count}} этикетки", + "labels.radiusMm": "Радиус", + "labels.sampleData": "Пример данных", + "labels.saved": "Сохранено", + "labels.saving": "Сохранение...", + "labels.searchFilaments": "Поиск филаментов...", + "labels.searchSpools": "Поиск катушек...", + "labels.selectAllShort": "Все", + "labels.selectAtLeastOne": "Выберите хотя бы одну катушку.", + "labels.selectAtLeastOneFilament": "Выберите хотя бы один филамент.", + "labels.selectDesign": "Выберите дизайн этикетки", + "labels.selectElement": "Выберите элемент для редактирования или добавьте его из палитры.", + "labels.selectNoneShort": "Ничего", + "labels.selectRecentHint": "Выбрать катушки, добавленные за последние 24 часа", + "labels.selectRecentShort": "Недавние ({{count}})", + "labels.showingFilament": "Отображение филамента #{{id}}", + "labels.showingReal": "Отображение реальных данных катушки.", + "labels.showingSpool": "Отображение катушки #{{id}}", + "labels.sortHint": "Сортировать катушки", + "labels.sortId": "#", + "labels.sortNewest": "Самые новые", "loading": "Загрузка", "locations.locations": "Местоположения", "locations.newLocation": "Новое местоположение", @@ -83,8 +315,8 @@ "settings.extraFields.booleanFalse": "Нет", "settings.extraFields.booleanTrue": "Да", "settings.extraFields.deleteConfirm": "Удалить поле {{name}}?", - "settings.extraFields.description.intro": "Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.", "settings.extraFields.description.constraints": "Как только поле добавлено, вы не можете изменить его ключ или тип, а для полей типа выбора вы не можете удалить варианты или изменить состояние множественного выбора. Если вы удалите поле, связанные данные для всех сущностей будут удалены.", + "settings.extraFields.description.intro": "Здесь вы можете добавлять дополнительные пользовательские поля к своим сущностям.", "settings.extraFields.description.keyUsage": "Ключевым моментом является то, как другие программы считывают/записывают данные, поэтому, если предполагается, что ваше пользовательское поле интегрируется со сторонней программой, убедитесь, что оно настроено правильно. Значение по умолчанию применяется только к новым элементам.", "settings.extraFields.description.tableViews": "Дополнительные поля нельзя отсортировать или отфильтровать в табличных представлениях.", "settings.extraFields.fieldType.boolean": "Логический", From 3c1d8bfa6d964a46f6e721d9ef4f4f04c8974e5a Mon Sep 17 00:00:00 2001 From: Donkie Date: Sun, 2 Aug 2026 08:23:10 +0200 Subject: [PATCH 3/6] client_v2: separate the search result's material tag from the match tag The material chip and the "matched: " chip sat side by side at the trailing edge with identical styling, so two unrelated facts read as one kind of thing. Move material next to the filament name (mirroring the group header) and give it an outline variant, leaving the filled chip to mean "why this result matched". --- .../src/lib/components/MaterialBadge.svelte | 19 +++++++++++++++-- .../lib/components/library/SearchBox.svelte | 21 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/client_v2/src/lib/components/MaterialBadge.svelte b/client_v2/src/lib/components/MaterialBadge.svelte index 156e0f3a..c6894943 100644 --- a/client_v2/src/lib/components/MaterialBadge.svelte +++ b/client_v2/src/lib/components/MaterialBadge.svelte @@ -1,8 +1,16 @@ -{label} +{label} diff --git a/client_v2/src/lib/components/library/SearchBox.svelte b/client_v2/src/lib/components/library/SearchBox.svelte index ea416125..9c254479 100644 --- a/client_v2/src/lib/components/library/SearchBox.svelte +++ b/client_v2/src/lib/components/library/SearchBox.svelte @@ -282,10 +282,17 @@ size={20} /> - {filament.entity.name} + + + {filament.entity.name} + {#if filament.entity.material} + + {/if} + {#if vendor?.name}{vendor.name}{/if} - {#if filament.entity.material}{/if} {matchLabel('filament', filament.matchField)} {/each} @@ -411,6 +418,12 @@ flex-direction: column; line-height: 1.25; } + .line { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } .title { font-size: 12.5px; font-weight: 600; @@ -418,6 +431,10 @@ overflow: hidden; text-overflow: ellipsis; } + /* A long name yields to the material chip rather than pushing it out of the row. */ + .line .title { + min-width: 0; + } .sub { font-size: 11px; color: var(--text-dim); From 18f114419fdaf93bc73b023fed1eb305a91e65a2 Mon Sep 17 00:00:00 2001 From: Donkie Date: Sun, 2 Aug 2026 08:35:18 +0200 Subject: [PATCH 4/6] Let extra field choices be reordered by dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choice values could only be appended, so the only way to get a value into the right place was to remove everything after it and type it all back in. Both clients now render each choice with a grip: drag it, or focus it and press the left/right arrow keys, to move it. The backend already accepts a reordered list — it only refuses removals. Fixes #994 --- client/public/locales/en/common.json | 1 + .../pages/settings/extraFieldsSettings.tsx | 136 ++++++++++++++++-- client_v2/locales/en/common.json | 1 + .../settings/ExtraFieldsManager.svelte | 63 +++++++- 4 files changed, 185 insertions(+), 16 deletions(-) diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index e6896f0f..956e9913 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -356,6 +356,7 @@ "boolean_true": "Yes", "boolean_false": "No", "choices_missing_error": "You cannot remove choices. The following choices are missing: {{choices}}", + "choices_reorder_hint": "Drag, or use the arrow keys, to reorder this choice.", "non_unique_key_error": "The key must be unique.", "key_not_changed": "Please change the key to something else.", "delete_confirm": "Delete field {{name}}?", diff --git a/client/src/pages/settings/extraFieldsSettings.tsx b/client/src/pages/settings/extraFieldsSettings.tsx index 76599ea7..61a15b4f 100644 --- a/client/src/pages/settings/extraFieldsSettings.tsx +++ b/client/src/pages/settings/extraFieldsSettings.tsx @@ -1,4 +1,4 @@ -import { PlusOutlined } from "@ant-design/icons"; +import { HolderOutlined, PlusOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; import { Button, @@ -12,6 +12,7 @@ import { Select, Space, Table, + Tag, message, } from "antd"; import { FormItemProps, Rule } from "antd/es/form"; @@ -20,7 +21,9 @@ import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; -import { useState } from "react"; +import { useRef, useState } from "react"; +import { DndProvider, useDrag, useDrop } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import { Trans } from "react-i18next"; import { useParams } from "react-router"; import { DateTimePicker } from "../../components/dateTimePicker"; @@ -55,6 +58,92 @@ const canEditField = (dataIndex: string, isNew: boolean) => { return dataIndex !== "key" && dataIndex !== "field_type" && dataIndex !== "multi_choice"; }; +const CHOICE_DRAG_TYPE = "extraFieldChoice"; + +interface ChoiceDragItem { + choice: string; +} + +interface DraggableChoiceTagProps { + label: React.ReactNode; + value: string; + closable: boolean; + onClose: (e?: React.MouseEvent) => void; + form: FormInstance; +} + +/** + * A tag in the choices editor that can be dragged, or arrow-keyed, to a new position. + * The order of the choices is the order they're presented in wherever the field is used, + * so it's the choices form value itself that gets reordered. + */ +const DraggableChoiceTag = ({ label, value, closable, onClose, form }: DraggableChoiceTagProps) => { + const t = useTranslate(); + const ref = useRef(null); + + // Choices are unique, so a choice's own value identifies its position. + const moveChoice = (choice: string, before: string) => { + const choices: string[] = [...(form.getFieldValue("choices") || [])]; + const from = choices.indexOf(choice); + const to = choices.indexOf(before); + if (from === -1 || to === -1 || from === to) { + return; + } + choices.splice(from, 1); + choices.splice(to, 0, choice); + form.setFieldValue("choices", choices); + }; + + const moveChoiceBy = (offset: number) => { + const choices: string[] = form.getFieldValue("choices") || []; + const to = choices.indexOf(value) + offset; + if (to < 0 || to >= choices.length) { + return; + } + moveChoice(value, choices[to]); + }; + + const [{ isDragging }, drag] = useDrag({ + type: CHOICE_DRAG_TYPE, + item: { choice: value }, + collect: (monitor) => ({ isDragging: monitor.isDragging() }), + }); + + const [, drop] = useDrop({ + accept: CHOICE_DRAG_TYPE, + hover: (item) => moveChoice(item.choice, value), + }); + + drag(drop(ref)); + + return ( + e.stopPropagation()} + style={{ display: "inline-flex", opacity: isDragging ? 0.4 : 1 }} + > + { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + moveChoiceBy(e.key === "ArrowLeft" ? -1 : 1); + } + }} + style={{ marginInlineEnd: 4, cursor: "grab", userSelect: "none" }} + > + + {label} + + + ); +}; + const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps }: EditableCellProps) => { const t = useTranslate(); @@ -221,7 +310,22 @@ const EditableCell = ({ record, editing, dataIndex, children, form, ...restProps } } else if (dataIndex === "choices") { if (fieldType === FieldType.choice) { - inputNode = ( + + )} + /> + ); rules.push({ required: true, min: 1, @@ -608,18 +712,20 @@ export function ExtraFieldsSettings() { }} />
- + +
+ {newField == null && ( diff --git a/client_v2/locales/en/common.json b/client_v2/locales/en/common.json index 5cbe8dcd..3bd3b0b6 100644 --- a/client_v2/locales/en/common.json +++ b/client_v2/locales/en/common.json @@ -417,6 +417,7 @@ "settings.extraFields.params.name": "Name", "settings.extraFields.params.order": "Order", "settings.extraFields.params.unit": "Unit", + "settings.extraFields.reorderChoice": "Drag, or use the arrow keys, to reorder this choice.", "settings.extraFields.saveField": "Save field", "settings.extraFields.tab": "Extra Fields", "settings.general.baseUrl.label": "Base URL", diff --git a/client_v2/src/lib/components/settings/ExtraFieldsManager.svelte b/client_v2/src/lib/components/settings/ExtraFieldsManager.svelte index a45ec4b8..c746a7eb 100644 --- a/client_v2/src/lib/components/settings/ExtraFieldsManager.svelte +++ b/client_v2/src/lib/components/settings/ExtraFieldsManager.svelte @@ -12,6 +12,7 @@ import * as m from '$lib/paraglide/messages'; import Plus from '@lucide/svelte/icons/plus'; import X from '@lucide/svelte/icons/x'; + import GripVertical from '@lucide/svelte/icons/grip-vertical'; const ENTITIES: { key: EntityType; label: () => string }[] = [ { key: 'spool', label: m['library.section.spool'] }, @@ -144,6 +145,38 @@ choices = choices.filter((x) => x !== c); } + // Choices may not be removed once saved, but their order is free to change — + // it is the order they are offered in wherever the field is used. + let draggedChoice = $state(null); + + function moveChoice(choice: string, before: string) { + const next = [...choices]; + const from = next.indexOf(choice); + const to = next.indexOf(before); + if (from === -1 || to === -1 || from === to) return; + next.splice(from, 1); + next.splice(to, 0, choice); + choices = next; + } + + function moveChoiceBy(choice: string, offset: number) { + const to = choices.indexOf(choice) + offset; + if (to < 0 || to >= choices.length) return; + moveChoice(choice, choices[to]); + } + + function onChoiceDragOver(e: DragEvent, target: string) { + if (draggedChoice === null) return; + e.preventDefault(); + moveChoice(draggedChoice, target); + } + + function onGripKeydown(e: KeyboardEvent, choice: string) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + e.preventDefault(); + moveChoiceBy(choice, e.key === 'ArrowLeft' ? -1 : 1); + } + async function save() { error = ''; if (!/^[a-z0-9_]+$/.test(key)) { @@ -303,7 +336,24 @@ {m['settings.extraFields.params.choices']()}
{#each choices as c (c)} - + onChoiceDragOver(e, c)} + > + {c} {#if isNew || !originalChoices.includes(c)}