From eff83e745e1d73045c0ffcdd6fa02aa6fa180f36 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2026 13:42:11 +0000 Subject: [PATCH 01/80] Install packages for blocknote --- src/frontend/package.json | 9 + src/frontend/yarn.lock | 398 +++++++++++++++++++++++++++++++++++++- 2 files changed, 402 insertions(+), 5 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index dd80683ac50c..d74629a5a3ca 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -40,6 +40,9 @@ "compile": "lingui compile --typescript" }, "dependencies": { + "@blocknote/core": "^0.51.0", + "@blocknote/mantine": "^0.51.0", + "@blocknote/react": "^0.51.0", "@codemirror/autocomplete": "^6.20.1", "@codemirror/lang-liquid": "^6.3.2", "@codemirror/language": "^6.12.2", @@ -70,6 +73,7 @@ "@mantine/modals": "^9.2.1", "@mantine/notifications": "^9.2.1", "@mantine/spotlight": "^9.2.1", + "@mantine/utils": "^6.0.22", "@mantine/vanilla-extract": "^9.2.1", "@messageformat/date-skeleton": "^1.1.0", "@sentry/react": "^10.43.0", @@ -138,6 +142,11 @@ "vite-plugin-externals": "^0.6.2", "vite-plugin-istanbul": "^8.0.0" }, + "overrides": { + "@blocknote/core": { + "prosemirror-model": ">=1.25.4" + } + }, "resolutions": { "undici": "^6.24.0", "vite": "^6.4.2" diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index ba2887a4753d..6d421af0e8b2 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -347,6 +347,68 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@blocknote/core@0.51.0", "@blocknote/core@^0.51.0": + version "0.51.0" + resolved "https://registry.yarnpkg.com/@blocknote/core/-/core-0.51.0.tgz#d08007a91b9a7e319a3894023e073c1f0e3f08bd" + integrity sha512-QQIBhHD5iHNIlvNdOM7JpyMyjmpK1pzAyCcM/Uj5o/fkopGRGTlWzv34A38TWuNNG2nWaL2ksM/zmtgneH2LGg== + dependencies: + "@emoji-mart/data" "^1.2.1" + "@handlewithcare/prosemirror-inputrules" "^0.1.4" + "@shikijs/types" "^4" + "@tanstack/store" "^0.7.7" + "@tiptap/core" "^3.13.0" + "@tiptap/extension-bold" "^3.13.0" + "@tiptap/extension-code" "^3.13.0" + "@tiptap/extension-horizontal-rule" "^3.13.0" + "@tiptap/extension-italic" "^3.13.0" + "@tiptap/extension-paragraph" "^3.13.0" + "@tiptap/extension-strike" "^3.13.0" + "@tiptap/extension-text" "^3.13.0" + "@tiptap/extension-underline" "^3.13.0" + "@tiptap/extensions" "^3.13.0" + "@tiptap/pm" "^3.13.0" + emoji-mart "^5.6.0" + fast-deep-equal "^3.1.3" + lib0 "^0.2.99" + prosemirror-highlight "^0.15.1" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-tables "^1.8.3" + prosemirror-transform "^1.11.0" + prosemirror-view "^1.41.4" + y-prosemirror "^1.3.7" + y-protocols "^1.0.6" + yjs "^13.6.27" + +"@blocknote/mantine@^0.51.0": + version "0.51.0" + resolved "https://registry.yarnpkg.com/@blocknote/mantine/-/mantine-0.51.0.tgz#c2e199d496b663f7dff084ed36c221636c26fece" + integrity sha512-aIIpwOa6fudnOoMw/1aocAxCyHn6ef2yWXCxmXjpNyNFtwZJlEhV945FwcIZ+Sqr+e21nf6aK0QpXEeSvmW5nA== + dependencies: + "@blocknote/core" "0.51.0" + "@blocknote/react" "0.51.0" + react-icons "^5.5.0" + +"@blocknote/react@0.51.0", "@blocknote/react@^0.51.0": + version "0.51.0" + resolved "https://registry.yarnpkg.com/@blocknote/react/-/react-0.51.0.tgz#e03cddb10ea3b885fa043bb3d14e643a6d5a2011" + integrity sha512-mkvv/jkc0xrn3C3ozM/+nm1JINbKhb4rykPAzxrq5k244J/OQr034kL8D8sUqq/XLvy0juwLqANn4HC8WkxUtw== + dependencies: + "@blocknote/core" "0.51.0" + "@emoji-mart/data" "^1.2.1" + "@floating-ui/react" "^0.27.18" + "@floating-ui/utils" "^0.2.10" + "@tanstack/react-store" "0.7.7" + "@tiptap/core" "^3.13.0" + "@tiptap/pm" "^3.13.0" + "@tiptap/react" "^3.13.0" + "@types/use-sync-external-store" "1.5.0" + emoji-mart "^5.6.0" + fast-deep-equal "^3.1.3" + lodash.merge "^4.6.2" + react-icons "^5.5.0" + use-sync-external-store "1.6.0" + "@codecov/bundler-plugin-core@^1.9.1": version "1.9.1" resolved "https://registry.yarnpkg.com/@codecov/bundler-plugin-core/-/bundler-plugin-core-1.9.1.tgz#d964f4937d528b6118e8c0918df2a7daef4c8f29" @@ -497,6 +559,11 @@ style-mod "^4.1.0" w3c-keyname "^2.2.4" +"@emoji-mart/data@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c" + integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw== + "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0" @@ -859,7 +926,7 @@ dependencies: "@floating-ui/utils" "^0.2.11" -"@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.7.6": +"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.7.6": version "1.7.6" resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.6.tgz#f915bba5abbb177e1f227cacee1b4d0634b187bf" integrity sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ== @@ -874,7 +941,7 @@ dependencies: "@floating-ui/dom" "^1.7.6" -"@floating-ui/react@^0.27.19": +"@floating-ui/react@^0.27.18", "@floating-ui/react@^0.27.19": version "0.27.19" resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.19.tgz#d8d5d895b7cb97dac370bfbf55f3e630878fdf1f" integrity sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog== @@ -883,7 +950,7 @@ "@floating-ui/utils" "^0.2.11" tabbable "^6.0.0" -"@floating-ui/utils@^0.2.11": +"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.11": version "0.2.11" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f" integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg== @@ -946,6 +1013,14 @@ resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-2.1.1.tgz#648e63fc28050917d2882cc2b27817a88cb420fc" integrity sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ== +"@handlewithcare/prosemirror-inputrules@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@handlewithcare/prosemirror-inputrules/-/prosemirror-inputrules-0.1.4.tgz#d3cd2a9031b475f1e7ca14be2ca2d340d9f6fa36" + integrity sha512-GMqlBeG2MKM+tXEFd2N+wIv5z4VvJTg8JtfJUrdjvFq2W6v+AW8oTgiWyFw8L3iEQwvtQcVJxU873iB0LXUNNw== + dependencies: + prosemirror-history "^1.4.1" + prosemirror-transform "^1.0.0" + "@isaacs/cliui@^9.0.0": version "9.0.0" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3" @@ -1243,6 +1318,11 @@ resolved "https://registry.yarnpkg.com/@mantine/store/-/store-9.2.1.tgz#27a3548c4cc1567baa2613490d7dcb9300b391ba" integrity sha512-sBTHt9ilfSZAeXQlqFkm8nRm22RunhevxuOUtdSwS9HhuMuS8T27dRRgbdKH2oEFUbaccdQSy5bHbmGbEgVO8w== +"@mantine/utils@^6.0.22": + version "6.0.22" + resolved "https://registry.yarnpkg.com/@mantine/utils/-/utils-6.0.22.tgz#7eace697084e2bc5a831eb0fd7cbbc04cc1b0354" + integrity sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ== + "@mantine/vanilla-extract@^9.2.1": version "9.2.1" resolved "https://registry.yarnpkg.com/@mantine/vanilla-extract/-/vanilla-extract-9.2.1.tgz#ba86032eaaf278153d3dd110c8e4327457cc0606" @@ -1794,6 +1874,19 @@ "@sentry/browser" "10.46.0" "@sentry/core" "10.46.0" +"@shikijs/types@^4": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@shikijs/types/-/types-4.0.2.tgz#75180a19acf124b37f48b53a9e6373de2e2e4f28" + integrity sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg== + dependencies: + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.4" + +"@shikijs/vscode-textmate@^10.0.2": + version "10.0.2" + resolved "https://registry.yarnpkg.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224" + integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg== + "@sinclair/typebox@^0.27.8": version "0.27.10" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.10.tgz#beefe675f1853f73676aecc915b2bd2ac98c4fc6" @@ -1833,6 +1926,111 @@ dependencies: "@tanstack/query-core" "5.95.2" +"@tanstack/react-store@0.7.7": + version "0.7.7" + resolved "https://registry.yarnpkg.com/@tanstack/react-store/-/react-store-0.7.7.tgz#6c51761956a1b3713ae0a8dbc008ea181f82df1f" + integrity sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg== + dependencies: + "@tanstack/store" "0.7.7" + use-sync-external-store "^1.5.0" + +"@tanstack/store@0.7.7", "@tanstack/store@^0.7.7": + version "0.7.7" + resolved "https://registry.yarnpkg.com/@tanstack/store/-/store-0.7.7.tgz#2c8b1d8c094f3614ae4e0483253239abd0e14488" + integrity sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ== + +"@tiptap/core@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.23.4.tgz#f0d6427375d41e69127664d50bd48c91909b63d9" + integrity sha512-ni2LWE52bVeSt3L2HVBSmbBw+elc32ATej9C68EyKzN/8vR5ILxFn6RCdDTKm4asmwZyq2jys12dKmBdWMr9QA== + +"@tiptap/extension-bold@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.23.4.tgz#c39a61a2afb78fcad5302902510fc823844fd668" + integrity sha512-3L9tnZ12i+98u5df2nV2zGu/sc3rhI87E3ocn1YYAO8PJUAgZnMwdet8JawCrS1uut5sRKlxo3SXEmdNfRVm/w== + +"@tiptap/extension-bubble-menu@^3.23.4": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.4.tgz#9e2d5d892d6d4917e44c5ebfe87de68e44878ead" + integrity sha512-EPTpL/IFp/aTGZErBq/Mc3dKznj6G/qNEkVYWjueOn1oKApyT0P6WVHGvu/vpMdErhzmoGDuFPPGVS6T8Upx2Q== + dependencies: + "@floating-ui/dom" "^1.0.0" + +"@tiptap/extension-code@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.23.4.tgz#8afbc0a1dc132d1d05890cc652fc897a8f94d9e0" + integrity sha512-C0TeRipMycUEBnV+Mzx6eLp/yZb6Vi/waP3Tkb0lO5/ikg7LWLB7AlmMunjIXEUcR/pJHID/aEh5PfJFpysUDg== + +"@tiptap/extension-floating-menu@^3.23.4": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.4.tgz#ae6dfc00182811decc43402106c29d704179b29b" + integrity sha512-eAc72bKM26yIPx0jsU8qdjE71vFNVu5R9jGbdItBMFc0SPLS4qY8g+8RJ+iWoLwbcSEpgooLS9D9sLfdAU+Tvw== + +"@tiptap/extension-horizontal-rule@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.4.tgz#c9a4e9bd2c881f04a0eae037c3f0b1b696fa76e6" + integrity sha512-EA4kK8ywZ4dQNOdxeZbplmDDs5T5LjMgHpqxRwukj9wwKiILOK5E3fcKm1fCKh9Q02w96jax6YVccHwmgJP3sQ== + +"@tiptap/extension-italic@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.23.4.tgz#70ff0badf85503bf5d5f887fdbf5c03d5802a8d0" + integrity sha512-jUAHi+HZlg47BzgVIy6y/UH5vev7vPQ95jddhB5K3hC122kvWFMXlken7UOnqzbxNcHs2+4Oi/ZJirYMpT4P5w== + +"@tiptap/extension-paragraph@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.23.4.tgz#f27e6f66e663c9e5f3a660921e04a303b465030d" + integrity sha512-KbhXjCFzWphvFn5VU7E4dtmYDm+bssI1i0+CnXPWCXkjdaaX88ck68Xp1fKz8/bbI/CqlgiNDO/3TvqgtZ6woQ== + +"@tiptap/extension-strike@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.23.4.tgz#9db6b0982e3f550e90e729e03e898cef23b711a3" + integrity sha512-Vnq5vW801zPbu1LtKeA5k4R241jY+hRjXeijYwIPxy15KzIiipY12518HiCf6/8kkRbMxgOfdYg9X4BRV3HV3g== + +"@tiptap/extension-text@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.23.4.tgz#1ab44d78d34c746f97100ff34cc28e0914231eaa" + integrity sha512-q9kxver/MR18p66aWZHSPycnr9hcBFyVGeGj8gf+BQCzn5hpvtSYTfLvk1nq8GFhygdQ9/e3f7B5ovrm/jnpvw== + +"@tiptap/extension-underline@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.23.4.tgz#f85d5b047d9f9f167c59099cb8244a1bca360d79" + integrity sha512-F1ocPT10LV+seky25R1TMCRdc/Iof99jLcDSYDGr6mNEDY4ct2RvOeSM8aDdYq6CkH+vXt3i3JDeRwV23KzswQ== + +"@tiptap/extensions@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.23.4.tgz#f57253d162cecbdf3ae835dfcc1422af926ec6ac" + integrity sha512-SlGPXauW8iKWG7wwuwC/0y/smLImp0h6GBIGgNnTBgIP/ThXQnjLMSZH0mW/REO87dQxkku01V3ARRywi+juhg== + +"@tiptap/pm@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.23.4.tgz#91eca287eb5a4e29bc155a702d10bcf9b56a117e" + integrity sha512-+C5ngcoza47n3MjtjVBqBEBICPC0McdbwzJ+X6SSCviCLoqnSYanv5mIX9HWG0Q4fJ4BkdNM3VibZUxQaTbKyQ== + dependencies: + prosemirror-changeset "^2.3.0" + prosemirror-commands "^1.6.2" + prosemirror-dropcursor "^1.8.1" + prosemirror-gapcursor "^1.3.2" + prosemirror-history "^1.4.1" + prosemirror-keymap "^1.2.2" + prosemirror-model "^1.24.1" + prosemirror-schema-list "^1.5.0" + prosemirror-state "^1.4.3" + prosemirror-tables "^1.6.4" + prosemirror-transform "^1.10.2" + prosemirror-view "^1.38.1" + +"@tiptap/react@^3.13.0": + version "3.23.4" + resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.23.4.tgz#df7cf9d8a2d42a3b3eb9bc73e5569fb7ddf8fe3d" + integrity sha512-mb5aIY9PuLreOVLExqs+8BAI20I/8+jCUBfEIqheuFY2GRRuBiwczejSlYuADfVDBbPVN5uPw4UMADCaH5wueQ== + dependencies: + "@types/use-sync-external-store" "^0.0.6" + fast-equals "^5.3.3" + use-sync-external-store "^1.4.0" + optionalDependencies: + "@tiptap/extension-bubble-menu" "^3.23.4" + "@tiptap/extension-floating-menu" "^3.23.4" + "@types/argparse@1.0.38": version "1.0.38" resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" @@ -1934,6 +2132,13 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/hast@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + "@types/history@^4.7.11": version "4.7.11" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" @@ -2047,6 +2252,16 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== +"@types/unist@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/use-sync-external-store@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#222c28a98eb8f4f8a72c1a7e9fe6d8946eca6383" + integrity sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw== + "@types/use-sync-external-store@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" @@ -2943,6 +3158,11 @@ embla-carousel@8.6.0, embla-carousel@^8.5.2: resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.6.0.tgz#abcedff2bff36992ea8ac27cd30080ca5b6a3f58" integrity sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA== +emoji-mart@^5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023" + integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -3138,6 +3358,11 @@ fast-equals@^4.0.3: resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-4.0.3.tgz#72884cc805ec3c6679b99875f6b7654f39f0e8c7" integrity sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg== +fast-equals@^5.3.3: + version "5.4.0" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.4.0.tgz#b60073b8764f27029598447f05773c7534ba7f1e" + integrity sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw== + fast-uri@^3.0.1: version "3.1.2" resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" @@ -3532,6 +3757,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +isomorphic.js@^0.2.4: + version "0.2.5" + resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88" + integrity sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw== + istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" @@ -3701,6 +3931,13 @@ leven@^3.1.0: resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== +lib0@^0.2.109, lib0@^0.2.85, lib0@^0.2.99: + version "0.2.117" + resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.117.tgz#6c3f926475d28904af05b590703cbbbc29475716" + integrity sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw== + dependencies: + isomorphic.js "^0.2.4" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -3734,6 +3971,11 @@ lodash.flattendeep@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" integrity sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ== +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + lodash@^4.17.21: version "4.18.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" @@ -4033,6 +4275,11 @@ ora@^5.1.0: strip-ansi "^6.0.0" wcwidth "^1.0.1" +orderedmap@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2" + integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== + otpauth@^9.4.1: version "9.5.0" resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-9.5.0.tgz#17a19eee2e8598cc8d16242ec640fe40b6cf3f52" @@ -4285,6 +4532,116 @@ prop-types@15.x, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +prosemirror-changeset@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz#685091245bf3299cd1cae2b8983cf9b0342e6b39" + integrity sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw== + dependencies: + prosemirror-transform "^1.0.0" + +prosemirror-commands@^1.6.2: + version "1.7.1" + resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz#d101fef85618b1be53d5b99ea17bee5600781b38" + integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.10.2" + +prosemirror-dropcursor@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz#2ed30c4796109ddeb1cf7282372b3850528b7228" + integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw== + dependencies: + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + prosemirror-view "^1.1.0" + +prosemirror-gapcursor@^1.3.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz#da33c905fece147df577342c06f4929b25d365ee" + integrity sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw== + dependencies: + prosemirror-keymap "^1.0.0" + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-view "^1.0.0" + +prosemirror-highlight@^0.15.1: + version "0.15.1" + resolved "https://registry.yarnpkg.com/prosemirror-highlight/-/prosemirror-highlight-0.15.1.tgz#27523155005b4652fea84c7a9a2f60b03d10bf57" + integrity sha512-KcJUGNgqLED+eK/cisNtY3M+eDNLkZyWCdyi7B3RoW3rKHnhkKawnJAcr9p1F/e3q+oDB5Y5OiIrC11bxP7tFA== + +prosemirror-history@^1.4.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz#ee21fc5de85a1473e3e3752015ffd6d649a06859" + integrity sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg== + dependencies: + prosemirror-state "^1.2.2" + prosemirror-transform "^1.0.0" + prosemirror-view "^1.31.0" + rope-sequence "^1.3.0" + +prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2, prosemirror-keymap@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz#c0f6ab95f75c0b82c97e44eb6aaf29cbfc150472" + integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw== + dependencies: + prosemirror-state "^1.0.0" + w3c-keyname "^2.2.0" + +prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.4: + version "1.25.7" + resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.7.tgz#0ef150e8098a9037703b48b623d668218f355b9c" + integrity sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug== + dependencies: + orderedmap "^2.0.0" + +prosemirror-schema-list@^1.5.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz#5869c8f749e8745c394548bb11820b0feb1e32f5" + integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.7.3" + +prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3, prosemirror-state@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz#72b5e926f9e92dcee12b62a05fcc8a2de3bf5b39" + integrity sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-transform "^1.0.0" + prosemirror-view "^1.27.0" + +prosemirror-tables@^1.6.4, prosemirror-tables@^1.8.3: + version "1.8.5" + resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz#104427012e5a5da1d2a38c122efee8d66bdd5104" + integrity sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw== + dependencies: + prosemirror-keymap "^1.2.3" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-transform "^1.10.5" + prosemirror-view "^1.41.4" + +prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.11.0, prosemirror-transform@^1.7.3: + version "1.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz#0239288d0e98d91e6af3dd269a8968466be406d7" + integrity sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w== + dependencies: + prosemirror-model "^1.21.0" + +prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4: + version "1.41.8" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.8.tgz#bfb48d9dc328f1aa2a0eea1600b0828818be03f1" + integrity sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA== + dependencies: + prosemirror-model "^1.20.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + proxy-from-env@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" @@ -4352,6 +4709,11 @@ react-hook-form@^7.62.0: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.72.0.tgz#995a655b894249fd8798f36383e43f55ed66ae25" integrity sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw== +react-icons@^5.5.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.6.0.tgz#27bcc4acbc836e762548d76041cf9b9fef4e3837" + integrity sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -4677,6 +5039,11 @@ rollup@^4.59.0: "@rollup/rollup-win32-x64-msvc" "4.60.0" fsevents "~2.3.2" +rope-sequence@^1.3.0: + version "1.3.4" + resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425" + integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ== + safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -5077,7 +5444,7 @@ use-sidecar@^1.1.3: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0: +use-sync-external-store@1.6.0, use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.5.0: version "1.6.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== @@ -5199,7 +5566,7 @@ vscode-uri@^3.0.8: resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== -w3c-keyname@^2.2.4: +w3c-keyname@^2.2.0, w3c-keyname@^2.2.4: version "2.2.8" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== @@ -5252,6 +5619,20 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" +y-prosemirror@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/y-prosemirror/-/y-prosemirror-1.3.7.tgz#f88e553da4ea33278b114cf0b6a0ea978b154e84" + integrity sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg== + dependencies: + lib0 "^0.2.109" + +y-protocols@^1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.7.tgz#6631c492e75b78b3a61353a60067e6f8a4c38d5f" + integrity sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw== + dependencies: + lib0 "^0.2.85" + y18n@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" @@ -5297,6 +5678,13 @@ yargs@^15.0.2, yargs@^15.3.1: y18n "^4.0.0" yargs-parser "^18.1.2" +yjs@^13.6.27: + version "13.6.30" + resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.30.tgz#cd7ee5399431fcd35812d7a43694b57bfa2f005d" + integrity sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ== + dependencies: + lib0 "^0.2.99" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" From 01bc34e08ab8389b167f966f35925e36d93ac659 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2026 13:46:47 +0000 Subject: [PATCH 02/80] Add new notes editor using blocknote --- .../src/components/editors/NotesEditor.tsx | 36 ++++++++++++++++++- .../src/components/panels/NotesPanel.tsx | 4 +-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 7f1920ac9a62..826b62a16d52 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -13,6 +13,40 @@ import type { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; import { useApi } from '../../contexts/ApiContext'; +import '@blocknote/core/fonts/inter.css'; +import { BlockNoteView } from '@blocknote/mantine'; +import '@blocknote/mantine/style.css'; +import { useCreateBlockNote } from '@blocknote/react'; +import { Paper } from '@mantine/core'; +import { useUserState } from '../../states/UserState'; + +export default function NotesEditor({ + modelType, + modelId, + editable, + setDirtyCallback +}: Readonly<{ + modelType: ModelType; + modelId: number; + editable?: boolean; + setDirtyCallback?: (dirty: boolean) => void; +}>) { + const user = useUserState(); + + const canEdit = useMemo( + () => user.hasChangePermission(modelType), + [user, modelType] + ); + + const editor = useCreateBlockNote(); + + return ( + + + + ); +} + /* * A text editor component for editing notes against a model type and instance. * Uses the react-simple-mde editor: https://github.com/RIP21/react-simplemde-editor @@ -22,7 +56,7 @@ import { useApi } from '../../contexts/ApiContext'; * - Allow image resizing in the future (requires back-end validation changes)) * - Allow user to configure the editor toolbar (i.e. hide some buttons if they don't want them) */ -export default function NotesEditor({ +export function OldNotesEditor({ modelType, modelId, editable, diff --git a/src/frontend/src/components/panels/NotesPanel.tsx b/src/frontend/src/components/panels/NotesPanel.tsx index 66696384bb24..fcfb7501b368 100644 --- a/src/frontend/src/components/panels/NotesPanel.tsx +++ b/src/frontend/src/components/panels/NotesPanel.tsx @@ -4,10 +4,10 @@ import { IconNotes } from '@tabler/icons-react'; import type { ModelType } from '@lib/enums/ModelType'; import type { PanelType } from '@lib/types/Panel'; -import { lazy } from 'react'; import { useUserState } from '../../states/UserState'; +import NotesEditor from '../editors/NotesEditor'; -const NotesEditor = lazy(() => import('../editors/NotesEditor')); +// const NotesEditor = lazy(() => import('../editors/NotesEditor')); export default function NotesPanel({ model_type, From 71d51ff7b47429ffca60be403d867c6836c98c74 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2026 13:56:34 +0000 Subject: [PATCH 03/80] Add new generic "notes" model --- src/backend/InvenTree/InvenTree/models.py | 82 +++++++++++++++- .../InvenTree/common/migrations/0042_note.py | 95 +++++++++++++++++++ src/backend/InvenTree/common/models.py | 82 ++++++++++++++++ 3 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 src/backend/InvenTree/common/migrations/0042_note.py diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index b6f5692c0a0a..efd334464221 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -632,14 +632,14 @@ def parameters_map(self) -> dict: return params - def check_parameter_delete(self, parameter): + def check_parameter_delete(self, parameter) -> bool: """Run a check to determine if the provided parameter can be deleted. The default implementation always returns True, but this can be overridden in the implementing class. """ return True - def check_parameter_save(self, parameter): + def check_parameter_save(self, parameter) -> bool: """Run a check to determine if the provided parameter can be saved. The default implementation always returns True, but this can be overridden in the implementing class. @@ -647,6 +647,84 @@ def check_parameter_save(self, parameter): return True +class InvenTreeNoteMixin(InvenTreePermissionCheckMixin): + """Provides an abstracted class for managing notes. + + Links the implementing model to the common.models.Note table, + and provides multiple accessor / helper methods. + """ + + class Meta: + """Metaclass options for InvenTreeNoteMixin.""" + + abstract = True + + # Define a reverse relation to the Note model + notes_list = GenericRelation( + 'common.Note', content_type_field='model_type', object_id_field='model_id' + ) + + @property + def notes(self) -> QuerySet: + """Return a queryset containing all notes for this model.""" + # Check the query cache for pre-fetched parameters + if cache := getattr(self, '_prefetched_objects_cache', None): + if 'notes_list' in cache: + return cache['notes_list'] + + return self.notes_list.all() + + def delete(self, *args, **kwargs): + """Handle the deletion of a model instance. + + Before deleting the model instance, delete any associated notes. + """ + self.notes_list.all().delete() + super().delete(*args, **kwargs) + + @transaction.atomic + def copy_notes_from(self, other, **kwargs): + """Copy all notes from another model instance. + + Arguments: + other: The other model instance to copy notes from + **kwargs: Additional keyword arguments to pass to the Note constructor + """ + import common.models + + notes = [] + + content_type = ContentType.objects.get_for_model(self.__class__) + + for note in other.notes.all(): + note.pk = None + note.model_id = self.pk + note.model_type = content_type + + notes.append(note) + + if len(notes) > 0: + common.models.Note.objects.bulk_create(notes, batch_size=250) + + def get_note(self, title: str): + """Return a Note instance for the given note title.""" + return self.notes_list.filter(title=title).first() + + def check_note_delete(self, note) -> bool: + """Run a check to determine if the provided note can be deleted. + + The default implementation always returns True, but this can be overridden in the implementing class. + """ + return True + + def check_note_save(self, note) -> bool: + """Run a check to determine if the provided note can be saved. + + The default implementation always returns True, but this can be overridden in the implementing class. + """ + return True + + class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin): """Provides an abstracted class for managing file attachments. diff --git a/src/backend/InvenTree/common/migrations/0042_note.py b/src/backend/InvenTree/common/migrations/0042_note.py new file mode 100644 index 000000000000..02d2737ca9c1 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0042_note.py @@ -0,0 +1,95 @@ +# Generated by Django 5.2.14 on 2026-05-18 13:55 + +import InvenTree.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0041_auto_20251203_1244"), + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Note", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "updated", + models.DateTimeField( + blank=True, + default=None, + help_text="Timestamp of last update", + null=True, + verbose_name="Updated", + ), + ), + ("model_id", models.PositiveIntegerField()), + ( + "title", + models.CharField( + help_text="Note title", max_length=100, verbose_name="Title" + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Optional description field", + max_length=250, + verbose_name="Description", + ), + ), + ( + "model_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + help_text="User who last updated this object", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + verbose_name="Update By", + ), + ), + ], + options={ + "verbose_name": "Note", + "verbose_name_plural": "Notes", + }, + bases=( + InvenTree.models.ContentTypeMixin, + InvenTree.models.PluginValidationMixin, + models.Model, + ), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 72a78665683a..e0c6f089878a 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2906,6 +2906,88 @@ def description(self): return self.template.description +class Note( + UpdatedUserMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel +): + """Class which represents a note assigned to a particular model instance. + + Attributes: + model_type: The type of model to which this note is linked + model_id: The ID of the model to which this note is linked + user: The user who created the note + title: The title of the note + description: A description of the note (optional) + content: The content of the note + created: Date/time that the note was created + """ + + class Meta: + """Meta options for Note model.""" + + verbose_name = _('Note') + verbose_name_plural = _('Notes') + + @staticmethod + def get_api_url() -> str: + """Return the API URL associated with the Parameter model.""" + return reverse('api-note-list') + + def save(self, *args, **kwargs): + """Perform custom save checks before saving a Note instance.""" + self.check_save() + super().save(*args, **kwargs) + + def delete(self): + """Perform custom delete checks before deleting a Parameter instance.""" + self.check_delete() + super().delete() + + def clean(self): + """Clean / validate the note before saving to the database.""" + # TODO: Implement this + + def check_save(self): + """Check if this note can be saved.""" + from InvenTree.models import InvenTreeNoteMixin + + try: + instance = self.content_object + except InvenTree.models.InvenTreeModel.DoesNotExist: + return + + if instance and isinstance(instance, InvenTreeNoteMixin): + instance.check_note_save(self) + + def check_delete(self): + """Check if this note can be deleted.""" + from InvenTree.models import InvenTreeNoteMixin + + try: + instance = self.content_object + except InvenTree.models.InvenTreeModel.DoesNotExist: + return + + if instance and isinstance(instance, InvenTreeNoteMixin): + instance.check_note_delete(self) + + model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + + model_id = models.PositiveIntegerField() + + content_object = GenericForeignKey('model_type', 'model_id') + + title = models.CharField( + max_length=100, verbose_name=_('Title'), help_text=_('Note title') + ) + + description = models.CharField( + max_length=250, + blank=True, + verbose_name=_('Description'), + help_text=_('Optional description field'), + ) + + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results.""" From 176733d76229cb892f04dc1052a93a8313676483 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2026 14:07:58 +0000 Subject: [PATCH 04/80] Add basic tab group for switching notes --- .../src/components/editors/NotesEditor.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 826b62a16d52..96581208193b 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -17,7 +17,7 @@ import '@blocknote/core/fonts/inter.css'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; -import { Paper } from '@mantine/core'; +import { Box, Flex, Paper, Tabs } from '@mantine/core'; import { useUserState } from '../../states/UserState'; export default function NotesEditor({ @@ -41,9 +41,23 @@ export default function NotesEditor({ const editor = useCreateBlockNote(); return ( - - - + + + + + + + + + Manufacturing + Washing + + + ); } From 1078475acbe49d7c869a124ab668ea9357cfd266 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 18 May 2026 14:17:20 +0000 Subject: [PATCH 05/80] API / serializer updates --- src/backend/InvenTree/common/api.py | 55 ++++++++++++++ .../InvenTree/common/migrations/0042_note.py | 8 +- src/backend/InvenTree/common/models.py | 4 + src/backend/InvenTree/common/serializers.py | 76 +++++++++++++++++++ src/backend/InvenTree/common/validators.py | 17 +++++ 5 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 05575f73a873..2ce6eff97552 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -819,6 +819,47 @@ def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) +class NoteFilter(FilterSet): + """Filterset class for the NoteList API endpoint.""" + + class Meta: + """Metaclass options for the filterset.""" + + model = common.models.Note + fields = ['model_type', 'model_id', 'updated_by'] + + model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type') + + def filter_model_type(self, queryset, name, value): + """Filter queryset to include only Parameters of the given model type.""" + return common.filters.filter_content_type( + queryset, 'model_type', value, allow_null=False + ) + + +class NoteMixin: + """Mixin class for the Note views.""" + + queryset = common.models.Note.objects.all() + serializer_class = common.serializers.NoteSerializer + permission_classes = [IsAuthenticatedOrReadScope] + + +class NoteList(NoteMixin, ListCreateAPI): + """List API endpoint for Note objects.""" + + filter_backends = SEARCH_ORDER_FILTER + filterset_class = NoteFilter + + ordering_fields = ['model_id', 'model_type', 'user', 'creation'] + search_fields = ['content', 'model_id', 'model_type', 'user__username'] + unique_create_fields = ['model_type', 'model_id'] + + +class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI): + """Detail API endpoint for Note objects.""" + + class ParameterTemplateFilter(FilterSet): """FilterSet class for the ParameterTemplateList API endpoint.""" @@ -1477,6 +1518,20 @@ def create(self, request, *args, **kwargs): path('', AttachmentList.as_view(), name='api-attachment-list'), ]), ), + # Notes + path( + 'note/', + include([ + path( + '/', + include([ + meta_path(common.models.Note), + path('', NoteDetail.as_view(), name='api-note-detail'), + ]), + ), + path('', NoteList.as_view(), name='api-note-list'), + ]), + ), # Parameters and templates path( 'parameter/', diff --git a/src/backend/InvenTree/common/migrations/0042_note.py b/src/backend/InvenTree/common/migrations/0042_note.py index 02d2737ca9c1..4d4dbea255e3 100644 --- a/src/backend/InvenTree/common/migrations/0042_note.py +++ b/src/backend/InvenTree/common/migrations/0042_note.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.14 on 2026-05-18 13:55 +# Generated by Django 5.2.14 on 2026-05-18 14:16 import InvenTree.models import django.db.models.deletion @@ -62,6 +62,12 @@ class Migration(migrations.Migration): verbose_name="Description", ), ), + ( + "content", + models.TextField( + blank=True, help_text="Note content", verbose_name="Content" + ), + ), ( "model_type", models.ForeignKey( diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index e0c6f089878a..332e7fedfd04 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2987,6 +2987,10 @@ def check_delete(self): help_text=_('Optional description field'), ) + content = models.TextField( + blank=True, verbose_name=_('Content'), help_text=_('Note content') + ) + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 025c28f6d077..328540452288 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -809,6 +809,82 @@ def save(self, **kwargs): return super().save(**kwargs) +class NoteSerializer(InvenTreeModelSerializer): + """Serializer for the Note model.""" + + class Meta: + """Meta options for NoteSerializer.""" + + model = common_models.Note + fields = [ + 'pk', + 'model_type', + 'model_id', + 'title', + 'description', + 'content', + 'updated', + 'updated_by', + ] + + read_only_fields = ['updated', 'updated_by'] + + def save(self, **kwargs): + """Save the Note instance.""" + from InvenTree.models import InvenTreeNoteMixin + from users.permissions import check_user_permission + + model_type = self.validated_data.get('model_type', None) + + if model_type is None and self.instance: + model_type = self.instance.model_type + + # Ensure that the user has permission to modify notes for the specified model + user = self.context.get('request').user + + target_model_class = model_type.model_class() + + if not issubclass(target_model_class, InvenTreeNoteMixin): + raise PermissionDenied(_('Invalid model type specified for note')) + + permission_error_msg = _( + 'User does not have permission to create or edit notes for this model' + ) + + if not check_user_permission(user, target_model_class, 'change'): + raise PermissionDenied(permission_error_msg) + + if not target_model_class.check_related_permission('change', user): + raise PermissionDenied(permission_error_msg) + + instance = super().save(**kwargs) + instance.updated_by = user + instance.save() + + return instance + + # Note: The choices are overridden at run-time on class initialization + model_type = ContentTypeField( + mixin_class=InvenTreeParameterMixin, + choices=common.validators.note_model_options, + label=_('Model Type'), + default='', + allow_null=False, + ) + + updated_by_detail = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={ + 'source': 'updated_by', + 'read_only': True, + 'allow_null': True, + 'many': False, + }, + default_include=True, + prefetch_fields=['updated_by'], + ) + + @register_importer() class ParameterTemplateSerializer( DataImportExportSerializerMixin, InvenTreeModelSerializer diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index 02c3805f9640..13107bcd3314 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -9,6 +9,23 @@ from common.settings import get_global_setting +def note_model_types(): + """Return a list of valid note model choices.""" + import InvenTree.models + + return list( + InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNotesMixin) + ) + + +def note_model_options(): + """Return a list of options for models which support notes.""" + return [ + (model.__name__.lower(), model._meta.verbose_name) + for model in note_model_types() + ] + + def parameter_model_types(): """Return a list of valid parameter model choices.""" import InvenTree.models From 88158610af9af53d3dad4e6bf6521f21a7069bf0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 20 May 2026 11:43:17 +0000 Subject: [PATCH 06/80] Add notes fetch --- src/frontend/lib/enums/ApiEndpoints.tsx | 1 + .../src/components/editors/NotesEditor.tsx | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index addb35378b34..a035175bf0d6 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -240,6 +240,7 @@ export enum ApiEndpoints { error_report_list = 'error-report/', project_code_list = 'project-code/', custom_unit_list = 'units/', + note_list = 'note/', notes_image_upload = 'notes-image-upload/', email_list = 'admin/email/', email_test = 'admin/email/test/', diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 96581208193b..9350da915082 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -31,11 +31,38 @@ export default function NotesEditor({ editable?: boolean; setDirtyCallback?: (dirty: boolean) => void; }>) { + const api = useApi(); const user = useUserState(); + const [selectedNote, setSelectedNote] = useState( + undefined + ); + + // Fetch the available notes for the given model type and ID + const notesQuery = useQuery({ + queryKey: ['notes', modelType, modelId], + queryFn: async () => { + return api + .get(apiUrl(ApiEndpoints.note_list), { + params: { + model_id: modelId, + model_type: modelType + } + }) + .then((response) => response.data ?? []); + }, + staleTime: 0, + refetchOnWindowFocus: false, + refetchOnMount: true, + enabled: !!modelId && !!modelType + }); + const canEdit = useMemo( - () => user.hasChangePermission(modelType), - [user, modelType] + () => + user.hasChangePermission(modelType) && + notesQuery.isFetched && + notesQuery.isSuccess, + [user, modelType, notesQuery] ); const editor = useCreateBlockNote(); @@ -46,6 +73,7 @@ export default function NotesEditor({ From 6df13fe156201c469a117ddc0cab55b05e936189 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 20 May 2026 11:50:50 +0000 Subject: [PATCH 07/80] Add note mixin support for existing models --- src/backend/InvenTree/InvenTree/models.py | 2 ++ src/backend/InvenTree/build/models.py | 1 + src/backend/InvenTree/common/validators.py | 6 +++--- src/backend/InvenTree/company/models.py | 3 +++ src/backend/InvenTree/order/models.py | 2 ++ src/backend/InvenTree/part/models.py | 1 + src/backend/InvenTree/stock/models.py | 1 + 7 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index efd334464221..2a827e5b6ffb 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -1277,6 +1277,8 @@ class InvenTreeNotesMixin(models.Model): - notes : A text field for storing notes """ + # TODO: THIS MIXIN IS TO BE REMOVED IN FAVOUR OF THE GENERIC RELATIONSHIP TO THE Note MODEL + class Meta: """Metaclass options for this mixin. diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 9bd92609eb89..c43434503398 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -80,6 +80,7 @@ class Build( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.ReferenceIndexingMixin, StateTransitionMixin, diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index 13107bcd3314..52cb42531285 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -14,7 +14,7 @@ def note_model_types(): import InvenTree.models return list( - InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNotesMixin) + InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNoteMixin) ) @@ -96,7 +96,7 @@ def validate_attachment_model_type(value): def validate_notes_model_type(value): """Ensure that the provided model type is valid. - The provided value must map to a model which implements the 'InvenTreeNotesMixin'. + The provided value must map to a model which implements the 'InvenTreeNoteMixin'. """ import InvenTree.helpers_model import InvenTree.models @@ -106,7 +106,7 @@ def validate_notes_model_type(value): return model_types = list( - InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNotesMixin) + InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNoteMixin) ) model_names = [model.__name__.lower() for model in model_types] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 431f7ae11b58..d8049d3df78e 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -79,6 +79,7 @@ class CompanyReportContext(report.mixins.BaseReportContext, TypedDict): class Company( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeParameterMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeImageMixin, @@ -486,6 +487,7 @@ class ManufacturerPart( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeNotesMixin, InvenTree.models.InvenTreeMetadataModel, ): @@ -603,6 +605,7 @@ class SupplierPart( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeNotesMixin, common.models.MetaMixin, InvenTree.models.InvenTreeModel, diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index c597e8c0114d..354a1036c0fc 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -271,6 +271,7 @@ class Order( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, @@ -2290,6 +2291,7 @@ class SalesOrderShipment( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 3ad08f999dde..2c17fd9fb2c3 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -465,6 +465,7 @@ class Part( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeImageMixin, diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 2c8e4d48bbff..61f1d99a9bc7 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -402,6 +402,7 @@ class StockItem( InvenTree.models.PluginValidationMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeNotesMixin, StatusCodeMixin, report.mixins.InvenTreeReportMixin, From 51d8468e7669cffe79f9cf30248770f7b16495a2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 20 May 2026 11:57:02 +0000 Subject: [PATCH 08/80] Fix serializer --- src/backend/InvenTree/common/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 328540452288..a3a54345fb4c 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -809,7 +809,7 @@ def save(self, **kwargs): return super().save(**kwargs) -class NoteSerializer(InvenTreeModelSerializer): +class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): """Serializer for the Note model.""" class Meta: From a0e528a5091118ce09c55d3f214ece31f5bd439e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 20 May 2026 12:13:59 +0000 Subject: [PATCH 09/80] Create new notes --- .../src/components/editors/NotesEditor.tsx | 104 ++++++++++++++---- src/frontend/src/forms/CommonForms.tsx | 23 ++++ 2 files changed, 107 insertions(+), 20 deletions(-) diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 9350da915082..8d775dcd7e62 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -17,7 +17,19 @@ import '@blocknote/core/fonts/inter.css'; import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; -import { Box, Flex, Paper, Tabs } from '@mantine/core'; +import { + Box, + Button, + Flex, + Group, + Paper, + Stack, + Tabs, + Text +} from '@mantine/core'; +import { IconCirclePlus } from '@tabler/icons-react'; +import { useNoteFields } from '../../forms/CommonForms'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useUserState } from '../../states/UserState'; export default function NotesEditor({ @@ -34,7 +46,8 @@ export default function NotesEditor({ const api = useApi(); const user = useUserState(); - const [selectedNote, setSelectedNote] = useState( + // The ID of the selected note + const [selectedNote, setSelectedNote] = useState( undefined ); @@ -57,6 +70,20 @@ export default function NotesEditor({ enabled: !!modelId && !!modelType }); + // Adjust the note selection + useEffect(() => { + // If the currently selected note is not in the list of available notes, then we need to adjust the selection + if ( + selectedNote && + notesQuery.data && + !notesQuery.data.some((note: any) => note.pk === selectedNote) + ) { + setSelectedNote( + notesQuery.data.length > 0 ? notesQuery.data[0].pk : undefined + ); + } + }, [notesQuery.data]); + const canEdit = useMemo( () => user.hasChangePermission(modelType) && @@ -67,25 +94,62 @@ export default function NotesEditor({ const editor = useCreateBlockNote(); + const noteFields = useNoteFields({ modelType: modelType, modelId: modelId }); + + const createNote = useCreateApiFormModal({ + title: t`Add Note`, + fields: noteFields, + url: apiUrl(ApiEndpoints.note_list), + method: 'POST', + successMessage: null, + onFormSuccess: (response: any) => { + notesQuery.refetch().then(() => { + // Select the newly created note + setSelectedNote(response.pk); + }); + } + }); + return ( - - - - - - - - - Manufacturing - Washing - - - + <> + {createNote.modal} + + + + + + + + + + + {notesQuery.data?.map((note: any) => ( + setSelectedNote(note.pk)} + > + + {note.title} + + + ))} + + + + + ); } diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index 71ae140ac337..2abd4ae649c5 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -283,3 +283,26 @@ export function useParameterFields({ user ]); } + +export function useNoteFields({ + modelType, + modelId +}: { + modelType: ModelType; + modelId: number; +}): ApiFormFieldSet { + return useMemo(() => { + return { + model_type: { + hidden: true, + value: modelType + }, + model_id: { + hidden: true, + value: modelId + }, + title: {}, + description: {} + }; + }, [modelType, modelId]); +} From bc197f9409f13644af05ab2108df71b32c542433 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 20 May 2026 12:22:19 +0000 Subject: [PATCH 10/80] Omit migration tests from code coverage --- codecov.yml | 3 +++ pyproject.toml | 1 + 2 files changed, 4 insertions(+) diff --git a/codecov.yml b/codecov.yml index b06601b57bdf..b49179c3ed1c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,6 @@ +ignore: + - "src/backend/InvenTree/**/test_migrations.py" + coverage: status: project: diff --git a/pyproject.toml b/pyproject.toml index 63a0fd5a6eb9..169e5a1f8923 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ possibly-unbound-attribute="ignore" # 21 [tool.coverage.run] source = ["src/backend/InvenTree", "InvenTree"] dynamic_context = "test_function" +omit = ["*/test_migrations.py"] [tool.coverage.html] show_contexts = true From ac8e8d978c65ff1de888765f0dbd6b07510df591 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Wed, 20 May 2026 12:31:31 +0000 Subject: [PATCH 11/80] Add "no notes" item --- .../src/components/editors/NotesEditor.tsx | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 8d775dcd7e62..0a191d070731 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -18,6 +18,7 @@ import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; import { + Alert, Box, Button, Flex, @@ -27,7 +28,7 @@ import { Tabs, Text } from '@mantine/core'; -import { IconCirclePlus } from '@tabler/icons-react'; +import { IconCirclePlus, IconInfoCircle } from '@tabler/icons-react'; import { useNoteFields } from '../../forms/CommonForms'; import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useUserState } from '../../states/UserState'; @@ -88,10 +89,15 @@ export default function NotesEditor({ () => user.hasChangePermission(modelType) && notesQuery.isFetched && - notesQuery.isSuccess, + notesQuery.isSuccess && + !!notesQuery.data, [user, modelType, notesQuery] ); + const hasNotes = useMemo(() => { + return notesQuery.data && notesQuery.data.length > 0; + }, [notesQuery.data]); + const editor = useCreateBlockNote(); const noteFields = useNoteFields({ modelType: modelType, modelId: modelId }); @@ -115,39 +121,49 @@ export default function NotesEditor({ {createNote.modal} - - + + {hasNotes ? ( + + + + ) : ( + }> + {t`There are no notes yet for this item.`} + + )} - - - - - {notesQuery.data?.map((note: any) => ( - setSelectedNote(note.pk)} - > - - {note.title} - - - ))} - - - + + + + + + {notesQuery.data?.map((note: any) => ( + setSelectedNote(note.pk)} + > + + {note.title} + + + ))} + + + + ); From ab0da25de8b44a068ff72651cf7929ac33013028 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 07:26:28 +0000 Subject: [PATCH 12/80] Add "primary" field to Note model --- .../InvenTree/common/migrations/0042_note.py | 8 +++++++ src/backend/InvenTree/common/models.py | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/backend/InvenTree/common/migrations/0042_note.py b/src/backend/InvenTree/common/migrations/0042_note.py index 4d4dbea255e3..409eec9f5ce2 100644 --- a/src/backend/InvenTree/common/migrations/0042_note.py +++ b/src/backend/InvenTree/common/migrations/0042_note.py @@ -87,6 +87,14 @@ class Migration(migrations.Migration): verbose_name="Update By", ), ), + ( + "primary", + models.BooleanField( + default=False, + help_text="Is this the primary note for the associated model?", + verbose_name="Primary", + ), + ) ], options={ "verbose_name": "Note", diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 332e7fedfd04..cc0e3566a308 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2935,8 +2935,23 @@ def get_api_url() -> str: def save(self, *args, **kwargs): """Perform custom save checks before saving a Note instance.""" self.check_save() + + others = Note.objects.filter( + model_type=self.model_type, model_id=self.model_id + ).exclude(pk=self.pk) + + # If this is the *only* note for this model instance, then set it as the primary note + if not others.exists(): + self.primary = True + super().save(*args, **kwargs) + # Once this note is saved, mark other notes as non-primary + if self.primary: + Note.objects.filter( + model_type=self.model_type, model_id=self.model_id + ).exclude(pk=self.pk).update(primary=False) + def delete(self): """Perform custom delete checks before deleting a Parameter instance.""" self.check_delete() @@ -2976,6 +2991,12 @@ def check_delete(self): content_object = GenericForeignKey('model_type', 'model_id') + primary = models.BooleanField( + default=False, + verbose_name=_('Primary'), + help_text=_('Is this the primary note for the associated model?'), + ) + title = models.CharField( max_length=100, verbose_name=_('Title'), help_text=_('Note title') ) From c18fd25431ae4fe92dc847e230bf40b65a0613d7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 07:26:54 +0000 Subject: [PATCH 13/80] Update serializer --- src/backend/InvenTree/common/serializers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index a3a54345fb4c..9dc55f09b94c 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -820,6 +820,7 @@ class Meta: 'pk', 'model_type', 'model_id', + 'primary', 'title', 'description', 'content', From d77be246aeb218607ef407d438d6c670cb664d23 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 07:51:05 +0000 Subject: [PATCH 14/80] Add unit test --- src/backend/InvenTree/common/test_api.py | 96 ++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index febbf5622e85..94b74c271446 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -789,3 +789,99 @@ def test_attachments(self): for att in attachments: # Ensure that the file associated with each attachment has been removed self.assertFalse(default_storage.exists(att.attachment.path)) + + +class NoteAPITests(InvenTreeAPITestCase): + """API tests for the Note model, focusing on the 'primary' flag behaviour.""" + + def setUp(self): + """Create a Part instance to attach notes to.""" + from part.models import Part + + super().setUp() + + self.assignRole('part.add') + + self.part = Part.objects.create( + name='Test Part', description='A part for testing notes' + ) + + def _note_url(self, pk=None): + if pk: + return reverse('api-note-detail', kwargs={'pk': pk}) + return reverse('api-note-list') + + def _create_note(self, title, primary=None, expected_code=201): + data = {'model_type': 'part', 'model_id': self.part.pk, 'title': title} + if primary is not None: + data['primary'] = primary + return self.post(self._note_url(), data=data, expected_code=expected_code) + + def test_first_note_is_primary(self): + """A note created when no other notes exist is automatically primary.""" + response = self._create_note('Only Note') + self.assertTrue(response.data['primary']) + + def test_second_note_not_primary_by_default(self): + """Notes created after the first are not primary by default.""" + first = self._create_note('First Note') + second = self._create_note('Second Note') + + self.assertTrue(first.data['primary']) + self.assertFalse(second.data['primary']) + + # Confirm the first is still marked primary in the database + from common.models import Note + + self.assertTrue(Note.objects.get(pk=first.data['pk']).primary) + + def test_setting_primary_clears_others(self): + """Marking a note as primary demotes all sibling notes.""" + first = self._create_note('First Note') + second = self._create_note('Second Note') + third = self._create_note('Third Note') + + # Only the first should be primary after creation + self.assertTrue(first.data['primary']) + self.assertFalse(second.data['primary']) + self.assertFalse(third.data['primary']) + + # Promote the third note via PATCH + response = self.patch( + self._note_url(third.data['pk']), data={'primary': True}, expected_code=200 + ) + self.assertTrue(response.data['primary']) + + # Verify via the list endpoint that only the third is primary + list_response = self.get( + self._note_url(), + data={'model_type': 'part', 'model_id': self.part.pk}, + expected_code=200, + ) + primary_pks = [n['pk'] for n in list_response.data if n['primary']] + self.assertEqual(primary_pks, [third.data['pk']]) + + def test_primary_flag_isolated_per_model_instance(self): + """Primary flag changes on one model instance do not affect notes on another.""" + from part.models import Part + + other_part = Part.objects.create(name='Other Part', description='Another part') + + note_a = self._create_note('Note on Part A') + self.assertTrue(note_a.data['primary']) + + # Create a note on the other part; it should be primary for *that* part + note_b_response = self.post( + self._note_url(), + data={ + 'model_type': 'part', + 'model_id': other_part.pk, + 'title': 'Note on Part B', + }, + expected_code=201, + ) + self.assertTrue(note_b_response.data['primary']) + + # The note on Part A should still be primary + note_a_detail = self.get(self._note_url(note_a.data['pk']), expected_code=200) + self.assertTrue(note_a_detail.data['primary']) From c7d11dc9391176e664ccfa0a634b086fdbda202a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 09:25:19 +0000 Subject: [PATCH 15/80] Mark content field as 'safe' --- src/backend/InvenTree/common/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index cc0e3566a308..2cd07f3b61d6 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2921,6 +2921,10 @@ class Note( created: Date/time that the note was created """ + # Ignore default sanitizing of the 'content' field + # Note: This is handled explicitly in the 'save' method + SAFE_FIELDS = ['content'] + class Meta: """Meta options for Note model.""" From 9e8764d28ae22bcf47c1b3e2914f814762246e41 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 09:27:08 +0000 Subject: [PATCH 16/80] Fix conflict (for now) --- src/backend/InvenTree/InvenTree/models.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 2a827e5b6ffb..68e28518edf4 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -664,15 +664,16 @@ class Meta: 'common.Note', content_type_field='model_type', object_id_field='model_id' ) - @property - def notes(self) -> QuerySet: - """Return a queryset containing all notes for this model.""" - # Check the query cache for pre-fetched parameters - if cache := getattr(self, '_prefetched_objects_cache', None): - if 'notes_list' in cache: - return cache['notes_list'] - - return self.notes_list.all() + # TODO: Un-comment this once the InvenTreeNotesMixin class is removed + # @property + # def notes(self) -> QuerySet: + # """Return a queryset containing all notes for this model.""" + # # Check the query cache for pre-fetched parameters + # if cache := getattr(self, '_prefetched_objects_cache', None): + # if 'notes_list' in cache: + # return cache['notes_list'] + + # return self.notes_list.all() def delete(self, *args, **kwargs): """Handle the deletion of a model instance. From df340e363c765610977be2df8e8cb7d5fa3bef7f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 09:59:47 +0000 Subject: [PATCH 17/80] Load notes into editor --- src/backend/InvenTree/common/api.py | 4 ++ src/backend/InvenTree/common/models.py | 4 -- .../src/components/editors/NotesEditor.tsx | 69 +++++++++++++++++-- 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 2ce6eff97552..8a5c8d66fe9b 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -840,6 +840,10 @@ def filter_model_type(self, queryset, name, value): class NoteMixin: """Mixin class for the Note views.""" + # Ignore default sanitizing of the 'content' field + # Note: This is handled explicitly in the 'save' method of the Note model + SAFE_FIELDS = ['content'] + queryset = common.models.Note.objects.all() serializer_class = common.serializers.NoteSerializer permission_classes = [IsAuthenticatedOrReadScope] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 2cd07f3b61d6..cc0e3566a308 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -2921,10 +2921,6 @@ class Note( created: Date/time that the note was created """ - # Ignore default sanitizing of the 'content' field - # Note: This is handled explicitly in the 'save' method - SAFE_FIELDS = ['content'] - class Meta: """Meta options for Note model.""" diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 0a191d070731..3b8156753e68 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -28,7 +28,11 @@ import { Tabs, Text } from '@mantine/core'; -import { IconCirclePlus, IconInfoCircle } from '@tabler/icons-react'; +import { + IconCirclePlus, + IconInfoCircle, + IconUpload +} from '@tabler/icons-react'; import { useNoteFields } from '../../forms/CommonForms'; import { useCreateApiFormModal } from '../../hooks/UseForm'; import { useUserState } from '../../states/UserState'; @@ -47,6 +51,8 @@ export default function NotesEditor({ const api = useApi(); const user = useUserState(); + const editor = useCreateBlockNote(); + // The ID of the selected note const [selectedNote, setSelectedNote] = useState( undefined @@ -71,6 +77,20 @@ export default function NotesEditor({ enabled: !!modelId && !!modelType }); + useEffect(() => { + const note = notesQuery.data?.find((note: any) => note.pk === selectedNote); + + if (note) { + const blocks = editor.tryParseHTMLToBlocks(note.content ?? ''); + + if (blocks) { + editor.replaceBlocks(editor.document, blocks); + } + } else { + editor.replaceBlocks(editor.document, []); + } + }, [editor, selectedNote, notesQuery.data]); + // Adjust the note selection useEffect(() => { // If the currently selected note is not in the list of available notes, then we need to adjust the selection @@ -98,8 +118,6 @@ export default function NotesEditor({ return notesQuery.data && notesQuery.data.length > 0; }, [notesQuery.data]); - const editor = useCreateBlockNote(); - const noteFields = useNoteFields({ modelType: modelType, modelId: modelId }); const createNote = useCreateApiFormModal({ @@ -116,6 +134,47 @@ export default function NotesEditor({ } }); + const saveNote = useCallback(() => { + // if (!selectedNote) { + // return; + // } + + const blocks = editor.document; + const html = editor.blocksToHTMLLossy(blocks); + + // TODO: Sanitize the HTML content before sending to the server (or ensure it's sanitized on the back-end) + + if (selectedNote) { + const url = apiUrl(ApiEndpoints.note_list, selectedNote); + + notifications.hide('note-update-status'); + + api + .patch(url, { content: html }) + .then(() => { + notifications.show({ + title: t`Success`, + message: t`Note updated`, + color: 'green', + id: 'note-update-status', + autoClose: 2000 + }); + }) + .catch((error) => { + notifications.show({ + title: t`Error`, + message: t`Failed to update note: ${error.message}`, + color: 'red', + id: 'note-update-status', + autoClose: 2000 + }); + }) + .finally(() => { + notesQuery.refetch(); + }); + } + }, [selectedNote, editor]); + return ( <> {createNote.modal} @@ -126,7 +185,6 @@ export default function NotesEditor({ @@ -140,6 +198,9 @@ export default function NotesEditor({ + From 689bd1f2247fbd425261d6d786f45df41ced3de0 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 11:45:01 +0000 Subject: [PATCH 18/80] Refactor save UX --- .../src/components/editors/NotesEditor.tsx | 113 +++++++++++++----- 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 3b8156753e68..fdcec07c31d7 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import DOMPurify from 'dompurify'; import EasyMDE, { type default as SimpleMde } from 'easymde'; import 'easymde/dist/easymde.min.css'; +import { useHotkeys } from '@mantine/hooks'; import { useCallback, useEffect, useMemo, useState } from 'react'; import SimpleMDE from 'react-simplemde-editor'; @@ -18,20 +19,22 @@ import { BlockNoteView } from '@blocknote/mantine'; import '@blocknote/mantine/style.css'; import { useCreateBlockNote } from '@blocknote/react'; import { + ActionIcon, Alert, Box, - Button, Flex, Group, Paper, Stack, Tabs, - Text + Text, + Tooltip } from '@mantine/core'; import { IconCirclePlus, + IconDeviceFloppy, IconInfoCircle, - IconUpload + IconReload } from '@tabler/icons-react'; import { useNoteFields } from '../../forms/CommonForms'; import { useCreateApiFormModal } from '../../hooks/UseForm'; @@ -53,6 +56,8 @@ export default function NotesEditor({ const editor = useCreateBlockNote(); + const [isDirty, setIsDirty] = useState(false); + // The ID of the selected note const [selectedNote, setSelectedNote] = useState( undefined @@ -78,31 +83,43 @@ export default function NotesEditor({ }); useEffect(() => { - const note = notesQuery.data?.find((note: any) => note.pk === selectedNote); + return editor.onChange(() => setIsDirty(true)); + }, [editor]); + + const loadNote = useCallback( + (noteId: number) => { + const note = notesQuery.data?.find((note: any) => note.pk === noteId); - if (note) { - const blocks = editor.tryParseHTMLToBlocks(note.content ?? ''); + if (note) { + const blocks = editor.tryParseHTMLToBlocks(note.content ?? ''); - if (blocks) { - editor.replaceBlocks(editor.document, blocks); + if (blocks) { + editor.replaceBlocks(editor.document, blocks); + } + } else { + editor.replaceBlocks(editor.document, []); } - } else { - editor.replaceBlocks(editor.document, []); - } + + setIsDirty(false); + }, + [editor, notesQuery.data] + ); + + useEffect(() => { + loadNote(selectedNote ?? -1); }, [editor, selectedNote, notesQuery.data]); // Adjust the note selection useEffect(() => { - // If the currently selected note is not in the list of available notes, then we need to adjust the selection - if ( + if (!notesQuery.data) return; + + const stillExists = selectedNote && - notesQuery.data && - !notesQuery.data.some((note: any) => note.pk === selectedNote) - ) { - setSelectedNote( - notesQuery.data.length > 0 ? notesQuery.data[0].pk : undefined - ); - } + notesQuery.data.some((note: any) => note.pk === selectedNote); + if (stillExists) return; + + const primary = notesQuery.data.find((note: any) => note.primary); + setSelectedNote((primary ?? notesQuery.data[0])?.pk ?? undefined); }, [notesQuery.data]); const canEdit = useMemo( @@ -134,10 +151,14 @@ export default function NotesEditor({ } }); + const reloadNote = useCallback(() => { + loadNote(selectedNote ?? -1); + }, [selectedNote, loadNote]); + const saveNote = useCallback(() => { - // if (!selectedNote) { - // return; - // } + if (!selectedNote) { + return; + } const blocks = editor.document; const html = editor.blocksToHTMLLossy(blocks); @@ -152,6 +173,7 @@ export default function NotesEditor({ api .patch(url, { content: html }) .then(() => { + setIsDirty(false); notifications.show({ title: t`Success`, message: t`Note updated`, @@ -173,7 +195,9 @@ export default function NotesEditor({ notesQuery.refetch(); }); } - }, [selectedNote, editor]); + }, [selectedNote, editor, setIsDirty]); + + useHotkeys([['mod+s', saveNote]]); return ( <> @@ -198,12 +222,40 @@ export default function NotesEditor({ - - + {canEdit && ( + + + + + + + + + + + + + + + + + + )} ( setSelectedNote(note.pk)} > From 36e81c40c90bc96ad4959a6c1080690aef3a9b90 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 11:46:37 +0000 Subject: [PATCH 19/80] Sanitize before uploading --- src/frontend/src/components/editors/NotesEditor.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index fdcec07c31d7..4f3c1619ae35 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -162,8 +162,9 @@ export default function NotesEditor({ const blocks = editor.document; const html = editor.blocksToHTMLLossy(blocks); + const cleanHtml = DOMPurify.sanitize(html); - // TODO: Sanitize the HTML content before sending to the server (or ensure it's sanitized on the back-end) + // Sanitize the HTML content before sending to the server (or ensure it's sanitized on the back-end) if (selectedNote) { const url = apiUrl(ApiEndpoints.note_list, selectedNote); @@ -171,7 +172,7 @@ export default function NotesEditor({ notifications.hide('note-update-status'); api - .patch(url, { content: html }) + .patch(url, { content: cleanHtml }) .then(() => { setIsDirty(false); notifications.show({ From 136c9256cb9d08a83bc9000606c8ea4b18cf42c3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 11:47:58 +0000 Subject: [PATCH 20/80] remove old editor --- .../src/components/editors/NotesEditor.tsx | 228 ------------------ 1 file changed, 228 deletions(-) diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 4f3c1619ae35..89dc3005acd6 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -2,14 +2,11 @@ import { t } from '@lingui/core/macro'; import { notifications } from '@mantine/notifications'; import { useQuery } from '@tanstack/react-query'; import DOMPurify from 'dompurify'; -import EasyMDE, { type default as SimpleMde } from 'easymde'; import 'easymde/dist/easymde.min.css'; import { useHotkeys } from '@mantine/hooks'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import SimpleMDE from 'react-simplemde-editor'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; -import { ModelInformationDict } from '@lib/enums/ModelInformation'; import type { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; import { useApi } from '../../contexts/ApiContext'; @@ -283,228 +280,3 @@ export default function NotesEditor({ ); } - -/* - * A text editor component for editing notes against a model type and instance. - * Uses the react-simple-mde editor: https://github.com/RIP21/react-simplemde-editor - * - * TODO: - * - Disable editing by default when the component is launched - user can click an "edit" button to enable - * - Allow image resizing in the future (requires back-end validation changes)) - * - Allow user to configure the editor toolbar (i.e. hide some buttons if they don't want them) - */ -export function OldNotesEditor({ - modelType, - modelId, - editable, - setDirtyCallback -}: Readonly<{ - modelType: ModelType; - modelId: number; - editable?: boolean; - setDirtyCallback?: (dirty: boolean) => void; -}>) { - const api = useApi(); - // In addition to the editable prop, we also need to check if the user has "enabled" editing - const [editing, setEditing] = useState(false); - const [localIsDirty, setLocalIsDirty] = useState(false); - - const [markdown, setMarkdown] = useState(''); - - useEffect(() => { - // Initially disable editing mode on load - setEditing(false); - }, [editable, modelId, modelType]); - - useEffect(() => { - setDirtyCallback?.(localIsDirty); - }, [localIsDirty]); - - const noteUrl: string = useMemo(() => { - const modelInfo = ModelInformationDict[modelType]; - return apiUrl(modelInfo.api_endpoint, modelId); - }, [modelType, modelId]); - - // Image upload handler - const imageUploadHandler = useCallback( - ( - file: File, - onSuccess: (url: string) => void, - onError: (error: string) => void - ) => { - const formData = new FormData(); - formData.append('image', file); - - formData.append('model_type', modelType); - formData.append('model_id', modelId.toString()); - - api - .post(apiUrl(ApiEndpoints.notes_image_upload), formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }) - .catch((error) => { - onError(error.message); - notifications.hide('notes'); - notifications.show({ - id: 'notes', - title: t`Error`, - message: t`Image upload failed`, - color: 'red' - }); - }) - .then((response: any) => { - onSuccess(response.data.image); - notifications.hide('notes'); - notifications.show({ - id: 'notes', - title: t`Success`, - message: t`Image uploaded successfully`, - color: 'green' - }); - }); - }, - [modelType, modelId] - ); - - const dataQuery = useQuery({ - queryKey: ['notes-editor', noteUrl, modelType, modelId], - retry: 5, - queryFn: () => - api.get(noteUrl).then((response) => response.data?.notes ?? ''), - enabled: true - }); - - // Update internal markdown data when the query data changes - useEffect(() => { - setMarkdown(dataQuery.data ?? ''); - }, [dataQuery.data]); - - // Callback to save notes to the server - const saveNotes = useCallback( - (markdown: string) => { - if (!noteUrl) { - return; - } - - api - .patch(noteUrl, { notes: markdown }) - .then(() => { - notifications.hide('notes'); - notifications.show({ - title: t`Success`, - message: t`Notes saved successfully`, - color: 'green', - id: 'notes', - autoClose: 2000 - }); - setLocalIsDirty(false); - }) - .catch((error) => { - notifications.hide('notes'); - - const msg = - error?.response?.data?.non_field_errors[0] ?? - t`Failed to save notes`; - - notifications.show({ - title: t`Error Saving Notes`, - message: msg, - color: 'red', - id: 'notes' - }); - }); - }, - [api, noteUrl] - ); - - const editorOptions: SimpleMde.Options = useMemo(() => { - const icons: any[] = []; - - if (editing) { - icons.push({ - name: 'save-notes', - action: (editor: SimpleMde) => { - saveNotes(editor.value()); - }, - className: 'fa fa-save', - title: t`Save Notes` - }); - - icons.push('|'); - - icons.push('heading-1', 'heading-2', 'heading-3', '|'); // Headings - icons.push('bold', 'italic', 'strikethrough', '|'); // Text styles - icons.push('unordered-list', 'ordered-list', 'code', 'quote', '|'); // Text formatting - icons.push('table', 'link', 'image', '|'); - icons.push('horizontal-rule', '|', 'guide'); // Misc - - icons.push('|', 'undo', 'redo'); // Undo/Redo - - icons.push('|'); - - icons.push({ - name: 'edit-disabled', - action: () => setEditing(false), - className: 'fa fa-times', - title: t`Close Editor` - }); - } else if (editable) { - icons.push({ - name: 'edit-enabled', - action: () => setEditing(true), - className: 'fa fa-edit', - title: t`Enable Editing` - }); - } - - return { - toolbar: icons, - uploadImage: true, - imagePathAbsolute: true, - imageUploadFunction: imageUploadHandler, - renderingConfig: { - sanitizerFunction: (html: string) => { - return DOMPurify.sanitize(html); - } - }, - sideBySideFullscreen: false, - shortcuts: {}, - spellChecker: false - }; - }, [editable, editing]); - - const [mdeInstance, setMdeInstance] = useState(null); - - useEffect(() => { - if (mdeInstance) { - const previewMode = !(editable && editing); - - mdeInstance.codemirror?.setOption('readOnly', previewMode); - - // Ensure the preview mode is toggled if required - if (mdeInstance.isPreviewActive() != previewMode) { - const sibling = - mdeInstance?.codemirror.getWrapperElement()?.nextSibling; - - if (sibling != null && editable != false) { - EasyMDE.togglePreview(mdeInstance); - } - } - } - }, [mdeInstance, editable, editing]); - - return ( - setMdeInstance(instance)} - onChange={(value: string) => { - setMarkdown(value); - setLocalIsDirty(true); - }} - options={editorOptions} - value={markdown} - /> - ); -} From 0c1aa65cb94715409773105f8a15958248556b6b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Thu, 21 May 2026 11:49:41 +0000 Subject: [PATCH 21/80] Remove old deps --- src/frontend/package.json | 2 - .../src/components/editors/NotesEditor.tsx | 3 +- src/frontend/yarn.lock | 61 +------------------ 3 files changed, 2 insertions(+), 64 deletions(-) diff --git a/src/frontend/package.json b/src/frontend/package.json index d74629a5a3ca..349137c2a780 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -88,7 +88,6 @@ "codemirror": "^6.0.2", "dayjs": "^1.11.13", "dompurify": "^3.2.4", - "easymde": "^2.20.0", "embla-carousel": "^8.5.2", "embla-carousel-react": "^8.5.2", "fuse.js": "^7.0.0", @@ -103,7 +102,6 @@ "react-is": "^19.2.4", "react-router-dom": "^6.26.2", "react-select": "^5.9.0", - "react-simplemde-editor": "^5.2.0", "react-window": "1.8.11", "recharts": "^3.1.2", "styled-components": "^6.1.14", diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 89dc3005acd6..abff7deebf0c 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -1,9 +1,8 @@ import { t } from '@lingui/core/macro'; +import { useHotkeys } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { useQuery } from '@tanstack/react-query'; import DOMPurify from 'dompurify'; -import 'easymde/dist/easymde.min.css'; -import { useHotkeys } from '@mantine/hooks'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 6d421af0e8b2..f60eb5155d8c 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -2069,13 +2069,6 @@ dependencies: "@babel/types" "^7.28.2" -"@types/codemirror@^5.60.10", "@types/codemirror@~5.60.5": - version "5.60.17" - resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.17.tgz#754649d285e0e775fe912ad2f5e757f22a70e1cf" - integrity sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw== - dependencies: - "@types/tern" "*" - "@types/d3-array@^3.0.3": version "3.2.2" resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" @@ -2127,7 +2120,7 @@ resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== -"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0": +"@types/estree@1.0.8", "@types/estree@^1.0.0": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -2163,11 +2156,6 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/marked@^4.0.7": - version "4.3.2" - resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.3.2.tgz#e2e0ad02ebf5626bd215c5bae2aff6aff0ce9eac" - integrity sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w== - "@types/node@*", "@types/node@^25.5.0": version "25.5.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.5.0.tgz#5c99f37c443d9ccc4985866913f1ed364217da31" @@ -2240,13 +2228,6 @@ resolved "https://registry.yarnpkg.com/@types/stylis/-/stylis-4.2.7.tgz#1813190525da9d2a2b6976583bdd4af5301d9fd4" integrity sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA== -"@types/tern@*": - version "0.23.9" - resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.23.9.tgz#6f6093a4a9af3e6bb8dde528e024924d196b367c" - integrity sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw== - dependencies: - "@types/estree" "*" - "@types/trusted-types@^2.0.7": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -2790,18 +2771,6 @@ clsx@^2.0.0, clsx@^2.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== -codemirror-spell-checker@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz#1c660f9089483ccb5113b9ba9ca19c3f4993371e" - integrity sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ== - dependencies: - typo-js "*" - -codemirror@^5.65.15: - version "5.65.21" - resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.21.tgz#cacf320606c5450ad3b3da34bb9c666afec21068" - integrity sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ== - codemirror@^6.0.0, codemirror@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.2.tgz#4d3fea1ad60b6753f97ca835f2f48c6936a8946e" @@ -3124,17 +3093,6 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -easymde@^2.20.0: - version "2.20.0" - resolved "https://registry.yarnpkg.com/easymde/-/easymde-2.20.0.tgz#88b3161feab6e1900afa9c4dab3f1da352b0a26e" - integrity sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ== - dependencies: - "@types/codemirror" "^5.60.10" - "@types/marked" "^4.0.7" - codemirror "^5.65.15" - codemirror-spell-checker "1.1.2" - marked "^4.1.0" - electron-to-chromium@^1.5.263: version "1.5.325" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz#c2b3d510435a2b65dd65e891dde7eac0362edfb7" @@ -4063,11 +4021,6 @@ mantine-datatable@^9.2.0: resolved "https://registry.yarnpkg.com/mantine-datatable/-/mantine-datatable-9.2.0.tgz#936f2307462d5420dc1ba7b520ae825dc6b2d97c" integrity sha512-TK6SZ6dH/PQUedfhkJuSLMcd4P4m5L6kMJWfAF9cS4wBeoAtBWGpLs+n/E8yI6w1rYAtLsGQvFA6S9Xnfw0JFw== -marked@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" - integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== - math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -4804,13 +4757,6 @@ react-select@^5.9.0: react-transition-group "^4.3.0" use-isomorphic-layout-effect "^1.2.0" -react-simplemde-editor@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/react-simplemde-editor/-/react-simplemde-editor-5.2.0.tgz#7a4c8b97e4989cb129b45ba140145d71bdc0684e" - integrity sha512-GkTg1MlQHVK2Rks++7sjuQr/GVS/xm6y+HchZ4GPBWrhcgLieh4CjK04GTKbsfYorSRYKa0n37rtNSJmOzEDkQ== - dependencies: - "@types/codemirror" "~5.60.5" - react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" @@ -5378,11 +5324,6 @@ typescript@^5.9.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== -typo-js@*: - version "1.3.1" - resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.3.1.tgz#c80f8c7b292caaa17a507226bf74c1cddf6a29e6" - integrity sha512-elJkpCL6Z77Ghw0Lv0lGnhBAjSTOQ5FhiVOCfOuxhaoTT2xtLVbqikYItK5HHchzPbHEUFAcjOH669T2ZzeCbg== - ufo@^1.6.3: version "1.6.3" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.3.tgz#799666e4e88c122a9659805e30b9dc071c3aed4f" From 754a4c2e742b0ecf8b6e8eeb079a249b8ef3c8f4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2026 09:42:19 +0000 Subject: [PATCH 22/80] Fix migration conflict --- .../InvenTree/common/migrations/{0042_note.py => 0044_note.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/backend/InvenTree/common/migrations/{0042_note.py => 0044_note.py} (98%) diff --git a/src/backend/InvenTree/common/migrations/0042_note.py b/src/backend/InvenTree/common/migrations/0044_note.py similarity index 98% rename from src/backend/InvenTree/common/migrations/0042_note.py rename to src/backend/InvenTree/common/migrations/0044_note.py index 409eec9f5ce2..68ff194a472a 100644 --- a/src/backend/InvenTree/common/migrations/0042_note.py +++ b/src/backend/InvenTree/common/migrations/0044_note.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ("common", "0041_auto_20251203_1244"), + ("common", "0043_auto_20260518_1206"), ("contenttypes", "0002_remove_content_type_name"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] From 8377f53b6f15e15f48e84e15857801a70e0d89bf Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2026 10:08:33 +0000 Subject: [PATCH 23/80] Run nh3 cleaner over backend notes --- src/backend/InvenTree/InvenTree/helpers.py | 4 ++-- src/backend/InvenTree/InvenTree/sanitizer.py | 4 ++-- src/backend/InvenTree/common/models.py | 7 ++++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 502ac9c3b0a6..8f57b77bfe33 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -30,7 +30,7 @@ from common.currency import currency_code_default from InvenTree.sanitizer import ( - DEAFAULT_ATTRS, + DEFAULT_ATTRS, DEFAULT_CSS, DEFAULT_PROTOCOLS, DEFAULT_TAGS, @@ -963,7 +963,7 @@ def clean_markdown(value: str) -> str: # nh3 sanitizer settings whitelist_tags = markdownify_settings.get('WHITELIST_TAGS', DEFAULT_TAGS) - whitelist_attrs = markdownify_settings.get('WHITELIST_ATTRS', DEAFAULT_ATTRS) + whitelist_attrs = markdownify_settings.get('WHITELIST_ATTRS', DEFAULT_ATTRS) whitelist_styles = markdownify_settings.get('WHITELIST_STYLES', DEFAULT_CSS) whitelist_protocols = markdownify_settings.get( 'WHITELIST_PROTOCOLS', DEFAULT_PROTOCOLS diff --git a/src/backend/InvenTree/InvenTree/sanitizer.py b/src/backend/InvenTree/InvenTree/sanitizer.py index ea5936c65a37..c742e7a967f5 100644 --- a/src/backend/InvenTree/InvenTree/sanitizer.py +++ b/src/backend/InvenTree/InvenTree/sanitizer.py @@ -244,7 +244,7 @@ ] # Default allowlists (matching bleach's original defaults) -# TODO: I do not see us needing a bunch of these but I do not want to introduce a breaking change; we might want to narroy this down with the next breaking change +# TODO: I do not see us needing a bunch of these but I do not want to introduce a breaking change; we might want to narrow this down with the next breaking change DEFAULT_TAGS = frozenset([ 'a', 'abbr', @@ -259,7 +259,7 @@ 'strong', 'ul', ]) -DEAFAULT_ATTRS = {'a': {'href', 'title'}, 'abbr': {'title'}, 'acronym': {'title'}} +DEFAULT_ATTRS = {'a': {'href', 'title'}, 'abbr': {'title'}, 'acronym': {'title'}} DEFAULT_CSS = frozenset([ 'azimuth', 'background-color', diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 1b97b6b4cb3d..2aadccf93317 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -42,6 +42,7 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ +import nh3 import structlog from anymail.signals import inbound, tracking from django_q.signals import post_spawn @@ -3045,6 +3046,8 @@ def save(self, *args, **kwargs): if not others.exists(): self.primary = True + self.clean() + super().save(*args, **kwargs) # Once this note is saved, mark other notes as non-primary @@ -3060,7 +3063,9 @@ def delete(self): def clean(self): """Clean / validate the note before saving to the database.""" - # TODO: Implement this + if self.content: + self.content = self.content.strip() + self.content = nh3.clean(self.content) def check_save(self): """Check if this note can be saved.""" From 9e91663d077644a42f888e94f9792c81ea6612c3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2026 10:10:28 +0000 Subject: [PATCH 24/80] Update note accessors --- src/backend/InvenTree/InvenTree/models.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 68e28518edf4..99b42d5469ba 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -707,9 +707,23 @@ def copy_notes_from(self, other, **kwargs): if len(notes) > 0: common.models.Note.objects.bulk_create(notes, batch_size=250) - def get_note(self, title: str): - """Return a Note instance for the given note title.""" - return self.notes_list.filter(title=title).first() + @property + def primary_note(self): + """Return the primary note for this model instance, if it exists.""" + return self.notes_list.all().order_by('-primary').first() + + def get_note(self, title: Optional[str] = None): + """Return a Note instance for the given note title. + + Arguments: + title: Title of the note to retrieve. If None, returns the primary note (if it exists) + """ + notes = self.notes_list.all().order_by('-primary') + + if title: + notes = notes.filter(title=title) + + return notes.first() def check_note_delete(self, note) -> bool: """Run a check to determine if the provided note can be deleted. From 5813c4baeab9f127f30e056bda81f23490d0d88f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2026 10:33:09 +0000 Subject: [PATCH 25/80] edit / delete existing notes from the UI --- src/backend/InvenTree/common/api.py | 3 +- .../src/components/editors/NotesEditor.tsx | 193 +++++++++++++----- src/frontend/src/forms/CommonForms.tsx | 3 +- 3 files changed, 143 insertions(+), 56 deletions(-) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 8429e660e2e9..6397d493a7c4 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -865,7 +865,8 @@ class NoteList(NoteMixin, ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER filterset_class = NoteFilter - ordering_fields = ['model_id', 'model_type', 'user', 'creation'] + ordering = '-primary' + ordering_fields = ['model_id', 'model_type', 'user', 'creation', 'primary'] search_fields = ['content', 'model_id', 'model_type', 'user__username'] unique_create_fields = ['model_type', 'model_id'] diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index abff7deebf0c..bd2e9818edec 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -17,7 +17,9 @@ import { useCreateBlockNote } from '@blocknote/react'; import { ActionIcon, Alert, + Badge, Box, + Button, Flex, Group, Paper, @@ -30,11 +32,21 @@ import { IconCirclePlus, IconDeviceFloppy, IconInfoCircle, - IconReload + IconReload, + IconStar } from '@tabler/icons-react'; import { useNoteFields } from '../../forms/CommonForms'; -import { useCreateApiFormModal } from '../../hooks/UseForm'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useUserState } from '../../states/UserState'; +import { + DeleteItemAction, + EditItemAction, + OptionsActionDropdown +} from '../items/ActionDropdown'; export default function NotesEditor({ modelType, @@ -91,9 +103,9 @@ export default function NotesEditor({ if (blocks) { editor.replaceBlocks(editor.document, blocks); + } else { + editor.replaceBlocks(editor.document, []); } - } else { - editor.replaceBlocks(editor.document, []); } setIsDirty(false); @@ -147,6 +159,29 @@ export default function NotesEditor({ } }); + const deleteNote = useDeleteApiFormModal({ + title: t`Delete Note`, + url: apiUrl(ApiEndpoints.note_list), + pk: selectedNote, + onFormSuccess: () => { + setSelectedNote(undefined); + notesQuery.refetch(); + } + }); + + const editNote = useEditApiFormModal({ + title: t`Edit Note`, + fields: noteFields, + url: apiUrl(ApiEndpoints.note_list), + pk: selectedNote, + onFormSuccess: (response: any) => { + notesQuery.refetch().then(() => { + // Select the updated note + setSelectedNote(response.pk); + }); + } + }); + const reloadNote = useCallback(() => { loadNote(selectedNote ?? -1); }, [selectedNote, loadNote]); @@ -199,60 +234,101 @@ export default function NotesEditor({ return ( <> {createNote.modal} + {deleteNote.modal} + {editNote.modal} - - - {hasNotes ? ( - - - - ) : ( - }> - {t`There are no notes yet for this item.`} - + + + {selectedNote && ( + + + + Note Title Here + Note description here + + {canEdit && ( + + {isDirty && ( + {t`Unsaved Changes`} + )} + {isDirty && ( + + + + + + )} + {isDirty && ( + + + + + + )} + { + editNote.open(); + } + }), + DeleteItemAction({ + hidden: + !selectedNote || + !user.hasDeletePermission(modelType), + onClick: () => { + deleteNote.open(); + } + }) + ]} + /> + + )} + + )} - + + {hasNotes ? ( + + + + ) : ( + }> + {t`There are no notes yet for this item.`} + + )} + + - {canEdit && ( - - - - - - - - - - - - - - - - - - )} + + setSelectedNote(note.pk)} > - + {note.title} + {note.primary && ( + + + + )} ))} diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index 2abd4ae649c5..6353c2c33c5f 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -302,7 +302,8 @@ export function useNoteFields({ value: modelId }, title: {}, - description: {} + description: {}, + primary: {} }; }, [modelType, modelId]); } From d798aacb04710896fa7292f2daa7f1c0c0552925 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 24 May 2026 10:40:12 +0000 Subject: [PATCH 26/80] Layout tweaks --- src/frontend/src/components/editors/NotesEditor.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index bd2e9818edec..ec37ab247c01 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -236,9 +236,9 @@ export default function NotesEditor({ {createNote.modal} {deleteNote.modal} {editNote.modal} - - - + + + {selectedNote && ( @@ -255,7 +255,7 @@ export default function NotesEditor({ @@ -267,7 +267,6 @@ export default function NotesEditor({ @@ -318,7 +317,7 @@ export default function NotesEditor({ - +