From 30115bd16a7ac9d06b7d73b165ef4ff46904f811 Mon Sep 17 00:00:00 2001 From: akira69 Date: Fri, 27 Mar 2026 00:13:09 -0500 Subject: [PATCH] feat(printing): add filament template tag parity --- client/public/locales/en/common.json | 4 +- .../printing/filamentQrCodePrintingDialog.tsx | 342 ++++++++++++++---- 2 files changed, 270 insertions(+), 76 deletions(-) diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json index a481ecbc5..6b0b4c3ad 100644 --- a/client/public/locales/en/common.json +++ b/client/public/locales/en/common.json @@ -109,10 +109,8 @@ "printSpoolTitle": "Print Spool Labels", "title": "Label Printing", "template": "Label Template", - "templateHelp": "Use {} to insert values of the spool object as text. For example, {id} will be replaced with the spool id, or {filament.material} will be replaced with the material of the spool. if a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Lot Nr: {lot_nr}} will only show the label if the spool has a lot number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.", - "templateHelpFilament": "Use {} to insert values of the filament object as text. For example, {id} will be replaced with the filament id, or {vendor.name} will be replaced with the vendor name. If a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Article: {article_number}} will only show the label if a filament has an article number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.", "templateHelp": "Use {} to insert spool values and optional |modifiers for datetime or numeric formatting. Use Available Tags... to copy ready-to-paste values, or open Detailed template help for examples. Enclose text with double asterisks ** to make it bold.", - "templateHelpFilament": "Use {} to insert values of the filament object as text. For example, {id} will be replaced with the filament id, or {vendor.name} will be replaced with the vendor name. If a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Article: {article_number}} will only show the label if a filament has an article number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.", + "templateHelpFilament": "Use {} to insert filament values and optional |modifiers for datetime or numeric formatting. Use Available Tags... to copy ready-to-paste values, or open Detailed template help for examples. Enclose text with double asterisks ** to make it bold.", "templateTagButton": "Available Tags...", "templateHelpLink": "Detailed template help", "templateTagDialog": { diff --git a/client/src/pages/printing/filamentQrCodePrintingDialog.tsx b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx index 6826c87bb..08c8c307b 100644 --- a/client/src/pages/printing/filamentQrCodePrintingDialog.tsx +++ b/client/src/pages/printing/filamentQrCodePrintingDialog.tsx @@ -1,10 +1,12 @@ import { CopyOutlined, DeleteOutlined, PlusOutlined, SaveOutlined } from "@ant-design/icons"; import { useTranslate } from "@refinedev/core"; -import { Button, Flex, Form, Input, Modal, Popconfirm, Select, Table, Typography, message } from "antd"; +import { Button, Flex, Form, Grid, Input, Modal, Popconfirm, Select, Typography, message } from "antd"; +import { Table } from "antd"; import TextArea from "antd/es/input/TextArea"; import { useState } from "react"; +import { Link } from "react-router"; import { v4 as uuidv4 } from "uuid"; -import { EntityType, useGetFields } from "../../utils/queryFields"; +import { EntityType, FieldType, useGetFields } from "../../utils/queryFields"; import { useGetSetting } from "../../utils/querySettings"; import { useSavedState } from "../../utils/saveload"; import { useGetFilamentsByIds } from "../filaments/functions"; @@ -23,8 +25,73 @@ interface FilamentQRCodePrintingDialogProps { filamentIds: number[]; } +const DATETIME_MODIFIERS = [ + "|date", + "|time", + "|date_local", + "|time_local", + "|datetime_short", + "|datetime_short_local", +] as const; + +const NUMBER_MODIFIERS = ["|round", "|fixed1", "|fixed2"] as const; +const DATE_ORDER_SUFFIXES = [":ymd", ":mdy", ":dmy"] as const; + +type TemplateModifierType = "datetime" | "number"; + +interface TemplateTagRow { + tag: string; + modifierType?: TemplateModifierType; +} + +interface TemplateTagTableRow { + key: string; + tag: string; + modifiers: readonly string[]; + dateOrderSuffixes?: readonly string[]; +} + +function getEstimatedColumnWidth(values: string[], minWidth: number): number { + const longestValueLength = values.reduce((longest, value) => Math.max(longest, value.length), 0); + const averageMonospaceCharWidth = 6.8; + const cellPadding = 28; + return Math.max(minWidth, Math.ceil(longestValueLength * averageMonospaceCharWidth + cellPadding)); +} + +function getModifierTypeForField(fieldType: FieldType): TemplateModifierType | undefined { + if (fieldType === FieldType.datetime) { + return "datetime"; + } + + if (fieldType === FieldType.integer || fieldType === FieldType.float) { + return "number"; + } + + return undefined; +} + +async function copyTextToClipboard(value: string): Promise { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand("copy"); + document.body.removeChild(textarea); + return copied; + } +} + const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDialogProps) => { const t = useTranslate(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const baseUrlSetting = useGetSetting("base_url"); const baseUrlRoot = baseUrlSetting.data?.value !== undefined && JSON.parse(baseUrlSetting.data?.value) !== "" @@ -34,11 +101,7 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia const [useHTTPUrl, setUseHTTPUrl] = useSavedState("print-useHTTPUrl-filament", false); const itemQueries = useGetFilamentsByIds(filamentIds); - const items = itemQueries - .map((itemQuery) => { - return itemQuery.data ?? null; - }) - .filter((item) => item !== null) as IFilament[]; + const items = itemQueries.map((itemQuery) => itemQuery.data ?? null).filter((item) => item !== null) as IFilament[]; const [selectedPresetState, setSelectedPresetState] = useSavedState( "selectedPresetFilament", @@ -107,40 +170,31 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia }, }, }; + } else if (localOrRemotePresets.length === 0) { + const newSetting = addNewPreset(); + if (!newSetting) { + console.error("Error adding new setting, this should never happen"); + return; + } + localOrRemotePresets.push(newSetting); + curPreset = newSetting; + } else if (!selectedPresetState) { + curPreset = localOrRemotePresets[0]; + setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id); } else { - if (localOrRemotePresets.length === 0) { - const newSetting = addNewPreset(); - if (!newSetting) { - console.error("Error adding new setting, this should never happen"); - return; - } - localOrRemotePresets.push(newSetting); - curPreset = newSetting; + const foundSetting = localOrRemotePresets.find( + (settings) => settings.labelSettings.printSettings.id === selectedPresetState, + ); + if (foundSetting) { + curPreset = foundSetting; } else { - if (!selectedPresetState) { - curPreset = localOrRemotePresets[0]; - setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id); - } else { - const foundSetting = localOrRemotePresets.find( - (settings) => settings.labelSettings.printSettings.id === selectedPresetState, - ); - if (foundSetting) { - curPreset = foundSetting; - } else { - curPreset = { - labelSettings: { - printSettings: { - id: "TEMP", - name: t("printing.generic.newSetting"), - }, - }, - }; - } - } + curPreset = localOrRemotePresets[0]; + setSelectedPresetState(localOrRemotePresets[0].labelSettings.printSettings.id); } } const [templateHelpOpen, setTemplateHelpOpen] = useState(false); + const [hoveredCopyValue, setHoveredCopyValue] = useState(null); const template = curPreset.template ?? `**{vendor.name} - {name} @@ -155,20 +209,20 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia {comment} {vendor.comment}`; - const filamentTags = [ + const filamentTags: TemplateTagRow[] = [ { tag: "id" }, - { tag: "registered" }, + { tag: "registered", modifierType: "datetime" }, { tag: "name" }, { tag: "material" }, - { tag: "price" }, - { tag: "density" }, - { tag: "diameter" }, - { tag: "weight" }, - { tag: "spool_weight" }, + { tag: "price", modifierType: "number" }, + { tag: "density", modifierType: "number" }, + { tag: "diameter", modifierType: "number" }, + { tag: "weight", modifierType: "number" }, + { tag: "spool_weight", modifierType: "number" }, { tag: "article_number" }, { tag: "comment" }, - { tag: "settings_extruder_temp" }, - { tag: "settings_bed_temp" }, + { tag: "settings_extruder_temp", modifierType: "number" }, + { tag: "settings_bed_temp", modifierType: "number" }, { tag: "color_hex" }, { tag: "multi_color_hexes" }, { tag: "multi_color_direction" }, @@ -177,25 +231,78 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia const filamentFields = useGetFields(EntityType.filament); if (filamentFields.data !== undefined) { filamentFields.data.forEach((field) => { - filamentTags.push({ tag: `extra.${field.key}` }); + filamentTags.push({ tag: `extra.${field.key}`, modifierType: getModifierTypeForField(field.field_type) }); }); } - const vendorTags = [ + + const vendorTags: TemplateTagRow[] = [ { tag: "vendor.id" }, - { tag: "vendor.registered" }, + { tag: "vendor.registered", modifierType: "datetime" }, { tag: "vendor.name" }, { tag: "vendor.comment" }, - { tag: "vendor.empty_spool_weight" }, + { tag: "vendor.empty_spool_weight", modifierType: "number" }, { tag: "vendor.external_id" }, ]; const vendorFields = useGetFields(EntityType.vendor); if (vendorFields.data !== undefined) { vendorFields.data.forEach((field) => { - vendorTags.push({ tag: `vendor.extra.${field.key}` }); + vendorTags.push({ tag: `vendor.extra.${field.key}`, modifierType: getModifierTypeForField(field.field_type) }); }); } - const templateTags = [...filamentTags, ...vendorTags]; + const templateTags: TemplateTagTableRow[] = [...filamentTags, ...vendorTags].map((tagRow) => ({ + key: tagRow.tag, + tag: tagRow.tag, + modifiers: + tagRow.modifierType === "datetime" + ? DATETIME_MODIFIERS + : tagRow.modifierType === "number" + ? NUMBER_MODIFIERS + : [], + dateOrderSuffixes: tagRow.modifierType === "datetime" ? DATE_ORDER_SUFFIXES : undefined, + })); + + const tagColumnWidth = getEstimatedColumnWidth( + templateTags.map((tagRow) => tagRow.tag), + 180, + ); + const modifierColumnWidth = getEstimatedColumnWidth( + templateTags.flatMap((tagRow) => { + if (tagRow.modifiers.length === 0) { + return [t("printing.qrcode.templateTagDialog.modifiers.none")]; + } + const samples = [tagRow.modifiers.join(", ")]; + if (tagRow.dateOrderSuffixes) { + samples.push( + `${t("printing.qrcode.templateTagDialog.modifiers.dateOrderPrefix")} ${tagRow.dateOrderSuffixes.join(", ")}`, + ); + } + return samples; + }), + 220, + ); + const tableScrollWidth = tagColumnWidth + modifierColumnWidth; + const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1366; + const desktopMaxDialogWidth = Math.floor(viewportWidth * 0.94); + const desktopDialogWidth = Math.min(desktopMaxDialogWidth, tableScrollWidth + 64); + const dialogWidth = isMobile ? "96vw" : desktopDialogWidth; + const contentWidthForTable = isMobile ? tableScrollWidth : desktopDialogWidth - 32; + const enableHorizontalTableScroll = isMobile || tableScrollWidth > contentWidthForTable; + + const closeTemplateHelp = () => { + setTemplateHelpOpen(false); + setHoveredCopyValue(null); + }; + + const handleCopyValue = async (value: string) => { + const copied = await copyTextToClipboard(value); + if (!copied) { + messageApi.error(t("printing.qrcode.templateTagDialog.copyError")); + return; + } + messageApi.success(t("printing.qrcode.templateTagDialog.copySuccess", { value })); + closeTemplateHelp(); + }; return ( <> @@ -303,38 +410,127 @@ const FilamentQRCodePrintingDialog = ({ filamentIds }: FilamentQRCodePrintingDia }} /> - setTemplateHelpOpen(false)}> + +
{t("printing.qrcode.templateTagDialog.title")}
+ + {t("printing.qrcode.templateTagDialog.copyHint")} + + + } + open={templateHelpOpen} + footer={null} + width={dialogWidth} + onCancel={closeTemplateHelp} + > ({ style: { whiteSpace: "nowrap" } }), + render: (tag: string) => ( + setHoveredCopyValue(`{${tag}}`)} + onMouseLeave={() => setHoveredCopyValue(null)} + onClick={() => void handleCopyValue(`{${tag}}`)} + > + {tag} + + ), + }, + { + title: t("printing.qrcode.templateTagDialog.columns.modifiers"), + dataIndex: "modifiers", + width: modifierColumnWidth, + onCell: () => ({ style: { whiteSpace: "nowrap" } }), + render: (modifiers: readonly string[], row: TemplateTagTableRow) => + modifiers.length > 0 ? ( +
+ + {modifiers.map((modifier) => { + const copyValue = `{${row.tag}${modifier}}`; + return ( + setHoveredCopyValue(copyValue)} + onMouseLeave={() => setHoveredCopyValue(null)} + onClick={() => void handleCopyValue(copyValue)} + > + {modifier} + + ); + })} + + {row.dateOrderSuffixes && ( + + {t("printing.qrcode.templateTagDialog.modifiers.dateOrderPrefix")}{" "} + {row.dateOrderSuffixes.join(", ")} + + )} +
+ ) : ( + + {t("printing.qrcode.templateTagDialog.modifiers.none")} + + ), + }, + ]} dataSource={templateTags} /> + + {hoveredCopyValue + ? `${t("printing.qrcode.templateTagDialog.hoverCopyPrefix")} ` + : t("printing.qrcode.templateTagDialog.hoverHint")} + {hoveredCopyValue && ( + <> + + {hoveredCopyValue} + {" "} + {t("printing.qrcode.templateTagDialog.hoverCopySuffix")} + + )} + - - {t("printing.qrcode.templateHelpFilament")}{" "} - - +
+ + {t("printing.qrcode.templateHelpFilament")} + + + + + + {t("printing.qrcode.templateHelpLink")} + + + +
} extraButtons={ - <> - - + } />