diff --git a/.changeset/sidebar-menu-tree.md b/.changeset/sidebar-menu-tree.md new file mode 100644 index 000000000..16c712307 --- /dev/null +++ b/.changeset/sidebar-menu-tree.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": minor +"emdash": minor +--- + +Adds sidebar menu tree with collection grouping, plugin subgroups, and public menu sync. Collections can now be organized into collapsible sidebar groups via a new `group` field and ordered with `sortOrder`. Plugin admin pages support the same grouping and custom icon resolution (25+ Phosphor icons). The sidebar supports one level of nested submenus and can hide unused core features via `hideCoreFeatures` / `hideCollections` config. New menu sync engine auto-populates public menus from sidebar structure on collection creation, with preview (`GET /_emdash/api/menus/:name/sync-diff`) and apply (`POST /_emdash/api/menus/:name/sync`) endpoints. Drag-and-drop reordering UI added to the Content Types admin page. diff --git a/packages/admin/.vitest-attachments/00cd7062cce0c0e9c831a5219c0bc2d551e7471c.png b/packages/admin/.vitest-attachments/00cd7062cce0c0e9c831a5219c0bc2d551e7471c.png new file mode 100644 index 000000000..390414129 Binary files /dev/null and b/packages/admin/.vitest-attachments/00cd7062cce0c0e9c831a5219c0bc2d551e7471c.png differ diff --git a/packages/admin/.vitest-attachments/0105327f2d6a7d6be67f18065674ed82fbc78f9f.png b/packages/admin/.vitest-attachments/0105327f2d6a7d6be67f18065674ed82fbc78f9f.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/0105327f2d6a7d6be67f18065674ed82fbc78f9f.png differ diff --git a/packages/admin/.vitest-attachments/02ba9ca14b2554bbecc76c89b3ff539ec07b87c5.png b/packages/admin/.vitest-attachments/02ba9ca14b2554bbecc76c89b3ff539ec07b87c5.png new file mode 100644 index 000000000..13e70d786 Binary files /dev/null and b/packages/admin/.vitest-attachments/02ba9ca14b2554bbecc76c89b3ff539ec07b87c5.png differ diff --git a/packages/admin/.vitest-attachments/05c8ac3e5bf729bd03cbe7b033d7e696380425c9.png b/packages/admin/.vitest-attachments/05c8ac3e5bf729bd03cbe7b033d7e696380425c9.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/05c8ac3e5bf729bd03cbe7b033d7e696380425c9.png differ diff --git a/packages/admin/.vitest-attachments/09a570d7344581fcd930eb21d68fe1ebee7f124c.png b/packages/admin/.vitest-attachments/09a570d7344581fcd930eb21d68fe1ebee7f124c.png new file mode 100644 index 000000000..3e60c977b Binary files /dev/null and b/packages/admin/.vitest-attachments/09a570d7344581fcd930eb21d68fe1ebee7f124c.png differ diff --git a/packages/admin/.vitest-attachments/143e928da07bd8825b897d53b8fe4c332e69b7ce.png b/packages/admin/.vitest-attachments/143e928da07bd8825b897d53b8fe4c332e69b7ce.png new file mode 100644 index 000000000..858a630ce Binary files /dev/null and b/packages/admin/.vitest-attachments/143e928da07bd8825b897d53b8fe4c332e69b7ce.png differ diff --git a/packages/admin/.vitest-attachments/158118c7152dbd853e8b08d8640f26ff267fc83a.png b/packages/admin/.vitest-attachments/158118c7152dbd853e8b08d8640f26ff267fc83a.png new file mode 100644 index 000000000..3e60c977b Binary files /dev/null and b/packages/admin/.vitest-attachments/158118c7152dbd853e8b08d8640f26ff267fc83a.png differ diff --git a/packages/admin/.vitest-attachments/16cd9c77379953252fef8882efda76d25708fa89.png b/packages/admin/.vitest-attachments/16cd9c77379953252fef8882efda76d25708fa89.png new file mode 100644 index 000000000..3e60c977b Binary files /dev/null and b/packages/admin/.vitest-attachments/16cd9c77379953252fef8882efda76d25708fa89.png differ diff --git a/packages/admin/.vitest-attachments/17ba83b93d04368ee7c5d0f87d86a8dfe4e8e7ea.png b/packages/admin/.vitest-attachments/17ba83b93d04368ee7c5d0f87d86a8dfe4e8e7ea.png new file mode 100644 index 000000000..3e60c977b Binary files /dev/null and b/packages/admin/.vitest-attachments/17ba83b93d04368ee7c5d0f87d86a8dfe4e8e7ea.png differ diff --git a/packages/admin/.vitest-attachments/1f2ba428ee0e0416ba554741e67dec25ecc1bf2b.png b/packages/admin/.vitest-attachments/1f2ba428ee0e0416ba554741e67dec25ecc1bf2b.png new file mode 100644 index 000000000..040a17d32 Binary files /dev/null and b/packages/admin/.vitest-attachments/1f2ba428ee0e0416ba554741e67dec25ecc1bf2b.png differ diff --git a/packages/admin/.vitest-attachments/1fa99949f18e3cfaa01f9e0ee9709a9eb702fa4e.png b/packages/admin/.vitest-attachments/1fa99949f18e3cfaa01f9e0ee9709a9eb702fa4e.png new file mode 100644 index 000000000..a328ce3d6 Binary files /dev/null and b/packages/admin/.vitest-attachments/1fa99949f18e3cfaa01f9e0ee9709a9eb702fa4e.png differ diff --git a/packages/admin/.vitest-attachments/2042b96677862c8d210f1f7ffdc801a54212699e.png b/packages/admin/.vitest-attachments/2042b96677862c8d210f1f7ffdc801a54212699e.png new file mode 100644 index 000000000..aba5b6c21 Binary files /dev/null and b/packages/admin/.vitest-attachments/2042b96677862c8d210f1f7ffdc801a54212699e.png differ diff --git a/packages/admin/.vitest-attachments/218bacb899e5cc6fcb0cfc640bba9577c5a2147a.png b/packages/admin/.vitest-attachments/218bacb899e5cc6fcb0cfc640bba9577c5a2147a.png new file mode 100644 index 000000000..d96a74bb2 Binary files /dev/null and b/packages/admin/.vitest-attachments/218bacb899e5cc6fcb0cfc640bba9577c5a2147a.png differ diff --git a/packages/admin/.vitest-attachments/22511385449a64d03969a6cc2448d2d8729deef9.png b/packages/admin/.vitest-attachments/22511385449a64d03969a6cc2448d2d8729deef9.png new file mode 100644 index 000000000..60ed4877f Binary files /dev/null and b/packages/admin/.vitest-attachments/22511385449a64d03969a6cc2448d2d8729deef9.png differ diff --git a/packages/admin/.vitest-attachments/25520c6e208888c7300ecb937263788fb1ad16aa.png b/packages/admin/.vitest-attachments/25520c6e208888c7300ecb937263788fb1ad16aa.png new file mode 100644 index 000000000..2867107c3 Binary files /dev/null and b/packages/admin/.vitest-attachments/25520c6e208888c7300ecb937263788fb1ad16aa.png differ diff --git a/packages/admin/.vitest-attachments/28c918dbbd0814f3006d77cd0596b92af16bedef.png b/packages/admin/.vitest-attachments/28c918dbbd0814f3006d77cd0596b92af16bedef.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/28c918dbbd0814f3006d77cd0596b92af16bedef.png differ diff --git a/packages/admin/.vitest-attachments/28e713bf629c6165684993536f1f98523b796566.png b/packages/admin/.vitest-attachments/28e713bf629c6165684993536f1f98523b796566.png new file mode 100644 index 000000000..f47b9cc24 Binary files /dev/null and b/packages/admin/.vitest-attachments/28e713bf629c6165684993536f1f98523b796566.png differ diff --git a/packages/admin/.vitest-attachments/2ae04db564ff63ffb04ffacbd092a6915f2f6533.png b/packages/admin/.vitest-attachments/2ae04db564ff63ffb04ffacbd092a6915f2f6533.png new file mode 100644 index 000000000..490378ef2 Binary files /dev/null and b/packages/admin/.vitest-attachments/2ae04db564ff63ffb04ffacbd092a6915f2f6533.png differ diff --git a/packages/admin/.vitest-attachments/2b814d77a216b91f4e692cb02ba27bd029e35865.png b/packages/admin/.vitest-attachments/2b814d77a216b91f4e692cb02ba27bd029e35865.png new file mode 100644 index 000000000..e8375dd91 Binary files /dev/null and b/packages/admin/.vitest-attachments/2b814d77a216b91f4e692cb02ba27bd029e35865.png differ diff --git a/packages/admin/.vitest-attachments/30898cf4f6c4fcb8c6ab7c0b2ac5633b82e7f259.png b/packages/admin/.vitest-attachments/30898cf4f6c4fcb8c6ab7c0b2ac5633b82e7f259.png new file mode 100644 index 000000000..e54cf5698 Binary files /dev/null and b/packages/admin/.vitest-attachments/30898cf4f6c4fcb8c6ab7c0b2ac5633b82e7f259.png differ diff --git a/packages/admin/.vitest-attachments/31e357ccd37e960c139732625c30850f360bcc1e.png b/packages/admin/.vitest-attachments/31e357ccd37e960c139732625c30850f360bcc1e.png new file mode 100644 index 000000000..546ddff76 Binary files /dev/null and b/packages/admin/.vitest-attachments/31e357ccd37e960c139732625c30850f360bcc1e.png differ diff --git a/packages/admin/.vitest-attachments/3205151453020363daed9170fc2b9dc399362e4b.png b/packages/admin/.vitest-attachments/3205151453020363daed9170fc2b9dc399362e4b.png new file mode 100644 index 000000000..a72e639fe Binary files /dev/null and b/packages/admin/.vitest-attachments/3205151453020363daed9170fc2b9dc399362e4b.png differ diff --git a/packages/admin/.vitest-attachments/323a3113f576b0f32ad2025cee1782f1e310d95b.png b/packages/admin/.vitest-attachments/323a3113f576b0f32ad2025cee1782f1e310d95b.png new file mode 100644 index 000000000..e8375dd91 Binary files /dev/null and b/packages/admin/.vitest-attachments/323a3113f576b0f32ad2025cee1782f1e310d95b.png differ diff --git a/packages/admin/.vitest-attachments/39b0868b9205cae5c00816956ca07f0718369fc0.png b/packages/admin/.vitest-attachments/39b0868b9205cae5c00816956ca07f0718369fc0.png new file mode 100644 index 000000000..de8d73978 Binary files /dev/null and b/packages/admin/.vitest-attachments/39b0868b9205cae5c00816956ca07f0718369fc0.png differ diff --git a/packages/admin/.vitest-attachments/4185a480e49906f80a212053e71954d7954c9382.png b/packages/admin/.vitest-attachments/4185a480e49906f80a212053e71954d7954c9382.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/4185a480e49906f80a212053e71954d7954c9382.png differ diff --git a/packages/admin/.vitest-attachments/42647a7b6360c28534d7533e32a719f51f7bd1f9.png b/packages/admin/.vitest-attachments/42647a7b6360c28534d7533e32a719f51f7bd1f9.png new file mode 100644 index 000000000..1d7c663c6 Binary files /dev/null and b/packages/admin/.vitest-attachments/42647a7b6360c28534d7533e32a719f51f7bd1f9.png differ diff --git a/packages/admin/.vitest-attachments/454953b33ab01bb20c2e7d6583e3f782eed48c60.png b/packages/admin/.vitest-attachments/454953b33ab01bb20c2e7d6583e3f782eed48c60.png new file mode 100644 index 000000000..3df25c0d1 Binary files /dev/null and b/packages/admin/.vitest-attachments/454953b33ab01bb20c2e7d6583e3f782eed48c60.png differ diff --git a/packages/admin/.vitest-attachments/48639df225759bff749e86382580a512fa97ae84.png b/packages/admin/.vitest-attachments/48639df225759bff749e86382580a512fa97ae84.png new file mode 100644 index 000000000..a9e476827 Binary files /dev/null and b/packages/admin/.vitest-attachments/48639df225759bff749e86382580a512fa97ae84.png differ diff --git a/packages/admin/.vitest-attachments/491e832983b0b65d018d589e416b15c78b48e168.png b/packages/admin/.vitest-attachments/491e832983b0b65d018d589e416b15c78b48e168.png new file mode 100644 index 000000000..5e6f83a9e Binary files /dev/null and b/packages/admin/.vitest-attachments/491e832983b0b65d018d589e416b15c78b48e168.png differ diff --git a/packages/admin/.vitest-attachments/4c3cf002cacaff3a756c2c60d74750c4f6285acb.png b/packages/admin/.vitest-attachments/4c3cf002cacaff3a756c2c60d74750c4f6285acb.png new file mode 100644 index 000000000..6e9224972 Binary files /dev/null and b/packages/admin/.vitest-attachments/4c3cf002cacaff3a756c2c60d74750c4f6285acb.png differ diff --git a/packages/admin/.vitest-attachments/4c843e2162c2de6ff5f57cd200dc6ed7e27a3205.png b/packages/admin/.vitest-attachments/4c843e2162c2de6ff5f57cd200dc6ed7e27a3205.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/4c843e2162c2de6ff5f57cd200dc6ed7e27a3205.png differ diff --git a/packages/admin/.vitest-attachments/4eab7bcd9721800650cc28e14fd1584d53a6e6da.png b/packages/admin/.vitest-attachments/4eab7bcd9721800650cc28e14fd1584d53a6e6da.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/4eab7bcd9721800650cc28e14fd1584d53a6e6da.png differ diff --git a/packages/admin/.vitest-attachments/51e868185647270a809810316faddc8b6be31fbd.png b/packages/admin/.vitest-attachments/51e868185647270a809810316faddc8b6be31fbd.png new file mode 100644 index 000000000..2a5bfcdaa Binary files /dev/null and b/packages/admin/.vitest-attachments/51e868185647270a809810316faddc8b6be31fbd.png differ diff --git a/packages/admin/.vitest-attachments/5297c214546cabe01a04e000cc9bcfa8fc3cc89a.png b/packages/admin/.vitest-attachments/5297c214546cabe01a04e000cc9bcfa8fc3cc89a.png new file mode 100644 index 000000000..417f6d3ea Binary files /dev/null and b/packages/admin/.vitest-attachments/5297c214546cabe01a04e000cc9bcfa8fc3cc89a.png differ diff --git a/packages/admin/.vitest-attachments/5a6e408eaea84b225b132cdb545c242c26b0cb64.png b/packages/admin/.vitest-attachments/5a6e408eaea84b225b132cdb545c242c26b0cb64.png new file mode 100644 index 000000000..46649ecc1 Binary files /dev/null and b/packages/admin/.vitest-attachments/5a6e408eaea84b225b132cdb545c242c26b0cb64.png differ diff --git a/packages/admin/.vitest-attachments/5de9c052a08f2b071e3457606fb8f1e5495479e8.png b/packages/admin/.vitest-attachments/5de9c052a08f2b071e3457606fb8f1e5495479e8.png new file mode 100644 index 000000000..a328ce3d6 Binary files /dev/null and b/packages/admin/.vitest-attachments/5de9c052a08f2b071e3457606fb8f1e5495479e8.png differ diff --git a/packages/admin/.vitest-attachments/5f431b3e8a4055f652b9684d7630082466e39369.png b/packages/admin/.vitest-attachments/5f431b3e8a4055f652b9684d7630082466e39369.png new file mode 100644 index 000000000..b6f043236 Binary files /dev/null and b/packages/admin/.vitest-attachments/5f431b3e8a4055f652b9684d7630082466e39369.png differ diff --git a/packages/admin/.vitest-attachments/5fafba1dcf35159d32901512d8d807cba54192db.png b/packages/admin/.vitest-attachments/5fafba1dcf35159d32901512d8d807cba54192db.png new file mode 100644 index 000000000..551f07583 Binary files /dev/null and b/packages/admin/.vitest-attachments/5fafba1dcf35159d32901512d8d807cba54192db.png differ diff --git a/packages/admin/.vitest-attachments/613a13ffc558a5a1eeeaf7ffdc695334e9075ce9.png b/packages/admin/.vitest-attachments/613a13ffc558a5a1eeeaf7ffdc695334e9075ce9.png new file mode 100644 index 000000000..46649ecc1 Binary files /dev/null and b/packages/admin/.vitest-attachments/613a13ffc558a5a1eeeaf7ffdc695334e9075ce9.png differ diff --git a/packages/admin/.vitest-attachments/6710883cf5c09ac9542a87fbc7a581a41a4d0316.png b/packages/admin/.vitest-attachments/6710883cf5c09ac9542a87fbc7a581a41a4d0316.png new file mode 100644 index 000000000..3bfe5c9b9 Binary files /dev/null and b/packages/admin/.vitest-attachments/6710883cf5c09ac9542a87fbc7a581a41a4d0316.png differ diff --git a/packages/admin/.vitest-attachments/6d61b1dd737f7c0d6ec93a1e5431a295f7fc5297.png b/packages/admin/.vitest-attachments/6d61b1dd737f7c0d6ec93a1e5431a295f7fc5297.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/6d61b1dd737f7c0d6ec93a1e5431a295f7fc5297.png differ diff --git a/packages/admin/.vitest-attachments/6deb228ca0a27f08266af66e0f36074e3ea85608.png b/packages/admin/.vitest-attachments/6deb228ca0a27f08266af66e0f36074e3ea85608.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/6deb228ca0a27f08266af66e0f36074e3ea85608.png differ diff --git a/packages/admin/.vitest-attachments/71753f3dce10408038d2ffd8b4388bfe640193df.png b/packages/admin/.vitest-attachments/71753f3dce10408038d2ffd8b4388bfe640193df.png new file mode 100644 index 000000000..a424cc8a1 Binary files /dev/null and b/packages/admin/.vitest-attachments/71753f3dce10408038d2ffd8b4388bfe640193df.png differ diff --git a/packages/admin/.vitest-attachments/76dc9bf6570cac56b9faa85fae58632ac01d94a8.png b/packages/admin/.vitest-attachments/76dc9bf6570cac56b9faa85fae58632ac01d94a8.png new file mode 100644 index 000000000..a9e476827 Binary files /dev/null and b/packages/admin/.vitest-attachments/76dc9bf6570cac56b9faa85fae58632ac01d94a8.png differ diff --git a/packages/admin/.vitest-attachments/7bc8d39e420d2d05aeb9187246737d1fc2674a1d.png b/packages/admin/.vitest-attachments/7bc8d39e420d2d05aeb9187246737d1fc2674a1d.png new file mode 100644 index 000000000..3df25c0d1 Binary files /dev/null and b/packages/admin/.vitest-attachments/7bc8d39e420d2d05aeb9187246737d1fc2674a1d.png differ diff --git a/packages/admin/.vitest-attachments/8024f231a370fa95348e8e7fa1cf2c5bca10372a.png b/packages/admin/.vitest-attachments/8024f231a370fa95348e8e7fa1cf2c5bca10372a.png new file mode 100644 index 000000000..83dd1b815 Binary files /dev/null and b/packages/admin/.vitest-attachments/8024f231a370fa95348e8e7fa1cf2c5bca10372a.png differ diff --git a/packages/admin/.vitest-attachments/80f3e3396ba51fc18ef4ccfb5b0979f7d3b35e09.png b/packages/admin/.vitest-attachments/80f3e3396ba51fc18ef4ccfb5b0979f7d3b35e09.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/80f3e3396ba51fc18ef4ccfb5b0979f7d3b35e09.png differ diff --git a/packages/admin/.vitest-attachments/848dc6de06b059ffc45eb009375c03215cc5fbfd.png b/packages/admin/.vitest-attachments/848dc6de06b059ffc45eb009375c03215cc5fbfd.png new file mode 100644 index 000000000..2e12255d3 Binary files /dev/null and b/packages/admin/.vitest-attachments/848dc6de06b059ffc45eb009375c03215cc5fbfd.png differ diff --git a/packages/admin/.vitest-attachments/8494a2541e923bd306b905dc2d50067482a0459c.png b/packages/admin/.vitest-attachments/8494a2541e923bd306b905dc2d50067482a0459c.png new file mode 100644 index 000000000..b6396c568 Binary files /dev/null and b/packages/admin/.vitest-attachments/8494a2541e923bd306b905dc2d50067482a0459c.png differ diff --git a/packages/admin/.vitest-attachments/85eb45831d8446ac2e12fe211722820170c45201.png b/packages/admin/.vitest-attachments/85eb45831d8446ac2e12fe211722820170c45201.png new file mode 100644 index 000000000..faa09a912 Binary files /dev/null and b/packages/admin/.vitest-attachments/85eb45831d8446ac2e12fe211722820170c45201.png differ diff --git a/packages/admin/.vitest-attachments/87b0eed318d4324d30eabae17f8d9633c2d89cb5.png b/packages/admin/.vitest-attachments/87b0eed318d4324d30eabae17f8d9633c2d89cb5.png new file mode 100644 index 000000000..05db40e86 Binary files /dev/null and b/packages/admin/.vitest-attachments/87b0eed318d4324d30eabae17f8d9633c2d89cb5.png differ diff --git a/packages/admin/.vitest-attachments/8d86f276e3eecd2ea7dd8e1b49a24e05034aaa16.png b/packages/admin/.vitest-attachments/8d86f276e3eecd2ea7dd8e1b49a24e05034aaa16.png new file mode 100644 index 000000000..3f6806a0b Binary files /dev/null and b/packages/admin/.vitest-attachments/8d86f276e3eecd2ea7dd8e1b49a24e05034aaa16.png differ diff --git a/packages/admin/.vitest-attachments/913e9ee5b712045d72afeea5d64e26fe4ab88884.png b/packages/admin/.vitest-attachments/913e9ee5b712045d72afeea5d64e26fe4ab88884.png new file mode 100644 index 000000000..a9e476827 Binary files /dev/null and b/packages/admin/.vitest-attachments/913e9ee5b712045d72afeea5d64e26fe4ab88884.png differ diff --git a/packages/admin/.vitest-attachments/929910ec4b9dfd4bd5b5beb0f68f859f5826b6ab.png b/packages/admin/.vitest-attachments/929910ec4b9dfd4bd5b5beb0f68f859f5826b6ab.png new file mode 100644 index 000000000..ec77dc4ea Binary files /dev/null and b/packages/admin/.vitest-attachments/929910ec4b9dfd4bd5b5beb0f68f859f5826b6ab.png differ diff --git a/packages/admin/.vitest-attachments/99aa177b5f0629b41977631d6cbb3699fac82665.png b/packages/admin/.vitest-attachments/99aa177b5f0629b41977631d6cbb3699fac82665.png new file mode 100644 index 000000000..3bfe5c9b9 Binary files /dev/null and b/packages/admin/.vitest-attachments/99aa177b5f0629b41977631d6cbb3699fac82665.png differ diff --git a/packages/admin/.vitest-attachments/99eb76db41faeb2ab901e31b49aaaae26bee2a29.png b/packages/admin/.vitest-attachments/99eb76db41faeb2ab901e31b49aaaae26bee2a29.png new file mode 100644 index 000000000..78c99a8f4 Binary files /dev/null and b/packages/admin/.vitest-attachments/99eb76db41faeb2ab901e31b49aaaae26bee2a29.png differ diff --git a/packages/admin/.vitest-attachments/9cf8e0602b2f8a9a34d54649c49502ae94c230c9.png b/packages/admin/.vitest-attachments/9cf8e0602b2f8a9a34d54649c49502ae94c230c9.png new file mode 100644 index 000000000..a184a7789 Binary files /dev/null and b/packages/admin/.vitest-attachments/9cf8e0602b2f8a9a34d54649c49502ae94c230c9.png differ diff --git a/packages/admin/.vitest-attachments/9e0cfae8cd4e864ae4f0769e4317842ed667d312.png b/packages/admin/.vitest-attachments/9e0cfae8cd4e864ae4f0769e4317842ed667d312.png new file mode 100644 index 000000000..01996ca04 Binary files /dev/null and b/packages/admin/.vitest-attachments/9e0cfae8cd4e864ae4f0769e4317842ed667d312.png differ diff --git a/packages/admin/.vitest-attachments/a62761e130cc555c4045cf2879a8cf371432cc19.png b/packages/admin/.vitest-attachments/a62761e130cc555c4045cf2879a8cf371432cc19.png new file mode 100644 index 000000000..fc1701848 Binary files /dev/null and b/packages/admin/.vitest-attachments/a62761e130cc555c4045cf2879a8cf371432cc19.png differ diff --git a/packages/admin/.vitest-attachments/a6cdd0da84556c60625f83d7ab217cf66c0a2c05.png b/packages/admin/.vitest-attachments/a6cdd0da84556c60625f83d7ab217cf66c0a2c05.png new file mode 100644 index 000000000..19cb4bdf0 Binary files /dev/null and b/packages/admin/.vitest-attachments/a6cdd0da84556c60625f83d7ab217cf66c0a2c05.png differ diff --git a/packages/admin/.vitest-attachments/a8183c0ce5001a8d8663507cc7d101fd0ed69e98.png b/packages/admin/.vitest-attachments/a8183c0ce5001a8d8663507cc7d101fd0ed69e98.png new file mode 100644 index 000000000..9dce6c71b Binary files /dev/null and b/packages/admin/.vitest-attachments/a8183c0ce5001a8d8663507cc7d101fd0ed69e98.png differ diff --git a/packages/admin/.vitest-attachments/a9b9ddacd4901f6dec266bff318e7a73417cea19.png b/packages/admin/.vitest-attachments/a9b9ddacd4901f6dec266bff318e7a73417cea19.png new file mode 100644 index 000000000..a3e1e9a88 Binary files /dev/null and b/packages/admin/.vitest-attachments/a9b9ddacd4901f6dec266bff318e7a73417cea19.png differ diff --git a/packages/admin/.vitest-attachments/aa5018500798fff7857ebd108f711eb709ddf65a.png b/packages/admin/.vitest-attachments/aa5018500798fff7857ebd108f711eb709ddf65a.png new file mode 100644 index 000000000..2867107c3 Binary files /dev/null and b/packages/admin/.vitest-attachments/aa5018500798fff7857ebd108f711eb709ddf65a.png differ diff --git a/packages/admin/.vitest-attachments/acce0011deda7e2a0bbb9be202c70ff21eb32a46.png b/packages/admin/.vitest-attachments/acce0011deda7e2a0bbb9be202c70ff21eb32a46.png new file mode 100644 index 000000000..490378ef2 Binary files /dev/null and b/packages/admin/.vitest-attachments/acce0011deda7e2a0bbb9be202c70ff21eb32a46.png differ diff --git a/packages/admin/.vitest-attachments/ae9d4306f7ca6d5bb7aae6157df36b40c615e27f.png b/packages/admin/.vitest-attachments/ae9d4306f7ca6d5bb7aae6157df36b40c615e27f.png new file mode 100644 index 000000000..899f85b12 Binary files /dev/null and b/packages/admin/.vitest-attachments/ae9d4306f7ca6d5bb7aae6157df36b40c615e27f.png differ diff --git a/packages/admin/.vitest-attachments/af137b817d128c018ff2aede578ee0723ddaeb3f.png b/packages/admin/.vitest-attachments/af137b817d128c018ff2aede578ee0723ddaeb3f.png new file mode 100644 index 000000000..b6646a09f Binary files /dev/null and b/packages/admin/.vitest-attachments/af137b817d128c018ff2aede578ee0723ddaeb3f.png differ diff --git a/packages/admin/.vitest-attachments/af52abd7df5b252ad87fd68b4f704eaf4d683dc8.png b/packages/admin/.vitest-attachments/af52abd7df5b252ad87fd68b4f704eaf4d683dc8.png new file mode 100644 index 000000000..a328ce3d6 Binary files /dev/null and b/packages/admin/.vitest-attachments/af52abd7df5b252ad87fd68b4f704eaf4d683dc8.png differ diff --git a/packages/admin/.vitest-attachments/b126503bacd595d14f57847587f077dd5223b1ec.png b/packages/admin/.vitest-attachments/b126503bacd595d14f57847587f077dd5223b1ec.png new file mode 100644 index 000000000..9d0dffa70 Binary files /dev/null and b/packages/admin/.vitest-attachments/b126503bacd595d14f57847587f077dd5223b1ec.png differ diff --git a/packages/admin/.vitest-attachments/b1d764bac130774561a927a94c59b60ecf9aa5ca.png b/packages/admin/.vitest-attachments/b1d764bac130774561a927a94c59b60ecf9aa5ca.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/b1d764bac130774561a927a94c59b60ecf9aa5ca.png differ diff --git a/packages/admin/.vitest-attachments/b1eff60bfa86d9f2331a114821420b057a4e1d68.png b/packages/admin/.vitest-attachments/b1eff60bfa86d9f2331a114821420b057a4e1d68.png new file mode 100644 index 000000000..8a5b75784 Binary files /dev/null and b/packages/admin/.vitest-attachments/b1eff60bfa86d9f2331a114821420b057a4e1d68.png differ diff --git a/packages/admin/.vitest-attachments/b4f4ced18a4391f54528018f0843ebe45361b636.png b/packages/admin/.vitest-attachments/b4f4ced18a4391f54528018f0843ebe45361b636.png new file mode 100644 index 000000000..7d60d3c2d Binary files /dev/null and b/packages/admin/.vitest-attachments/b4f4ced18a4391f54528018f0843ebe45361b636.png differ diff --git a/packages/admin/.vitest-attachments/bf5909457eaefe33ac2d9864b225c70e5775b57d.png b/packages/admin/.vitest-attachments/bf5909457eaefe33ac2d9864b225c70e5775b57d.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/bf5909457eaefe33ac2d9864b225c70e5775b57d.png differ diff --git a/packages/admin/.vitest-attachments/bf804f1ed8d1780b7530d706ecd296638fbcc91b.png b/packages/admin/.vitest-attachments/bf804f1ed8d1780b7530d706ecd296638fbcc91b.png new file mode 100644 index 000000000..490378ef2 Binary files /dev/null and b/packages/admin/.vitest-attachments/bf804f1ed8d1780b7530d706ecd296638fbcc91b.png differ diff --git a/packages/admin/.vitest-attachments/c49e6cbb8adb5aa0a4d224da6d27aa60072472bb.png b/packages/admin/.vitest-attachments/c49e6cbb8adb5aa0a4d224da6d27aa60072472bb.png new file mode 100644 index 000000000..13e70d786 Binary files /dev/null and b/packages/admin/.vitest-attachments/c49e6cbb8adb5aa0a4d224da6d27aa60072472bb.png differ diff --git a/packages/admin/.vitest-attachments/c4a0e9d53c7702d636a06ca1f55255618a05b0e2.png b/packages/admin/.vitest-attachments/c4a0e9d53c7702d636a06ca1f55255618a05b0e2.png new file mode 100644 index 000000000..d9a88ffc3 Binary files /dev/null and b/packages/admin/.vitest-attachments/c4a0e9d53c7702d636a06ca1f55255618a05b0e2.png differ diff --git a/packages/admin/.vitest-attachments/c5cb93af173bc68664bdaf065552f060771d1b98.png b/packages/admin/.vitest-attachments/c5cb93af173bc68664bdaf065552f060771d1b98.png new file mode 100644 index 000000000..4eed088f2 Binary files /dev/null and b/packages/admin/.vitest-attachments/c5cb93af173bc68664bdaf065552f060771d1b98.png differ diff --git a/packages/admin/.vitest-attachments/c7c2413f4e11a4eeda2ebeae2e532c9565c3771b.png b/packages/admin/.vitest-attachments/c7c2413f4e11a4eeda2ebeae2e532c9565c3771b.png new file mode 100644 index 000000000..0fb8a7c58 Binary files /dev/null and b/packages/admin/.vitest-attachments/c7c2413f4e11a4eeda2ebeae2e532c9565c3771b.png differ diff --git a/packages/admin/.vitest-attachments/ca71d94ed238a0815d4217e160fb10ded4696be3.png b/packages/admin/.vitest-attachments/ca71d94ed238a0815d4217e160fb10ded4696be3.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/ca71d94ed238a0815d4217e160fb10ded4696be3.png differ diff --git a/packages/admin/.vitest-attachments/ccf3f464c3d88974749c4071d41a7770c2070797.png b/packages/admin/.vitest-attachments/ccf3f464c3d88974749c4071d41a7770c2070797.png new file mode 100644 index 000000000..a72e639fe Binary files /dev/null and b/packages/admin/.vitest-attachments/ccf3f464c3d88974749c4071d41a7770c2070797.png differ diff --git a/packages/admin/.vitest-attachments/d03c1bb4d3956d70a092cb9d37cede7457c79cd9.png b/packages/admin/.vitest-attachments/d03c1bb4d3956d70a092cb9d37cede7457c79cd9.png new file mode 100644 index 000000000..6479d4876 Binary files /dev/null and b/packages/admin/.vitest-attachments/d03c1bb4d3956d70a092cb9d37cede7457c79cd9.png differ diff --git a/packages/admin/.vitest-attachments/d138dc8c95d36cf9b01477779da9bd3057678673.png b/packages/admin/.vitest-attachments/d138dc8c95d36cf9b01477779da9bd3057678673.png new file mode 100644 index 000000000..a8b502b39 Binary files /dev/null and b/packages/admin/.vitest-attachments/d138dc8c95d36cf9b01477779da9bd3057678673.png differ diff --git a/packages/admin/.vitest-attachments/d29d506a3c05989eeae85262dc8b0b957629b92d.png b/packages/admin/.vitest-attachments/d29d506a3c05989eeae85262dc8b0b957629b92d.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/d29d506a3c05989eeae85262dc8b0b957629b92d.png differ diff --git a/packages/admin/.vitest-attachments/daaf346307761f78aa62d07c6c14e5cf95741893.png b/packages/admin/.vitest-attachments/daaf346307761f78aa62d07c6c14e5cf95741893.png new file mode 100644 index 000000000..b05e92888 Binary files /dev/null and b/packages/admin/.vitest-attachments/daaf346307761f78aa62d07c6c14e5cf95741893.png differ diff --git a/packages/admin/.vitest-attachments/dcfbf50554d7230d0a4992613d3cae4feac9312a.png b/packages/admin/.vitest-attachments/dcfbf50554d7230d0a4992613d3cae4feac9312a.png new file mode 100644 index 000000000..4d7d29e33 Binary files /dev/null and b/packages/admin/.vitest-attachments/dcfbf50554d7230d0a4992613d3cae4feac9312a.png differ diff --git a/packages/admin/.vitest-attachments/dd1e04e81176a253d93d9e968c73532958bfbb00.png b/packages/admin/.vitest-attachments/dd1e04e81176a253d93d9e968c73532958bfbb00.png new file mode 100644 index 000000000..c27dd143a Binary files /dev/null and b/packages/admin/.vitest-attachments/dd1e04e81176a253d93d9e968c73532958bfbb00.png differ diff --git a/packages/admin/.vitest-attachments/df7e230d44f526e94ea7e1b3ff86296d45f14476.png b/packages/admin/.vitest-attachments/df7e230d44f526e94ea7e1b3ff86296d45f14476.png new file mode 100644 index 000000000..89c43327d Binary files /dev/null and b/packages/admin/.vitest-attachments/df7e230d44f526e94ea7e1b3ff86296d45f14476.png differ diff --git a/packages/admin/.vitest-attachments/e3a69922b0931917cc9318361604549958ad879c.png b/packages/admin/.vitest-attachments/e3a69922b0931917cc9318361604549958ad879c.png new file mode 100644 index 000000000..74adcb158 Binary files /dev/null and b/packages/admin/.vitest-attachments/e3a69922b0931917cc9318361604549958ad879c.png differ diff --git a/packages/admin/.vitest-attachments/e496f47eca8574003f637f502fff449bddcdaacb.png b/packages/admin/.vitest-attachments/e496f47eca8574003f637f502fff449bddcdaacb.png new file mode 100644 index 000000000..a328ce3d6 Binary files /dev/null and b/packages/admin/.vitest-attachments/e496f47eca8574003f637f502fff449bddcdaacb.png differ diff --git a/packages/admin/.vitest-attachments/eb834413c3e1b32074856b883cc001f13ea39a7c.png b/packages/admin/.vitest-attachments/eb834413c3e1b32074856b883cc001f13ea39a7c.png new file mode 100644 index 000000000..72f2321c8 Binary files /dev/null and b/packages/admin/.vitest-attachments/eb834413c3e1b32074856b883cc001f13ea39a7c.png differ diff --git a/packages/admin/.vitest-attachments/eca1f72371a1b1134437a365d1d43c0f68f000af.png b/packages/admin/.vitest-attachments/eca1f72371a1b1134437a365d1d43c0f68f000af.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/eca1f72371a1b1134437a365d1d43c0f68f000af.png differ diff --git a/packages/admin/.vitest-attachments/f21c383740d3c08d9e5828be1ddb872b426e3c8d.png b/packages/admin/.vitest-attachments/f21c383740d3c08d9e5828be1ddb872b426e3c8d.png new file mode 100644 index 000000000..3bfe5c9b9 Binary files /dev/null and b/packages/admin/.vitest-attachments/f21c383740d3c08d9e5828be1ddb872b426e3c8d.png differ diff --git a/packages/admin/.vitest-attachments/f495074a20e8a504a8bf5b1d4f0e063368c11c99.png b/packages/admin/.vitest-attachments/f495074a20e8a504a8bf5b1d4f0e063368c11c99.png new file mode 100644 index 000000000..ffc963c14 Binary files /dev/null and b/packages/admin/.vitest-attachments/f495074a20e8a504a8bf5b1d4f0e063368c11c99.png differ diff --git a/packages/admin/.vitest-attachments/fc1169537f8a96808773f99835480156fc79f17a.png b/packages/admin/.vitest-attachments/fc1169537f8a96808773f99835480156fc79f17a.png new file mode 100644 index 000000000..a72e639fe Binary files /dev/null and b/packages/admin/.vitest-attachments/fc1169537f8a96808773f99835480156fc79f17a.png differ diff --git a/packages/admin/.vitest-attachments/fc9e8f6b8ada1e26853df72166ac8f42a53df71b.png b/packages/admin/.vitest-attachments/fc9e8f6b8ada1e26853df72166ac8f42a53df71b.png new file mode 100644 index 000000000..0c04c0926 Binary files /dev/null and b/packages/admin/.vitest-attachments/fc9e8f6b8ada1e26853df72166ac8f42a53df71b.png differ diff --git a/packages/admin/.vitest-attachments/ff4b14f6b38a7c47dfe291e37f855eaf89553987.png b/packages/admin/.vitest-attachments/ff4b14f6b38a7c47dfe291e37f855eaf89553987.png new file mode 100644 index 000000000..a424cc8a1 Binary files /dev/null and b/packages/admin/.vitest-attachments/ff4b14f6b38a7c47dfe291e37f855eaf89553987.png differ diff --git a/packages/admin/src/components/CollectionReorderDialog.tsx b/packages/admin/src/components/CollectionReorderDialog.tsx new file mode 100644 index 000000000..4a54c3949 --- /dev/null +++ b/packages/admin/src/components/CollectionReorderDialog.tsx @@ -0,0 +1,176 @@ +import { Button, Dialog } from "@cloudflare/kumo"; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, + arrayMove, +} from "@dnd-kit/sortable"; +import { useLingui } from "@lingui/react/macro"; +import { DotsSixVertical, X } from "@phosphor-icons/react"; +import * as React from "react"; + +import { cn } from "../lib/utils"; + +interface CollectionOrderItem { + slug: string; + label: string; + sortOrder: number; +} + +interface CollectionReorderDialogProps { + open: boolean; + onClose: () => void; + collections: CollectionOrderItem[]; + onReorder: (collections: Array<{ slug: string; sortOrder: number }>) => Promise; +} + +function SortableCollectionRow({ item }: { item: CollectionOrderItem }) { + const { t } = useLingui(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: item.slug, + }); + + const style: React.CSSProperties = { + transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined, + transition: transition ?? undefined, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ + {item.label} + + {item.slug} + +
+ ); +} + +/** + * Dialog for reordering collections via drag-and-drop. + */ +export function CollectionReorderDialog({ + open, + onClose, + collections, + onReorder, +}: CollectionReorderDialogProps) { + const { t } = useLingui(); + const [order, setOrder] = React.useState([]); + const [saving, setSaving] = React.useState(false); + + React.useEffect(() => { + if (open) { + setOrder( + collections.toSorted((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label)), + ); + } + }, [open, collections]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + + setOrder((prev) => { + const oldIndex = prev.findIndex((c) => c.slug === active.id); + const newIndex = prev.findIndex((c) => c.slug === over.id); + if (oldIndex === -1 || newIndex === -1) return prev; + return arrayMove(prev, oldIndex, newIndex); + }); + }; + + const handleSave = async () => { + setSaving(true); + try { + await onReorder(order.map((c, i) => ({ slug: c.slug, sortOrder: i }))); + onClose(); + } finally { + setSaving(false); + } + }; + + return ( + !v && onClose()}> + +
+ + {t`Reorder Collections`} + + ( + + )} + /> +
+ + {t`Drag and drop to change the order of collections in the sidebar.`} + + +
+ + c.slug)} + strategy={verticalListSortingStrategy} + > + {order.map((item) => ( + + ))} + + +
+ +
+ + +
+
+
+ ); +} diff --git a/packages/admin/src/components/ContentTypeList.tsx b/packages/admin/src/components/ContentTypeList.tsx index 8b215a9f1..a833cd6dc 100644 --- a/packages/admin/src/components/ContentTypeList.tsx +++ b/packages/admin/src/components/ContentTypeList.tsx @@ -1,12 +1,22 @@ import { Badge, Button } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; -import { Plus, Pencil, Trash, Database, FileText, Warning, Check } from "@phosphor-icons/react"; +import { + Plus, + Pencil, + Trash, + Database, + FileText, + Warning, + Check, + ArrowsVertical, +} from "@phosphor-icons/react"; import { Link } from "@tanstack/react-router"; import * as React from "react"; import type { SchemaCollection, OrphanedTable } from "../lib/api"; import { cn } from "../lib/utils"; +import { CollectionReorderDialog } from "./CollectionReorderDialog"; import { ConfirmDialog } from "./ConfirmDialog"; import { RouterLinkButton } from "./RouterLinkButton.js"; @@ -16,6 +26,7 @@ export interface ContentTypeListProps { isLoading?: boolean; onDelete?: (slug: string) => void; onRegisterOrphan?: (slug: string) => void; + onReorder?: (collections: Array<{ slug: string; sortOrder: number }>) => Promise; } /** @@ -27,9 +38,11 @@ export function ContentTypeList({ isLoading, onDelete, onRegisterOrphan, + onReorder, }: ContentTypeListProps) { const { t } = useLingui(); const [deleteTarget, setDeleteTarget] = React.useState(null); + const [reorderOpen, setReorderOpen] = React.useState(false); const hasOrphans = orphanedTables && orphanedTables.length > 0; return ( @@ -40,9 +53,18 @@ export function ContentTypeList({

{t`Content Types`}

{t`Define the structure of your content`}

- }> - {t`New Content Type`} - +
+ + }> + {t`New Content Type`} + +
{/* Orphaned Tables Warning */} @@ -156,6 +178,17 @@ export function ContentTypeList({ } }} /> + + setReorderOpen(false)} + collections={collections.map((c) => ({ + slug: c.slug, + label: c.label, + sortOrder: c.sortOrder ?? 0, + }))} + onReorder={onReorder ?? (async () => {})} + /> ); } diff --git a/packages/admin/src/components/Shell.tsx b/packages/admin/src/components/Shell.tsx index af2684755..36991319d 100644 --- a/packages/admin/src/components/Shell.tsx +++ b/packages/admin/src/components/Shell.tsx @@ -9,12 +9,25 @@ import { WelcomeModal } from "./WelcomeModal"; export interface ShellProps { children: React.ReactNode; manifest: { - collections: Record; + collections: Record< + string, + { + label: string; + sortOrder?: number; + group?: string; + } + >; plugins: Record< string, { package?: string; - adminPages?: Array<{ path: string; label?: string; icon?: string }>; + adminPages?: Array<{ + path: string; + label?: string; + icon?: string; + group?: string; + sortOrder?: number; + }>; } >; taxonomies: Array<{ diff --git a/packages/admin/src/components/Sidebar.tsx b/packages/admin/src/components/Sidebar.tsx index 874cb82f4..a5e3632ba 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -16,6 +16,24 @@ import { Users, Stack, ArrowsLeftRight, + ChartBar, + ChartPie, + Rocket, + Globe, + MagnifyingGlass, + ShieldCheck, + PenNib, + Calendar, + Envelope, + Phone, + MapPin, + Star, + Tag, + Book, + GraduationCap, + Wrench, + Lightbulb, + CaretRight, } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { Link, useLocation } from "@tanstack/react-router"; @@ -34,9 +52,57 @@ export { KumoSidebar as Sidebar, useSidebar }; const ROLE_ADMIN = 50; const ROLE_EDITOR = 40; +/** Map of icon names to Phosphor icon components for plugin pages and collections. */ +const ICON_MAP: Record = { + gear: Gear, + chart: ChartBar, + "chart-pie": ChartPie, + rocket: Rocket, + globe: Globe, + database: Database, + list: List, + search: MagnifyingGlass, + shield: ShieldCheck, + puzzle: PuzzlePiece, + pen: PenNib, + calendar: Calendar, + mail: Envelope, + phone: Phone, + map: MapPin, + star: Star, + tag: Tag, + book: Book, + graduation: GraduationCap, + wrench: Wrench, + lightbulb: Lightbulb, + upload: Upload, + grid: GridFour, + users: Users, + palette: Palette, + storefront: Storefront, + arrows: ArrowsLeftRight, + file: FileText, + image: Image, + chat: ChatCircle, +}; + +/** Resolve a Phosphor icon component by name, falling back to the default. */ +function resolveIcon(name: string | undefined, fallback: React.ElementType): React.ElementType { + if (!name) return fallback; + return ICON_MAP[name] ?? fallback; +} + export interface SidebarNavProps { manifest: { - collections: Record; + collections: Record< + string, + { + label: string; + sortOrder?: number; + group?: string; + icon?: string; + } + >; plugins: Record< string, { @@ -47,6 +113,8 @@ export interface SidebarNavProps { path: string; label?: string; icon?: string; + group?: string; + sortOrder?: number; }>; dashboardWidgets?: Array<{ id: string; title?: string }>; version?: string; @@ -64,6 +132,10 @@ export interface SidebarNavProps { siteName?: string; favicon?: string; }; + sidebar?: { + hideCoreFeatures?: string[]; + hideCollections?: string[]; + }; }; } @@ -76,12 +148,23 @@ interface NavItem { minRole?: number; /** Optional badge count (e.g., pending comments) */ badge?: number; + /** Child items for nested submenu (max 1 level) */ + children?: NavItem[]; } +interface NavGroup { + label: string; + items: NavItem[]; + /** Sort order for the group itself (lower = earlier) */ + sortOrder?: number; +} + +/** Regex to strip leading slash from a path */ +const LEADING_SLASH_PATTERN = /^\//; + /** * Navigation item rendered as a TanStack Router inside kumo's * Sidebar.MenuItem. Styled to match kumo MenuButton appearance. - * This approach guarantees client-side navigation works correctly. */ function NavMenuLink({ item, isActive }: { item: NavItem; isActive: boolean }) { const { state } = useSidebar(); @@ -143,6 +226,82 @@ function resolveItemPath(item: NavItem): string { return path; } +/** + * Expandable nav item with children (submenu). + * The parent item is not navigable — clicking toggles the children. + */ +function NavSubMenu({ item, currentPath }: { item: NavItem; currentPath: string }) { + const { state } = useSidebar(); + const [expanded, setExpanded] = React.useState(false); + const Icon = item.icon; + + const hasActiveChild = + item.children?.some((child) => { + const childPath = resolveItemPath(child); + return isItemActive(childPath, currentPath); + }) ?? false; + + React.useEffect(() => { + if (hasActiveChild) setExpanded(true); + }, [hasActiveChild]); + + return ( + + + {expanded && item.children && ( +
+ {item.children.map((child, idx) => { + const childPath = resolveItemPath(child); + const childActive = isItemActive(childPath, currentPath); + return ( + + {state === "collapsed" ? ( + + + + ) : ( + + )} + + ); + })} +
+ )} +
+ ); +} + /** Checks if a nav item is active based on the current router path. */ function isItemActive(itemPath: string, currentPath: string): boolean { return itemPath === "/" @@ -173,16 +332,66 @@ export function SidebarNav({ manifest }: SidebarNavProps) { // --- Build nav item groups --- - const contentItems: NavItem[] = [{ to: "/", label: t`Dashboard`, icon: SquaresFour }]; + const dashboardItem: NavItem = { to: "/", label: t`Dashboard`, icon: SquaresFour }; + + // Sidebar config for hiding items + const hideCollections = new Set(manifest.sidebar?.hideCollections ?? []); + const hideCoreFeatures = new Set(manifest.sidebar?.hideCoreFeatures ?? []); + + // Map of core feature slugs to their nav item "to" paths for hiding + const CORE_FEATURE_PATHS: Record = { + comments: "/comments", + menus: "/menus", + redirects: "/redirects", + widgets: "/widgets", + sections: "/sections", + bylines: "/bylines", + "content-types": "/content-types", + users: "/users", + "plugins-manager": "/plugins-manager", + import: "/import/wordpress", + settings: "/settings", + }; + + // Group collections by their `group` field + const collectionGroups = new Map(); for (const [name, config] of Object.entries(manifest.collections)) { - contentItems.push({ + if (hideCollections.has(name)) continue; + const groupName = config.group || ""; + const items = collectionGroups.get(groupName) ?? []; + items.push({ to: "/content/$collection", label: config.label, - icon: FileText, + icon: resolveIcon(config.icon, FileText), params: { collection: name }, }); + collectionGroups.set(groupName, items); } - contentItems.push({ to: "/media", label: t`Media`, icon: Image }); + // Sort items within each group by sortOrder (from the manifest) + for (const [, items] of collectionGroups) { + items.sort((a, b) => { + const aOrder = manifest.collections[a.params?.collection ?? ""]?.sortOrder ?? 0; + const bOrder = manifest.collections[b.params?.collection ?? ""]?.sortOrder ?? 0; + return aOrder - bOrder || a.label.localeCompare(b.label); + }); + } + + // Build content groups: ungrouped collections + Media go into "Content", + // named groups become their own collapsible sections + const ungroupedCollections = collectionGroups.get("") ?? []; + const namedCollectionGroups = [...collectionGroups.entries()] + .filter(([group]) => group !== "") + .map( + ([group, items]): NavGroup => ({ + label: group, + items, + }), + ); + + const contentItems: NavItem[] = [ + ...ungroupedCollections, + { to: "/media", label: t`Media`, icon: Image }, + ]; const manageItems: NavItem[] = [ { @@ -204,7 +413,7 @@ export function SidebarNav({ manifest }: SidebarNavProps) { minRole: ROLE_EDITOR, })), { to: "/bylines", label: t`Bylines`, icon: FileText, minRole: ROLE_EDITOR }, - ]; + ].filter((item) => !hideCoreFeatures.has(item.to.replace(LEADING_SLASH_PATTERN, ""))); const adminItems: NavItem[] = [ { to: "/content-types", label: t`Content Types`, icon: Database, minRole: ROLE_ADMIN }, @@ -229,7 +438,17 @@ export function SidebarNav({ manifest }: SidebarNavProps) { { to: "/settings", label: t`Settings`, icon: Gear, minRole: ROLE_ADMIN }, ); - const pluginItems: NavItem[] = []; + // Filter admin items by hideCoreFeatures + const filteredAdminItems = adminItems.filter((item) => { + // Map the item's path to a core feature slug + for (const [feature, path] of Object.entries(CORE_FEATURE_PATHS)) { + if (item.to === path && hideCoreFeatures.has(feature)) return false; + } + return true; + }); + + // Group plugin pages by their `group` field + const pluginGroupItems = new Map(); for (const [pluginId, config] of Object.entries(manifest.plugins)) { if (config.enabled === false) continue; if (config.adminPages && config.adminPages.length > 0) { @@ -241,29 +460,67 @@ export function SidebarNav({ manifest }: SidebarNavProps) { page.label || pluginId .split("-") - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .map((w: string) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); - pluginItems.push({ to: `/plugins/${pluginId}${page.path}`, label, icon: PuzzlePiece }); + const groupName = page.group || ""; + const entries = pluginGroupItems.get(groupName) ?? []; + entries.push({ + item: { + to: `/plugins/${pluginId}${page.path}`, + label, + icon: resolveIcon(page.icon, PuzzlePiece), + }, + sortOrder: page.sortOrder ?? 0, + }); + pluginGroupItems.set(groupName, entries); } } } + // Sort items within each plugin group by sortOrder, then label + for (const [, entries] of pluginGroupItems) { + entries.sort((a, b) => a.sortOrder - b.sortOrder || a.item.label.localeCompare(b.item.label)); + } + + const ungroupedPlugins = (pluginGroupItems.get("") ?? []).map((e) => e.item); + const namedPluginGroups = [...pluginGroupItems.entries()] + .filter(([group]) => group !== "") + .map(([group, entries]): NavGroup => ({ label: group, items: entries.map((e) => e.item) })); const filterByRole = (items: NavItem[]) => items.filter((item) => !item.minRole || userRole >= item.minRole); const visibleContent = filterByRole(contentItems); const visibleManage = filterByRole(manageItems); - const visibleAdmin = filterByRole(adminItems); - const visiblePlugins = filterByRole(pluginItems); + const visibleAdmin = filterByRole(filteredAdminItems); + const visibleUngroupedPlugins = filterByRole(ungroupedPlugins); + const visibleNamedPluginGroups = namedPluginGroups + .map((group): NavGroup => ({ ...group, items: filterByRole(group.items) })) + .filter((group) => group.items.length > 0); function renderNavItems(items: NavItem[]) { return items.map((item, index) => { + if (item.children && item.children.length > 0) { + return ; + } const itemPath = resolveItemPath(item); const active = isItemActive(itemPath, currentPath); return ; }); } + function renderGroup(group: NavGroup, defaultOpen = true) { + return ( + + + {group.label} + + + {renderNavItems(group.items)} + + + ); + } + return ( <> {/* Injected styles — Tailwind 4 strips [data-sidebar] attribute selectors from CSS files. @@ -397,27 +654,30 @@ export function SidebarNav({ manifest }: SidebarNavProps) { {/* Dashboard — standalone */} - + - {/* Content — collections + media (collapsible) */} - {visibleContent.length > 1 && ( + {/* Content — ungrouped collections + media (collapsible) */} + {visibleContent.length > 0 && ( {t`Content`} - - {renderNavItems(visibleContent.filter((i) => i.to !== "/"))} - + {renderNavItems(visibleContent)} )} + {/* Named collection groups (e.g., "Blog", "Shop") */} + {namedCollectionGroups.map((group) => ( + + + {renderGroup(group)} + + ))} + {/* Manage — comments, menus, taxonomies, etc. (collapsible) */} @@ -442,14 +702,22 @@ export function SidebarNav({ manifest }: SidebarNavProps) { )} - {/* Plugin pages (collapsible) */} - {visiblePlugins.length > 0 && ( + {/* Named plugin groups (e.g., "SEO", "Analytics") */} + {visibleNamedPluginGroups.length > 0 && ( + <> + + {visibleNamedPluginGroups.map((group) => renderGroup(group))} + + )} + + {/* Ungrouped plugin pages (collapsible) */} + {visibleUngroupedPlugins.length > 0 && ( <> {t`Plugins`} - {renderNavItems(visiblePlugins)} + {renderNavItems(visibleUngroupedPlugins)} diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index 5ccba56ef..d7dfc8e93 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -89,6 +89,7 @@ export { updateField, deleteField, reorderFields, + reorderCollections, fetchOrphanedTables, registerOrphanedTable, } from "./schema.js"; @@ -173,6 +174,8 @@ export { reorderMenuItems, fetchMenuTranslations, createMenuTranslation, + type SyncDiff, + type SyncResult, } from "./menus.js"; // Widget areas diff --git a/packages/admin/src/lib/api/menus.ts b/packages/admin/src/lib/api/menus.ts index b137a45b9..76a7f7955 100644 --- a/packages/admin/src/lib/api/menus.ts +++ b/packages/admin/src/lib/api/menus.ts @@ -266,3 +266,27 @@ export async function createMenuTranslation( ); return parseApiResponse(response, "Failed to create menu translation"); } + +// ============================================ +// Menu Sync +// ============================================ + +export interface SyncDiff { + toAdd: Array<{ + menuName: string; + label: string; + referenceCollection: string; + sortOrder: number; + }>; + toRemove: string[]; + toReorder: Array<{ + id: string; + sortOrder: number; + }>; +} + +export interface SyncResult { + added: number; + removed: number; + reordered: number; +} diff --git a/packages/admin/src/lib/api/schema.ts b/packages/admin/src/lib/api/schema.ts index 1b991befa..530dc6e00 100644 --- a/packages/admin/src/lib/api/schema.ts +++ b/packages/admin/src/lib/api/schema.ts @@ -40,6 +40,8 @@ export interface SchemaCollection { commentsModeration: "all" | "first_time" | "none"; commentsClosedAfterDays: number; commentsAutoApproveUsers: boolean; + sortOrder?: number; + group?: string; createdAt: string; updatedAt: string; } @@ -83,6 +85,10 @@ export interface CreateCollectionInput { supports?: string[]; urlPattern?: string; hasSeo?: boolean; + sortOrder?: number; + group?: string; + /** Auto-add to a public menu on creation (menu name, e.g. "primary") */ + addToMenu?: string; } export interface UpdateCollectionInput { @@ -97,6 +103,8 @@ export interface UpdateCollectionInput { commentsModeration?: "all" | "first_time" | "none"; commentsClosedAfterDays?: number; commentsAutoApproveUsers?: boolean; + sortOrder?: number; + group?: string; } export interface CreateFieldInput { @@ -293,6 +301,20 @@ export async function reorderFields(collectionSlug: string, fieldSlugs: string[] if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to reorder fields`)); } +/** + * Reorder collections + */ +export async function reorderCollections( + collections: Array<{ slug: string; sortOrder: number }>, +): Promise { + const response = await apiFetch(`${API_BASE}/schema/collections/reorder`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ collections }), + }); + if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to reorder collections`)); +} + // ============================================ // Orphaned Tables // ============================================ diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 810cfb897..bc9693c9b 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -80,6 +80,7 @@ import { updateField, deleteField, reorderFields, + reorderCollections, fetchOrphanedTables, registerOrphanedTable, fetchUsers, @@ -1491,6 +1492,14 @@ function ContentTypesListPage() { }, }); + const reorderMutation = useMutation({ + mutationFn: (cols: Array<{ slug: string; sortOrder: number }>) => reorderCollections(cols), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["schema", "collections"] }); + void queryClient.invalidateQueries({ queryKey: ["manifest"] }); + }, + }); + const error = collectionsError || orphansError; if (error) { return ; @@ -1503,6 +1512,7 @@ function ContentTypesListPage() { isLoading={collectionsLoading || orphansLoading} onDelete={(slug) => deleteMutation.mutate(slug)} onRegisterOrphan={(slug) => registerOrphanMutation.mutate(slug)} + onReorder={(cols) => reorderMutation.mutateAsync(cols)} /> ); } diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index f33e8e0cb..74827423d 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -66,6 +66,7 @@ export { handleSchemaCollectionCreate, handleSchemaCollectionUpdate, handleSchemaCollectionDelete, + handleSchemaCollectionReorder, handleSchemaFieldList, handleSchemaFieldGet, handleSchemaFieldCreate, @@ -74,6 +75,9 @@ export { handleSchemaFieldReorder, handleOrphanedTableList, handleOrphanedTableRegister, + handleSchemaCollectionMenuSync, + syncCollectionToMenu, + removeCollectionFromMenu, type CollectionListResponse, type CollectionResponse, type CollectionWithFieldsResponse, @@ -122,6 +126,14 @@ export { type MenuSetItemsInput, } from "./menus.js"; +// Menu sync handlers +export { + computeMenuSyncDiff, + applyMenuSyncDiff, + syncSidebarToMenu, + type SyncDiff, +} from "./menu-sync.js"; + // Section handlers export { handleSectionList, diff --git a/packages/core/src/api/handlers/menu-sync.ts b/packages/core/src/api/handlers/menu-sync.ts new file mode 100644 index 000000000..1b028deac --- /dev/null +++ b/packages/core/src/api/handlers/menu-sync.ts @@ -0,0 +1,211 @@ +/** + * Bidirectional sidebar-menu sync engine + * + * Keeps admin sidebar structure and public menus aligned. + */ + +import type { Kysely } from "kysely"; +import { ulid } from "ulidx"; + +import { withTransaction } from "../../database/transaction.js"; +import type { Database } from "../../database/types.js"; +import type { ApiResult } from "../types.js"; + +export interface SyncDiff { + /** Menu items to add */ + toAdd: Array<{ + menuName: string; + label: string; + referenceCollection: string; + sortOrder: number; + }>; + /** Menu items to remove (by ID) */ + toRemove: string[]; + /** Menu items to reorder */ + toReorder: Array<{ + id: string; + sortOrder: number; + }>; +} + +/** + * Compute the diff between sidebar structure and a public menu. + * Returns items that need to be added, removed, or reordered. + */ +export async function computeMenuSyncDiff( + db: Kysely, + menuName: string, + locale = "en", +): Promise> { + try { + // Get all collections ordered by sort_order + const collections = await db + .selectFrom("_emdash_collections") + .select(["slug", "label", "sort_order"]) + .orderBy("sort_order", "asc") + .orderBy("slug", "asc") + .execute(); + + // Get existing menu items of type 'collection' for the specific locale + const menuItems = await db + .selectFrom("_emdash_menu_items") + .select(["_emdash_menu_items.id", "reference_collection", "sort_order"]) + .innerJoin("_emdash_menus", "_emdash_menu_items.menu_id", "_emdash_menus.id") + .where("_emdash_menus.name", "=", menuName) + .where("_emdash_menus.locale", "=", locale) + .where("type", "=", "collection") + .orderBy("sort_order", "asc") + .execute(); + + const menuCollectionSlugs = new Set(menuItems.map((item) => item.reference_collection)); + const sidebarSlugs = new Set(collections.map((c) => c.slug)); + + // Collections in sidebar but not in menu -> add + const toAdd = collections + .filter((c) => !menuCollectionSlugs.has(c.slug)) + .map((c, i) => ({ + menuName, + label: c.label, + referenceCollection: c.slug, + sortOrder: menuItems.length + i, + })); + + // Menu items referencing deleted collections -> remove + const toRemove = menuItems + .filter((item) => !sidebarSlugs.has(item.reference_collection!)) + .map((item) => item.id); + + // Items that exist in both but have wrong sort order -> reorder + const sortOrderMap = new Map(collections.map((c, i) => [c.slug, i])); + const toReorder = menuItems + .filter((item) => sidebarSlugs.has(item.reference_collection!)) + .map((item) => ({ + id: item.id, + sortOrder: sortOrderMap.get(item.reference_collection!) ?? item.sort_order, + })) + .filter((item) => { + const menuItem = menuItems.find((m) => m.id === item.id); + return menuItem && item.sortOrder !== menuItem.sort_order; + }); + + return { + success: true, + data: { toAdd, toRemove, toReorder }, + }; + } catch (error) { + console.error("[menu-sync] computeMenuSyncDiff failed:", error); + return { + success: false, + error: { + code: "SYNC_DIFF_ERROR", + message: "Failed to compute sync diff", + }, + }; + } +} + +/** + * Apply a sync diff to a public menu. + */ +export async function applyMenuSyncDiff( + db: Kysely, + diff: SyncDiff, + menuName: string, + locale = "en", +): Promise> { + try { + // Validate menu exists up front using the passed menuName + const menu = await db + .selectFrom("_emdash_menus") + .select(["id", "locale"]) + .where("name", "=", menuName) + .where("locale", "=", locale) + .executeTakeFirst(); + + if (!menu) { + return { + success: false, + error: { code: "MENU_NOT_FOUND", message: `Menu not found: ${menuName}` }, + }; + } + + return withTransaction(db, async (trx) => { + let added = 0; + let removed = 0; + let reordered = 0; + + // Add new items + for (const item of diff.toAdd) { + const id = ulid(); + await trx + .insertInto("_emdash_menu_items") + .values({ + id, + menu_id: menu.id, + type: "collection", + reference_collection: item.referenceCollection, + reference_id: null, + custom_url: null, + label: item.label, + sort_order: item.sortOrder, + title_attr: null, + target: null, + css_classes: null, + locale: menu.locale ?? "en", + translation_group: id, + }) + .execute(); + added++; + } + + // Remove orphaned items (scoped to this menu) + if (diff.toRemove.length > 0) { + await trx + .deleteFrom("_emdash_menu_items") + .where("id", "in", diff.toRemove) + .where("menu_id", "=", menu.id) + .execute(); + removed = diff.toRemove.length; + } + + // Reorder items (scoped to this menu) + for (const item of diff.toReorder) { + await trx + .updateTable("_emdash_menu_items") + .set({ sort_order: item.sortOrder }) + .where("id", "=", item.id) + .where("menu_id", "=", menu.id) + .execute(); + reordered++; + } + + return { + success: true, + data: { added, removed, reordered }, + }; + }); + } catch (error) { + console.error("[menu-sync] applyMenuSyncDiff failed:", error); + return { + success: false, + error: { + code: "SYNC_APPLY_ERROR", + message: "Failed to apply sync diff", + }, + }; + } +} + +/** + * Full sync: compute diff and apply it in one step. + */ +export async function syncSidebarToMenu( + db: Kysely, + menuName: string, + locale = "en", +): Promise> { + const diffResult = await computeMenuSyncDiff(db, menuName, locale); + if (!diffResult.success) return diffResult; + + return applyMenuSyncDiff(db, diffResult.data, menuName, locale); +} diff --git a/packages/core/src/api/handlers/schema.ts b/packages/core/src/api/handlers/schema.ts index 71ca4e6ff..967113ec6 100644 --- a/packages/core/src/api/handlers/schema.ts +++ b/packages/core/src/api/handlers/schema.ts @@ -3,6 +3,7 @@ */ import type { Kysely } from "kysely"; +import { ulid } from "ulidx"; import type { Database } from "../../database/types.js"; import { @@ -125,9 +126,33 @@ export async function handleSchemaCollectionCreate( input: CreateCollectionInput, ): Promise> { try { + // Validate menu exists if addToMenu is specified + if (input.addToMenu) { + const menu = await db + .selectFrom("_emdash_menus") + .select("id") + .where("name", "=", input.addToMenu) + .executeTakeFirst(); + + if (!menu) { + return { + success: false, + error: { + code: "MENU_NOT_FOUND", + message: `Menu "${input.addToMenu}" not found. Collection was not created.`, + }, + }; + } + } + const registry = new SchemaRegistry(db); const item = await registry.createCollection(input); + // Sync to public menu if requested + if (input.addToMenu) { + await syncCollectionToMenu(db, item.slug, item.label, input.addToMenu); + } + return { success: true, data: { item }, @@ -203,6 +228,9 @@ export async function handleSchemaCollectionDelete( const registry = new SchemaRegistry(db); await registry.deleteCollection(slug, options); + // Remove from public menus + await removeCollectionFromMenu(db, slug); + return { success: true, data: { success: true }, @@ -452,6 +480,42 @@ export async function handleSchemaFieldReorder( } } +/** + * Reorder collections + */ +export async function handleSchemaCollectionReorder( + db: Kysely, + collections: Array<{ slug: string; sortOrder: number }>, +): Promise> { + try { + const registry = new SchemaRegistry(db); + await registry.reorderCollections(collections); + + return { + success: true, + data: { success: true }, + }; + } catch (error) { + if (error instanceof SchemaError) { + return { + success: false, + error: { + code: error.code, + message: error.message, + details: error.details, + }, + }; + } + return { + success: false, + error: { + code: "SCHEMA_COLLECTION_REORDER_ERROR", + message: "Failed to reorder collections", + }, + }; + } +} + // ============================================ // Orphaned Table Discovery // ============================================ @@ -532,3 +596,156 @@ export async function handleOrphanedTableRegister( }; } } + +// ============================================ +// Menu Sync Helpers +// ============================================ + +/** + * Add a collection-type menu item to a public menu. + * Called when a collection is created with `addToMenu`. + * Returns true if the menu item was added, false if the menu doesn't exist. + */ +export async function syncCollectionToMenu( + db: Kysely, + collectionSlug: string, + collectionLabel: string, + menuName: string, + locale = "en", +): Promise { + try { + // Check if a menu item for this collection already exists + const existing = await db + .selectFrom("_emdash_menu_items") + .select("_emdash_menu_items.id") + .innerJoin("_emdash_menus", "_emdash_menu_items.menu_id", "_emdash_menus.id") + .where("_emdash_menus.name", "=", menuName) + .where("_emdash_menus.locale", "=", locale) + .where("type", "=", "collection") + .where("reference_collection", "=", collectionSlug) + .executeTakeFirst(); + + if (existing) return true; // Already exists + + // Get the menu ID + const menu = await db + .selectFrom("_emdash_menus") + .select(["id", "locale"]) + .where("name", "=", menuName) + .where("locale", "=", locale) + .executeTakeFirst(); + + if (!menu) { + console.warn("[emdash] Menu not found for addToMenu:", menuName, "locale:", locale); + return false; + } + + // Get the max sort_order to append at the end + const maxOrder = await db + .selectFrom("_emdash_menu_items") + .select(db.fn.max("sort_order").as("maxOrder")) + .where("menu_id", "=", menu.id) + .executeTakeFirst(); + + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) + const sortOrder = ((maxOrder?.maxOrder as number) ?? 0) + 1; + + const id = ulid(); + + // Insert the menu item + await db + .insertInto("_emdash_menu_items") + .values({ + id, + menu_id: menu.id, + type: "collection", + reference_collection: collectionSlug, + reference_id: null, + custom_url: null, + label: collectionLabel, + sort_order: sortOrder, + title_attr: null, + target: null, + css_classes: null, + locale: menu.locale ?? "en", + translation_group: id, + }) + .execute(); + return true; + } catch (error) { + console.error("[emdash] syncCollectionToMenu failed:", error); + return false; + } +} + +/** + * Remove collection-type menu items referencing a collection. + * Called when a collection is deleted. + */ +export async function removeCollectionFromMenu( + db: Kysely, + collectionSlug: string, +): Promise { + try { + await db + .deleteFrom("_emdash_menu_items") + .where("type", "=", "collection") + .where("reference_collection", "=", collectionSlug) + .execute(); + } catch (error) { + console.error("[emdash] Menu cleanup failed for collection:", collectionSlug, error); + } +} + +/** + * Manually sync a collection to a public menu. + * Called via POST /_emdash/api/schema/collections/:slug/sync-menu + */ +export async function handleSchemaCollectionMenuSync( + db: Kysely, + collectionSlug: string, + menuName: string, + locale = "en", +): Promise> { + try { + const registry = new SchemaRegistry(db); + const collection = await registry.getCollection(collectionSlug); + if (!collection) { + return { + success: false, + error: { code: "NOT_FOUND", message: `Collection not found: ${collectionSlug}` }, + }; + } + + // Validate menu exists before syncing + const menu = await db + .selectFrom("_emdash_menus") + .select("id") + .where("name", "=", menuName) + .where("locale", "=", locale) + .executeTakeFirst(); + + if (!menu) { + return { + success: false, + error: { code: "MENU_NOT_FOUND", message: `Menu not found: ${menuName}` }, + }; + } + + await syncCollectionToMenu(db, collectionSlug, collection.label, menuName, locale); + + return { + success: true, + data: { success: true }, + }; + } catch (error) { + console.error("[emdash] handleSchemaCollectionMenuSync failed:", error); + return { + success: false, + error: { + code: "MENU_SYNC_ERROR", + message: "Failed to sync collection to menu", + }, + }; + } +} diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index e82ded719..43ec72c40 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -516,6 +516,31 @@ export interface EmDashConfig { /** URL or path to a custom favicon for the admin panel. */ favicon?: string; }; + + /** + * Admin sidebar configuration. + * + * Use to hide built-in core features from the sidebar for sites that + * only use a subset of EmDash's content surface. Hidden items remain + * fully functional (APIs work, direct URLs work) — they are just not + * shown in the navigation. + * + * @example + * ```ts + * emdash({ + * sidebar: { + * hideCoreFeatures: ["comments", "redirects", "widgets", "sections", "bylines"], + * hideCollections: ["tags", "categories"], + * }, + * }) + * ``` + */ + sidebar?: { + /** Core feature slugs to hide from the sidebar. */ + hideCoreFeatures?: string[]; + /** Collection slugs to hide from the sidebar. */ + hideCollections?: string[]; + }; } /** diff --git a/packages/core/src/astro/routes/api/manifest.ts b/packages/core/src/astro/routes/api/manifest.ts index 55bddceec..d58801ca4 100644 --- a/packages/core/src/astro/routes/api/manifest.ts +++ b/packages/core/src/astro/routes/api/manifest.ts @@ -62,6 +62,7 @@ export const GET: APIRoute = async ({ locals }) => { authMode: authMode.type === "external" ? authMode.providerType : "passkey", signupEnabled, admin: adminBranding, + sidebar: emdash?.config?.sidebar, } : { version: VERSION, @@ -73,6 +74,7 @@ export const GET: APIRoute = async ({ locals }) => { authMode: "passkey", signupEnabled, admin: adminBranding, + sidebar: emdash?.config?.sidebar, }; return Response.json( diff --git a/packages/core/src/astro/routes/api/menus/[name]/sync.ts b/packages/core/src/astro/routes/api/menus/[name]/sync.ts new file mode 100644 index 000000000..2f5639697 --- /dev/null +++ b/packages/core/src/astro/routes/api/menus/[name]/sync.ts @@ -0,0 +1,58 @@ +/** + * Menu sync endpoints + * + * GET /_emdash/api/menus/:name/sync-diff - Preview sync changes + * POST /_emdash/api/menus/:name/sync - Apply sync changes + */ + +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { requireDb, unwrapResult } from "#api/error.js"; +import { computeMenuSyncDiff, syncSidebarToMenu } from "#api/index.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, locals, url }) => { + const { emdash, user } = locals; + const name = params.name; + const locale = url.searchParams.get("locale") ?? "en"; + + if (!name) { + return unwrapResult({ + success: false, + error: { code: "MISSING_PARAM", message: "Menu name is required" }, + }); + } + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "menus:manage"); + if (denied) return denied; + + const result = await computeMenuSyncDiff(emdash!.db, name, locale); + return unwrapResult(result); +}; + +export const POST: APIRoute = async ({ params, locals, url }) => { + const { emdash, user } = locals; + const name = params.name; + const locale = url.searchParams.get("locale") ?? "en"; + + if (!name) { + return unwrapResult({ + success: false, + error: { code: "MISSING_PARAM", message: "Menu name is required" }, + }); + } + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "menus:manage"); + if (denied) return denied; + + const result = await syncSidebarToMenu(emdash!.db, name, locale); + return unwrapResult(result); +}; diff --git a/packages/core/src/astro/routes/api/schema/collections/[slug]/sync-menu.ts b/packages/core/src/astro/routes/api/schema/collections/[slug]/sync-menu.ts new file mode 100644 index 000000000..bcd77c8f0 --- /dev/null +++ b/packages/core/src/astro/routes/api/schema/collections/[slug]/sync-menu.ts @@ -0,0 +1,43 @@ +/** + * Schema collection menu sync endpoint + * + * POST /_emdash/api/schema/collections/:slug/sync-menu + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { requireDb, unwrapResult } from "#api/error.js"; +import { handleSchemaCollectionMenuSync } from "#api/index.js"; +import { parseBody, isParseError } from "#api/parse.js"; + +export const prerender = false; + +const syncBody = z.object({ + menuName: z.string().min(1), +}); + +export const POST: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + const slug = params.slug; + + if (!slug) { + return unwrapResult({ + success: false, + error: { code: "MISSING_PARAM", message: "Collection slug is required" }, + }); + } + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + + const body = await parseBody(request, syncBody); + if (isParseError(body)) return body; + + const result = await handleSchemaCollectionMenuSync(emdash!.db, slug, body.menuName); + return unwrapResult(result); +}; diff --git a/packages/core/src/astro/routes/api/schema/collections/reorder.ts b/packages/core/src/astro/routes/api/schema/collections/reorder.ts new file mode 100644 index 000000000..ba16a8913 --- /dev/null +++ b/packages/core/src/astro/routes/api/schema/collections/reorder.ts @@ -0,0 +1,45 @@ +/** + * Schema collections reorder endpoint + * + * POST /_emdash/api/schema/collections/reorder + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { requireDb, unwrapResult } from "#api/error.js"; +import { handleSchemaCollectionReorder } from "#api/index.js"; +import { parseBody, isParseError } from "#api/parse.js"; + +export const prerender = false; + +const reorderBody = z.object({ + collections: z + .array( + z.object({ + slug: z.string().min(1), + sortOrder: z.number().int().min(0), + }), + ) + .min(1) + .max(200), +}); + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + + const body = await parseBody(request, reorderBody); + if (isParseError(body)) return body; + + const result = await handleSchemaCollectionReorder(emdash!.db, body.collections); + // Manifest is built fresh per-request via requestCached, so no cache invalidation needed. + // The next admin request will see the updated sort_order values. + return unwrapResult(result); +}; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 16dd2c7df..1794d9356 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -29,6 +29,12 @@ export interface ManifestCollection { supports: string[]; hasSeo: boolean; urlPattern?: string; + /** Sidebar sort order (lower = earlier) */ + sortOrder: number; + /** Sidebar group name (e.g., "Blog", "Shop") */ + group?: string; + /** Sidebar icon name (maps to Phosphor icon) */ + icon?: string; fields: Record< string, { @@ -190,6 +196,14 @@ export interface EmDashManifest { siteName?: string; favicon?: string; }; + /** + * Sidebar configuration for hiding core features and collections. + * Set via the `sidebar` config in `astro.config.mjs`. + */ + sidebar?: { + hideCoreFeatures?: string[]; + hideCollections?: string[]; + }; } /** diff --git a/packages/core/src/database/migrations/039_collection_grouping.ts b/packages/core/src/database/migrations/039_collection_grouping.ts new file mode 100644 index 000000000..af1bbd128 --- /dev/null +++ b/packages/core/src/database/migrations/039_collection_grouping.ts @@ -0,0 +1,26 @@ +import { type Kysely } from "kysely"; + +import { columnExists } from "../dialect-helpers.js"; + +export async function up(db: Kysely): Promise { + if (!(await columnExists(db, "_emdash_collections", "sort_order"))) { + await db.schema + .alterTable("_emdash_collections") + .addColumn("sort_order", "integer", (col) => col.defaultTo(0)) + .execute(); + } + + if (!(await columnExists(db, "_emdash_collections", "group"))) { + await db.schema.alterTable("_emdash_collections").addColumn("group", "text").execute(); + } +} + +export async function down(db: Kysely): Promise { + if (await columnExists(db, "_emdash_collections", "sort_order")) { + await db.schema.alterTable("_emdash_collections").dropColumn("sort_order").execute(); + } + + if (await columnExists(db, "_emdash_collections", "group")) { + await db.schema.alterTable("_emdash_collections").dropColumn("group").execute(); + } +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 81102b200..153118d1a 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -39,6 +39,7 @@ import * as m035 from "./035_bounded_404_log.js"; import * as m036 from "./036_i18n_menus_and_taxonomies.js"; import * as m037 from "./037_credential_algorithm.js"; import * as m038 from "./038_registry_plugin_state.js"; +import * as m039 from "./039_collection_grouping.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -78,6 +79,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "036_i18n_menus_and_taxonomies": m036, "037_credential_algorithm": m037, "038_registry_plugin_state": m038, + "039_collection_grouping": m039, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index acce67ca6..ecb1e8f04 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -221,6 +221,8 @@ export interface CollectionTable { comments_moderation: Generated; // 'all' | 'first_time' | 'none' comments_closed_after_days: Generated; // 0 = never close comments_auto_approve_users: Generated; // 0 or 1 + sort_order: Generated; // 0 = default, lower = earlier in sidebar + group: string | null; // Sidebar group name (e.g., "Blog", "Shop") created_at: Generated; updated_at: Generated; } diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index dc4ace5c9..84a8026b1 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -1415,6 +1415,9 @@ export class EmDashRuntime { supports: collection.supports || [], hasSeo: collection.hasSeo, urlPattern: collection.urlPattern, + sortOrder: collection.sortOrder, + group: collection.group, + icon: collection.icon, fields, }; } diff --git a/packages/core/src/schema/registry.ts b/packages/core/src/schema/registry.ts index 27fe1ca68..b9d56ce64 100644 --- a/packages/core/src/schema/registry.ts +++ b/packages/core/src/schema/registry.ts @@ -113,6 +113,7 @@ export class SchemaRegistry { const rows = await this.db .selectFrom("_emdash_collections") .selectAll() + .orderBy("sort_order", "asc") .orderBy("slug", "asc") .execute(); @@ -164,6 +165,7 @@ export class SchemaRegistry { const collectionRows = await this.db .selectFrom("_emdash_collections") .selectAll() + .orderBy("sort_order", "asc") .orderBy("slug", "asc") .execute(); @@ -245,6 +247,8 @@ export class SchemaRegistry { has_seo: hasSeo ? 1 : 0, comments_enabled: input.commentsEnabled ? 1 : 0, url_pattern: input.urlPattern ?? null, + sort_order: input.sortOrder ?? 0, + group: input.group ?? null, }) .execute(); @@ -317,6 +321,8 @@ export class SchemaRegistry { : existing.commentsAutoApproveUsers ? 1 : 0, + sort_order: input.sortOrder ?? existing.sortOrder, + group: input.group !== undefined ? (input.group ?? null) : (existing.group ?? null), updated_at: now, }) .where("slug", "=", slug) @@ -679,6 +685,43 @@ export class SchemaRegistry { } } + /** + * Reorder collections by updating their sort_order values. + * + * Accepts an array of `{ slug, sortOrder }` objects and updates each + * collection's sort_order in a single batch. Used by the admin UI + * drag-and-drop reordering. + */ + async reorderCollections(collections: Array<{ slug: string; sortOrder: number }>): Promise { + // Batch validate all slugs in one query + const existingSlugs = await this.db + .selectFrom("_emdash_collections") + .select("slug") + .where( + "slug", + "in", + collections.map((c) => c.slug), + ) + .execute(); + + const existingSlugSet = new Set(existingSlugs.map((c) => c.slug)); + const missing = collections.filter((c) => !existingSlugSet.has(c.slug)); + if (missing.length > 0) { + throw new SchemaError(`Collection not found: ${missing[0].slug}`, "COLLECTION_NOT_FOUND"); + } + + // Batch update in a transaction + await withTransaction(this.db, async (trx) => { + for (const { slug, sortOrder } of collections) { + await trx + .updateTable("_emdash_collections") + .set({ sort_order: sortOrder }) + .where("slug", "=", slug) + .execute(); + } + }); + } + // ============================================ // DDL Operations // ============================================ @@ -984,6 +1027,8 @@ export class SchemaRegistry { : "first_time", commentsClosedAfterDays: row.comments_closed_after_days ?? 90, commentsAutoApproveUsers: row.comments_auto_approve_users === 1, + sortOrder: row.sort_order, + group: row.group ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at, }; diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index ec16698fc..285e9213f 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -169,6 +169,10 @@ export interface Collection { commentsClosedAfterDays: number; /** Auto-approve comments from authenticated CMS users */ commentsAutoApproveUsers: boolean; + /** Sidebar sort order (lower = earlier) */ + sortOrder: number; + /** Sidebar group name */ + group?: string; createdAt: string; updatedAt: string; } @@ -210,6 +214,12 @@ export interface CreateCollectionInput { urlPattern?: string; hasSeo?: boolean; commentsEnabled?: boolean; + /** Sidebar sort order (lower = earlier) */ + sortOrder?: number; + /** Sidebar group name */ + group?: string; + /** Auto-add to a public menu on creation (menu name, e.g., "primary") */ + addToMenu?: string; } /** @@ -227,6 +237,10 @@ export interface UpdateCollectionInput { commentsModeration?: "all" | "first_time" | "none"; commentsClosedAfterDays?: number; commentsAutoApproveUsers?: boolean; + /** Sidebar sort order (lower = earlier) */ + sortOrder?: number; + /** Sidebar group name */ + group?: string; } /** diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 72d6e1bc2..b3f162862 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -118,6 +118,7 @@ describe("Database Migrations (Integration)", () => { "036_i18n_menus_and_taxonomies", "037_credential_algorithm", "038_registry_plugin_state", + "039_collection_grouping", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/unit/menus/menu-sync-exports.test.ts b/packages/core/tests/unit/menus/menu-sync-exports.test.ts new file mode 100644 index 000000000..4576ef07a --- /dev/null +++ b/packages/core/tests/unit/menus/menu-sync-exports.test.ts @@ -0,0 +1,37 @@ +/** + * Quick test to verify menu-sync exports work. + */ + +import { describe, it, expect } from "vitest"; + +import { + computeMenuSyncDiff, + applyMenuSyncDiff, + syncSidebarToMenu, +} from "../../../src/api/handlers/menu-sync.js"; +import { + syncCollectionToMenu, + removeCollectionFromMenu, +} from "../../../src/api/handlers/schema.js"; + +describe("Menu Sync Exports", () => { + it("exports syncCollectionToMenu as a function", () => { + expect(typeof syncCollectionToMenu).toBe("function"); + }); + + it("exports removeCollectionFromMenu as a function", () => { + expect(typeof removeCollectionFromMenu).toBe("function"); + }); + + it("exports computeMenuSyncDiff as a function", () => { + expect(typeof computeMenuSyncDiff).toBe("function"); + }); + + it("exports applyMenuSyncDiff as a function", () => { + expect(typeof applyMenuSyncDiff).toBe("function"); + }); + + it("exports syncSidebarToMenu as a function", () => { + expect(typeof syncSidebarToMenu).toBe("function"); + }); +}); diff --git a/packages/core/tests/unit/menus/menu-sync.test.ts b/packages/core/tests/unit/menus/menu-sync.test.ts new file mode 100644 index 000000000..4dd4bee1b --- /dev/null +++ b/packages/core/tests/unit/menus/menu-sync.test.ts @@ -0,0 +1,319 @@ +/** + * Tests for menu sync helpers. + * + * Covers: + * - syncCollectionToMenu: adds collection-type menu item + * - removeCollectionFromMenu: removes collection-type menu items + * - computeMenuSyncDiff: computes diff between sidebar and menu + * - applyMenuSyncDiff: applies the diff + * - syncSidebarToMenu: full sync in one step + */ + +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { ulid } from "ulidx"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { + computeMenuSyncDiff, + applyMenuSyncDiff, + syncSidebarToMenu, +} from "../../../src/api/handlers/menu-sync.js"; +import { + syncCollectionToMenu, + removeCollectionFromMenu, +} from "../../../src/api/handlers/schema.js"; +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import type { Database as EmDashDatabase } from "../../../src/database/types.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; + +describe("Menu Sync", () => { + let db: Kysely; + let registry: SchemaRegistry; + let menuId: string; + + beforeEach(async () => { + const sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + await runMigrations(db); + registry = new SchemaRegistry(db); + + // Create a primary menu for testing + menuId = ulid(); + await db + .insertInto("_emdash_menus") + .values({ + id: menuId, + name: "primary", + label: "Primary Navigation", + locale: "en", + }) + .execute(); + }); + + afterEach(async () => { + await db.destroy(); + }); + + async function getMenuItems() { + return db + .selectFrom("_emdash_menu_items") + .selectAll() + .where("menu_id", "=", menuId) + .orderBy("sort_order", "asc") + .execute(); + } + + describe("syncCollectionToMenu", () => { + it("adds a collection-type menu item to the specified menu", async () => { + await registry.createCollection({ slug: "posts", label: "Blog Posts" }); + + await syncCollectionToMenu(db, "posts", "Blog Posts", "primary"); + + const items = await getMenuItems(); + const postItem = items.find((i) => i.reference_collection === "posts"); + + expect(postItem).toBeDefined(); + expect(postItem!.type).toBe("collection"); + expect(postItem!.label).toBe("Blog Posts"); + }); + + it("does not duplicate menu items on repeated calls", async () => { + await registry.createCollection({ slug: "posts", label: "Blog Posts" }); + + await syncCollectionToMenu(db, "posts", "Blog Posts", "primary"); + await syncCollectionToMenu(db, "posts", "Blog Posts", "primary"); + + const items = await getMenuItems(); + const postItems = items.filter((i) => i.reference_collection === "posts"); + + expect(postItems).toHaveLength(1); + }); + + it("silently fails when menu does not exist", async () => { + await registry.createCollection({ slug: "posts", label: "Blog Posts" }); + + // Should not throw + await syncCollectionToMenu(db, "posts", "Blog Posts", "nonexistent"); + + const items = await getMenuItems(); + expect(items).toHaveLength(0); + }); + + it("appends item at the end of existing items", async () => { + // Add an existing item + await db + .insertInto("_emdash_menu_items") + .values({ + id: ulid(), + menu_id: menuId, + type: "custom", + label: "Home", + custom_url: "/", + sort_order: 0, + }) + .execute(); + + await registry.createCollection({ slug: "posts", label: "Blog Posts" }); + await syncCollectionToMenu(db, "posts", "Blog Posts", "primary"); + + const items = await getMenuItems(); + expect(items).toHaveLength(2); + expect(items[1].reference_collection).toBe("posts"); + }); + }); + + describe("removeCollectionFromMenu", () => { + it("removes collection-type menu items referencing the collection", async () => { + await db + .insertInto("_emdash_menu_items") + .values({ + id: ulid(), + menu_id: menuId, + type: "collection", + reference_collection: "posts", + label: "Blog", + sort_order: 0, + }) + .execute(); + + await removeCollectionFromMenu(db, "posts"); + + const items = await getMenuItems(); + expect(items).toHaveLength(0); + }); + + it("does not affect non-collection menu items", async () => { + await db + .insertInto("_emdash_menu_items") + .values({ + id: ulid(), + menu_id: menuId, + type: "custom", + label: "Home", + custom_url: "/", + sort_order: 0, + }) + .execute(); + + await removeCollectionFromMenu(db, "posts"); + + const items = await getMenuItems(); + expect(items).toHaveLength(1); + expect(items[0].type).toBe("custom"); + }); + }); + + describe("computeMenuSyncDiff", () => { + it("returns empty diff when sidebar and menu are in sync", async () => { + await registry.createCollection({ slug: "posts", label: "Posts", sortOrder: 0 }); + await registry.createCollection({ slug: "pages", label: "Pages", sortOrder: 1 }); + + // Add matching menu items + await db + .insertInto("_emdash_menu_items") + .values([ + { + id: ulid(), + menu_id: menuId, + type: "collection", + reference_collection: "posts", + label: "Posts", + sort_order: 0, + }, + { + id: ulid(), + menu_id: menuId, + type: "collection", + reference_collection: "pages", + label: "Pages", + sort_order: 1, + }, + ]) + .execute(); + + const result = await computeMenuSyncDiff(db, "primary"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.toAdd).toHaveLength(0); + expect(result.data.toRemove).toHaveLength(0); + } + }); + + it("identifies collections to add when not in menu", async () => { + await registry.createCollection({ slug: "posts", label: "Posts", sortOrder: 0 }); + await registry.createCollection({ slug: "products", label: "Products", sortOrder: 1 }); + + const result = await computeMenuSyncDiff(db, "primary"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.toAdd).toHaveLength(2); + expect(result.data.toAdd.map((i) => i.referenceCollection)).toContain("posts"); + expect(result.data.toAdd.map((i) => i.referenceCollection)).toContain("products"); + } + }); + + it("identifies menu items to remove when collection is deleted", async () => { + const itemId = ulid(); + + await db + .insertInto("_emdash_menu_items") + .values({ + id: itemId, + menu_id: menuId, + type: "collection", + reference_collection: "old_posts", + label: "Old Posts", + sort_order: 0, + }) + .execute(); + + const result = await computeMenuSyncDiff(db, "primary"); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.toRemove).toContain(itemId); + } + }); + }); + + describe("applyMenuSyncDiff", () => { + it("adds new menu items from diff", async () => { + await registry.createCollection({ slug: "posts", label: "Posts", sortOrder: 0 }); + + const result = await applyMenuSyncDiff( + db, + { + toAdd: [ + { menuName: "primary", label: "Posts", referenceCollection: "posts", sortOrder: 0 }, + ], + toRemove: [], + toReorder: [], + }, + "primary", + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.added).toBe(1); + } + + const items = await getMenuItems(); + expect(items).toHaveLength(1); + expect(items[0].reference_collection).toBe("posts"); + }); + + it("removes menu items from diff", async () => { + const itemId = ulid(); + + await db + .insertInto("_emdash_menu_items") + .values({ + id: itemId, + menu_id: menuId, + type: "collection", + reference_collection: "old", + label: "Old", + sort_order: 0, + locale: "en", + }) + .execute(); + + const result = await applyMenuSyncDiff( + db, + { + toAdd: [], + toRemove: [itemId], + toReorder: [], + }, + "primary", + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.removed).toBe(1); + } + + const items = await getMenuItems(); + expect(items).toHaveLength(0); + }); + }); + + describe("syncSidebarToMenu", () => { + it("performs full sync in one step", async () => { + await registry.createCollection({ slug: "posts", label: "Posts", sortOrder: 0 }); + await registry.createCollection({ slug: "pages", label: "Pages", sortOrder: 1 }); + + const result = await syncSidebarToMenu(db, "primary"); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.added).toBe(2); + } + + const items = await getMenuItems(); + expect(items).toHaveLength(2); + }); + }); +}); diff --git a/packages/core/tests/unit/schema/collection-ordering.test.ts b/packages/core/tests/unit/schema/collection-ordering.test.ts new file mode 100644 index 000000000..00f988fa6 --- /dev/null +++ b/packages/core/tests/unit/schema/collection-ordering.test.ts @@ -0,0 +1,178 @@ +/** + * Tests for collection ordering, grouping, and reordering features. + * + * Covers: + * - sort_order and group fields on collections + * - listCollections ordering by sort_order ASC, slug ASC + * - listCollectionsWithFields ordering + * - reorderCollections batch update + */ + +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import type { Database as EmDashDatabase } from "../../../src/database/types.js"; +import { SchemaRegistry } from "../../../src/schema/registry.js"; + +describe("Collection Ordering and Grouping", () => { + let db: Kysely; + let registry: SchemaRegistry; + + beforeEach(async () => { + const sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + await runMigrations(db); + registry = new SchemaRegistry(db); + }); + + afterEach(async () => { + await db.destroy(); + }); + + describe("sort_order and group fields", () => { + it("creates a collection with default sort_order=0 and null group", async () => { + const collection = await registry.createCollection({ + slug: "posts", + label: "Posts", + }); + + expect(collection.sortOrder).toBe(0); + expect(collection.group ?? null).toBeNull(); + }); + + it("creates a collection with explicit sortOrder and group", async () => { + const collection = await registry.createCollection({ + slug: "blog", + label: "Blog", + sortOrder: 5, + group: "Content", + }); + + expect(collection.sortOrder).toBe(5); + expect(collection.group).toBe("Content"); + }); + + it("updates sortOrder and group on an existing collection", async () => { + await registry.createCollection({ slug: "posts", label: "Posts" }); + + const updated = await registry.updateCollection("posts", { + sortOrder: 10, + group: "Blog", + }); + + expect(updated.sortOrder).toBe(10); + expect(updated.group).toBe("Blog"); + }); + }); + + describe("listCollections ordering", () => { + it("orders by sort_order ASC, then slug ASC", async () => { + await registry.createCollection({ slug: "zebra", label: "Zebra", sortOrder: 1 }); + await registry.createCollection({ slug: "alpha", label: "Alpha", sortOrder: 1 }); + await registry.createCollection({ slug: "beta", label: "Beta", sortOrder: 0 }); + + const collections = await registry.listCollections(); + const slugs = collections.map((c) => c.slug); + + // sortOrder=0 first, then sortOrder=1 sorted by slug + expect(slugs).toEqual(["beta", "alpha", "zebra"]); + }); + + it("places default sortOrder=0 collections before higher values", async () => { + await registry.createCollection({ slug: "high", label: "High", sortOrder: 100 }); + await registry.createCollection({ slug: "default", label: "Default" }); + + const collections = await registry.listCollections(); + expect(collections[0].slug).toBe("default"); + expect(collections[1].slug).toBe("high"); + }); + }); + + describe("listCollectionsWithFields ordering", () => { + it("orders by sort_order ASC, then slug ASC", async () => { + // Use inputs where alphabetical and sort_order disagree + await registry.createCollection({ slug: "zebra", label: "Zebra", sortOrder: 0 }); + await registry.createCollection({ slug: "art", label: "Art", sortOrder: 1 }); + await registry.createCollection({ slug: "blog", label: "Blog", sortOrder: 0 }); + + const collections = await registry.listCollectionsWithFields(); + const slugs = collections.map((c) => c.slug); + + // sort_order=0 first (blog, zebra by slug), then sort_order=1 (art) + expect(slugs).toEqual(["blog", "zebra", "art"]); + }); + + it("includes group field in results", async () => { + await registry.createCollection({ + slug: "products", + label: "Products", + group: "Shop", + sortOrder: 1, + }); + + const collections = await registry.listCollectionsWithFields(); + const products = collections.find((c) => c.slug === "products"); + + expect(products).toBeDefined(); + expect(products!.group).toBe("Shop"); + expect(products!.sortOrder).toBe(1); + }); + }); + + describe("reorderCollections", () => { + it("updates sort_order for multiple collections", async () => { + await registry.createCollection({ slug: "posts", label: "Posts", sortOrder: 0 }); + await registry.createCollection({ slug: "pages", label: "Pages", sortOrder: 0 }); + await registry.createCollection({ slug: "blog", label: "Blog", sortOrder: 0 }); + + await registry.reorderCollections([ + { slug: "blog", sortOrder: 0 }, + { slug: "posts", sortOrder: 1 }, + { slug: "pages", sortOrder: 2 }, + ]); + + const collections = await registry.listCollections(); + expect(collections.map((c) => c.slug)).toEqual(["blog", "posts", "pages"]); + }); + + it("preserves slug ordering for equal sort_order values", async () => { + await registry.createCollection({ slug: "zebra", label: "Zebra" }); + await registry.createCollection({ slug: "alpha", label: "Alpha" }); + + await registry.reorderCollections([ + { slug: "alpha", sortOrder: 0 }, + { slug: "zebra", sortOrder: 0 }, + ]); + + const collections = await registry.listCollections(); + expect(collections.map((c) => c.slug)).toEqual(["alpha", "zebra"]); + }); + + it("handles partial reorder (only specified collections updated)", async () => { + await registry.createCollection({ slug: "a", label: "A", sortOrder: 0 }); + await registry.createCollection({ slug: "b", label: "B", sortOrder: 0 }); + await registry.createCollection({ slug: "c", label: "C", sortOrder: 0 }); + + await registry.reorderCollections([ + { slug: "c", sortOrder: 0 }, + { slug: "a", sortOrder: 1 }, + ]); + + const collections = await registry.listCollections(); + // c=0, a=1, b=0 (unchanged) -> b and c both 0, sorted by slug + expect(collections.map((c) => c.slug)).toEqual(["b", "c", "a"]); + }); + + it("throws SchemaError for non-existent collection slug", async () => { + await registry.createCollection({ slug: "posts", label: "Posts" }); + + await expect( + registry.reorderCollections([{ slug: "nonexistent", sortOrder: 0 }]), + ).rejects.toThrow(); + }); + }); +});