@agent-native/core 0.91.1 → 0.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. package/blueprints/action/crud.md +1 -1
  2. package/blueprints/channel/discord.md +1 -1
  3. package/blueprints/provider/stripe.md +1 -1
  4. package/blueprints/sandbox/docker.md +1 -1
  5. package/corpus/README.md +2 -2
  6. package/corpus/core/CHANGELOG.md +31 -0
  7. package/corpus/core/blueprints/action/crud.md +1 -1
  8. package/corpus/core/blueprints/channel/discord.md +1 -1
  9. package/corpus/core/blueprints/provider/stripe.md +1 -1
  10. package/corpus/core/blueprints/sandbox/docker.md +1 -1
  11. package/corpus/core/docs/content/faq.mdx +17 -0
  12. package/corpus/core/docs/content/notifications.mdx +12 -6
  13. package/corpus/core/package.json +6 -5
  14. package/corpus/core/src/agent/production-agent.ts +140 -46
  15. package/corpus/core/src/agent/run-manager.ts +23 -2
  16. package/corpus/core/src/agent/run-store.ts +2 -2
  17. package/corpus/core/src/agent/tool-search.ts +71 -4
  18. package/corpus/core/src/cli/add.ts +1 -1
  19. package/corpus/core/src/cli/create.ts +8 -3
  20. package/corpus/core/src/cli/index.ts +25 -4
  21. package/corpus/core/src/cli/upgrade.ts +798 -0
  22. package/corpus/core/src/client/AssistantChat.tsx +1 -1
  23. package/corpus/core/src/client/HighlightedCodeBlock.tsx +20 -3
  24. package/corpus/core/src/client/agent-chat-adapter.ts +31 -6
  25. package/corpus/core/src/client/chat/message-components.tsx +85 -8
  26. package/corpus/core/src/client/chat/tool-call-display.tsx +355 -144
  27. package/corpus/core/src/client/code-agent-chat-adapter.ts +2 -9
  28. package/corpus/core/src/client/extensions/ExtensionSlot.tsx +7 -0
  29. package/corpus/core/src/client/settings/SettingsPanel.tsx +533 -389
  30. package/corpus/core/src/client/settings/SettingsSection.tsx +154 -17
  31. package/corpus/core/src/client/sse-event-processor.ts +29 -2
  32. package/corpus/core/src/extensions/actions.ts +113 -5
  33. package/corpus/core/src/jobs/scheduler.ts +30 -1
  34. package/corpus/core/src/localization/default-messages.ts +21 -0
  35. package/corpus/core/src/notifications/channels.ts +88 -25
  36. package/corpus/core/src/notifications/registry.ts +17 -3
  37. package/corpus/core/src/notifications/types.ts +4 -1
  38. package/corpus/core/src/server/agent-chat-plugin.ts +7 -3
  39. package/corpus/core/src/server/request-context.ts +7 -0
  40. package/corpus/core/src/styles/agent-conversation.css +1 -1
  41. package/corpus/core/src/templates/default/.agents/skills/self-modifying-code/SKILL.md +20 -2
  42. package/corpus/core/src/templates/default/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  43. package/corpus/core/src/templates/default/AGENTS.md +10 -3
  44. package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +21 -0
  45. package/corpus/core/src/templates/default/app/i18n/de-DE.ts +21 -0
  46. package/corpus/core/src/templates/default/app/i18n/en-US.ts +21 -0
  47. package/corpus/core/src/templates/default/app/i18n/es-ES.ts +21 -0
  48. package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +21 -0
  49. package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +20 -0
  50. package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +21 -0
  51. package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +20 -0
  52. package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +21 -0
  53. package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +20 -0
  54. package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +19 -0
  55. package/corpus/core/src/templates/default/package.json +4 -3
  56. package/corpus/core/src/templates/headless/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  57. package/corpus/core/src/templates/headless/AGENTS.md +9 -3
  58. package/corpus/core/src/templates/headless/package.json +4 -3
  59. package/corpus/core/src/templates/workspace-core/.agents/skills/self-modifying-code/SKILL.md +20 -2
  60. package/corpus/core/src/templates/workspace-core/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  61. package/corpus/core/src/templates/workspace-core/package.json +6 -6
  62. package/corpus/core/src/templates/workspace-root/AGENTS.md +10 -4
  63. package/corpus/core/src/templates/workspace-root/package.json +3 -2
  64. package/corpus/core/src/triggers/dispatcher.ts +30 -2
  65. package/corpus/core/src/vite/client.ts +96 -1
  66. package/corpus/templates/analytics/.agents/skills/self-modifying-code/SKILL.md +20 -2
  67. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +20 -2
  68. package/corpus/templates/analytics/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  69. package/corpus/templates/analytics/AGENTS.md +7 -5
  70. package/corpus/templates/analytics/actions/save-monitor.ts +14 -0
  71. package/corpus/templates/analytics/app/components/dashboard/DataTable.tsx +41 -6
  72. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +122 -11
  73. package/corpus/templates/analytics/app/global.css +26 -10
  74. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  75. package/corpus/templates/analytics/app/i18n-data.ts +25 -0
  76. package/corpus/templates/analytics/app/pages/monitoring/MonitoringPage.tsx +8 -2
  77. package/corpus/templates/analytics/app/pages/monitoring/uptime/MonitorFormPage.tsx +151 -32
  78. package/corpus/templates/analytics/app/pages/monitoring/uptime/i18n.ts +17 -2
  79. package/corpus/templates/analytics/app/pages/monitoring/uptime/types.ts +4 -0
  80. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +461 -286
  81. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +285 -294
  82. package/corpus/templates/analytics/changelog/2026-07-08-console-details-expand-inline-under-each-row-and-the-event-t.md +6 -0
  83. package/corpus/templates/analytics/changelog/2026-07-08-dashboard-tables-keep-their-size-while-loading.md +6 -0
  84. package/corpus/templates/analytics/changelog/2026-07-08-monitoring-uptime-and-errors-tabs-now-clearly-show-which-view-is-active.md +6 -0
  85. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-keeps-a-usable-stage-when-dev-tools-is-open-a.md +6 -0
  86. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-resumes-after-scrubbing-and-expands-console-r.md +6 -0
  87. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-uses-stock-rrweb-sizing-again-so-recorded.md +6 -0
  88. package/corpus/templates/analytics/changelog/2026-07-08-session-replays-wait-for-full-load-keep-a-normal-viewport-an.md +6 -0
  89. package/corpus/templates/analytics/changelog/2026-07-08-sidebar-dashboard-menus-now-open-above-the-dashboard-canvas.md +6 -0
  90. package/corpus/templates/analytics/changelog/2026-07-08-uptime-alerting-clarifies-in-app-inbox-vs-email-and-lets-you-paste-slack-or-webhook-urls-per-monitor.md +6 -0
  91. package/corpus/templates/analytics/docs/uptime-monitoring.md +13 -11
  92. package/corpus/templates/analytics/package.json +2 -2
  93. package/corpus/templates/analytics/server/db/schema-monitoring.ts +4 -0
  94. package/corpus/templates/analytics/server/lib/uptime-monitors.ts +60 -12
  95. package/corpus/templates/analytics/server/plugins/db.ts +10 -0
  96. package/corpus/templates/assets/.agents/skills/self-modifying-code/SKILL.md +20 -2
  97. package/corpus/templates/assets/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  98. package/corpus/templates/assets/package.json +2 -2
  99. package/corpus/templates/brain/.agents/skills/self-modifying-code/SKILL.md +20 -2
  100. package/corpus/templates/brain/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  101. package/corpus/templates/brain/package.json +2 -2
  102. package/corpus/templates/calendar/.agents/skills/self-modifying-code/SKILL.md +20 -2
  103. package/corpus/templates/calendar/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  104. package/corpus/templates/calendar/AGENTS.md +10 -0
  105. package/corpus/templates/calendar/actions/create-event.ts +5 -1
  106. package/corpus/templates/calendar/actions/event-action-helpers.ts +41 -0
  107. package/corpus/templates/calendar/actions/get-attendee-timezones.ts +28 -0
  108. package/corpus/templates/calendar/actions/set-attendee-timezone.ts +68 -0
  109. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +150 -11
  110. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +36 -1
  111. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +32 -0
  112. package/corpus/templates/calendar/app/hooks/use-attendee-timezones.ts +24 -0
  113. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  114. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  115. package/corpus/templates/calendar/app/lib/attendee-local-time.ts +122 -0
  116. package/corpus/templates/calendar/changelog/2026-07-08-event-details-now-support-extension-widgets-and-you-can-see-.md +6 -0
  117. package/corpus/templates/calendar/changelog/2026-07-08-when-you-invite-guests-to-a-new-event-you-now-appear-in.md +6 -0
  118. package/corpus/templates/calendar/package.json +2 -2
  119. package/corpus/templates/calendar/server/handlers/events.ts +5 -1
  120. package/corpus/templates/calendar/shared/api.ts +6 -0
  121. package/corpus/templates/chat/.agents/skills/self-modifying-code/SKILL.md +20 -2
  122. package/corpus/templates/chat/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  123. package/corpus/templates/chat/package.json +2 -2
  124. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +6 -2
  125. package/corpus/templates/clips/.agents/skills/self-modifying-code/SKILL.md +20 -2
  126. package/corpus/templates/clips/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  127. package/corpus/templates/clips/actions/create-meeting.ts +8 -3
  128. package/corpus/templates/clips/actions/list-meetings.ts +31 -3
  129. package/corpus/templates/clips/changelog/2026-07-08-after-finishing-a-desktop-recording-reopening-clips-restor.md +6 -0
  130. package/corpus/templates/clips/changelog/2026-07-08-clips-now-pops-up-to-take-notes-when-you-join-an-adhoc-zoom-.md +6 -0
  131. package/corpus/templates/clips/changelog/2026-07-08-desktop-clip-recordings-are-louder-after-capture-mic-only.md +6 -0
  132. package/corpus/templates/clips/changelog/2026-07-08-desktop-meeting-notes-stay-active-from-tray-and-shortcut-controls.md +6 -0
  133. package/corpus/templates/clips/changelog/2026-07-08-desktop-meeting-popovers-can-start-notes-and-join-the-meetin.md +6 -0
  134. package/corpus/templates/clips/changelog/2026-07-08-meeting-notes-keep-their-recording-state-when-the-desktop.md +6 -0
  135. package/corpus/templates/clips/changelog/2026-07-08-meeting-reminder-dropdowns-now-expand-fully-instead-of-being.md +6 -0
  136. package/corpus/templates/clips/changelog/2026-07-08-meeting-reminders-now-appear-1-minute-before-start-stay-unti.md +6 -0
  137. package/corpus/templates/clips/changelog/2026-07-08-the-desktop-meetings-panel-has-room-for-its-drop-shadow-and-.md +6 -0
  138. package/corpus/templates/clips/chrome-extension/package.json +2 -2
  139. package/corpus/templates/clips/desktop/package.json +2 -2
  140. package/corpus/templates/clips/desktop/src/app.tsx +214 -37
  141. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +30 -6
  142. package/corpus/templates/clips/desktop/src/lib/meeting-notification-timing.ts +83 -0
  143. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +21 -0
  144. package/corpus/templates/clips/desktop/src/lib/recorder.ts +98 -20
  145. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +197 -50
  146. package/corpus/templates/clips/desktop/src/styles.css +150 -53
  147. package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +362 -0
  148. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +41 -7
  149. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +9 -2
  150. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +89 -11
  151. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +71 -17
  152. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +36 -1
  153. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +62 -10
  154. package/corpus/templates/clips/desktop/src-tauri/src/shortcuts.rs +3 -3
  155. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +1 -25
  156. package/corpus/templates/clips/desktop/src-tauri/src/state.rs +5 -0
  157. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +18 -6
  158. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +36 -1
  159. package/corpus/templates/clips/package.json +2 -2
  160. package/corpus/templates/content/.agents/skills/self-modifying-code/SKILL.md +20 -2
  161. package/corpus/templates/content/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  162. package/corpus/templates/content/actions/_batch-utils.ts +8 -0
  163. package/corpus/templates/content/actions/_builder-cms-read-client.ts +7 -5
  164. package/corpus/templates/content/actions/_content-database-personal-view.ts +80 -0
  165. package/corpus/templates/content/actions/_database-source-utils.ts +776 -319
  166. package/corpus/templates/content/actions/_database-utils.ts +6 -1
  167. package/corpus/templates/content/actions/_property-utils.ts +168 -0
  168. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +19 -12
  169. package/corpus/templates/content/actions/bind-content-database-source-field.ts +19 -12
  170. package/corpus/templates/content/actions/delete-document.ts +9 -13
  171. package/corpus/templates/content/actions/get-content-database-personal-view.ts +27 -0
  172. package/corpus/templates/content/actions/refresh-content-database-source.ts +5 -5
  173. package/corpus/templates/content/actions/update-content-database-personal-view.ts +31 -0
  174. package/corpus/templates/content/actions/update-content-database-view.ts +3 -0
  175. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +1 -1
  176. package/corpus/templates/content/app/components/editor/body-hydration.ts +35 -11
  177. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2631 -551
  178. package/corpus/templates/content/app/components/editor/database/filter-sort.ts +220 -18
  179. package/corpus/templates/content/app/hooks/use-content-database.ts +32 -0
  180. package/corpus/templates/content/app/hooks/use-documents.ts +8 -2
  181. package/corpus/templates/content/app/i18n/zh-TW.ts +16 -0
  182. package/corpus/templates/content/app/i18n-data.ts +154 -0
  183. package/corpus/templates/content/changelog/2026-07-08-builder-source-sync-now-completes-reliably-on-hosted-databases.md +6 -0
  184. package/corpus/templates/content/changelog/2026-07-08-database-filters-now-support-notion-style-reset-save-for-eve.md +6 -0
  185. package/corpus/templates/content/changelog/2026-07-08-date-filters-can-now-target-a-between-range.md +6 -0
  186. package/corpus/templates/content/changelog/2026-07-08-fixed-builder-database-sync-so-large-sources-continue-past-t.md +6 -0
  187. package/corpus/templates/content/package.json +2 -2
  188. package/corpus/templates/content/parity/matrix.md +1 -1
  189. package/corpus/templates/content/parity/matrix.ts +2 -0
  190. package/corpus/templates/content/server/plugins/db.ts +19 -0
  191. package/corpus/templates/content/shared/api.ts +24 -0
  192. package/corpus/templates/design/.agents/skills/self-modifying-code/SKILL.md +20 -2
  193. package/corpus/templates/design/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  194. package/corpus/templates/design/package.json +2 -2
  195. package/corpus/templates/dispatch/.agents/skills/self-modifying-code/SKILL.md +20 -2
  196. package/corpus/templates/dispatch/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  197. package/corpus/templates/dispatch/app/i18n/zh-TW.ts +34 -1
  198. package/corpus/templates/dispatch/app/i18n-data.ts +387 -10
  199. package/corpus/templates/dispatch/app/routes/automations.tsx +1 -0
  200. package/corpus/templates/dispatch/changelog/2026-07-08-added-an-automations-page-to-review-pause-and-create.md +6 -0
  201. package/corpus/templates/dispatch/changelog/2026-07-08-destinations-now-shows-outbound-delivery-queue-health.md +6 -0
  202. package/corpus/templates/dispatch/package.json +2 -2
  203. package/corpus/templates/forms/.agents/skills/self-modifying-code/SKILL.md +20 -2
  204. package/corpus/templates/forms/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  205. package/corpus/templates/forms/package.json +2 -2
  206. package/corpus/templates/macros/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  207. package/corpus/templates/macros/package.json +2 -2
  208. package/corpus/templates/mail/.agents/skills/self-modifying-code/SKILL.md +20 -2
  209. package/corpus/templates/mail/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  210. package/corpus/templates/mail/app/hooks/use-emails.ts +56 -44
  211. package/corpus/templates/mail/app/lib/gmail-mutation-queue.ts +325 -0
  212. package/corpus/templates/mail/changelog/2026-07-08-rapid-archive-and-mark-read-now-batch-gmail-updates-so-the-i.md +6 -0
  213. package/corpus/templates/mail/package.json +2 -2
  214. package/corpus/templates/plan/.agents/skills/self-modifying-code/SKILL.md +20 -2
  215. package/corpus/templates/plan/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  216. package/corpus/templates/plan/package.json +2 -2
  217. package/corpus/templates/slides/.agents/skills/self-modifying-code/SKILL.md +20 -2
  218. package/corpus/templates/slides/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  219. package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +46 -29
  220. package/corpus/templates/slides/app/context/DeckContext.tsx +45 -18
  221. package/corpus/templates/slides/app/i18n/ar-SA.ts +4 -0
  222. package/corpus/templates/slides/app/i18n/de-DE.ts +4 -0
  223. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  224. package/corpus/templates/slides/app/i18n/es-ES.ts +4 -0
  225. package/corpus/templates/slides/app/i18n/fr-FR.ts +4 -0
  226. package/corpus/templates/slides/app/i18n/hi-IN.ts +4 -0
  227. package/corpus/templates/slides/app/i18n/ja-JP.ts +4 -0
  228. package/corpus/templates/slides/app/i18n/ko-KR.ts +4 -0
  229. package/corpus/templates/slides/app/i18n/pt-BR.ts +4 -0
  230. package/corpus/templates/slides/app/i18n/zh-CN.ts +4 -0
  231. package/corpus/templates/slides/app/i18n/zh-TW.ts +4 -0
  232. package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +115 -0
  233. package/corpus/templates/slides/app/pages/DeckEditor.tsx +22 -13
  234. package/corpus/templates/slides/changelog/2026-07-08-dropping-an-image-onto-a-slide-and-sending-it-to-the-agent-w.md +6 -0
  235. package/corpus/templates/slides/changelog/2026-07-08-undo-works-after-the-agent-edits-a-slide-so-you-can-reverse.md +6 -0
  236. package/corpus/templates/slides/package.json +2 -2
  237. package/corpus/templates/videos/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  238. package/corpus/templates/voice/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  239. package/dist/agent/production-agent.d.ts +5 -1
  240. package/dist/agent/production-agent.d.ts.map +1 -1
  241. package/dist/agent/production-agent.js +112 -42
  242. package/dist/agent/production-agent.js.map +1 -1
  243. package/dist/agent/run-manager.d.ts.map +1 -1
  244. package/dist/agent/run-manager.js +23 -2
  245. package/dist/agent/run-manager.js.map +1 -1
  246. package/dist/agent/run-store.js +2 -2
  247. package/dist/agent/run-store.js.map +1 -1
  248. package/dist/agent/tool-search.d.ts +2 -0
  249. package/dist/agent/tool-search.d.ts.map +1 -1
  250. package/dist/agent/tool-search.js +60 -4
  251. package/dist/agent/tool-search.js.map +1 -1
  252. package/dist/catalog.json +2 -2
  253. package/dist/cli/add.js +1 -1
  254. package/dist/cli/add.js.map +1 -1
  255. package/dist/cli/create.d.ts.map +1 -1
  256. package/dist/cli/create.js +8 -3
  257. package/dist/cli/create.js.map +1 -1
  258. package/dist/cli/index.js +24 -4
  259. package/dist/cli/index.js.map +1 -1
  260. package/dist/cli/upgrade.d.ts +100 -0
  261. package/dist/cli/upgrade.d.ts.map +1 -0
  262. package/dist/cli/upgrade.js +678 -0
  263. package/dist/cli/upgrade.js.map +1 -0
  264. package/dist/client/AssistantChat.js +1 -1
  265. package/dist/client/AssistantChat.js.map +1 -1
  266. package/dist/client/HighlightedCodeBlock.d.ts +2 -0
  267. package/dist/client/HighlightedCodeBlock.d.ts.map +1 -1
  268. package/dist/client/HighlightedCodeBlock.js +10 -1
  269. package/dist/client/HighlightedCodeBlock.js.map +1 -1
  270. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  271. package/dist/client/agent-chat-adapter.js +32 -4
  272. package/dist/client/agent-chat-adapter.js.map +1 -1
  273. package/dist/client/chat/message-components.d.ts.map +1 -1
  274. package/dist/client/chat/message-components.js +61 -8
  275. package/dist/client/chat/message-components.js.map +1 -1
  276. package/dist/client/chat/tool-call-display.d.ts +12 -0
  277. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  278. package/dist/client/chat/tool-call-display.js +136 -42
  279. package/dist/client/chat/tool-call-display.js.map +1 -1
  280. package/dist/client/code-agent-chat-adapter.js +2 -9
  281. package/dist/client/code-agent-chat-adapter.js.map +1 -1
  282. package/dist/client/extensions/ExtensionSlot.js +6 -0
  283. package/dist/client/extensions/ExtensionSlot.js.map +1 -1
  284. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  285. package/dist/client/settings/SettingsPanel.js +52 -19
  286. package/dist/client/settings/SettingsPanel.js.map +1 -1
  287. package/dist/client/settings/SettingsSection.d.ts +19 -4
  288. package/dist/client/settings/SettingsSection.d.ts.map +1 -1
  289. package/dist/client/settings/SettingsSection.js +33 -4
  290. package/dist/client/settings/SettingsSection.js.map +1 -1
  291. package/dist/client/sse-event-processor.d.ts +9 -0
  292. package/dist/client/sse-event-processor.d.ts.map +1 -1
  293. package/dist/client/sse-event-processor.js +20 -2
  294. package/dist/client/sse-event-processor.js.map +1 -1
  295. package/dist/collab/routes.d.ts +1 -1
  296. package/dist/collab/struct-routes.d.ts +1 -1
  297. package/dist/extensions/actions.d.ts.map +1 -1
  298. package/dist/extensions/actions.js +85 -5
  299. package/dist/extensions/actions.js.map +1 -1
  300. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  301. package/dist/jobs/scheduler.js +26 -1
  302. package/dist/jobs/scheduler.js.map +1 -1
  303. package/dist/localization/default-messages.d.ts +19 -0
  304. package/dist/localization/default-messages.d.ts.map +1 -1
  305. package/dist/localization/default-messages.js +19 -0
  306. package/dist/localization/default-messages.js.map +1 -1
  307. package/dist/notifications/channels.d.ts +8 -3
  308. package/dist/notifications/channels.d.ts.map +1 -1
  309. package/dist/notifications/channels.js +61 -22
  310. package/dist/notifications/channels.js.map +1 -1
  311. package/dist/notifications/registry.d.ts.map +1 -1
  312. package/dist/notifications/registry.js +14 -3
  313. package/dist/notifications/registry.js.map +1 -1
  314. package/dist/notifications/routes.d.ts +1 -1
  315. package/dist/notifications/types.d.ts +4 -1
  316. package/dist/notifications/types.d.ts.map +1 -1
  317. package/dist/notifications/types.js.map +1 -1
  318. package/dist/observability/routes.d.ts +1 -1
  319. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  320. package/dist/resources/handlers.d.ts +3 -3
  321. package/dist/server/agent-chat-plugin.d.ts +5 -1
  322. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  323. package/dist/server/agent-chat-plugin.js +2 -2
  324. package/dist/server/agent-chat-plugin.js.map +1 -1
  325. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  326. package/dist/server/request-context.d.ts +7 -0
  327. package/dist/server/request-context.d.ts.map +1 -1
  328. package/dist/server/request-context.js.map +1 -1
  329. package/dist/styles/agent-conversation.css +1 -1
  330. package/dist/templates/default/.agents/skills/self-modifying-code/SKILL.md +20 -2
  331. package/dist/templates/default/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  332. package/dist/templates/default/AGENTS.md +10 -3
  333. package/dist/templates/default/app/i18n/ar-SA.ts +21 -0
  334. package/dist/templates/default/app/i18n/de-DE.ts +21 -0
  335. package/dist/templates/default/app/i18n/en-US.ts +21 -0
  336. package/dist/templates/default/app/i18n/es-ES.ts +21 -0
  337. package/dist/templates/default/app/i18n/fr-FR.ts +21 -0
  338. package/dist/templates/default/app/i18n/hi-IN.ts +20 -0
  339. package/dist/templates/default/app/i18n/ja-JP.ts +21 -0
  340. package/dist/templates/default/app/i18n/ko-KR.ts +20 -0
  341. package/dist/templates/default/app/i18n/pt-BR.ts +21 -0
  342. package/dist/templates/default/app/i18n/zh-CN.ts +20 -0
  343. package/dist/templates/default/app/i18n/zh-TW.ts +19 -0
  344. package/dist/templates/default/package.json +4 -3
  345. package/dist/templates/headless/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  346. package/dist/templates/headless/AGENTS.md +9 -3
  347. package/dist/templates/headless/package.json +4 -3
  348. package/dist/templates/workspace-core/.agents/skills/self-modifying-code/SKILL.md +20 -2
  349. package/dist/templates/workspace-core/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  350. package/dist/templates/workspace-core/package.json +6 -6
  351. package/dist/templates/workspace-root/AGENTS.md +10 -4
  352. package/dist/templates/workspace-root/package.json +3 -2
  353. package/dist/triggers/dispatcher.js +27 -2
  354. package/dist/triggers/dispatcher.js.map +1 -1
  355. package/dist/vite/client.d.ts.map +1 -1
  356. package/dist/vite/client.js +70 -1
  357. package/dist/vite/client.js.map +1 -1
  358. package/docs/content/faq.mdx +17 -0
  359. package/docs/content/notifications.mdx +12 -6
  360. package/package.json +7 -6
  361. package/src/templates/default/.agents/skills/self-modifying-code/SKILL.md +20 -2
  362. package/src/templates/default/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  363. package/src/templates/default/AGENTS.md +10 -3
  364. package/src/templates/default/app/i18n/ar-SA.ts +21 -0
  365. package/src/templates/default/app/i18n/de-DE.ts +21 -0
  366. package/src/templates/default/app/i18n/en-US.ts +21 -0
  367. package/src/templates/default/app/i18n/es-ES.ts +21 -0
  368. package/src/templates/default/app/i18n/fr-FR.ts +21 -0
  369. package/src/templates/default/app/i18n/hi-IN.ts +20 -0
  370. package/src/templates/default/app/i18n/ja-JP.ts +21 -0
  371. package/src/templates/default/app/i18n/ko-KR.ts +20 -0
  372. package/src/templates/default/app/i18n/pt-BR.ts +21 -0
  373. package/src/templates/default/app/i18n/zh-CN.ts +20 -0
  374. package/src/templates/default/app/i18n/zh-TW.ts +19 -0
  375. package/src/templates/default/package.json +4 -3
  376. package/src/templates/headless/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  377. package/src/templates/headless/AGENTS.md +9 -3
  378. package/src/templates/headless/package.json +4 -3
  379. package/src/templates/workspace-core/.agents/skills/self-modifying-code/SKILL.md +20 -2
  380. package/src/templates/workspace-core/.agents/skills/upgrade-agent-native/SKILL.md +87 -0
  381. package/src/templates/workspace-core/package.json +6 -6
  382. package/src/templates/workspace-root/AGENTS.md +10 -4
  383. package/src/templates/workspace-root/package.json +3 -2
@@ -4,6 +4,7 @@ import {
4
4
  useBuilderConnectFlow,
5
5
  useBuilderStatus,
6
6
  useCodeMode,
7
+ useSession,
7
8
  } from "@agent-native/core/client";
8
9
  import {
9
10
  AlertDialog,
@@ -56,6 +57,7 @@ import {
56
57
  type ContentDatabaseSourceJoinRequest,
57
58
  type ContentDatabaseSourceReviewPayload,
58
59
  type SourceJoinSuggestion,
60
+ type ContentDatabasePersonalViewOverrides,
59
61
  type ContentDatabaseView,
60
62
  type ContentDatabaseViewConfig,
61
63
  type ContentDatabaseColumnCalculation,
@@ -145,6 +147,7 @@ import {
145
147
  useBuilderCmsModels,
146
148
  useChangeContentDatabaseSourceRole,
147
149
  useContentDatabase,
150
+ useContentDatabasePersonalView,
148
151
  useContentDatabases,
149
152
  contentDatabaseQueryKey,
150
153
  useDeleteDatabaseItems,
@@ -158,6 +161,7 @@ import {
158
161
  useRefreshContentDatabaseSource,
159
162
  useSetContentDatabaseSourceWriteMode,
160
163
  useSuggestSourceJoinKey,
164
+ useUpdateContentDatabasePersonalView,
161
165
  useUpdateContentDatabaseView,
162
166
  } from "@/hooks/use-content-database";
163
167
  import {
@@ -207,6 +211,9 @@ import {
207
211
  filesMediaItems,
208
212
  displayValue,
209
213
  nextPropertyOption,
214
+ personItems,
215
+ personLabel,
216
+ PersonPill,
210
217
  removePropertyOption,
211
218
  renamePropertyOption,
212
219
  updatePropertyOptionColor,
@@ -265,9 +272,14 @@ const DATABASE_OPEN_PAGES_IN: ContentDatabaseOpenPagesIn[] = [
265
272
  "full_page",
266
273
  ];
267
274
  const DATABASE_FILTER_MODES: DatabaseFilterMode[] = ["and", "or"];
275
+ const DATABASE_ADVANCED_FILTER_GROUP_ID = "advanced";
276
+ export const PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION = 1;
268
277
  export const BUILDER_SOURCE_CONTINUATION_STALL_MS = 5_000;
269
278
  export const BUILDER_SOURCE_CONTINUATION_MAX_BACKOFF_MS = 30_000;
270
279
 
280
+ export type PersonalDatabaseViewOverrides =
281
+ ContentDatabasePersonalViewOverrides;
282
+
271
283
  type DatabaseMessageKey = keyof (typeof messagesByLocale)["en-US"]["database"];
272
284
 
273
285
  function dbText(
@@ -526,6 +538,8 @@ function DatabaseTable({
526
538
  const isLoadingMoreItems =
527
539
  database.isFetching && data?.pagination?.limit !== databaseItemLimit;
528
540
  const databaseId = data?.database.id ?? expectedDatabaseId;
541
+ const personalView = useContentDatabasePersonalView(databaseId);
542
+ const updatePersonalView = useUpdateContentDatabasePersonalView(databaseId);
529
543
  const source = data?.source ?? null;
530
544
  const sources = data?.sources ?? (source ? [source] : []);
531
545
  const [previewDocumentId, setPreviewDocumentId] = useState<string | null>(
@@ -540,6 +554,18 @@ function DatabaseTable({
540
554
  const [searchOpen, setSearchOpen] = useState(false);
541
555
  const [searchQuery, setSearchQuery] = useState("");
542
556
  const [settingsOpen, setSettingsOpen] = useState(false);
557
+ const [inlineFilterControlsOpen, setInlineFilterControlsOpen] =
558
+ useState(false);
559
+ const [inlineAddFilterOpen, setInlineAddFilterOpen] = useState(false);
560
+ const [toolbarFilterOpen, setToolbarFilterOpen] = useState(false);
561
+ const [inlineFilterOpenIndex, setInlineFilterOpenIndex] = useState<
562
+ number | null
563
+ >(null);
564
+ const [inlineAdvancedFilterOpen, setInlineAdvancedFilterOpen] =
565
+ useState(false);
566
+ const [inlineSortOpenIndex, setInlineSortOpenIndex] = useState<number | null>(
567
+ null,
568
+ );
543
569
  const [builderReviewOpen, setBuilderReviewOpen] = useState(false);
544
570
  const [builderReviewResult, setBuilderReviewResult] =
545
571
  useState<ContentDatabaseSourceReviewPayload | null>(null);
@@ -551,6 +577,9 @@ function DatabaseTable({
551
577
  const [viewConfig, setViewConfig] = useState<ContentDatabaseViewConfig>(
552
578
  defaultDatabaseViewConfig(),
553
579
  );
580
+ const [savedViewConfig, setSavedViewConfig] =
581
+ useState<ContentDatabaseViewConfig>(defaultDatabaseViewConfig());
582
+ const [personalQueryDirty, setPersonalQueryDirty] = useState(false);
554
583
  const [dateViewMonth, setDateViewMonth] = useState(() =>
555
584
  startOfMonth(new Date()),
556
585
  );
@@ -584,6 +613,9 @@ function DatabaseTable({
584
613
  );
585
614
  const hydratedViewRef = useRef("");
586
615
  const saveViewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
616
+ const personalViewSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
617
+ null,
618
+ );
587
619
  const autoContinueBuilderSourceRef = useRef<string | null>(null);
588
620
  const builderContinuationWatchdogRef = useRef<{
589
621
  key: string | null;
@@ -1122,15 +1154,111 @@ function DatabaseTable({
1122
1154
  }
1123
1155
 
1124
1156
  function setActiveSorts(nextSorts: DatabaseSort[]) {
1125
- updateActiveView((view) => ({ ...view, sorts: nextSorts }));
1157
+ updatePersonalQueryView((view) => ({ ...view, sorts: nextSorts }));
1126
1158
  }
1127
1159
 
1128
1160
  function setActiveFilters(nextFilters: DatabaseFilter[]) {
1129
- updateActiveView((view) => ({ ...view, filters: nextFilters }));
1161
+ updatePersonalQueryView((view) => ({ ...view, filters: nextFilters }));
1130
1162
  }
1131
1163
 
1132
1164
  function setFilterMode(filterMode: DatabaseFilterMode) {
1133
- updateActiveView((view) => ({ ...view, filterMode }));
1165
+ updatePersonalQueryView((view) => ({ ...view, filterMode }));
1166
+ }
1167
+
1168
+ function updatePersonalQueryView(
1169
+ update: (view: ContentDatabaseView) => ContentDatabaseView,
1170
+ ) {
1171
+ setViewConfig((current) => {
1172
+ const next = updateActiveDatabaseView(current, update);
1173
+ if (databaseId) {
1174
+ schedulePersonalDatabaseViewOverrideWrite(databaseId, next);
1175
+ }
1176
+ setPersonalQueryDirty(
1177
+ databaseViewHasPersonalQueryChanges(next, savedViewConfig),
1178
+ );
1179
+ return next;
1180
+ });
1181
+ }
1182
+
1183
+ async function savePersonalQueryForEveryone() {
1184
+ if (!databaseId) return;
1185
+
1186
+ try {
1187
+ const response = await updateView.mutateAsync({
1188
+ databaseId,
1189
+ viewConfig,
1190
+ });
1191
+ const nextViewConfig = normalizeClientDatabaseViewConfig(
1192
+ response.database.viewConfig,
1193
+ );
1194
+ if (personalViewSaveTimerRef.current) {
1195
+ clearTimeout(personalViewSaveTimerRef.current);
1196
+ personalViewSaveTimerRef.current = null;
1197
+ }
1198
+ updatePersonalView.mutate({ databaseId, overrides: null });
1199
+ setSavedViewConfig(nextViewConfig);
1200
+ setViewConfig(nextViewConfig);
1201
+ setPersonalQueryDirty(false);
1202
+ hydratedViewRef.current = databaseViewStateKey(
1203
+ databaseId,
1204
+ nextViewConfig,
1205
+ );
1206
+ } catch (err) {
1207
+ toast.error(dbText("failedToSaveView"), {
1208
+ description:
1209
+ err instanceof Error ? err.message : dbText("somethingWentWrong"),
1210
+ });
1211
+ }
1212
+ }
1213
+
1214
+ function resetPersonalQueryChanges() {
1215
+ if (!databaseId) return;
1216
+ if (personalViewSaveTimerRef.current) {
1217
+ clearTimeout(personalViewSaveTimerRef.current);
1218
+ personalViewSaveTimerRef.current = null;
1219
+ }
1220
+ setViewConfig((current) => {
1221
+ const nextViewConfig = databaseViewConfigWithSavedQueryState(
1222
+ current,
1223
+ savedViewConfig,
1224
+ );
1225
+ updatePersonalView.mutate({
1226
+ databaseId,
1227
+ overrides: null,
1228
+ });
1229
+ setPersonalQueryDirty(false);
1230
+ hydratedViewRef.current = databaseViewStateKey(
1231
+ databaseId,
1232
+ nextViewConfig,
1233
+ );
1234
+ return nextViewConfig;
1235
+ });
1236
+ }
1237
+
1238
+ function schedulePersonalDatabaseViewOverrideWrite(
1239
+ databaseId: string,
1240
+ viewConfig: ContentDatabaseViewConfig,
1241
+ ) {
1242
+ if (personalViewSaveTimerRef.current) {
1243
+ clearTimeout(personalViewSaveTimerRef.current);
1244
+ }
1245
+ const overrides = personalDatabaseViewOverridesFromConfig(viewConfig);
1246
+ personalViewSaveTimerRef.current = setTimeout(() => {
1247
+ personalViewSaveTimerRef.current = null;
1248
+ updatePersonalView.mutate(
1249
+ { databaseId, overrides },
1250
+ {
1251
+ onError: (err) => {
1252
+ toast.error(dbText("failedToSaveView"), {
1253
+ description:
1254
+ err instanceof Error
1255
+ ? err.message
1256
+ : dbText("somethingWentWrong"),
1257
+ });
1258
+ },
1259
+ },
1260
+ );
1261
+ }, 300);
1134
1262
  }
1135
1263
 
1136
1264
  function clearSearchAndFilters() {
@@ -1309,38 +1437,71 @@ function DatabaseTable({
1309
1437
 
1310
1438
  useEffect(() => {
1311
1439
  if (!data?.database.id) return;
1312
- const nextViewConfig = normalizeClientDatabaseViewConfig(
1440
+ if (personalView.isLoading) return;
1441
+ const nextSavedViewConfig = normalizeClientDatabaseViewConfig(
1313
1442
  data.database.viewConfig,
1314
1443
  );
1444
+ const nextViewConfig = applyPersonalDatabaseViewOverrides(
1445
+ nextSavedViewConfig,
1446
+ normalizePersonalDatabaseViewOverrides(personalView.data?.overrides),
1447
+ );
1315
1448
  const nextKey = databaseViewStateKey(data.database.id, nextViewConfig);
1316
1449
  if (hydratedViewRef.current === nextKey) return;
1317
1450
  hydratedViewRef.current = nextKey;
1451
+ setSavedViewConfig(nextSavedViewConfig);
1452
+ setPersonalQueryDirty(
1453
+ databaseViewHasPersonalQueryChanges(nextViewConfig, nextSavedViewConfig),
1454
+ );
1318
1455
  setViewConfig((current) =>
1319
1456
  databaseViewStateKey(data.database.id, current) === nextKey
1320
1457
  ? current
1321
1458
  : nextViewConfig,
1322
1459
  );
1323
- }, [data?.database.id, data?.database.viewConfig]);
1460
+ }, [
1461
+ data?.database.id,
1462
+ data?.database.viewConfig,
1463
+ personalView.data?.overrides,
1464
+ personalView.isLoading,
1465
+ ]);
1466
+
1467
+ useEffect(() => {
1468
+ return () => {
1469
+ if (personalViewSaveTimerRef.current) {
1470
+ clearTimeout(personalViewSaveTimerRef.current);
1471
+ }
1472
+ };
1473
+ }, []);
1324
1474
 
1325
1475
  useEffect(() => {
1326
1476
  if (!databaseId) return;
1327
- const nextKey = databaseViewStateKey(databaseId, viewConfig);
1328
- if (hydratedViewRef.current === nextKey) return;
1477
+ const sharedViewConfig = personalQueryDirty
1478
+ ? databaseViewConfigWithSavedQueryState(viewConfig, savedViewConfig)
1479
+ : viewConfig;
1480
+ const nextKey = databaseViewStateKey(databaseId, sharedViewConfig);
1481
+ if (databaseViewStateKey(databaseId, savedViewConfig) === nextKey) return;
1329
1482
  if (!canEdit) return;
1330
1483
  if (saveViewTimerRef.current) {
1331
1484
  clearTimeout(saveViewTimerRef.current);
1332
1485
  }
1333
1486
  saveViewTimerRef.current = setTimeout(() => {
1334
1487
  updateView.mutate(
1335
- { databaseId, viewConfig },
1488
+ { databaseId, viewConfig: sharedViewConfig },
1336
1489
  {
1337
1490
  onSuccess: (response) => {
1338
1491
  const nextViewConfig = normalizeClientDatabaseViewConfig(
1339
1492
  response.database.viewConfig,
1340
1493
  );
1494
+ setSavedViewConfig(nextViewConfig);
1341
1495
  hydratedViewRef.current = databaseViewStateKey(
1342
1496
  response.database.id,
1343
- nextViewConfig,
1497
+ personalQueryDirty
1498
+ ? applyPersonalDatabaseViewOverrides(
1499
+ nextViewConfig,
1500
+ normalizePersonalDatabaseViewOverrides(
1501
+ personalView.data?.overrides,
1502
+ ),
1503
+ )
1504
+ : nextViewConfig,
1344
1505
  );
1345
1506
  },
1346
1507
  },
@@ -1351,7 +1512,15 @@ function DatabaseTable({
1351
1512
  clearTimeout(saveViewTimerRef.current);
1352
1513
  }
1353
1514
  };
1354
- }, [canEdit, databaseId, updateView, viewConfig]);
1515
+ }, [
1516
+ canEdit,
1517
+ databaseId,
1518
+ personalQueryDirty,
1519
+ personalView.data?.overrides,
1520
+ savedViewConfig,
1521
+ updateView,
1522
+ viewConfig,
1523
+ ]);
1355
1524
 
1356
1525
  function resizeColumn(
1357
1526
  key: ColumnKey,
@@ -1444,12 +1613,36 @@ function DatabaseTable({
1444
1613
  onSortsChange={setActiveSorts}
1445
1614
  />
1446
1615
  <FilterMenu
1447
- documentId={document.id}
1448
- properties={orderedProperties}
1449
1616
  filters={filters}
1450
- filterMode={filterMode}
1451
- onFiltersChange={setActiveFilters}
1452
- onFilterModeChange={setFilterMode}
1617
+ properties={orderedProperties}
1618
+ inlineOpen={inlineFilterControlsOpen}
1619
+ open={toolbarFilterOpen}
1620
+ onOpenChange={setToolbarFilterOpen}
1621
+ onAddFilter={(key, label) => {
1622
+ setActiveFilters([
1623
+ ...filters,
1624
+ createDatabaseFilterForField(key, label, orderedProperties),
1625
+ ]);
1626
+ setInlineFilterControlsOpen(true);
1627
+ setInlineAddFilterOpen(false);
1628
+ setInlineFilterOpenIndex(filters.length);
1629
+ setToolbarFilterOpen(false);
1630
+ }}
1631
+ onAddAdvancedFilter={(key, label) => {
1632
+ setActiveFilters([
1633
+ ...filters,
1634
+ createDatabaseFilterForField(
1635
+ key,
1636
+ label,
1637
+ orderedProperties,
1638
+ true,
1639
+ ),
1640
+ ]);
1641
+ setInlineFilterControlsOpen(true);
1642
+ setInlineAddFilterOpen(false);
1643
+ setInlineAdvancedFilterOpen(true);
1644
+ setToolbarFilterOpen(false);
1645
+ }}
1453
1646
  />
1454
1647
  {renderMode === "inline" ? (
1455
1648
  <Tooltip>
@@ -1522,29 +1715,58 @@ function DatabaseTable({
1522
1715
  </div>
1523
1716
 
1524
1717
  <DatabaseActiveConstraintsBar
1718
+ documentId={document.id}
1719
+ items={items}
1525
1720
  searchQuery={searchQuery}
1526
1721
  sorts={sorts}
1527
1722
  filters={filters}
1723
+ filterMode={filterMode}
1528
1724
  properties={properties}
1529
1725
  constraintCount={activeConstraintCount}
1726
+ forceShow={inlineFilterControlsOpen}
1727
+ addFilterOpen={inlineAddFilterOpen}
1728
+ openSortIndex={inlineSortOpenIndex}
1729
+ openFilterIndex={inlineFilterOpenIndex}
1730
+ advancedFilterOpen={inlineAdvancedFilterOpen}
1731
+ hasPersonalQueryChanges={personalQueryDirty}
1732
+ savePending={updateView.isPending}
1733
+ onAddFilterOpenChange={(open) => {
1734
+ setInlineAddFilterOpen(open);
1735
+ if (open) setInlineFilterControlsOpen(true);
1736
+ }}
1737
+ onSortOpenIndexChange={setInlineSortOpenIndex}
1738
+ onFilterOpenIndexChange={setInlineFilterOpenIndex}
1739
+ onAdvancedFilterOpenChange={setInlineAdvancedFilterOpen}
1530
1740
  onClearSearch={() => {
1531
1741
  setSearchQuery("");
1532
1742
  setSearchOpen(false);
1533
1743
  }}
1534
- onRemoveSort={(index) =>
1535
- setActiveSorts(sorts.filter((_, sortIndex) => sortIndex !== index))
1536
- }
1537
- onRemoveFilter={(index) =>
1744
+ onRemoveSort={(index) => {
1745
+ setActiveSorts(sorts.filter((_, sortIndex) => sortIndex !== index));
1746
+ setInlineSortOpenIndex(null);
1747
+ }}
1748
+ onRemoveFilter={(index) => {
1538
1749
  setActiveFilters(
1539
1750
  filters.filter((_, filterIndex) => filterIndex !== index),
1540
- )
1541
- }
1751
+ );
1752
+ setInlineFilterOpenIndex(null);
1753
+ }}
1754
+ onFiltersChange={setActiveFilters}
1755
+ onSortsChange={setActiveSorts}
1756
+ onFilterModeChange={setFilterMode}
1542
1757
  onClearAll={() => {
1543
1758
  setSearchQuery("");
1544
1759
  setSearchOpen(false);
1545
1760
  setActiveSorts([]);
1546
1761
  setActiveFilters([]);
1762
+ setInlineFilterControlsOpen(false);
1763
+ setInlineAddFilterOpen(false);
1764
+ setInlineFilterOpenIndex(null);
1765
+ setInlineAdvancedFilterOpen(false);
1766
+ setInlineSortOpenIndex(null);
1547
1767
  }}
1768
+ onResetPersonalChanges={resetPersonalQueryChanges}
1769
+ onSaveForEveryone={() => void savePersonalQueryForEveryone()}
1548
1770
  />
1549
1771
 
1550
1772
  <BuilderSourceContinuationBar
@@ -3813,138 +4035,1368 @@ function DatabaseTableView({
3813
4035
  }
3814
4036
 
3815
4037
  function DatabaseActiveConstraintsBar({
4038
+ documentId,
4039
+ items,
3816
4040
  searchQuery,
3817
4041
  sorts,
3818
4042
  filters,
4043
+ filterMode,
3819
4044
  properties,
3820
4045
  constraintCount,
4046
+ forceShow,
4047
+ addFilterOpen,
4048
+ openSortIndex,
4049
+ openFilterIndex,
4050
+ advancedFilterOpen,
4051
+ hasPersonalQueryChanges,
4052
+ savePending,
4053
+ onAddFilterOpenChange,
4054
+ onSortOpenIndexChange,
4055
+ onFilterOpenIndexChange,
4056
+ onAdvancedFilterOpenChange,
3821
4057
  onClearSearch,
3822
4058
  onRemoveSort,
3823
4059
  onRemoveFilter,
4060
+ onSortsChange,
4061
+ onFiltersChange,
4062
+ onFilterModeChange,
3824
4063
  onClearAll,
4064
+ onResetPersonalChanges,
4065
+ onSaveForEveryone,
3825
4066
  }: {
4067
+ documentId: string;
4068
+ items: ContentDatabaseItem[];
3826
4069
  searchQuery: string;
3827
4070
  sorts: DatabaseSort[];
3828
4071
  filters: DatabaseFilter[];
4072
+ filterMode: DatabaseFilterMode;
3829
4073
  properties: DocumentProperty[];
3830
4074
  constraintCount: number;
4075
+ forceShow: boolean;
4076
+ addFilterOpen: boolean;
4077
+ openSortIndex: number | null;
4078
+ openFilterIndex: number | null;
4079
+ advancedFilterOpen: boolean;
4080
+ hasPersonalQueryChanges: boolean;
4081
+ savePending: boolean;
4082
+ onAddFilterOpenChange: (open: boolean) => void;
4083
+ onSortOpenIndexChange: (index: number | null) => void;
4084
+ onFilterOpenIndexChange: (index: number | null) => void;
4085
+ onAdvancedFilterOpenChange: (open: boolean) => void;
3831
4086
  onClearSearch: () => void;
3832
4087
  onRemoveSort: (index: number) => void;
3833
4088
  onRemoveFilter: (index: number) => void;
4089
+ onSortsChange: (sorts: DatabaseSort[]) => void;
4090
+ onFiltersChange: (filters: DatabaseFilter[]) => void;
4091
+ onFilterModeChange: (filterMode: DatabaseFilterMode) => void;
3834
4092
  onClearAll: () => void;
4093
+ onResetPersonalChanges: () => void;
4094
+ onSaveForEveryone: () => void;
3835
4095
  }) {
3836
- if (constraintCount === 0) return null;
3837
- const activeFilterEntries = filters
3838
- .map((filter, index) => ({ filter, index }))
3839
- .filter((entry) => isActiveFilter(entry.filter));
4096
+ if (
4097
+ !forceShow &&
4098
+ constraintCount === 0 &&
4099
+ filters.length === 0 &&
4100
+ !hasPersonalQueryChanges
4101
+ )
4102
+ return null;
4103
+ const hasSearchOrSortConstraints =
4104
+ searchQuery.trim().length > 0 || sorts.length > 0;
4105
+ const showViewActionControls =
4106
+ hasPersonalQueryChanges ||
4107
+ constraintCount > 0 ||
4108
+ filters.length > 0 ||
4109
+ hasSearchOrSortConstraints;
4110
+ const filterEntries = filters.map((filter, index) => ({ filter, index }));
4111
+ const advancedFilterEntries = filterEntries.filter(({ filter }) =>
4112
+ isAdvancedDatabaseFilter(filter),
4113
+ );
4114
+ const regularFilterEntries = filterEntries.filter(
4115
+ ({ filter }) => !isAdvancedDatabaseFilter(filter),
4116
+ );
4117
+ const hasSortFilterDivider = sorts.length > 0 && filterEntries.length > 0;
3840
4118
 
3841
4119
  return (
3842
4120
  <div className="mb-2 flex min-h-8 flex-wrap items-center gap-1 border-b border-border pb-2 text-xs text-muted-foreground">
3843
- <span className="px-1.5">
3844
- Showing {constraintCount} condition{constraintCount === 1 ? "" : "s"}
3845
- </span>
3846
- {searchQuery.trim() ? (
3847
- <DatabaseConstraintChip
3848
- icon={<IconSearch className="size-3.5" />}
3849
- label={`Search: ${searchQuery.trim()}`}
3850
- onRemove={onClearSearch}
3851
- />
3852
- ) : null}
3853
4121
  {sorts.map((sort, index) => (
3854
- <DatabaseConstraintChip
4122
+ <DatabaseInlineSortControl
3855
4123
  key={`${sort.key}-${index}`}
3856
- icon={<IconArrowsSort className="size-3.5" />}
3857
- label={`${sort.label} ${sort.direction === "asc" ? "ascending" : "descending"}`}
4124
+ sort={sort}
4125
+ sortIndex={index}
4126
+ sorts={sorts}
4127
+ properties={properties}
4128
+ open={openSortIndex === index}
4129
+ showUnsavedIndicator={hasPersonalQueryChanges}
4130
+ onOpenChange={(open) => onSortOpenIndexChange(open ? index : null)}
4131
+ onSortsChange={onSortsChange}
3858
4132
  onRemove={() => onRemoveSort(index)}
3859
4133
  />
3860
4134
  ))}
3861
- {activeFilterEntries.map(({ filter, index }) => (
3862
- <DatabaseConstraintChip
4135
+ {hasSortFilterDivider ? (
4136
+ <div aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-border" />
4137
+ ) : null}
4138
+ {advancedFilterEntries.length === 0 && regularFilterEntries.length > 1 ? (
4139
+ <DatabaseFilterModeButton
4140
+ filterMode={filterMode}
4141
+ onFilterModeChange={onFilterModeChange}
4142
+ />
4143
+ ) : null}
4144
+ {advancedFilterEntries.length > 0 ? (
4145
+ <DatabaseAdvancedFilterGroupControl
4146
+ documentId={documentId}
4147
+ items={items}
4148
+ filters={filters}
4149
+ filterEntries={advancedFilterEntries}
4150
+ filterMode={filterMode}
4151
+ properties={properties}
4152
+ open={advancedFilterOpen}
4153
+ showUnsavedIndicator={hasPersonalQueryChanges}
4154
+ onOpenChange={onAdvancedFilterOpenChange}
4155
+ onFiltersChange={onFiltersChange}
4156
+ onFilterModeChange={onFilterModeChange}
4157
+ />
4158
+ ) : null}
4159
+ {regularFilterEntries.map(({ filter, index }) => (
4160
+ <DatabaseInlineFilterControl
3863
4161
  key={`${filter.key}-${index}`}
3864
- icon={<IconFilter className="size-3.5" />}
3865
- label={databaseFilterChipLabel(filter, properties)}
4162
+ documentId={documentId}
4163
+ filter={filter}
4164
+ filterIndex={index}
4165
+ filters={filters}
4166
+ items={items}
4167
+ properties={properties}
4168
+ open={openFilterIndex === index}
4169
+ showUnsavedIndicator={hasPersonalQueryChanges}
4170
+ onOpenChange={(open) => onFilterOpenIndexChange(open ? index : null)}
4171
+ onFiltersChange={onFiltersChange}
3866
4172
  onRemove={() => onRemoveFilter(index)}
3867
4173
  />
3868
4174
  ))}
3869
- <Button
3870
- type="button"
3871
- size="sm"
3872
- variant="ghost"
3873
- className="ml-auto h-7 px-2 text-xs"
3874
- onClick={onClearAll}
3875
- >
3876
- {dbText("clearAll")}
3877
- </Button>
4175
+ <DatabaseAddFilterButton
4176
+ open={addFilterOpen}
4177
+ filters={filters}
4178
+ properties={properties}
4179
+ onOpenChange={onAddFilterOpenChange}
4180
+ onAddFilter={(key, label) => {
4181
+ onFiltersChange([
4182
+ ...filters,
4183
+ createDatabaseFilterForField(key, label, properties),
4184
+ ]);
4185
+ onAddFilterOpenChange(false);
4186
+ onFilterOpenIndexChange(filters.length);
4187
+ }}
4188
+ onAddAdvancedFilter={(key, label) => {
4189
+ onFiltersChange([
4190
+ ...filters,
4191
+ createDatabaseFilterForField(key, label, properties, true),
4192
+ ]);
4193
+ onAddFilterOpenChange(false);
4194
+ onAdvancedFilterOpenChange(true);
4195
+ }}
4196
+ />
4197
+ {searchQuery.trim() ? (
4198
+ <DatabaseConstraintChip
4199
+ icon={<IconSearch className="size-3.5" />}
4200
+ label={`Search: ${searchQuery.trim()}`}
4201
+ onRemove={onClearSearch}
4202
+ />
4203
+ ) : null}
4204
+ <div className="ml-auto flex items-center gap-1 pl-2">
4205
+ {hasSearchOrSortConstraints ? (
4206
+ <Button
4207
+ type="button"
4208
+ size="sm"
4209
+ variant="ghost"
4210
+ className="h-7 px-2 text-xs text-muted-foreground"
4211
+ onClick={onClearAll}
4212
+ >
4213
+ {dbText("clearAll")}
4214
+ </Button>
4215
+ ) : null}
4216
+ {showViewActionControls ? (
4217
+ <>
4218
+ <Button
4219
+ type="button"
4220
+ size="sm"
4221
+ variant="ghost"
4222
+ className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
4223
+ disabled={savePending}
4224
+ onClick={onResetPersonalChanges}
4225
+ >
4226
+ {dbText("reset")}
4227
+ </Button>
4228
+ <div className="flex h-7 overflow-hidden rounded">
4229
+ <Button
4230
+ type="button"
4231
+ size="sm"
4232
+ className="h-7 rounded-none bg-amber-100 px-2 text-xs font-medium text-amber-900 shadow-none hover:bg-amber-200 dark:bg-amber-500/20 dark:text-amber-100 dark:hover:bg-amber-500/30"
4233
+ disabled={savePending}
4234
+ onClick={onSaveForEveryone}
4235
+ >
4236
+ {savePending ? <Spinner className="mr-1 size-3" /> : null}
4237
+ {dbText("saveForEveryone")}
4238
+ </Button>
4239
+ <DropdownMenu>
4240
+ <DropdownMenuTrigger asChild>
4241
+ <Button
4242
+ type="button"
4243
+ size="sm"
4244
+ className="h-7 w-7 rounded-none border-l border-amber-200 bg-amber-100 p-0 text-amber-900 shadow-none hover:bg-amber-200 dark:border-amber-500/20 dark:bg-amber-500/20 dark:text-amber-100 dark:hover:bg-amber-500/30"
4245
+ disabled={savePending}
4246
+ aria-label={dbText("saveForEveryone")}
4247
+ >
4248
+ <IconChevronDown className="size-3" />
4249
+ </Button>
4250
+ </DropdownMenuTrigger>
4251
+ <DropdownMenuContent align="end" className="w-48">
4252
+ <DropdownMenuItem
4253
+ onSelect={(event) => {
4254
+ event.preventDefault();
4255
+ onSaveForEveryone();
4256
+ }}
4257
+ >
4258
+ <IconCheck className="mr-2 size-4 text-muted-foreground" />
4259
+ {dbText("saveForEveryone")}
4260
+ </DropdownMenuItem>
4261
+ </DropdownMenuContent>
4262
+ </DropdownMenu>
4263
+ </div>
4264
+ </>
4265
+ ) : null}
4266
+ </div>
3878
4267
  </div>
3879
4268
  );
3880
4269
  }
3881
4270
 
3882
- function databaseToolbarIconButtonClass(active = false) {
3883
- return cn(
3884
- "h-7 w-7 p-0 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-45",
3885
- active && "bg-muted text-foreground",
3886
- );
3887
- }
3888
-
3889
- type DatabaseSettingsPanel =
3890
- | "main"
3891
- | "source"
3892
- | "layout"
3893
- | "property_visibility"
3894
- | "group";
3895
-
3896
- // One step in the Sources drill-down: Sources (root, empty stack) → provider
3897
- // (Builder) → space → model leaf. The model step carries the full summary so
3898
- // the leaf can attach without re-fetching.
3899
- // A second source being added, awaiting the canonical-key confirm step.
3900
- type PendingSourceCandidate = {
3901
- sourceType: "mock-local" | "builder-cms" | "local-table";
3902
- sourceName: string;
3903
- sourceTable: string;
3904
- displayName: string;
3905
- existingSourceId?: string;
3906
- };
3907
-
3908
- type SourceNavStep =
3909
- | { kind: "provider"; providerId: "builder" }
3910
- | { kind: "space"; spaceId: string; spaceName: string }
3911
- | { kind: "model"; model: BuilderCmsModelSummary }
3912
- | { kind: "addSource" }
3913
- | { kind: "secondarySource"; sourceId: string; sourceName: string }
3914
- | { kind: "keyConfirm"; candidate: PendingSourceCandidate }
3915
- | { kind: "fieldPicker"; sourceId: string; sourceName: string };
3916
-
3917
- function sourceNavTitle(stack: SourceNavStep[]): string {
3918
- const top = stack[stack.length - 1];
3919
- if (!top) return dbText("sources");
3920
- if (top.kind === "provider") return "Builder";
3921
- if (top.kind === "space") return top.spaceName;
3922
- if (top.kind === "addSource") return dbText("addASource");
3923
- if (top.kind === "secondarySource") return top.sourceName;
3924
- if (top.kind === "keyConfirm") return dbText("matchExistingItemsToDetails");
3925
- if (top.kind === "fieldPicker") return dbText("chooseFields");
3926
- return top.model.displayName;
3927
- }
4271
+ function DatabaseInlineSortControl({
4272
+ sort,
4273
+ sortIndex,
4274
+ sorts,
4275
+ properties,
4276
+ open,
4277
+ showUnsavedIndicator,
4278
+ onOpenChange,
4279
+ onSortsChange,
4280
+ onRemove,
4281
+ }: {
4282
+ sort: DatabaseSort;
4283
+ sortIndex: number;
4284
+ sorts: DatabaseSort[];
4285
+ properties: DocumentProperty[];
4286
+ open: boolean;
4287
+ showUnsavedIndicator: boolean;
4288
+ onOpenChange: (open: boolean) => void;
4289
+ onSortsChange: (sorts: DatabaseSort[]) => void;
4290
+ onRemove: () => void;
4291
+ }) {
4292
+ function updateSort(next: Partial<DatabaseSort>) {
4293
+ const nextSorts = [...sorts];
4294
+ nextSorts[sortIndex] = {
4295
+ ...(nextSorts[sortIndex] ?? defaultDatabaseSort()),
4296
+ ...next,
4297
+ };
4298
+ onSortsChange(nextSorts);
4299
+ }
3928
4300
 
3929
- // The Builder "B" brand mark (first glyph of the wordmark), drawn with
3930
- // currentColor so it themes against the panel background.
3931
- function BuilderLogoMark({ className }: { className?: string }) {
3932
- return (
3933
- <svg
3934
- viewBox="0 0 71 80"
3935
- fill="currentColor"
3936
- xmlns="http://www.w3.org/2000/svg"
3937
- className={className}
3938
- aria-hidden="true"
3939
- >
3940
- <path d="M70.86 24C70.86 10.69 60.06 0 46.86 0H6.31995C2.81995 0 0 2.84031 0 6.32031C0 12.8003 13.71 17.71 13.71 40C13.71 62.29 0 67.2102 0 73.6802C0 77.1602 2.81995 80 6.31995 80H46.86C60.06 80 70.86 69.31 70.86 56C70.86 46.22 64.98 40.25 64.75 40C64.98 39.75 70.86 33.78 70.86 24ZM8.37 6.86035H46.87C51.45 6.86035 55.75 8.64037 58.99 11.8804C62.23 15.1204 64.01 19.42 64.01 24C64.01 28.58 62.3199 32.62 59.3199 35.79L8.37 6.86035ZM58.99 68.1304C55.75 71.3704 51.45 73.1504 46.87 73.1504H8.37L59.3199 44.2202C62.3199 47.3902 64.01 51.5703 64.01 56.0103C64.01 60.4503 62.23 64.8904 58.99 68.1304ZM15.83 61.02C16.24 60.17 20.58 51.74 20.58 40C20.58 28.26 16.24 19.83 15.83 18.98L52.85 40L15.83 61.02Z" />
3941
- </svg>
4301
+ const existingSortKeys = useMemo(
4302
+ () => new Set(sorts.map((candidate) => candidate.key)),
4303
+ [sorts],
3942
4304
  );
3943
- }
3944
4305
 
3945
- // The Notion logo, reusing the shared `.notion-logo-icon` styling (same mark as
3946
- // the sidebar's Notion button) so it themes consistently.
3947
- function NotionLogoMark({ className }: { className?: string }) {
4306
+ function addSortForField(key: "name" | string, label: string) {
4307
+ onSortsChange([...sorts, { key, label, direction: "asc" }]);
4308
+ }
4309
+
4310
+ return (
4311
+ <DropdownMenu open={open} onOpenChange={onOpenChange}>
4312
+ <DropdownMenuTrigger asChild>
4313
+ <Button
4314
+ type="button"
4315
+ size="sm"
4316
+ variant="ghost"
4317
+ className={cn(
4318
+ "relative h-7 max-w-[16rem] rounded border border-[#2383e2]/30 bg-[#2383e2]/10 px-2 text-xs font-normal text-[#0f5ea8] shadow-none hover:bg-[#2383e2]/15 focus-visible:border-[#2383e2]/40 focus-visible:ring-[#2383e2]/25",
4319
+ open && "border-[#2383e2]/40 bg-[#2383e2]/15",
4320
+ )}
4321
+ >
4322
+ {sort.direction === "asc" ? (
4323
+ <IconArrowUp className="size-3.5 shrink-0" />
4324
+ ) : (
4325
+ <IconArrowDown className="size-3.5 shrink-0" />
4326
+ )}
4327
+ <span className="min-w-0 truncate">{sort.label}</span>
4328
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
4329
+ {showUnsavedIndicator ? (
4330
+ <span
4331
+ aria-hidden="true"
4332
+ className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full border border-background bg-amber-500"
4333
+ />
4334
+ ) : null}
4335
+ </Button>
4336
+ </DropdownMenuTrigger>
4337
+ <DropdownMenuContent align="start" className="w-[260px] p-0">
4338
+ <div
4339
+ className="grid gap-1 p-1"
4340
+ onKeyDown={(event) => event.stopPropagation()}
4341
+ >
4342
+ <div className="flex items-center gap-0.5">
4343
+ <DropdownMenuSub>
4344
+ <DropdownMenuSubTrigger className="h-8 min-w-0 flex-1 rounded px-1.5 text-xs [&>svg:last-child]:hidden">
4345
+ <SortFieldIcon sort={sort} properties={properties} />
4346
+ <span className="min-w-0 flex-1 truncate">{sort.label}</span>
4347
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
4348
+ </DropdownMenuSubTrigger>
4349
+ <DatabasePropertyPickerSubContent
4350
+ properties={properties}
4351
+ selectedKey={sort.key}
4352
+ includeName
4353
+ onSelect={(key, label) => updateSort({ key, label })}
4354
+ />
4355
+ </DropdownMenuSub>
4356
+
4357
+ <DropdownMenuSub>
4358
+ <DropdownMenuSubTrigger className="h-8 min-w-0 flex-1 rounded px-1.5 text-xs [&>svg:last-child]:hidden">
4359
+ <span className="min-w-0 flex-1 truncate">
4360
+ {databaseSortDirectionLabel(sort.direction)}
4361
+ </span>
4362
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
4363
+ </DropdownMenuSubTrigger>
4364
+ <DropdownMenuSubContent className="w-40">
4365
+ {(["asc", "desc"] as const).map((direction) => (
4366
+ <DropdownMenuItem
4367
+ key={direction}
4368
+ onSelect={(event) => {
4369
+ event.preventDefault();
4370
+ updateSort({ direction });
4371
+ }}
4372
+ >
4373
+ <span className="flex-1">
4374
+ {databaseSortDirectionLabel(direction)}
4375
+ </span>
4376
+ {sort.direction === direction ? (
4377
+ <IconCheck className="size-4 text-muted-foreground" />
4378
+ ) : null}
4379
+ </DropdownMenuItem>
4380
+ ))}
4381
+ </DropdownMenuSubContent>
4382
+ </DropdownMenuSub>
4383
+
4384
+ <button
4385
+ type="button"
4386
+ aria-label={`Remove ${sort.label} sort`}
4387
+ className="flex size-8 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
4388
+ onClick={onRemove}
4389
+ >
4390
+ <IconX className="size-4" />
4391
+ </button>
4392
+ </div>
4393
+
4394
+ <DropdownMenuSeparator />
4395
+ <DropdownMenuSub>
4396
+ <DropdownMenuSubTrigger className="[&>svg:last-child]:hidden">
4397
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
4398
+ <span className="flex-1">{dbText("addSort")}</span>
4399
+ </DropdownMenuSubTrigger>
4400
+ <DatabasePropertyPickerSubContent
4401
+ properties={properties}
4402
+ includeName
4403
+ excludeKeys={existingSortKeys}
4404
+ placeholder={dbText("searchForAProperty")}
4405
+ onSelect={addSortForField}
4406
+ />
4407
+ </DropdownMenuSub>
4408
+ <DropdownMenuItem
4409
+ onSelect={(event) => {
4410
+ event.preventDefault();
4411
+ onRemove();
4412
+ }}
4413
+ >
4414
+ <IconTrash className="mr-2 size-4 text-muted-foreground" />
4415
+ {dbText("deleteSort")}
4416
+ </DropdownMenuItem>
4417
+ </div>
4418
+ </DropdownMenuContent>
4419
+ </DropdownMenu>
4420
+ );
4421
+ }
4422
+
4423
+ function DatabaseAddFilterButton({
4424
+ open,
4425
+ filters,
4426
+ properties,
4427
+ onOpenChange,
4428
+ onAddFilter,
4429
+ onAddAdvancedFilter,
4430
+ }: {
4431
+ open: boolean;
4432
+ filters: DatabaseFilter[];
4433
+ properties: DocumentProperty[];
4434
+ onOpenChange: (open: boolean) => void;
4435
+ onAddFilter: (key: "name" | string, label: string) => void;
4436
+ onAddAdvancedFilter: (key: "name" | string, label: string) => void;
4437
+ }) {
4438
+ const excludedFilterKeys = useMemo(
4439
+ () => new Set(filters.filter(isActiveFilter).map((filter) => filter.key)),
4440
+ [filters],
4441
+ );
4442
+ const advancedFilterCandidate =
4443
+ databasePropertyPickerItems(properties, "", {
4444
+ includeName: false,
4445
+ excludeKeys: excludedFilterKeys,
4446
+ })[0] ??
4447
+ databasePropertyPickerItems(properties, "", {
4448
+ includeName: true,
4449
+ excludeKeys: excludedFilterKeys,
4450
+ })[0];
4451
+
4452
+ return (
4453
+ <DropdownMenu open={open} onOpenChange={onOpenChange}>
4454
+ <DropdownMenuTrigger asChild>
4455
+ <Button
4456
+ type="button"
4457
+ size="sm"
4458
+ variant="ghost"
4459
+ className="h-7 rounded px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
4460
+ >
4461
+ <IconPlus className="mr-1 size-3.5" />
4462
+ {dbText("filter")}
4463
+ </Button>
4464
+ </DropdownMenuTrigger>
4465
+ <DropdownMenuContent align="start" className="w-64">
4466
+ <div onKeyDown={(event) => event.stopPropagation()}>
4467
+ <DatabasePropertyPickerMenuItems
4468
+ properties={properties}
4469
+ includeName
4470
+ excludeKeys={excludedFilterKeys}
4471
+ placeholder={dbText("filterBy")}
4472
+ closeOnSelect
4473
+ onSelect={onAddFilter}
4474
+ />
4475
+ <DropdownMenuSeparator />
4476
+ <DropdownMenuItem
4477
+ disabled={!advancedFilterCandidate}
4478
+ onSelect={(event) => {
4479
+ event.preventDefault();
4480
+ if (!advancedFilterCandidate) return;
4481
+ onAddAdvancedFilter(
4482
+ advancedFilterCandidate.key,
4483
+ advancedFilterCandidate.label,
4484
+ );
4485
+ }}
4486
+ >
4487
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
4488
+ {dbText("addAdvancedFilter")}
4489
+ </DropdownMenuItem>
4490
+ </div>
4491
+ </DropdownMenuContent>
4492
+ </DropdownMenu>
4493
+ );
4494
+ }
4495
+
4496
+ function DatabaseFilterModeButton({
4497
+ filterMode,
4498
+ onFilterModeChange,
4499
+ }: {
4500
+ filterMode: DatabaseFilterMode;
4501
+ onFilterModeChange: (filterMode: DatabaseFilterMode) => void;
4502
+ }) {
4503
+ return (
4504
+ <DropdownMenu>
4505
+ <DropdownMenuTrigger asChild>
4506
+ <Button
4507
+ type="button"
4508
+ size="sm"
4509
+ variant="ghost"
4510
+ className="h-7 rounded border border-border/70 bg-background px-2 text-xs font-normal text-muted-foreground shadow-none hover:bg-muted hover:text-foreground"
4511
+ >
4512
+ {databaseFilterModeLabel(filterMode)}
4513
+ <IconChevronDown className="ml-1 size-3" />
4514
+ </Button>
4515
+ </DropdownMenuTrigger>
4516
+ <DropdownMenuContent align="start" className="w-44">
4517
+ {DATABASE_FILTER_MODES.map((mode) => (
4518
+ <DropdownMenuItem
4519
+ key={mode}
4520
+ onSelect={(event) => {
4521
+ event.preventDefault();
4522
+ onFilterModeChange(mode);
4523
+ }}
4524
+ >
4525
+ <span className="flex-1">{databaseFilterModeLabel(mode)}</span>
4526
+ {filterMode === mode ? (
4527
+ <IconCheck className="size-4 text-muted-foreground" />
4528
+ ) : null}
4529
+ </DropdownMenuItem>
4530
+ ))}
4531
+ </DropdownMenuContent>
4532
+ </DropdownMenu>
4533
+ );
4534
+ }
4535
+
4536
+ type DatabaseFilterEntry = {
4537
+ filter: DatabaseFilter;
4538
+ index: number;
4539
+ };
4540
+
4541
+ type DatabaseNestedFilterGroupEntry = {
4542
+ id: string;
4543
+ entries: DatabaseFilterEntry[];
4544
+ };
4545
+
4546
+ function DatabaseAdvancedFilterGroupControl({
4547
+ documentId,
4548
+ items,
4549
+ filters,
4550
+ filterEntries,
4551
+ filterMode,
4552
+ properties,
4553
+ open,
4554
+ showUnsavedIndicator,
4555
+ onOpenChange,
4556
+ onFiltersChange,
4557
+ onFilterModeChange,
4558
+ }: {
4559
+ documentId: string;
4560
+ items: ContentDatabaseItem[];
4561
+ filters: DatabaseFilter[];
4562
+ filterEntries: DatabaseFilterEntry[];
4563
+ filterMode: DatabaseFilterMode;
4564
+ properties: DocumentProperty[];
4565
+ open: boolean;
4566
+ showUnsavedIndicator: boolean;
4567
+ onOpenChange: (open: boolean) => void;
4568
+ onFiltersChange: (filters: DatabaseFilter[]) => void;
4569
+ onFilterModeChange: (filterMode: DatabaseFilterMode) => void;
4570
+ }) {
4571
+ const advancedFilters = filterEntries.map((entry) => entry.filter);
4572
+ const rootFilterEntries = filterEntries.filter(
4573
+ ({ filter }) => !filter.parentFilterGroupId,
4574
+ );
4575
+ const nestedGroupEntries = advancedNestedFilterGroups(filterEntries);
4576
+ const advancedKeys = useMemo(
4577
+ () => new Set(advancedFilters.map((filter) => filter.key)),
4578
+ [advancedFilters],
4579
+ );
4580
+ const addRuleCandidate =
4581
+ databasePropertyPickerItems(properties, "", {
4582
+ includeName: false,
4583
+ excludeKeys: advancedKeys,
4584
+ })[0] ??
4585
+ databasePropertyPickerItems(properties, "", {
4586
+ includeName: true,
4587
+ excludeKeys: advancedKeys,
4588
+ })[0];
4589
+
4590
+ function updateFilterAt(index: number, next: Partial<DatabaseFilter>) {
4591
+ const nextFilters = [...filters];
4592
+ const currentFilter = nextFilters[index] ?? defaultDatabaseFilter();
4593
+ const nextOperator = next.operator ?? currentFilter.operator;
4594
+ nextFilters[index] = {
4595
+ ...currentFilter,
4596
+ ...next,
4597
+ filterGroupId: next.filterGroupId ?? currentFilter.filterGroupId,
4598
+ parentFilterGroupId:
4599
+ next.parentFilterGroupId ?? currentFilter.parentFilterGroupId,
4600
+ value: filterOperatorNeedsValue(nextOperator)
4601
+ ? (next.value ?? currentFilter.value)
4602
+ : "",
4603
+ };
4604
+ onFiltersChange(nextFilters);
4605
+ }
4606
+
4607
+ function addAdvancedRule(key: "name" | string, label: string) {
4608
+ onFiltersChange([
4609
+ ...filters,
4610
+ createDatabaseFilterForField(key, label, properties, true),
4611
+ ]);
4612
+ }
4613
+
4614
+ function addNestedFilterGroup(key: "name" | string, label: string) {
4615
+ onFiltersChange([
4616
+ ...filters,
4617
+ createDatabaseFilterForField(key, label, properties, {
4618
+ filterGroupId: createAdvancedNestedFilterGroupId(),
4619
+ parentFilterGroupId: DATABASE_ADVANCED_FILTER_GROUP_ID,
4620
+ }),
4621
+ ]);
4622
+ }
4623
+
4624
+ function addNestedFilterRule(
4625
+ groupId: string,
4626
+ key: "name" | string,
4627
+ label: string,
4628
+ ) {
4629
+ onFiltersChange([
4630
+ ...filters,
4631
+ createDatabaseFilterForField(key, label, properties, {
4632
+ filterGroupId: groupId,
4633
+ parentFilterGroupId: DATABASE_ADVANCED_FILTER_GROUP_ID,
4634
+ }),
4635
+ ]);
4636
+ }
4637
+
4638
+ function removeFilterAt(index: number) {
4639
+ onFiltersChange(filters.filter((_, filterIndex) => filterIndex !== index));
4640
+ if (filterEntries.length <= 1) onOpenChange(false);
4641
+ }
4642
+
4643
+ function removeNestedFilterGroup(groupId: string) {
4644
+ onFiltersChange(
4645
+ filters.filter((filter) => filter.filterGroupId !== groupId),
4646
+ );
4647
+ }
4648
+
4649
+ return (
4650
+ <DropdownMenu open={open} onOpenChange={onOpenChange}>
4651
+ <DropdownMenuTrigger asChild>
4652
+ <Button
4653
+ type="button"
4654
+ size="sm"
4655
+ variant="ghost"
4656
+ className={cn(
4657
+ "relative h-7 rounded border border-[#2383e2]/30 bg-[#2383e2]/10 px-2 text-xs font-normal text-[#0f5ea8] shadow-none hover:bg-[#2383e2]/15 focus-visible:border-[#2383e2]/40 focus-visible:ring-[#2383e2]/25",
4658
+ open && "border-[#2383e2]/40 bg-[#2383e2]/15",
4659
+ )}
4660
+ >
4661
+ <IconFilter className="size-3.5 shrink-0" />
4662
+ <span>{advancedFilterGroupLabel(filterEntries.length)}</span>
4663
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
4664
+ {showUnsavedIndicator ? (
4665
+ <span
4666
+ aria-hidden="true"
4667
+ className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full border border-background bg-amber-500"
4668
+ />
4669
+ ) : null}
4670
+ </Button>
4671
+ </DropdownMenuTrigger>
4672
+ <DropdownMenuContent
4673
+ align="start"
4674
+ className="w-[640px] max-w-[calc(100vw-2rem)] p-0"
4675
+ >
4676
+ <div
4677
+ className="grid gap-1 rounded-lg bg-background p-1.5"
4678
+ onKeyDown={(event) => event.stopPropagation()}
4679
+ >
4680
+ {rootFilterEntries.map(({ filter, index }, ruleIndex) => (
4681
+ <DatabaseAdvancedFilterRuleRow
4682
+ key={`${filter.key}-${index}`}
4683
+ documentId={documentId}
4684
+ filter={filter}
4685
+ filters={filters}
4686
+ filterIndex={index}
4687
+ ruleIndex={ruleIndex}
4688
+ filterMode={filterMode}
4689
+ items={items}
4690
+ properties={properties}
4691
+ onFilterModeChange={onFilterModeChange}
4692
+ onUpdateFilter={(next) => updateFilterAt(index, next)}
4693
+ onRemove={() => removeFilterAt(index)}
4694
+ />
4695
+ ))}
4696
+ {nestedGroupEntries.map((group, groupIndex) => (
4697
+ <DatabaseAdvancedNestedFilterGroup
4698
+ key={group.id}
4699
+ documentId={documentId}
4700
+ items={items}
4701
+ filters={filters}
4702
+ group={group}
4703
+ ruleIndex={rootFilterEntries.length + groupIndex}
4704
+ filterMode={filterMode}
4705
+ properties={properties}
4706
+ onFilterModeChange={onFilterModeChange}
4707
+ onUpdateFilter={updateFilterAt}
4708
+ onAddNestedFilterRule={addNestedFilterRule}
4709
+ onRemoveFilter={removeFilterAt}
4710
+ onRemoveGroup={() => removeNestedFilterGroup(group.id)}
4711
+ />
4712
+ ))}
4713
+
4714
+ <DropdownMenuSeparator className="my-1" />
4715
+ <DropdownMenuSub>
4716
+ <DropdownMenuSubTrigger className="h-9 rounded px-2 text-xs text-muted-foreground [&>svg:last-child]:hidden">
4717
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
4718
+ <span className="flex-1">{dbText("addFilterRule")}</span>
4719
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
4720
+ </DropdownMenuSubTrigger>
4721
+ <DropdownMenuSubContent className="w-56">
4722
+ <DropdownMenuItem
4723
+ disabled={!addRuleCandidate}
4724
+ onSelect={(event) => {
4725
+ event.preventDefault();
4726
+ if (!addRuleCandidate) return;
4727
+ addAdvancedRule(addRuleCandidate.key, addRuleCandidate.label);
4728
+ }}
4729
+ >
4730
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
4731
+ {dbText("addFilterRule")}
4732
+ </DropdownMenuItem>
4733
+ <DropdownMenuItem
4734
+ disabled={!addRuleCandidate}
4735
+ onSelect={(event) => {
4736
+ event.preventDefault();
4737
+ if (!addRuleCandidate) return;
4738
+ addNestedFilterGroup(
4739
+ addRuleCandidate.key,
4740
+ addRuleCandidate.label,
4741
+ );
4742
+ }}
4743
+ >
4744
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
4745
+ {dbText("addFilterGroup")}
4746
+ </DropdownMenuItem>
4747
+ </DropdownMenuSubContent>
4748
+ </DropdownMenuSub>
4749
+ <DropdownMenuItem
4750
+ className="h-9 rounded px-2 text-xs text-muted-foreground"
4751
+ onSelect={(event) => {
4752
+ event.preventDefault();
4753
+ onFiltersChange(
4754
+ filters.filter((filter) => !isAdvancedDatabaseFilter(filter)),
4755
+ );
4756
+ onOpenChange(false);
4757
+ }}
4758
+ >
4759
+ <IconTrash className="mr-2 size-4 text-muted-foreground" />
4760
+ {dbText("deleteFilter")}
4761
+ </DropdownMenuItem>
4762
+ </div>
4763
+ </DropdownMenuContent>
4764
+ </DropdownMenu>
4765
+ );
4766
+ }
4767
+
4768
+ function DatabaseAdvancedFilterRuleRow({
4769
+ documentId,
4770
+ filter,
4771
+ filters,
4772
+ filterIndex,
4773
+ ruleIndex,
4774
+ filterMode,
4775
+ items,
4776
+ properties,
4777
+ onFilterModeChange,
4778
+ onUpdateFilter,
4779
+ onRemove,
4780
+ }: {
4781
+ documentId: string;
4782
+ filter: DatabaseFilter;
4783
+ filters: DatabaseFilter[];
4784
+ filterIndex: number;
4785
+ ruleIndex: number;
4786
+ filterMode: DatabaseFilterMode;
4787
+ items: ContentDatabaseItem[];
4788
+ properties: DocumentProperty[];
4789
+ onFilterModeChange: (filterMode: DatabaseFilterMode) => void;
4790
+ onUpdateFilter: (next: Partial<DatabaseFilter>) => void;
4791
+ onRemove: () => void;
4792
+ }) {
4793
+ function selectField(key: "name" | string, label: string) {
4794
+ onUpdateFilter({
4795
+ key,
4796
+ label,
4797
+ operator: defaultFilterOperatorForKey(key, properties),
4798
+ value: "",
4799
+ });
4800
+ }
4801
+
4802
+ return (
4803
+ <div className="flex items-start gap-2">
4804
+ {ruleIndex === 0 ? (
4805
+ <div className="flex h-9 w-16 shrink-0 items-center px-2 text-xs text-foreground">
4806
+ {dbText("where")}
4807
+ </div>
4808
+ ) : (
4809
+ <DropdownMenu>
4810
+ <DropdownMenuTrigger asChild>
4811
+ <button
4812
+ type="button"
4813
+ className="flex h-9 w-16 items-center rounded px-2 text-xs hover:bg-muted"
4814
+ >
4815
+ <span className="flex-1 text-left">
4816
+ {databaseFilterModeConjunctionLabel(filterMode)}
4817
+ </span>
4818
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
4819
+ </button>
4820
+ </DropdownMenuTrigger>
4821
+ <DropdownMenuContent align="start" className="w-32">
4822
+ {DATABASE_FILTER_MODES.map((mode) => (
4823
+ <DropdownMenuItem
4824
+ key={mode}
4825
+ onSelect={(event) => {
4826
+ event.preventDefault();
4827
+ onFilterModeChange(mode);
4828
+ }}
4829
+ >
4830
+ <span className="flex-1">
4831
+ {databaseFilterModeConjunctionLabel(mode)}
4832
+ </span>
4833
+ {filterMode === mode ? (
4834
+ <IconCheck className="size-4 text-muted-foreground" />
4835
+ ) : null}
4836
+ </DropdownMenuItem>
4837
+ ))}
4838
+ </DropdownMenuContent>
4839
+ </DropdownMenu>
4840
+ )}
4841
+
4842
+ <div className="grid min-w-0 flex-1 gap-1">
4843
+ <div className="flex items-center gap-0.5">
4844
+ <DropdownMenu>
4845
+ <DropdownMenuTrigger asChild>
4846
+ <button
4847
+ type="button"
4848
+ className="flex h-9 min-w-0 flex-1 items-center rounded border border-border bg-background px-2 text-left text-xs font-normal shadow-none hover:bg-muted/50 focus:bg-muted/50 data-[state=open]:bg-muted/50"
4849
+ >
4850
+ <FilterFieldIcon filter={filter} properties={properties} />
4851
+ <span className="min-w-0 flex-1 truncate">{filter.label}</span>
4852
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
4853
+ </button>
4854
+ </DropdownMenuTrigger>
4855
+ <DropdownMenuContent
4856
+ align="start"
4857
+ className="max-h-80 w-64 overflow-auto"
4858
+ >
4859
+ <DatabasePropertyPickerMenuItems
4860
+ properties={properties}
4861
+ selectedKey={filter.key}
4862
+ includeName
4863
+ closeOnSelect
4864
+ onSelect={selectField}
4865
+ />
4866
+ </DropdownMenuContent>
4867
+ </DropdownMenu>
4868
+
4869
+ <DropdownMenu>
4870
+ <DropdownMenuTrigger asChild>
4871
+ <button
4872
+ type="button"
4873
+ className="flex h-9 min-w-0 flex-1 items-center rounded border border-border bg-background px-2 text-left text-xs font-normal shadow-none hover:bg-muted/50 focus:bg-muted/50 data-[state=open]:bg-muted/50"
4874
+ >
4875
+ <span className="min-w-0 flex-1 truncate">
4876
+ {databaseFilterOperatorInlineLabel(
4877
+ filter.operator,
4878
+ filter.key,
4879
+ properties,
4880
+ )}
4881
+ </span>
4882
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
4883
+ </button>
4884
+ </DropdownMenuTrigger>
4885
+ <DropdownMenuContent align="start" className="w-44">
4886
+ {filterOperatorsForKey(filter.key, properties).map((operator) => (
4887
+ <DropdownMenuItem
4888
+ key={operator}
4889
+ onSelect={(event) => {
4890
+ event.preventDefault();
4891
+ onUpdateFilter({ operator });
4892
+ }}
4893
+ >
4894
+ <span className="flex-1">
4895
+ {databaseFilterOperatorLabel(
4896
+ operator,
4897
+ filter.key,
4898
+ properties,
4899
+ )}
4900
+ </span>
4901
+ {filter.operator === operator ? (
4902
+ <IconCheck className="size-4 text-muted-foreground" />
4903
+ ) : null}
4904
+ </DropdownMenuItem>
4905
+ ))}
4906
+ </DropdownMenuContent>
4907
+ </DropdownMenu>
4908
+
4909
+ {filterOperatorNeedsValue(filter.operator) ? (
4910
+ <div className="min-w-0 flex-[1.35] [&>div>div:first-child]:px-0 [&>input]:h-9 [&>input]:text-xs">
4911
+ <DatabaseFilterValueControl
4912
+ documentId={documentId}
4913
+ filter={filter}
4914
+ items={items}
4915
+ properties={properties}
4916
+ hideOptionsUntilQuery
4917
+ onValueChange={(value) => onUpdateFilter({ value })}
4918
+ />
4919
+ </div>
4920
+ ) : null}
4921
+
4922
+ <DropdownMenu>
4923
+ <DropdownMenuTrigger asChild>
4924
+ <button
4925
+ type="button"
4926
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded p-0 text-muted-foreground hover:bg-muted/50 data-[state=open]:bg-muted/50"
4927
+ >
4928
+ <IconDots className="size-3.5" />
4929
+ </button>
4930
+ </DropdownMenuTrigger>
4931
+ <DropdownMenuContent align="end" className="w-36">
4932
+ <DropdownMenuItem
4933
+ onSelect={(event) => {
4934
+ event.preventDefault();
4935
+ onRemove();
4936
+ }}
4937
+ >
4938
+ <IconX className="mr-2 size-4 text-muted-foreground" />
4939
+ Remove
4940
+ </DropdownMenuItem>
4941
+ </DropdownMenuContent>
4942
+ </DropdownMenu>
4943
+ </div>
4944
+ </div>
4945
+ </div>
4946
+ );
4947
+ }
4948
+
4949
+ function DatabaseAdvancedNestedFilterGroup({
4950
+ documentId,
4951
+ items,
4952
+ filters,
4953
+ group,
4954
+ ruleIndex,
4955
+ filterMode,
4956
+ properties,
4957
+ onFilterModeChange,
4958
+ onUpdateFilter,
4959
+ onAddNestedFilterRule,
4960
+ onRemoveFilter,
4961
+ onRemoveGroup,
4962
+ }: {
4963
+ documentId: string;
4964
+ items: ContentDatabaseItem[];
4965
+ filters: DatabaseFilter[];
4966
+ group: DatabaseNestedFilterGroupEntry;
4967
+ ruleIndex: number;
4968
+ filterMode: DatabaseFilterMode;
4969
+ properties: DocumentProperty[];
4970
+ onFilterModeChange: (filterMode: DatabaseFilterMode) => void;
4971
+ onUpdateFilter: (index: number, next: Partial<DatabaseFilter>) => void;
4972
+ onAddNestedFilterRule: (
4973
+ groupId: string,
4974
+ key: "name" | string,
4975
+ label: string,
4976
+ ) => void;
4977
+ onRemoveFilter: (index: number) => void;
4978
+ onRemoveGroup: () => void;
4979
+ }) {
4980
+ const nestedKeys = useMemo(
4981
+ () => new Set(group.entries.map(({ filter }) => filter.key)),
4982
+ [group.entries],
4983
+ );
4984
+ const addRuleCandidate =
4985
+ databasePropertyPickerItems(properties, "", {
4986
+ includeName: false,
4987
+ excludeKeys: nestedKeys,
4988
+ })[0] ??
4989
+ databasePropertyPickerItems(properties, "", {
4990
+ includeName: true,
4991
+ excludeKeys: nestedKeys,
4992
+ })[0];
4993
+
4994
+ return (
4995
+ <div className="flex items-start gap-2">
4996
+ <DropdownMenu>
4997
+ <DropdownMenuTrigger asChild>
4998
+ <button
4999
+ type="button"
5000
+ className="flex h-9 w-16 items-center rounded px-2 text-xs hover:bg-muted data-[state=open]:bg-muted"
5001
+ >
5002
+ <span className="flex-1 text-left">
5003
+ {databaseFilterModeConjunctionLabel(filterMode)}
5004
+ </span>
5005
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
5006
+ </button>
5007
+ </DropdownMenuTrigger>
5008
+ <DropdownMenuContent align="start" className="w-32">
5009
+ {DATABASE_FILTER_MODES.map((mode) => (
5010
+ <DropdownMenuItem
5011
+ key={mode}
5012
+ onSelect={(event) => {
5013
+ event.preventDefault();
5014
+ onFilterModeChange(mode);
5015
+ }}
5016
+ >
5017
+ <span className="flex-1">
5018
+ {databaseFilterModeConjunctionLabel(mode)}
5019
+ </span>
5020
+ {filterMode === mode ? (
5021
+ <IconCheck className="size-4 text-muted-foreground" />
5022
+ ) : null}
5023
+ </DropdownMenuItem>
5024
+ ))}
5025
+ </DropdownMenuContent>
5026
+ </DropdownMenu>
5027
+
5028
+ <div className="grid min-w-0 flex-1 gap-1 rounded border border-border/70 bg-muted/30 p-1.5">
5029
+ {group.entries.map(({ filter, index }, nestedRuleIndex) => (
5030
+ <DatabaseAdvancedFilterRuleRow
5031
+ key={`${filter.key}-${index}`}
5032
+ documentId={documentId}
5033
+ filter={filter}
5034
+ filters={filters}
5035
+ filterIndex={index}
5036
+ ruleIndex={nestedRuleIndex}
5037
+ filterMode={filterMode}
5038
+ items={items}
5039
+ properties={properties}
5040
+ onFilterModeChange={onFilterModeChange}
5041
+ onUpdateFilter={(next) =>
5042
+ onUpdateFilter(index, {
5043
+ ...next,
5044
+ filterGroupId: group.id,
5045
+ parentFilterGroupId: DATABASE_ADVANCED_FILTER_GROUP_ID,
5046
+ })
5047
+ }
5048
+ onRemove={() => {
5049
+ if (group.entries.length <= 1) {
5050
+ onRemoveGroup();
5051
+ } else {
5052
+ onRemoveFilter(index);
5053
+ }
5054
+ }}
5055
+ />
5056
+ ))}
5057
+
5058
+ <DropdownMenu>
5059
+ <DropdownMenuTrigger asChild>
5060
+ <button
5061
+ type="button"
5062
+ className="flex h-9 items-center rounded px-2 text-left text-xs text-muted-foreground hover:bg-muted data-[state=open]:bg-muted"
5063
+ >
5064
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
5065
+ <span className="flex-1">{dbText("addFilterRule")}</span>
5066
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
5067
+ </button>
5068
+ </DropdownMenuTrigger>
5069
+ <DropdownMenuContent align="start" className="w-56">
5070
+ <DropdownMenuItem
5071
+ disabled={!addRuleCandidate}
5072
+ onSelect={(event) => {
5073
+ event.preventDefault();
5074
+ if (!addRuleCandidate) return;
5075
+ onAddNestedFilterRule(
5076
+ group.id,
5077
+ addRuleCandidate.key,
5078
+ addRuleCandidate.label,
5079
+ );
5080
+ }}
5081
+ >
5082
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
5083
+ {dbText("addFilterRule")}
5084
+ </DropdownMenuItem>
5085
+ </DropdownMenuContent>
5086
+ </DropdownMenu>
5087
+ </div>
5088
+
5089
+ <DropdownMenu>
5090
+ <DropdownMenuTrigger asChild>
5091
+ <button
5092
+ type="button"
5093
+ className="flex h-9 w-9 shrink-0 items-center justify-center rounded p-0 text-muted-foreground hover:bg-muted/50 data-[state=open]:bg-muted/50"
5094
+ >
5095
+ <IconDots className="size-3.5" />
5096
+ </button>
5097
+ </DropdownMenuTrigger>
5098
+ <DropdownMenuContent align="end" className="w-36">
5099
+ <DropdownMenuItem
5100
+ onSelect={(event) => {
5101
+ event.preventDefault();
5102
+ onRemoveGroup();
5103
+ }}
5104
+ >
5105
+ <IconX className="mr-2 size-4 text-muted-foreground" />
5106
+ Remove
5107
+ </DropdownMenuItem>
5108
+ </DropdownMenuContent>
5109
+ </DropdownMenu>
5110
+ </div>
5111
+ );
5112
+ }
5113
+
5114
+ function DatabaseInlineFilterControl({
5115
+ documentId,
5116
+ filter,
5117
+ filterIndex,
5118
+ filters,
5119
+ items,
5120
+ properties,
5121
+ open,
5122
+ showUnsavedIndicator,
5123
+ onOpenChange,
5124
+ onFiltersChange,
5125
+ onRemove,
5126
+ }: {
5127
+ documentId: string;
5128
+ filter: DatabaseFilter;
5129
+ filterIndex: number;
5130
+ filters: DatabaseFilter[];
5131
+ items: ContentDatabaseItem[];
5132
+ properties: DocumentProperty[];
5133
+ open: boolean;
5134
+ showUnsavedIndicator: boolean;
5135
+ onOpenChange: (open: boolean) => void;
5136
+ onFiltersChange: (filters: DatabaseFilter[]) => void;
5137
+ onRemove: () => void;
5138
+ }) {
5139
+ function updateFilter(next: Partial<DatabaseFilter>) {
5140
+ const nextFilters = [...filters];
5141
+ const currentFilter = nextFilters[filterIndex] ?? defaultDatabaseFilter();
5142
+ const nextOperator = next.operator ?? currentFilter.operator;
5143
+ nextFilters[filterIndex] = {
5144
+ ...currentFilter,
5145
+ ...next,
5146
+ value: filterOperatorNeedsValue(nextOperator)
5147
+ ? (next.value ?? currentFilter.value)
5148
+ : "",
5149
+ };
5150
+ onFiltersChange(nextFilters);
5151
+ }
5152
+
5153
+ function selectField(key: "name" | string, label: string) {
5154
+ updateFilter({
5155
+ key,
5156
+ label,
5157
+ operator: defaultFilterOperatorForKey(key, properties),
5158
+ value: "",
5159
+ });
5160
+ }
5161
+
5162
+ const filterIsComplete =
5163
+ !filterOperatorNeedsValue(filter.operator) ||
5164
+ filter.value.trim().length > 0;
5165
+ return (
5166
+ <DropdownMenu open={open} onOpenChange={onOpenChange}>
5167
+ <DropdownMenuTrigger asChild>
5168
+ <Button
5169
+ type="button"
5170
+ size="sm"
5171
+ variant="ghost"
5172
+ className={cn(
5173
+ "relative h-7 max-w-[16rem] rounded border border-border/70 bg-background px-2 text-xs font-normal text-foreground shadow-none hover:bg-muted focus-visible:border-[#2383e2]/40 focus-visible:ring-[#2383e2]/25",
5174
+ filterIsComplete &&
5175
+ "border-[#2383e2]/30 bg-[#2383e2]/10 text-[#0f5ea8] hover:bg-[#2383e2]/15",
5176
+ open && "border-[#2383e2]/40 bg-[#2383e2]/15",
5177
+ !filterIsComplete && "text-muted-foreground",
5178
+ )}
5179
+ >
5180
+ <FilterFieldIcon filter={filter} properties={properties} />
5181
+ <span className="min-w-0 truncate">
5182
+ {databaseInlineFilterLabel(filter, properties)}
5183
+ </span>
5184
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
5185
+ {showUnsavedIndicator ? (
5186
+ <span
5187
+ aria-hidden="true"
5188
+ className="absolute -right-0.5 -top-0.5 size-1.5 rounded-full border border-background bg-amber-500"
5189
+ />
5190
+ ) : null}
5191
+ </Button>
5192
+ </DropdownMenuTrigger>
5193
+ <DropdownMenuContent align="start" className="w-[260px] p-0">
5194
+ <div
5195
+ className="grid gap-1 p-1"
5196
+ onKeyDown={(event) => event.stopPropagation()}
5197
+ >
5198
+ <div className="flex items-center gap-0.5">
5199
+ <DropdownMenu>
5200
+ <DropdownMenuTrigger asChild>
5201
+ <button
5202
+ type="button"
5203
+ className="flex h-7 min-w-0 flex-1 items-center rounded px-1.5 text-left text-xs hover:bg-muted data-[state=open]:bg-muted"
5204
+ >
5205
+ <FilterFieldIcon filter={filter} properties={properties} />
5206
+ <span className="min-w-0 flex-1 truncate">
5207
+ {filter.label}
5208
+ </span>
5209
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
5210
+ </button>
5211
+ </DropdownMenuTrigger>
5212
+ <DropdownMenuContent
5213
+ align="start"
5214
+ className="max-h-80 w-64 overflow-auto"
5215
+ >
5216
+ <DatabasePropertyPickerMenuItems
5217
+ properties={properties}
5218
+ selectedKey={filter.key}
5219
+ includeName
5220
+ closeOnSelect
5221
+ onSelect={selectField}
5222
+ />
5223
+ </DropdownMenuContent>
5224
+ </DropdownMenu>
5225
+
5226
+ <DropdownMenu>
5227
+ <DropdownMenuTrigger asChild>
5228
+ <button
5229
+ type="button"
5230
+ className="flex h-7 min-w-0 flex-1 items-center rounded px-1.5 text-left text-xs hover:bg-muted data-[state=open]:bg-muted"
5231
+ >
5232
+ <span className="min-w-0 flex-1 truncate">
5233
+ {databaseFilterOperatorInlineLabel(
5234
+ filter.operator,
5235
+ filter.key,
5236
+ properties,
5237
+ )}
5238
+ </span>
5239
+ <IconChevronDown className="ml-1 size-3 shrink-0 text-muted-foreground" />
5240
+ </button>
5241
+ </DropdownMenuTrigger>
5242
+ <DropdownMenuContent align="start" className="w-44">
5243
+ {filterOperatorsForKey(filter.key, properties).map(
5244
+ (operator) => (
5245
+ <DropdownMenuItem
5246
+ key={operator}
5247
+ onSelect={(event) => {
5248
+ event.preventDefault();
5249
+ updateFilter({ operator });
5250
+ }}
5251
+ >
5252
+ <span className="flex-1">
5253
+ {databaseFilterOperatorLabel(
5254
+ operator,
5255
+ filter.key,
5256
+ properties,
5257
+ )}
5258
+ </span>
5259
+ {filter.operator === operator ? (
5260
+ <IconCheck className="size-4 text-muted-foreground" />
5261
+ ) : null}
5262
+ </DropdownMenuItem>
5263
+ ),
5264
+ )}
5265
+ </DropdownMenuContent>
5266
+ </DropdownMenu>
5267
+
5268
+ <DropdownMenu>
5269
+ <DropdownMenuTrigger asChild>
5270
+ <button
5271
+ type="button"
5272
+ className="flex h-7 w-7 items-center justify-center rounded p-0 text-muted-foreground hover:bg-muted data-[state=open]:bg-muted"
5273
+ >
5274
+ <IconDots className="size-3.5" />
5275
+ </button>
5276
+ </DropdownMenuTrigger>
5277
+ <DropdownMenuContent align="end" className="w-36">
5278
+ <DropdownMenuItem
5279
+ disabled={filters.length <= 1 || filterIndex === 0}
5280
+ onSelect={(event) => {
5281
+ event.preventDefault();
5282
+ onFiltersChange(
5283
+ moveDatabaseFilter(filters, filterIndex, "up"),
5284
+ );
5285
+ }}
5286
+ >
5287
+ <IconArrowUp className="mr-2 size-4 text-muted-foreground" />
5288
+ {dbText("moveFilterUp")}
5289
+ </DropdownMenuItem>
5290
+ <DropdownMenuItem
5291
+ disabled={
5292
+ filters.length <= 1 || filterIndex >= filters.length - 1
5293
+ }
5294
+ onSelect={(event) => {
5295
+ event.preventDefault();
5296
+ onFiltersChange(
5297
+ moveDatabaseFilter(filters, filterIndex, "down"),
5298
+ );
5299
+ }}
5300
+ >
5301
+ <IconArrowDown className="mr-2 size-4 text-muted-foreground" />
5302
+ {dbText("moveFilterDown")}
5303
+ </DropdownMenuItem>
5304
+ <DropdownMenuSeparator />
5305
+ <DropdownMenuItem
5306
+ onSelect={(event) => {
5307
+ event.preventDefault();
5308
+ onRemove();
5309
+ }}
5310
+ >
5311
+ <IconX className="mr-2 size-4 text-muted-foreground" />
5312
+ Remove
5313
+ </DropdownMenuItem>
5314
+ </DropdownMenuContent>
5315
+ </DropdownMenu>
5316
+ </div>
5317
+
5318
+ {filterOperatorNeedsValue(filter.operator) ? (
5319
+ <DatabaseFilterValueControl
5320
+ autoFocus={!filter.value}
5321
+ documentId={documentId}
5322
+ filter={filter}
5323
+ items={items}
5324
+ properties={properties}
5325
+ onValueChange={(value) => updateFilter({ value })}
5326
+ />
5327
+ ) : null}
5328
+ </div>
5329
+ </DropdownMenuContent>
5330
+ </DropdownMenu>
5331
+ );
5332
+ }
5333
+
5334
+ function databaseToolbarIconButtonClass(active = false) {
5335
+ return cn(
5336
+ "h-7 w-7 p-0 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-45",
5337
+ active && "bg-muted text-foreground",
5338
+ );
5339
+ }
5340
+
5341
+ type DatabaseSettingsPanel =
5342
+ | "main"
5343
+ | "source"
5344
+ | "layout"
5345
+ | "property_visibility"
5346
+ | "group";
5347
+
5348
+ // One step in the Sources drill-down: Sources (root, empty stack) → provider
5349
+ // (Builder) → space → model leaf. The model step carries the full summary so
5350
+ // the leaf can attach without re-fetching.
5351
+ // A second source being added, awaiting the canonical-key confirm step.
5352
+ type PendingSourceCandidate = {
5353
+ sourceType: "mock-local" | "builder-cms" | "local-table";
5354
+ sourceName: string;
5355
+ sourceTable: string;
5356
+ displayName: string;
5357
+ existingSourceId?: string;
5358
+ };
5359
+
5360
+ type SourceNavStep =
5361
+ | { kind: "provider"; providerId: "builder" }
5362
+ | { kind: "space"; spaceId: string; spaceName: string }
5363
+ | { kind: "model"; model: BuilderCmsModelSummary }
5364
+ | { kind: "addSource" }
5365
+ | { kind: "secondarySource"; sourceId: string; sourceName: string }
5366
+ | { kind: "keyConfirm"; candidate: PendingSourceCandidate }
5367
+ | { kind: "fieldPicker"; sourceId: string; sourceName: string };
5368
+
5369
+ function sourceNavTitle(stack: SourceNavStep[]): string {
5370
+ const top = stack[stack.length - 1];
5371
+ if (!top) return dbText("sources");
5372
+ if (top.kind === "provider") return "Builder";
5373
+ if (top.kind === "space") return top.spaceName;
5374
+ if (top.kind === "addSource") return dbText("addASource");
5375
+ if (top.kind === "secondarySource") return top.sourceName;
5376
+ if (top.kind === "keyConfirm") return dbText("matchExistingItemsToDetails");
5377
+ if (top.kind === "fieldPicker") return dbText("chooseFields");
5378
+ return top.model.displayName;
5379
+ }
5380
+
5381
+ // The Builder "B" brand mark (first glyph of the wordmark), drawn with
5382
+ // currentColor so it themes against the panel background.
5383
+ function BuilderLogoMark({ className }: { className?: string }) {
5384
+ return (
5385
+ <svg
5386
+ viewBox="0 0 71 80"
5387
+ fill="currentColor"
5388
+ xmlns="http://www.w3.org/2000/svg"
5389
+ className={className}
5390
+ aria-hidden="true"
5391
+ >
5392
+ <path d="M70.86 24C70.86 10.69 60.06 0 46.86 0H6.31995C2.81995 0 0 2.84031 0 6.32031C0 12.8003 13.71 17.71 13.71 40C13.71 62.29 0 67.2102 0 73.6802C0 77.1602 2.81995 80 6.31995 80H46.86C60.06 80 70.86 69.31 70.86 56C70.86 46.22 64.98 40.25 64.75 40C64.98 39.75 70.86 33.78 70.86 24ZM8.37 6.86035H46.87C51.45 6.86035 55.75 8.64037 58.99 11.8804C62.23 15.1204 64.01 19.42 64.01 24C64.01 28.58 62.3199 32.62 59.3199 35.79L8.37 6.86035ZM58.99 68.1304C55.75 71.3704 51.45 73.1504 46.87 73.1504H8.37L59.3199 44.2202C62.3199 47.3902 64.01 51.5703 64.01 56.0103C64.01 60.4503 62.23 64.8904 58.99 68.1304ZM15.83 61.02C16.24 60.17 20.58 51.74 20.58 40C20.58 28.26 16.24 19.83 15.83 18.98L52.85 40L15.83 61.02Z" />
5393
+ </svg>
5394
+ );
5395
+ }
5396
+
5397
+ // The Notion logo, reusing the shared `.notion-logo-icon` styling (same mark as
5398
+ // the sidebar's Notion button) so it themes consistently.
5399
+ function NotionLogoMark({ className }: { className?: string }) {
3948
5400
  return (
3949
5401
  <svg
3950
5402
  viewBox="0 0 100 100"
@@ -5707,6 +7159,11 @@ function BuilderSourceContinuationBar({
5707
7159
  )
5708
7160
  : rawFetchedCount;
5709
7161
  const hasMore = source.metadata.lastReadHasMore === true && !rowsComplete;
7162
+ const fetchedCountDetail = builderSourceContinuationFetchedCountDetail(
7163
+ source,
7164
+ fetchedCount,
7165
+ rowsComplete,
7166
+ );
5710
7167
  const bodyHydrationActive =
5711
7168
  !hasMore &&
5712
7169
  !!bodyHydration &&
@@ -5760,7 +7217,9 @@ function BuilderSourceContinuationBar({
5760
7217
  failed: bodyHydration.error,
5761
7218
  },
5762
7219
  )
5763
- : dbText("builderRowsFetchedSoFar", { count: fetchedCount });
7220
+ : dbText("builderRowsFetchedSoFar", {
7221
+ count: fetchedCountDetail ?? fetchedCount,
7222
+ });
5764
7223
 
5765
7224
  return (
5766
7225
  <div
@@ -6578,6 +8037,24 @@ export function builderSourceContinuationProgressPercent(
6578
8037
  : rawPercent;
6579
8038
  }
6580
8039
 
8040
+ export function builderSourceContinuationFetchedCountDetail(
8041
+ source: Pick<ContentDatabaseSource, "metadata">,
8042
+ fetchedCount: number | null,
8043
+ rowsComplete = false,
8044
+ ) {
8045
+ const knownFetchTotal =
8046
+ !rowsComplete &&
8047
+ source.metadata.lastReadHasMore === true &&
8048
+ typeof source.metadata.lastReadLimit === "number"
8049
+ ? source.metadata.lastReadLimit
8050
+ : null;
8051
+ return fetchedCount !== null &&
8052
+ knownFetchTotal !== null &&
8053
+ knownFetchTotal > fetchedCount
8054
+ ? `${fetchedCount} of ${knownFetchTotal}`
8055
+ : fetchedCount;
8056
+ }
8057
+
6581
8058
  export function builderSourceContinuationWatchdogDecision(refires: number) {
6582
8059
  return "refire";
6583
8060
  }
@@ -7081,7 +8558,10 @@ type DatabasePropertyPickerOption = {
7081
8558
  export function databasePropertyPickerItems(
7082
8559
  properties: DocumentProperty[],
7083
8560
  query: string,
7084
- { includeName = true }: { includeName?: boolean } = {},
8561
+ {
8562
+ includeName = true,
8563
+ excludeKeys,
8564
+ }: { includeName?: boolean; excludeKeys?: ReadonlySet<string> } = {},
7085
8565
  ): DatabasePropertyPickerOption[] {
7086
8566
  const normalizedQuery = query.trim().toLowerCase();
7087
8567
  const items: DatabasePropertyPickerOption[] = [
@@ -7093,7 +8573,7 @@ export function databasePropertyPickerItems(
7093
8573
  label: property.definition.name,
7094
8574
  type: property.definition.type,
7095
8575
  })),
7096
- ];
8576
+ ].filter((item) => !excludeKeys?.has(item.key));
7097
8577
 
7098
8578
  if (!normalizedQuery) return items;
7099
8579
  return items.filter((item) =>
@@ -7106,9 +8586,11 @@ export function databasePropertyPickerItems(
7106
8586
  function DatabasePropertyPickerSearch({
7107
8587
  value,
7108
8588
  onChange,
8589
+ placeholder = dbText("searchProperties"),
7109
8590
  }: {
7110
8591
  value: string;
7111
8592
  onChange: (value: string) => void;
8593
+ placeholder?: string;
7112
8594
  }) {
7113
8595
  return (
7114
8596
  <div className="border-b border-border/70 p-1">
@@ -7118,8 +8600,8 @@ function DatabasePropertyPickerSearch({
7118
8600
  value={value}
7119
8601
  onChange={(event) => onChange(event.target.value)}
7120
8602
  onKeyDown={(event) => event.stopPropagation()}
7121
- placeholder={dbText("searchProperties")}
7122
- aria-label={dbText("searchProperties")}
8603
+ placeholder={placeholder}
8604
+ aria-label={placeholder}
7123
8605
  className="h-6 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
7124
8606
  />
7125
8607
  </div>
@@ -7131,16 +8613,18 @@ function DatabasePropertyPickerItem({
7131
8613
  item,
7132
8614
  selected,
7133
8615
  onSelect,
8616
+ closeOnSelect = false,
7134
8617
  }: {
7135
8618
  item: DatabasePropertyPickerOption;
7136
8619
  selected: boolean;
7137
8620
  onSelect: (key: string, label: string) => void;
8621
+ closeOnSelect?: boolean;
7138
8622
  }) {
7139
8623
  const Icon = item.type === "name" ? IconFileText : TYPE_ICONS[item.type];
7140
8624
  return (
7141
8625
  <DropdownMenuItem
7142
8626
  onSelect={(event) => {
7143
- event.preventDefault();
8627
+ if (!closeOnSelect) event.preventDefault();
7144
8628
  onSelect(item.key, item.label);
7145
8629
  }}
7146
8630
  >
@@ -7151,23 +8635,84 @@ function DatabasePropertyPickerItem({
7151
8635
  );
7152
8636
  }
7153
8637
 
8638
+ function DatabasePropertyPickerMenuItems({
8639
+ properties,
8640
+ selectedKey,
8641
+ includeName,
8642
+ excludeKeys,
8643
+ placeholder,
8644
+ closeOnSelect,
8645
+ onSelect,
8646
+ }: {
8647
+ properties: DocumentProperty[];
8648
+ selectedKey?: string;
8649
+ includeName?: boolean;
8650
+ excludeKeys?: ReadonlySet<string>;
8651
+ placeholder?: string;
8652
+ closeOnSelect?: boolean;
8653
+ onSelect: (key: string, label: string) => void;
8654
+ }) {
8655
+ const [query, setQuery] = useState("");
8656
+ const items = databasePropertyPickerItems(properties, query, {
8657
+ includeName,
8658
+ excludeKeys,
8659
+ });
8660
+
8661
+ return (
8662
+ <>
8663
+ <DatabasePropertyPickerSearch
8664
+ value={query}
8665
+ onChange={setQuery}
8666
+ placeholder={placeholder}
8667
+ />
8668
+ <div className="max-h-72 overflow-auto p-1">
8669
+ {items.map((item) => (
8670
+ <DatabasePropertyPickerItem
8671
+ key={item.key}
8672
+ item={item}
8673
+ selected={selectedKey === item.key}
8674
+ closeOnSelect={closeOnSelect}
8675
+ onSelect={onSelect}
8676
+ />
8677
+ ))}
8678
+ {items.length === 0 ? (
8679
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">
8680
+ {dbText("noPropertiesFound")}
8681
+ </div>
8682
+ ) : null}
8683
+ </div>
8684
+ </>
8685
+ );
8686
+ }
8687
+
7154
8688
  function DatabasePropertyPickerSubContent({
7155
8689
  properties,
7156
8690
  selectedKey,
7157
8691
  includeName,
8692
+ excludeKeys,
8693
+ placeholder,
7158
8694
  onSelect,
7159
8695
  }: {
7160
8696
  properties: DocumentProperty[];
7161
- selectedKey: string;
8697
+ selectedKey?: string;
7162
8698
  includeName?: boolean;
8699
+ excludeKeys?: ReadonlySet<string>;
8700
+ placeholder?: string;
7163
8701
  onSelect: (key: string, label: string) => void;
7164
8702
  }) {
7165
8703
  const [query, setQuery] = useState("");
7166
- const items = databasePropertyPickerItems(properties, query, { includeName });
8704
+ const items = databasePropertyPickerItems(properties, query, {
8705
+ includeName,
8706
+ excludeKeys,
8707
+ });
7167
8708
 
7168
8709
  return (
7169
8710
  <DropdownMenuSubContent className="max-h-80 w-64 overflow-auto">
7170
- <DatabasePropertyPickerSearch value={query} onChange={setQuery} />
8711
+ <DatabasePropertyPickerSearch
8712
+ value={query}
8713
+ onChange={setQuery}
8714
+ placeholder={placeholder}
8715
+ />
7171
8716
  {items.map((item) => (
7172
8717
  <DatabasePropertyPickerItem
7173
8718
  key={item.key}
@@ -7332,8 +8877,8 @@ function databaseFilterModeLabel(filterMode: DatabaseFilterMode) {
7332
8877
  return filterMode === "or" ? "Any" : "All";
7333
8878
  }
7334
8879
 
7335
- function databaseFilterModePhrase(filterMode: DatabaseFilterMode) {
7336
- return filterMode === "or" ? "any filter" : "all filters";
8880
+ function databaseFilterModeConjunctionLabel(filterMode: DatabaseFilterMode) {
8881
+ return filterMode === "or" ? "Or" : "And";
7337
8882
  }
7338
8883
 
7339
8884
  function DatabaseResultCountFooter({
@@ -10753,6 +12298,143 @@ function databaseViewStateKey(
10753
12298
  return JSON.stringify({ databaseId, viewConfig });
10754
12299
  }
10755
12300
 
12301
+ function normalizePersonalDatabaseViewOverrides(
12302
+ value: ContentDatabasePersonalViewOverrides | null | undefined,
12303
+ ): PersonalDatabaseViewOverrides | null {
12304
+ try {
12305
+ if (!value) return null;
12306
+ const parsed = value as Partial<PersonalDatabaseViewOverrides>;
12307
+ if (
12308
+ parsed.version !== PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION ||
12309
+ !Array.isArray(parsed.views)
12310
+ ) {
12311
+ return null;
12312
+ }
12313
+
12314
+ return {
12315
+ version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
12316
+ activeViewId:
12317
+ typeof parsed.activeViewId === "string"
12318
+ ? parsed.activeViewId
12319
+ : undefined,
12320
+ views: parsed.views
12321
+ .filter((view) => typeof view?.id === "string")
12322
+ .map((view) => ({
12323
+ id: view.id,
12324
+ sorts: Array.isArray(view.sorts)
12325
+ ? view.sorts.filter(isDatabaseSort)
12326
+ : [],
12327
+ filters: Array.isArray(view.filters)
12328
+ ? view.filters.filter(isDatabaseFilter)
12329
+ : [],
12330
+ filterMode: normalizeClientDatabaseFilterMode(view.filterMode),
12331
+ })),
12332
+ };
12333
+ } catch {
12334
+ return null;
12335
+ }
12336
+ }
12337
+
12338
+ function personalDatabaseViewOverridesFromConfig(
12339
+ viewConfig: ContentDatabaseViewConfig,
12340
+ ): PersonalDatabaseViewOverrides {
12341
+ const normalized = normalizeClientDatabaseViewConfig(viewConfig);
12342
+ return {
12343
+ version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
12344
+ activeViewId: normalized.activeViewId,
12345
+ views: normalized.views.map((view) => ({
12346
+ id: view.id,
12347
+ sorts: view.sorts,
12348
+ filters: view.filters,
12349
+ filterMode: view.filterMode ?? "and",
12350
+ })),
12351
+ };
12352
+ }
12353
+
12354
+ export function applyPersonalDatabaseViewOverrides(
12355
+ savedViewConfig: ContentDatabaseViewConfig,
12356
+ overrides: PersonalDatabaseViewOverrides | null,
12357
+ ) {
12358
+ const saved = normalizeClientDatabaseViewConfig(savedViewConfig);
12359
+ if (!overrides) return saved;
12360
+
12361
+ const overridesByViewId = new Map(
12362
+ overrides.views.map((view) => [view.id, view]),
12363
+ );
12364
+ return normalizeClientDatabaseViewConfig({
12365
+ ...saved,
12366
+ activeViewId: saved.views.some((view) => view.id === overrides.activeViewId)
12367
+ ? overrides.activeViewId
12368
+ : saved.activeViewId,
12369
+ views: saved.views.map((view) => {
12370
+ const override = overridesByViewId.get(view.id);
12371
+ if (!override) return view;
12372
+ return {
12373
+ ...view,
12374
+ sorts: override.sorts,
12375
+ filters: override.filters,
12376
+ filterMode: override.filterMode,
12377
+ };
12378
+ }),
12379
+ });
12380
+ }
12381
+
12382
+ export function databaseViewConfigWithSavedQueryState(
12383
+ currentViewConfig: ContentDatabaseViewConfig,
12384
+ savedViewConfig: ContentDatabaseViewConfig,
12385
+ ) {
12386
+ const current = normalizeClientDatabaseViewConfig(currentViewConfig);
12387
+ const saved = normalizeClientDatabaseViewConfig(savedViewConfig);
12388
+ const savedViewsById = new Map(saved.views.map((view) => [view.id, view]));
12389
+
12390
+ return normalizeClientDatabaseViewConfig({
12391
+ ...current,
12392
+ activeViewId: saved.activeViewId,
12393
+ views: current.views.map((view) => {
12394
+ const savedView = savedViewsById.get(view.id);
12395
+ if (!savedView) return view;
12396
+ return {
12397
+ ...view,
12398
+ sorts: savedView.sorts,
12399
+ filters: savedView.filters,
12400
+ filterMode: savedView.filterMode,
12401
+ };
12402
+ }),
12403
+ });
12404
+ }
12405
+
12406
+ export function databaseViewConfigWithClearedActiveFilters(
12407
+ viewConfig: ContentDatabaseViewConfig,
12408
+ ) {
12409
+ return updateActiveDatabaseView(
12410
+ normalizeClientDatabaseViewConfig(viewConfig),
12411
+ (view) => ({ ...view, filters: [], filterMode: "and" }),
12412
+ );
12413
+ }
12414
+
12415
+ export function databaseViewHasPersonalQueryChanges(
12416
+ currentViewConfig: ContentDatabaseViewConfig,
12417
+ savedViewConfig: ContentDatabaseViewConfig,
12418
+ ) {
12419
+ return (
12420
+ databaseViewQueryStateKey(currentViewConfig) !==
12421
+ databaseViewQueryStateKey(savedViewConfig)
12422
+ );
12423
+ }
12424
+
12425
+ function databaseViewQueryStateKey(viewConfig: ContentDatabaseViewConfig) {
12426
+ const normalized = normalizeClientDatabaseViewConfig(viewConfig);
12427
+ return JSON.stringify({
12428
+ activeViewId: normalized.activeViewId,
12429
+ views: normalized.views.map((view) => ({
12430
+ id: view.id,
12431
+ sorts: view.sorts,
12432
+ filters: view.filters,
12433
+ filterMode: view.filterMode ?? "and",
12434
+ })),
12435
+ });
12436
+ }
12437
+
10756
12438
  function isTablePropertyVisible(
10757
12439
  property: DocumentProperty,
10758
12440
  items: ContentDatabaseItem[],
@@ -10770,12 +12452,24 @@ function isTablePropertyVisible(
10770
12452
  });
10771
12453
  }
10772
12454
 
10773
- function databaseTableCellDisplayValue(property: DocumentProperty) {
12455
+ function databaseTableCellDisplayValue(
12456
+ property: DocumentProperty,
12457
+ item?: Pick<ContentDatabaseItem, "bodyHydration" | "document">,
12458
+ ) {
10774
12459
  // Blocks columns show a word count, never the dumped body content.
10775
12460
  if (property.definition.type === "blocks") {
10776
12461
  const content = typeof property.value === "string" ? property.value : "";
10777
12462
  const words = countWords(content);
10778
- if (words === 0) return <span aria-hidden="true">&nbsp;</span>;
12463
+ if (words === 0) {
12464
+ if (item && databaseItemBodyHydrationIsPending(item)) {
12465
+ return (
12466
+ <span className="text-muted-foreground">
12467
+ {dbText("builderBodySyncing")}
12468
+ </span>
12469
+ );
12470
+ }
12471
+ return <span aria-hidden="true">&nbsp;</span>;
12472
+ }
10779
12473
  return (
10780
12474
  <span className="text-muted-foreground">{formatWordCount(content)}</span>
10781
12475
  );
@@ -13355,13 +15049,12 @@ export function applyDatabaseView(
13355
15049
  const activeFilters = filters.filter(isActiveFilter);
13356
15050
  const filtered = activeFilters.length
13357
15051
  ? searched.filter((item) =>
13358
- filterMode === "or"
13359
- ? activeFilters.some((filter) =>
13360
- databaseItemMatchesFilter(item, properties, filter),
13361
- )
13362
- : activeFilters.every((filter) =>
13363
- databaseItemMatchesFilter(item, properties, filter),
13364
- ),
15052
+ databaseItemMatchesFilterTree(
15053
+ item,
15054
+ properties,
15055
+ activeFilters,
15056
+ filterMode,
15057
+ ),
13365
15058
  )
13366
15059
  : searched;
13367
15060
 
@@ -13377,25 +15070,137 @@ export function applyDatabaseView(
13377
15070
  return sort.direction === "asc" ? comparison : -comparison;
13378
15071
  }
13379
15072
  }
13380
- return 0;
13381
- });
15073
+ return 0;
15074
+ });
15075
+ }
15076
+
15077
+ function databaseItemMatchesFilterTree(
15078
+ item: ContentDatabaseItem,
15079
+ properties: DocumentProperty[],
15080
+ filters: DatabaseFilter[],
15081
+ filterMode: DatabaseFilterMode,
15082
+ ) {
15083
+ const rootFilters = filters.filter((filter) => !filter.parentFilterGroupId);
15084
+ const nestedGroups = nestedDatabaseFilterGroups(filters);
15085
+ const matches = [
15086
+ ...rootFilters.map((filter) =>
15087
+ databaseItemMatchesFilter(item, properties, filter),
15088
+ ),
15089
+ ...nestedGroups.map((group) =>
15090
+ combineDatabaseFilterMatches(
15091
+ group.map((filter) =>
15092
+ databaseItemMatchesFilter(item, properties, filter),
15093
+ ),
15094
+ filterMode,
15095
+ ),
15096
+ ),
15097
+ ];
15098
+
15099
+ return combineDatabaseFilterMatches(matches, filterMode);
15100
+ }
15101
+
15102
+ function nestedDatabaseFilterGroups(filters: DatabaseFilter[]) {
15103
+ const groups = new Map<string, DatabaseFilter[]>();
15104
+ for (const filter of filters) {
15105
+ if (!filter.parentFilterGroupId || !filter.filterGroupId) continue;
15106
+ groups.set(filter.filterGroupId, [
15107
+ ...(groups.get(filter.filterGroupId) ?? []),
15108
+ filter,
15109
+ ]);
15110
+ }
15111
+ return [...groups.values()].filter((group) => group.length > 0);
15112
+ }
15113
+
15114
+ function combineDatabaseFilterMatches(
15115
+ matches: boolean[],
15116
+ filterMode: DatabaseFilterMode,
15117
+ ) {
15118
+ if (matches.length === 0) return true;
15119
+ return filterMode === "or"
15120
+ ? matches.some((matched) => matched)
15121
+ : matches.every((matched) => matched);
15122
+ }
15123
+
15124
+ function defaultDatabaseSort(): DatabaseSort {
15125
+ return {
15126
+ key: "name",
15127
+ label: "Name",
15128
+ direction: "asc",
15129
+ };
15130
+ }
15131
+
15132
+ function defaultDatabaseFilter(): DatabaseFilter {
15133
+ return {
15134
+ key: "name",
15135
+ label: "Name",
15136
+ operator: "contains",
15137
+ value: "",
15138
+ };
15139
+ }
15140
+
15141
+ function createDatabaseFilterForField(
15142
+ key: ColumnKey,
15143
+ label: string,
15144
+ properties: DocumentProperty[],
15145
+ advanced:
15146
+ | boolean
15147
+ | { filterGroupId: string; parentFilterGroupId?: string } = false,
15148
+ ): DatabaseFilter {
15149
+ const group =
15150
+ typeof advanced === "object"
15151
+ ? advanced
15152
+ : advanced
15153
+ ? { filterGroupId: DATABASE_ADVANCED_FILTER_GROUP_ID }
15154
+ : null;
15155
+ return {
15156
+ key,
15157
+ label,
15158
+ operator: defaultFilterOperatorForKey(key, properties),
15159
+ value: "",
15160
+ ...(group ? { filterGroupId: group.filterGroupId } : {}),
15161
+ ...(group?.parentFilterGroupId
15162
+ ? { parentFilterGroupId: group.parentFilterGroupId }
15163
+ : {}),
15164
+ };
15165
+ }
15166
+
15167
+ function isAdvancedDatabaseFilter(filter: DatabaseFilter) {
15168
+ return (
15169
+ filter.filterGroupId === DATABASE_ADVANCED_FILTER_GROUP_ID ||
15170
+ filter.parentFilterGroupId === DATABASE_ADVANCED_FILTER_GROUP_ID
15171
+ );
15172
+ }
15173
+
15174
+ function advancedNestedFilterGroups(
15175
+ entries: DatabaseFilterEntry[],
15176
+ ): DatabaseNestedFilterGroupEntry[] {
15177
+ const groups = new Map<string, DatabaseFilterEntry[]>();
15178
+ for (const entry of entries) {
15179
+ if (
15180
+ entry.filter.parentFilterGroupId !== DATABASE_ADVANCED_FILTER_GROUP_ID ||
15181
+ !entry.filter.filterGroupId
15182
+ ) {
15183
+ continue;
15184
+ }
15185
+ groups.set(entry.filter.filterGroupId, [
15186
+ ...(groups.get(entry.filter.filterGroupId) ?? []),
15187
+ entry,
15188
+ ]);
15189
+ }
15190
+ return [...groups.entries()].map(([id, groupEntries]) => ({
15191
+ id,
15192
+ entries: groupEntries,
15193
+ }));
13382
15194
  }
13383
15195
 
13384
- function defaultDatabaseSort(): DatabaseSort {
13385
- return {
13386
- key: "name",
13387
- label: "Name",
13388
- direction: "asc",
13389
- };
15196
+ function createAdvancedNestedFilterGroupId() {
15197
+ return `advanced-${Date.now().toString(36)}-${Math.random()
15198
+ .toString(36)
15199
+ .slice(2, 8)}`;
13390
15200
  }
13391
15201
 
13392
- function defaultDatabaseFilter(): DatabaseFilter {
13393
- return {
13394
- key: "name",
13395
- label: "Name",
13396
- operator: "contains",
13397
- value: "",
13398
- };
15202
+ function advancedFilterGroupLabel(ruleCount: number) {
15203
+ return `${ruleCount} rule${ruleCount === 1 ? "" : "s"}`;
13399
15204
  }
13400
15205
 
13401
15206
  export function upsertDatabaseSort(
@@ -13553,7 +15358,7 @@ function isActiveFilter(
13553
15358
  ): filter is DatabaseFilter {
13554
15359
  if (!filter) return false;
13555
15360
  if (filterOperatorNeedsValue(filter.operator)) {
13556
- return filter.value.trim().length > 0;
15361
+ return databaseFilterSelectedValues(filter.value).length > 0;
13557
15362
  }
13558
15363
  return true;
13559
15364
  }
@@ -13598,21 +15403,42 @@ function databaseFilterDefaultValueForNewItem(
13598
15403
  if (filter.operator === "is_unchecked") {
13599
15404
  return property.definition.type === "checkbox" ? false : undefined;
13600
15405
  }
13601
- if (filter.operator !== "equals") return undefined;
15406
+ if (property.definition.type === "multi_select") {
15407
+ if (filter.operator !== "equals" && filter.operator !== "contains") {
15408
+ return undefined;
15409
+ }
15410
+ const values = databaseFilterSelectedValues(filter.value);
15411
+ if (values.length === 0) return undefined;
15412
+ return values.map(
15413
+ (value) =>
15414
+ databasePropertyOptionIdForFilterValue(property, value) ?? value,
15415
+ );
15416
+ }
15417
+ if (property.definition.type === "person" && filter.operator === "contains") {
15418
+ const values = databaseFilterSelectedValues(filter.value);
15419
+ return values.length > 0 ? values : undefined;
15420
+ }
13602
15421
 
13603
15422
  const value = filter.value.trim();
13604
15423
  if (!value) return undefined;
13605
15424
 
13606
- const optionValue = databasePropertyOptionIdForFilterValue(property, value);
13607
- if (property.definition.type === "multi_select") {
13608
- return [optionValue ?? value];
13609
- }
13610
15425
  if (
13611
15426
  property.definition.type === "select" ||
13612
15427
  property.definition.type === "status"
13613
15428
  ) {
13614
- return optionValue ?? value;
15429
+ if (filter.operator !== "equals" && filter.operator !== "contains") {
15430
+ return undefined;
15431
+ }
15432
+ const values = databaseFilterSelectedValues(filter.value);
15433
+ const firstValue = values[0]?.trim() ?? "";
15434
+ if (!firstValue) return undefined;
15435
+ return (
15436
+ databasePropertyOptionIdForFilterValue(property, firstValue) ?? firstValue
15437
+ );
13615
15438
  }
15439
+
15440
+ if (filter.operator !== "equals") return undefined;
15441
+
13616
15442
  if (property.definition.type === "date") {
13617
15443
  return /^\d{4}-\d{2}-\d{2}$/.test(value)
13618
15444
  ? { start: value, includeTime: false }
@@ -13657,10 +15483,20 @@ function databaseItemMatchesFilter(
13657
15483
  : current < target;
13658
15484
  }
13659
15485
 
13660
- if (filter.operator === "before" || filter.operator === "after") {
15486
+ if (
15487
+ filter.operator === "before" ||
15488
+ filter.operator === "after" ||
15489
+ filter.operator === "between"
15490
+ ) {
13661
15491
  const current = propertyDateValue(property);
15492
+ if (!Number.isFinite(current)) return false;
15493
+ if (filter.operator === "between") {
15494
+ const range = databaseFilterDateRangeValue(filter.value);
15495
+ if (!range) return false;
15496
+ return current >= range[0] && current <= range[1];
15497
+ }
13662
15498
  const target = new Date(filter.value.trim()).getTime();
13663
- if (!Number.isFinite(current) || !Number.isFinite(target)) return false;
15499
+ if (!Number.isFinite(target)) return false;
13664
15500
  return filter.operator === "before" ? current < target : current > target;
13665
15501
  }
13666
15502
 
@@ -13669,8 +15505,31 @@ function databaseItemMatchesFilter(
13669
15505
  properties,
13670
15506
  filter.key,
13671
15507
  ).map((candidate) => candidate.trim().toLowerCase());
15508
+ const selectedFilterValues = databaseFilterSelectedValues(filter.value).map(
15509
+ (candidate) => candidate.trim().toLowerCase(),
15510
+ );
13672
15511
  const normalizedValue = value.trim().toLowerCase();
13673
- const normalizedFilter = filter.value.trim().toLowerCase();
15512
+ const normalizedFilter = selectedFilterValues[0] ?? "";
15513
+ const usesDiscreteValues =
15514
+ property?.definition.type === "select" ||
15515
+ property?.definition.type === "status" ||
15516
+ property?.definition.type === "multi_select" ||
15517
+ property?.definition.type === "person";
15518
+
15519
+ if (
15520
+ usesDiscreteValues &&
15521
+ (filter.operator === "equals" || filter.operator === "contains")
15522
+ ) {
15523
+ return selectedFilterValues.some((filterValue) =>
15524
+ candidateValues.includes(filterValue),
15525
+ );
15526
+ }
15527
+ if (usesDiscreteValues && filter.operator === "does_not_equal") {
15528
+ return selectedFilterValues.every(
15529
+ (filterValue) => !candidateValues.includes(filterValue),
15530
+ );
15531
+ }
15532
+
13674
15533
  if (filter.operator === "equals") {
13675
15534
  return candidateValues.includes(normalizedFilter);
13676
15535
  }
@@ -13844,6 +15703,10 @@ function propertyDateValue(property: DocumentProperty | null | undefined) {
13844
15703
  return Number.isFinite(value) ? value : Number.NaN;
13845
15704
  }
13846
15705
 
15706
+ function databaseSortDirectionLabel(direction: SortDirection) {
15707
+ return direction === "asc" ? "Ascending" : "Descending";
15708
+ }
15709
+
13847
15710
  function SortMenu({
13848
15711
  properties,
13849
15712
  sorts,
@@ -13879,6 +15742,10 @@ function SortMenu({
13879
15742
  onSortsChange([...sorts, defaultDatabaseSort()]);
13880
15743
  }
13881
15744
 
15745
+ function addSortForField(key: "name" | string, label: string) {
15746
+ onSortsChange([...sorts, { key, label, direction: "asc" }]);
15747
+ }
15748
+
13882
15749
  function removeSort(index: number) {
13883
15750
  onSortsChange(sorts.filter((_, sortIndex) => sortIndex !== index));
13884
15751
  }
@@ -13911,363 +15778,210 @@ function SortMenu({
13911
15778
  ) : null}
13912
15779
  </Button>
13913
15780
  </DropdownMenuTrigger>
13914
- <DropdownMenuContent align="end" className="w-[340px]">
13915
- <div className="grid gap-2 p-2">
13916
- <div className="text-xs font-medium text-muted-foreground">
13917
- {dbText("sortRowsBy")}
13918
- </div>
13919
- <div className="grid gap-2">
13920
- {displayedSorts.map((sort, index) => (
13921
- <div
13922
- key={`${index}-${sort.key}`}
13923
- className="grid grid-cols-[minmax(0,1fr)_auto_auto_auto] gap-1 rounded border border-border/70 bg-background p-1.5"
13924
- >
13925
- <DropdownMenuSub>
13926
- <DropdownMenuSubTrigger className="min-w-0">
13927
- <SortFieldIcon sort={sort} properties={properties} />
13928
- <span className="min-w-0 flex-1 truncate">
13929
- {sort.label}
13930
- </span>
13931
- </DropdownMenuSubTrigger>
13932
- <DatabasePropertyPickerSubContent
13933
- properties={properties}
13934
- selectedKey={sort.key}
13935
- includeName
13936
- onSelect={(key, label) => selectSort(index, key, label)}
13937
- />
13938
- </DropdownMenuSub>
13939
- <button
13940
- type="button"
13941
- className="flex h-8 items-center rounded px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
13942
- onClick={() => toggleDirection(index)}
13943
- >
13944
- {sort.direction === "asc" ? "Asc" : "Desc"}
13945
- </button>
13946
- <div className="flex items-center">
13947
- <button
13948
- type="button"
13949
- aria-label={`Move sort ${index + 1} earlier`}
13950
- className="flex size-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
13951
- disabled={sorts.length <= 1 || index === 0}
13952
- onClick={() => moveSort(index, "up")}
13953
- >
13954
- <IconArrowUp className="size-3.5" />
13955
- </button>
13956
- <button
13957
- type="button"
13958
- aria-label={`Move sort ${index + 1} later`}
13959
- className="flex size-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
13960
- disabled={
13961
- sorts.length <= 1 || index >= displayedSorts.length - 1
13962
- }
13963
- onClick={() => moveSort(index, "down")}
13964
- >
13965
- <IconArrowDown className="size-3.5" />
13966
- </button>
13967
- </div>
13968
- <button
13969
- type="button"
13970
- aria-label={`Remove sort ${index + 1}`}
13971
- className="flex size-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
13972
- disabled={sorts.length === 0}
13973
- onClick={() => removeSort(index)}
13974
- >
13975
- <IconX className="size-4" />
13976
- </button>
13977
- </div>
13978
- ))}
13979
- </div>
13980
-
13981
- <div className="flex justify-between gap-2 border-t pt-2">
13982
- <Button
13983
- type="button"
13984
- size="sm"
13985
- variant="ghost"
13986
- className="h-8 px-2 text-xs"
13987
- onClick={addSort}
13988
- >
13989
- <IconPlus className="mr-1 size-3.5" />
13990
- {dbText("addSort")}
13991
- </Button>
13992
- <Button
13993
- type="button"
13994
- size="sm"
13995
- variant="ghost"
13996
- className="h-8 px-2 text-xs"
13997
- disabled={sorts.length === 0}
13998
- onClick={() => onSortsChange([])}
13999
- >
14000
- {dbText("clearSorts")}
14001
- </Button>
14002
- </div>
14003
- </div>
14004
- </DropdownMenuContent>
14005
- </DropdownMenu>
14006
- );
14007
- }
14008
-
14009
- function SortFieldIcon({
14010
- sort,
14011
- properties,
14012
- }: {
14013
- sort: DatabaseSort;
14014
- properties: DocumentProperty[];
14015
- }) {
14016
- if (sort.key === "name") {
14017
- return <IconFileText className="mr-2 size-4 text-muted-foreground" />;
14018
- }
14019
- const property = properties.find(
14020
- (candidate) => candidate.definition.id === sort.key,
14021
- );
14022
- const Icon = property ? TYPE_ICONS[property.definition.type] : IconFileText;
14023
- return <Icon className="mr-2 size-4 text-muted-foreground" />;
14024
- }
14025
-
14026
- function FilterMenu({
14027
- documentId,
14028
- properties,
14029
- filters,
14030
- filterMode,
14031
- onFiltersChange,
14032
- onFilterModeChange,
14033
- }: {
14034
- documentId: string;
14035
- properties: DocumentProperty[];
14036
- filters: DatabaseFilter[];
14037
- filterMode: DatabaseFilterMode;
14038
- onFiltersChange: (filters: DatabaseFilter[]) => void;
14039
- onFilterModeChange: (filterMode: DatabaseFilterMode) => void;
14040
- }) {
14041
- const activeFilters = filters.filter(isActiveFilter);
14042
- const active = activeFilters.length > 0;
14043
- const displayedFilters =
14044
- filters.length > 0 ? filters : [defaultDatabaseFilter()];
14045
-
14046
- function updateFilter(index: number, next: Partial<DatabaseFilter>) {
14047
- const baseFilters =
14048
- filters.length > 0 ? [...filters] : [defaultDatabaseFilter()];
14049
- const currentFilter = baseFilters[index] ?? defaultDatabaseFilter();
14050
- const nextOperator = next.operator ?? currentFilter.operator;
14051
- baseFilters[index] = {
14052
- ...currentFilter,
14053
- ...next,
14054
- value: filterOperatorNeedsValue(nextOperator)
14055
- ? (next.value ?? currentFilter.value)
14056
- : "",
14057
- };
14058
- onFiltersChange(baseFilters);
14059
- }
14060
-
14061
- function selectField(index: number, key: "name" | string, label: string) {
14062
- updateFilter(index, {
14063
- key,
14064
- label,
14065
- operator: defaultFilterOperatorForKey(key, properties),
14066
- value: "",
14067
- });
14068
- }
14069
-
14070
- function selectOperator(index: number, operator: FilterOperator) {
14071
- updateFilter(index, { operator });
14072
- }
14073
-
14074
- function addFilter() {
14075
- onFiltersChange([...filters, defaultDatabaseFilter()]);
14076
- }
14077
-
14078
- function removeFilter(index: number) {
14079
- onFiltersChange(filters.filter((_, filterIndex) => filterIndex !== index));
14080
- }
14081
-
14082
- function moveFilter(
14083
- index: number,
14084
- direction: DatabaseConditionMoveDirection,
14085
- ) {
14086
- onFiltersChange(moveDatabaseFilter(filters, index, direction));
14087
- }
14088
-
14089
- return (
14090
- <DropdownMenu>
14091
- <DropdownMenuTrigger asChild>
14092
- <Button
14093
- type="button"
14094
- variant="ghost"
14095
- size="sm"
14096
- aria-label={
14097
- active ? `${activeFilters.length} active filters` : "Filter"
14098
- }
14099
- title="Filter"
14100
- className={cn(databaseToolbarIconButtonClass(active), "relative")}
14101
- >
14102
- <IconFilter className="size-3.5" />
14103
- {active ? (
14104
- <span className="absolute -right-0.5 -top-0.5 flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded-full bg-foreground px-1 text-[9px] leading-none text-background">
14105
- {formatCompactCountBadge(activeFilters.length)}
14106
- </span>
14107
- ) : null}
14108
- </Button>
14109
- </DropdownMenuTrigger>
14110
- <DropdownMenuContent align="end" className="w-[360px]">
14111
- <div
14112
- className="grid gap-2 p-2"
14113
- onKeyDown={(event) => event.stopPropagation()}
14114
- >
14115
- <div className="text-xs font-medium text-muted-foreground">
14116
- {dbText("filterRowsWhere")}
15781
+ <DropdownMenuContent
15782
+ align="end"
15783
+ className={cn("w-[340px]", sorts.length === 0 && "w-64 p-0")}
15784
+ >
15785
+ {sorts.length === 0 ? (
15786
+ <div onKeyDown={(event) => event.stopPropagation()}>
15787
+ <DatabasePropertyPickerMenuItems
15788
+ properties={properties}
15789
+ includeName
15790
+ placeholder={dbText("sortBy")}
15791
+ closeOnSelect
15792
+ onSelect={addSortForField}
15793
+ />
14117
15794
  </div>
14118
- <DropdownMenuSub>
14119
- <DropdownMenuSubTrigger className="h-8 rounded border border-border/70 bg-background px-2 text-sm">
14120
- <span className="min-w-0 flex-1 truncate text-left">
14121
- Match {databaseFilterModePhrase(filterMode)}
14122
- </span>
14123
- </DropdownMenuSubTrigger>
14124
- <DropdownMenuSubContent className="w-44">
14125
- {DATABASE_FILTER_MODES.map((mode) => (
14126
- <DropdownMenuItem
14127
- key={mode}
14128
- onSelect={(event) => {
14129
- event.preventDefault();
14130
- onFilterModeChange(mode);
14131
- }}
15795
+ ) : null}
15796
+ {sorts.length > 0 ? (
15797
+ <div className="grid gap-2 p-2">
15798
+ <div className="text-xs font-medium text-muted-foreground">
15799
+ {dbText("sortRowsBy")}
15800
+ </div>
15801
+ <div className="grid gap-2">
15802
+ {displayedSorts.map((sort, index) => (
15803
+ <div
15804
+ key={`${index}-${sort.key}`}
15805
+ className="grid grid-cols-[minmax(0,1fr)_auto_auto_auto] gap-1 rounded border border-border/70 bg-background p-1.5"
14132
15806
  >
14133
- <span className="flex-1">
14134
- {databaseFilterModeLabel(mode)}
14135
- </span>
14136
- {filterMode === mode ? (
14137
- <IconCheck className="size-4 text-muted-foreground" />
14138
- ) : null}
14139
- </DropdownMenuItem>
14140
- ))}
14141
- </DropdownMenuSubContent>
14142
- </DropdownMenuSub>
14143
- <div className="grid gap-2">
14144
- {displayedFilters.map((currentFilter, index) => (
14145
- <div
14146
- key={`${index}-${currentFilter.key}`}
14147
- className="grid gap-1 rounded border border-border/70 bg-background p-1.5"
14148
- >
14149
- <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-1">
14150
15807
  <DropdownMenuSub>
14151
15808
  <DropdownMenuSubTrigger className="min-w-0">
14152
- <FilterFieldIcon
14153
- filter={currentFilter}
14154
- properties={properties}
14155
- />
15809
+ <SortFieldIcon sort={sort} properties={properties} />
14156
15810
  <span className="min-w-0 flex-1 truncate">
14157
- {currentFilter.label}
15811
+ {sort.label}
14158
15812
  </span>
14159
15813
  </DropdownMenuSubTrigger>
14160
15814
  <DatabasePropertyPickerSubContent
14161
15815
  properties={properties}
14162
- selectedKey={currentFilter.key}
15816
+ selectedKey={sort.key}
14163
15817
  includeName
14164
- onSelect={(key, label) => selectField(index, key, label)}
15818
+ onSelect={(key, label) => selectSort(index, key, label)}
14165
15819
  />
14166
15820
  </DropdownMenuSub>
14167
-
14168
- <DropdownMenuSub>
14169
- <DropdownMenuSubTrigger className="min-w-0">
14170
- <IconFilter className="mr-2 size-4 text-muted-foreground" />
14171
- <span className="min-w-0 flex-1 truncate">
14172
- {FILTER_OPERATOR_LABELS[currentFilter.operator] ??
14173
- "Contains"}
14174
- </span>
14175
- </DropdownMenuSubTrigger>
14176
- <DropdownMenuSubContent className="w-44">
14177
- {filterOperatorsForKey(currentFilter.key, properties).map(
14178
- (operator) => (
14179
- <DropdownMenuItem
14180
- key={operator}
14181
- onSelect={(event) => {
14182
- event.preventDefault();
14183
- selectOperator(index, operator);
14184
- }}
14185
- >
14186
- <span className="flex-1">
14187
- {FILTER_OPERATOR_LABELS[operator]}
14188
- </span>
14189
- {currentFilter.operator === operator ? (
14190
- <IconCheck className="size-4 text-muted-foreground" />
14191
- ) : null}
14192
- </DropdownMenuItem>
14193
- ),
14194
- )}
14195
- </DropdownMenuSubContent>
14196
- </DropdownMenuSub>
14197
-
15821
+ <button
15822
+ type="button"
15823
+ className="flex h-8 items-center rounded px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
15824
+ onClick={() => toggleDirection(index)}
15825
+ >
15826
+ {sort.direction === "asc" ? "Asc" : "Desc"}
15827
+ </button>
14198
15828
  <div className="flex items-center">
14199
15829
  <button
14200
15830
  type="button"
14201
- aria-label={`Move filter ${index + 1} earlier`}
15831
+ aria-label={`Move sort ${index + 1} earlier`}
14202
15832
  className="flex size-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
14203
- disabled={filters.length <= 1 || index === 0}
14204
- onClick={() => moveFilter(index, "up")}
15833
+ disabled={sorts.length <= 1 || index === 0}
15834
+ onClick={() => moveSort(index, "up")}
14205
15835
  >
14206
15836
  <IconArrowUp className="size-3.5" />
14207
15837
  </button>
14208
15838
  <button
14209
15839
  type="button"
14210
- aria-label={`Move filter ${index + 1} later`}
15840
+ aria-label={`Move sort ${index + 1} later`}
14211
15841
  className="flex size-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
14212
15842
  disabled={
14213
- filters.length <= 1 ||
14214
- index >= displayedFilters.length - 1
15843
+ sorts.length <= 1 || index >= displayedSorts.length - 1
14215
15844
  }
14216
- onClick={() => moveFilter(index, "down")}
15845
+ onClick={() => moveSort(index, "down")}
14217
15846
  >
14218
15847
  <IconArrowDown className="size-3.5" />
14219
15848
  </button>
14220
15849
  </div>
14221
-
14222
15850
  <button
14223
15851
  type="button"
14224
- aria-label={`Remove filter ${index + 1}`}
15852
+ aria-label={`Remove sort ${index + 1}`}
14225
15853
  className="flex size-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
14226
- disabled={filters.length === 0}
14227
- onClick={() => removeFilter(index)}
15854
+ disabled={sorts.length === 0}
15855
+ onClick={() => removeSort(index)}
14228
15856
  >
14229
15857
  <IconX className="size-4" />
14230
15858
  </button>
14231
15859
  </div>
15860
+ ))}
15861
+ </div>
15862
+
15863
+ <div className="flex justify-between gap-2 border-t pt-2">
15864
+ <Button
15865
+ type="button"
15866
+ size="sm"
15867
+ variant="ghost"
15868
+ className="h-8 px-2 text-xs"
15869
+ onClick={addSort}
15870
+ >
15871
+ <IconPlus className="mr-1 size-3.5" />
15872
+ {dbText("addSort")}
15873
+ </Button>
15874
+ <Button
15875
+ type="button"
15876
+ size="sm"
15877
+ variant="ghost"
15878
+ className="h-8 px-2 text-xs"
15879
+ disabled={sorts.length === 0}
15880
+ onClick={() => onSortsChange([])}
15881
+ >
15882
+ {dbText("clearSorts")}
15883
+ </Button>
15884
+ </div>
15885
+ </div>
15886
+ ) : null}
15887
+ </DropdownMenuContent>
15888
+ </DropdownMenu>
15889
+ );
15890
+ }
15891
+
15892
+ function SortFieldIcon({
15893
+ sort,
15894
+ properties,
15895
+ }: {
15896
+ sort: DatabaseSort;
15897
+ properties: DocumentProperty[];
15898
+ }) {
15899
+ if (sort.key === "name") {
15900
+ return <IconFileText className="mr-2 size-4 text-muted-foreground" />;
15901
+ }
15902
+ const property = properties.find(
15903
+ (candidate) => candidate.definition.id === sort.key,
15904
+ );
15905
+ const Icon = property ? TYPE_ICONS[property.definition.type] : IconFileText;
15906
+ return <Icon className="mr-2 size-4 text-muted-foreground" />;
15907
+ }
14232
15908
 
14233
- {filterOperatorNeedsValue(currentFilter.operator) ? (
14234
- <DatabaseFilterValueControl
14235
- autoFocus={index === displayedFilters.length - 1}
14236
- documentId={documentId}
14237
- filter={currentFilter}
14238
- properties={properties}
14239
- onValueChange={(value) => updateFilter(index, { value })}
14240
- />
14241
- ) : null}
14242
- </div>
14243
- ))}
14244
- </div>
15909
+ function FilterMenu({
15910
+ filters,
15911
+ properties,
15912
+ inlineOpen,
15913
+ open,
15914
+ onOpenChange,
15915
+ onAddFilter,
15916
+ onAddAdvancedFilter,
15917
+ }: {
15918
+ filters: DatabaseFilter[];
15919
+ properties: DocumentProperty[];
15920
+ inlineOpen: boolean;
15921
+ open: boolean;
15922
+ onOpenChange: (open: boolean) => void;
15923
+ onAddFilter: (key: "name" | string, label: string) => void;
15924
+ onAddAdvancedFilter: (key: "name" | string, label: string) => void;
15925
+ }) {
15926
+ const activeFilters = filters.filter(isActiveFilter);
15927
+ const active = activeFilters.length > 0 || inlineOpen;
15928
+ const excludedFilterKeys = useMemo(
15929
+ () => new Set(activeFilters.map((filter) => filter.key)),
15930
+ [activeFilters],
15931
+ );
15932
+ const advancedFilterCandidate =
15933
+ databasePropertyPickerItems(properties, "", {
15934
+ includeName: false,
15935
+ excludeKeys: excludedFilterKeys,
15936
+ })[0] ??
15937
+ databasePropertyPickerItems(properties, "", {
15938
+ includeName: true,
15939
+ excludeKeys: excludedFilterKeys,
15940
+ })[0];
14245
15941
 
14246
- <div className="flex justify-between gap-2 border-t pt-2">
14247
- <Button
14248
- type="button"
14249
- size="sm"
14250
- variant="ghost"
14251
- className="h-8 px-2 text-xs"
14252
- onClick={addFilter}
14253
- >
14254
- <IconPlus className="mr-1 size-3.5" />
14255
- {dbText("addFilter")}
14256
- </Button>
14257
- <Button
14258
- type="button"
14259
- size="sm"
14260
- variant="ghost"
14261
- className="h-8 px-2 text-xs"
14262
- disabled={filters.length === 0}
14263
- onClick={() => onFiltersChange([])}
14264
- >
14265
- {dbText("clearFilters")}
14266
- </Button>
14267
- </div>
14268
- <span className="text-xs text-muted-foreground">
14269
- {active ? `${activeFilters.length} active` : "Set a value to apply"}
14270
- </span>
15942
+ return (
15943
+ <DropdownMenu open={open} onOpenChange={onOpenChange}>
15944
+ <DropdownMenuTrigger asChild>
15945
+ <Button
15946
+ type="button"
15947
+ variant="ghost"
15948
+ size="sm"
15949
+ aria-label={
15950
+ activeFilters.length > 0
15951
+ ? `${activeFilters.length} active filters`
15952
+ : "Filter"
15953
+ }
15954
+ title="Filter"
15955
+ className={databaseToolbarIconButtonClass(active || open)}
15956
+ >
15957
+ <IconFilter className="size-3.5" />
15958
+ </Button>
15959
+ </DropdownMenuTrigger>
15960
+ <DropdownMenuContent align="end" className="w-64">
15961
+ <div onKeyDown={(event) => event.stopPropagation()}>
15962
+ <DatabasePropertyPickerMenuItems
15963
+ properties={properties}
15964
+ includeName
15965
+ excludeKeys={excludedFilterKeys}
15966
+ placeholder={dbText("filterBy")}
15967
+ closeOnSelect
15968
+ onSelect={onAddFilter}
15969
+ />
15970
+ <DropdownMenuSeparator />
15971
+ <DropdownMenuItem
15972
+ disabled={!advancedFilterCandidate}
15973
+ onSelect={(event) => {
15974
+ event.preventDefault();
15975
+ if (!advancedFilterCandidate) return;
15976
+ onAddAdvancedFilter(
15977
+ advancedFilterCandidate.key,
15978
+ advancedFilterCandidate.label,
15979
+ );
15980
+ }}
15981
+ >
15982
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
15983
+ {dbText("addAdvancedFilter")}
15984
+ </DropdownMenuItem>
14271
15985
  </div>
14272
15986
  </DropdownMenuContent>
14273
15987
  </DropdownMenu>
@@ -14277,18 +15991,29 @@ function FilterMenu({
14277
15991
  function DatabaseFilterValueControl({
14278
15992
  documentId,
14279
15993
  filter,
15994
+ items,
14280
15995
  properties,
14281
15996
  autoFocus,
15997
+ hideOptionsUntilQuery = false,
14282
15998
  onValueChange,
14283
15999
  }: {
14284
16000
  documentId: string;
14285
16001
  filter: DatabaseFilter;
16002
+ items: ContentDatabaseItem[];
14286
16003
  properties: DocumentProperty[];
14287
16004
  autoFocus?: boolean;
16005
+ hideOptionsUntilQuery?: boolean;
14288
16006
  onValueChange: (value: string) => void;
14289
16007
  }) {
14290
16008
  const configureProperty = useConfigureDocumentProperty(documentId);
14291
- const options = databaseFilterOptionChoices(filter.key, properties);
16009
+ const { session } = useSession();
16010
+ const currentUserEmail = session?.email?.trim() ?? "";
16011
+ const options = databaseFilterOptionChoices(
16012
+ filter.key,
16013
+ properties,
16014
+ items,
16015
+ currentUserEmail,
16016
+ );
14292
16017
  const type = filterPropertyTypeForKey(filter.key, properties);
14293
16018
  const [optionQuery, setOptionQuery] = useState("");
14294
16019
  const filteredOptions = filterPropertyOptions(options, optionQuery);
@@ -14297,7 +16022,57 @@ function DatabaseFilterValueControl({
14297
16022
  properties,
14298
16023
  );
14299
16024
  const canCreateOption =
14300
- !!optionProperty && canCreatePropertyOption(options, optionQuery);
16025
+ !!optionProperty &&
16026
+ optionProperty.definition.type !== "person" &&
16027
+ canCreatePropertyOption(options, optionQuery);
16028
+ const usesOptionFilterValues =
16029
+ optionProperty?.definition.type === "select" ||
16030
+ optionProperty?.definition.type === "status" ||
16031
+ optionProperty?.definition.type === "multi_select" ||
16032
+ optionProperty?.definition.type === "person";
16033
+ const allowsMultipleValues =
16034
+ usesOptionFilterValues &&
16035
+ (filter.operator === "contains" || filter.operator === "does_not_equal");
16036
+ const selectedValues = databaseFilterSelectedValues(filter.value);
16037
+ const selectedOptions = selectedValues
16038
+ .map((value) =>
16039
+ options.find(
16040
+ (option) =>
16041
+ databaseFilterChoiceValue(option) === value ||
16042
+ option.id === value ||
16043
+ option.name.trim().toLowerCase() === value.trim().toLowerCase(),
16044
+ ),
16045
+ )
16046
+ .filter((option): option is DatabaseFilterChoice => !!option);
16047
+ const showClearValue = selectedValues.length > 0;
16048
+ const showOptionChoices =
16049
+ !hideOptionsUntilQuery ||
16050
+ optionQuery.trim().length > 0 ||
16051
+ selectedValues.length > 0;
16052
+ const showCompactOptionValueInput =
16053
+ hideOptionsUntilQuery && selectedOptions.length === 0;
16054
+
16055
+ function setSelectedValues(values: string[]) {
16056
+ onValueChange(databaseFilterEncodeSelectedValues(values));
16057
+ }
16058
+
16059
+ function chooseFilterOption(option: DatabaseFilterChoice) {
16060
+ const optionValue = databaseFilterChoiceValue(option);
16061
+ if (!allowsMultipleValues) {
16062
+ onValueChange(optionValue);
16063
+ return;
16064
+ }
16065
+ const selected = databaseFilterOptionIsSelected(option, selectedValues);
16066
+ setSelectedValues(
16067
+ selected
16068
+ ? selectedValues.filter(
16069
+ (value) =>
16070
+ value !== optionValue &&
16071
+ value.trim().toLowerCase() !== option.name.trim().toLowerCase(),
16072
+ )
16073
+ : [...selectedValues, optionValue],
16074
+ );
16075
+ }
14301
16076
 
14302
16077
  async function createFilterOption() {
14303
16078
  if (!optionProperty || !canCreateOption) return;
@@ -14311,37 +16086,82 @@ function DatabaseFilterValueControl({
14311
16086
  options: { options: [...options, option] },
14312
16087
  });
14313
16088
  setOptionQuery("");
14314
- onValueChange(option.id);
16089
+ if (allowsMultipleValues) {
16090
+ setSelectedValues([...selectedValues, option.id]);
16091
+ } else {
16092
+ onValueChange(option.id);
16093
+ }
16094
+ }
16095
+
16096
+ if (
16097
+ filter.operator === "between" &&
16098
+ (type === "date" || type === "created_time" || type === "last_edited_time")
16099
+ ) {
16100
+ return (
16101
+ <DatabaseBetweenDateFilterValueControl
16102
+ filter={filter}
16103
+ onValueChange={onValueChange}
16104
+ />
16105
+ );
14315
16106
  }
14316
16107
 
14317
16108
  if (optionProperty) {
14318
16109
  return (
14319
- <DropdownMenuSub>
14320
- <DropdownMenuSubTrigger className="h-8 rounded border border-input bg-background px-2 text-sm">
14321
- <span className="min-w-0 flex-1 truncate text-left">
14322
- {databaseFilterValueLabel(filter, properties)}
14323
- </span>
14324
- </DropdownMenuSubTrigger>
14325
- <DropdownMenuSubContent className="max-h-72 w-56 overflow-auto">
14326
- <div
14327
- className="sticky top-0 z-10 border-b border-border bg-popover p-2"
14328
- onKeyDown={(event) => event.stopPropagation()}
14329
- >
14330
- <div className="flex h-8 items-center gap-1 rounded border border-border bg-background px-2">
16110
+ <div className="overflow-hidden rounded-sm">
16111
+ <div className="px-1 pb-1">
16112
+ <div className="flex min-h-9 flex-wrap items-center gap-1 rounded border border-input bg-background px-2 py-1 focus-within:border-[#2383e2] focus-within:ring-2 focus-within:ring-[#2383e2]/25">
16113
+ {showCompactOptionValueInput ? null : (
14331
16114
  <IconSearch className="size-3.5 shrink-0 text-muted-foreground" />
14332
- <Input
14333
- autoFocus={autoFocus}
14334
- value={optionQuery}
14335
- placeholder={dbText("searchOptions")}
14336
- aria-label={`Search ${filter.label} filter options`}
14337
- onChange={(event) => setOptionQuery(event.target.value)}
14338
- className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
14339
- />
14340
- </div>
16115
+ )}
16116
+ {selectedOptions.map((option) => (
16117
+ <button
16118
+ key={option.id}
16119
+ type="button"
16120
+ className={cn(
16121
+ "inline-flex max-w-[9rem] items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
16122
+ optionProperty?.definition.type === "person"
16123
+ ? "bg-muted text-foreground"
16124
+ : OPTION_COLOR_CLASSES[option.color ?? "gray"],
16125
+ )}
16126
+ onClick={(event) => {
16127
+ event.preventDefault();
16128
+ event.stopPropagation();
16129
+ setSelectedValues(
16130
+ selectedValues.filter(
16131
+ (value) => value !== databaseFilterChoiceValue(option),
16132
+ ),
16133
+ );
16134
+ }}
16135
+ >
16136
+ {optionProperty?.definition.type === "person" ? (
16137
+ <PersonPill value={databaseFilterChoiceValue(option)} />
16138
+ ) : (
16139
+ <span className="truncate">{option.name}</span>
16140
+ )}
16141
+ <IconX className="size-3 opacity-70" />
16142
+ </button>
16143
+ ))}
16144
+ <Input
16145
+ autoFocus={autoFocus}
16146
+ value={optionQuery}
16147
+ placeholder={
16148
+ showCompactOptionValueInput
16149
+ ? filterValuePlaceholder(filter.key, properties)
16150
+ : selectedOptions.length > 0
16151
+ ? ""
16152
+ : filterOptionSearchPlaceholder(filter.key, properties)
16153
+ }
16154
+ aria-label={`Search ${filter.label} filter options`}
16155
+ onChange={(event) => setOptionQuery(event.target.value)}
16156
+ className="h-7 min-w-[7rem] flex-1 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
16157
+ />
14341
16158
  </div>
14342
- {filter.value ? (
14343
- <>
16159
+ </div>
16160
+ {showOptionChoices ? (
16161
+ <div className="max-h-60 overflow-auto py-0.5">
16162
+ {showClearValue ? (
14344
16163
  <DropdownMenuItem
16164
+ className="text-xs"
14345
16165
  onSelect={(event) => {
14346
16166
  event.preventDefault();
14347
16167
  onValueChange("");
@@ -14350,45 +16170,56 @@ function DatabaseFilterValueControl({
14350
16170
  <IconX className="mr-2 size-4 text-muted-foreground" />
14351
16171
  {dbText("clearValue")}
14352
16172
  </DropdownMenuItem>
14353
- <DropdownMenuSeparator />
14354
- </>
14355
- ) : null}
14356
- {filteredOptions.length === 0 && !canCreateOption ? (
14357
- <div className="px-3 py-4 text-sm text-muted-foreground">
14358
- {dbText("noMatchingOptions")}
14359
- </div>
14360
- ) : null}
14361
- {filteredOptions.map((option) => (
14362
- <DropdownMenuItem
14363
- key={option.id}
14364
- onSelect={(event) => {
14365
- event.preventDefault();
14366
- onValueChange(option.id);
14367
- }}
14368
- >
14369
- <span className="min-w-0 flex-1 truncate">{option.name}</span>
14370
- {filter.value === option.id || filter.value === option.name ? (
14371
- <IconCheck className="size-4 text-muted-foreground" />
14372
- ) : null}
14373
- </DropdownMenuItem>
14374
- ))}
14375
- {canCreateOption ? (
14376
- <>
14377
- <DropdownMenuSeparator />
16173
+ ) : null}
16174
+ {filteredOptions.length === 0 && !canCreateOption ? (
16175
+ <div className="px-2 py-3 text-xs text-muted-foreground">
16176
+ {dbText("noMatchingOptions")}
16177
+ </div>
16178
+ ) : null}
16179
+ {filteredOptions.map((option) => (
14378
16180
  <DropdownMenuItem
14379
- disabled={configureProperty.isPending}
16181
+ key={option.id}
16182
+ className="text-xs"
14380
16183
  onSelect={(event) => {
14381
16184
  event.preventDefault();
14382
- void createFilterOption();
16185
+ chooseFilterOption(option);
14383
16186
  }}
14384
16187
  >
14385
- <IconPlus className="mr-2 size-4 text-muted-foreground" />
14386
- Create &ldquo;{optionQuery.trim()}&rdquo;
16188
+ <span className="min-w-0 flex-1 truncate">
16189
+ {optionProperty?.definition.type === "person" ? (
16190
+ option.name === "Me" ? (
16191
+ "Me"
16192
+ ) : (
16193
+ <PersonPill value={databaseFilterChoiceValue(option)} />
16194
+ )
16195
+ ) : (
16196
+ option.name
16197
+ )}
16198
+ </span>
16199
+ {databaseFilterOptionIsSelected(option, selectedValues) ? (
16200
+ <IconCheck className="size-4 text-muted-foreground" />
16201
+ ) : null}
14387
16202
  </DropdownMenuItem>
14388
- </>
14389
- ) : null}
14390
- </DropdownMenuSubContent>
14391
- </DropdownMenuSub>
16203
+ ))}
16204
+ {canCreateOption ? (
16205
+ <>
16206
+ <DropdownMenuSeparator />
16207
+ <DropdownMenuItem
16208
+ disabled={configureProperty.isPending}
16209
+ className="text-xs"
16210
+ onSelect={(event) => {
16211
+ event.preventDefault();
16212
+ void createFilterOption();
16213
+ }}
16214
+ >
16215
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
16216
+ Create &ldquo;{optionQuery.trim()}&rdquo;
16217
+ </DropdownMenuItem>
16218
+ </>
16219
+ ) : null}
16220
+ </div>
16221
+ ) : null}
16222
+ </div>
14392
16223
  );
14393
16224
  }
14394
16225
 
@@ -14400,7 +16231,7 @@ function DatabaseFilterValueControl({
14400
16231
  value={filter.value}
14401
16232
  placeholder={filterValuePlaceholder(filter.key, properties)}
14402
16233
  onChange={(event) => onValueChange(event.target.value)}
14403
- className="h-8"
16234
+ className="h-9 text-sm focus-visible:border-[#2383e2] focus-visible:ring-[#2383e2]/25"
14404
16235
  />
14405
16236
  );
14406
16237
  }
@@ -14430,12 +16261,39 @@ const FILTER_OPERATOR_LABELS: Record<FilterOperator, string> = {
14430
16261
  less_than: "Less than",
14431
16262
  before: "Before",
14432
16263
  after: "After",
16264
+ between: "Between",
14433
16265
  is_checked: "Checked",
14434
16266
  is_unchecked: "Unchecked",
14435
16267
  is_empty: "Is empty",
14436
16268
  is_not_empty: "Is not empty",
14437
16269
  };
14438
16270
 
16271
+ function databaseFilterOperatorLabel(
16272
+ operator: FilterOperator,
16273
+ key: string,
16274
+ properties: DocumentProperty[],
16275
+ ) {
16276
+ const type = filterPropertyTypeForKey(key, properties);
16277
+ if (
16278
+ operator === "does_not_equal" &&
16279
+ (type === "select" ||
16280
+ type === "status" ||
16281
+ type === "multi_select" ||
16282
+ type === "person")
16283
+ ) {
16284
+ return "Does not contain";
16285
+ }
16286
+ return FILTER_OPERATOR_LABELS[operator] ?? "Contains";
16287
+ }
16288
+
16289
+ function databaseFilterOperatorInlineLabel(
16290
+ operator: FilterOperator,
16291
+ key: string,
16292
+ properties: DocumentProperty[],
16293
+ ) {
16294
+ return databaseFilterOperatorLabel(operator, key, properties).toLowerCase();
16295
+ }
16296
+
14439
16297
  function filterOperatorsForKey(
14440
16298
  key: string,
14441
16299
  properties: DocumentProperty[],
@@ -14446,8 +16304,13 @@ function filterOperatorsForKey(
14446
16304
  return ["is_checked", "is_unchecked"];
14447
16305
  }
14448
16306
 
14449
- if (type === "select" || type === "status" || type === "multi_select") {
14450
- return ["equals", "does_not_equal", "is_empty", "is_not_empty"];
16307
+ if (
16308
+ type === "select" ||
16309
+ type === "status" ||
16310
+ type === "multi_select" ||
16311
+ type === "person"
16312
+ ) {
16313
+ return ["contains", "does_not_equal", "is_empty", "is_not_empty"];
14451
16314
  }
14452
16315
 
14453
16316
  if (type === "number") {
@@ -14471,6 +16334,7 @@ function filterOperatorsForKey(
14471
16334
  "does_not_equal",
14472
16335
  "before",
14473
16336
  "after",
16337
+ "between",
14474
16338
  "is_empty",
14475
16339
  "is_not_empty",
14476
16340
  ];
@@ -14514,6 +16378,18 @@ function filterValuePlaceholder(key: string, properties: DocumentProperty[]) {
14514
16378
  return "Value";
14515
16379
  }
14516
16380
 
16381
+ function filterOptionSearchPlaceholder(
16382
+ key: string,
16383
+ properties: DocumentProperty[],
16384
+ ) {
16385
+ const type = filterPropertyTypeForKey(key, properties);
16386
+ if (type === "person") return "Search for one or more people...";
16387
+ if (type === "select" || type === "status" || type === "multi_select") {
16388
+ return "Search for one or more options...";
16389
+ }
16390
+ return "Search for an option...";
16391
+ }
16392
+
14517
16393
  function filterValueInputType(type: DocumentPropertyType) {
14518
16394
  if (type === "number") return "number";
14519
16395
  if (type === "date" || type === "created_time" || type === "last_edited_time")
@@ -14521,12 +16397,106 @@ function filterValueInputType(type: DocumentPropertyType) {
14521
16397
  return "text";
14522
16398
  }
14523
16399
 
16400
+ function DatabaseBetweenDateFilterValueControl({
16401
+ filter,
16402
+ onValueChange,
16403
+ }: {
16404
+ filter: DatabaseFilter;
16405
+ onValueChange: (value: string) => void;
16406
+ }) {
16407
+ const [start, end] = databaseFilterDateRangeValues(filter.value);
16408
+ const updateRange = (nextStart: string, nextEnd: string) => {
16409
+ onValueChange(databaseFilterEncodeDateRangeValues(nextStart, nextEnd));
16410
+ };
16411
+
16412
+ return (
16413
+ <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
16414
+ <Input
16415
+ type="date"
16416
+ value={start}
16417
+ aria-label={`Start ${filter.label} filter date`}
16418
+ onChange={(event) => updateRange(event.target.value, end)}
16419
+ className="h-9 text-sm focus-visible:border-[#2383e2] focus-visible:ring-[#2383e2]/25"
16420
+ />
16421
+ <span className="text-xs text-muted-foreground">to</span>
16422
+ <Input
16423
+ type="date"
16424
+ value={end}
16425
+ aria-label={`End ${filter.label} filter date`}
16426
+ onChange={(event) => updateRange(start, event.target.value)}
16427
+ className="h-9 text-sm focus-visible:border-[#2383e2] focus-visible:ring-[#2383e2]/25"
16428
+ />
16429
+ </div>
16430
+ );
16431
+ }
16432
+
14524
16433
  export function databaseFilterOptionChoices(
14525
16434
  key: string,
14526
16435
  properties: DocumentProperty[],
16436
+ items: ContentDatabaseItem[] = [],
16437
+ currentUserEmail = "",
16438
+ ): DatabaseFilterChoice[] {
16439
+ const property = databaseFilterPropertyForKey(key, properties);
16440
+ if (property?.definition.type === "person") {
16441
+ return databaseFilterPersonChoices(
16442
+ property.definition.id,
16443
+ items,
16444
+ currentUserEmail,
16445
+ );
16446
+ }
16447
+ const optionProperty = databaseFilterOptionPropertyForKey(key, properties);
16448
+ return optionProperty?.definition.options.options ?? [];
16449
+ }
16450
+
16451
+ type DatabaseFilterChoice = DocumentPropertyOption & {
16452
+ filterValue?: string;
16453
+ };
16454
+
16455
+ function databaseFilterChoiceValue(option: DatabaseFilterChoice) {
16456
+ return option.filterValue ?? option.id;
16457
+ }
16458
+
16459
+ function databaseFilterPersonChoices(
16460
+ propertyId: string,
16461
+ items: ContentDatabaseItem[],
16462
+ currentUserEmail = "",
14527
16463
  ) {
14528
- const property = databaseFilterOptionPropertyForKey(key, properties);
14529
- return property?.definition.options.options ?? [];
16464
+ const people = new Map<string, string>();
16465
+ for (const item of items) {
16466
+ const property = item.properties.find(
16467
+ (candidate) => candidate.definition.id === propertyId,
16468
+ );
16469
+ for (const person of personItems(property?.value ?? null)) {
16470
+ const key = person.trim().toLowerCase();
16471
+ if (!people.has(key)) people.set(key, person);
16472
+ }
16473
+ }
16474
+ const choices: DatabaseFilterChoice[] = [];
16475
+ const currentUserLabel = currentUserEmail
16476
+ ? personLabel(currentUserEmail)
16477
+ : "";
16478
+ if (currentUserEmail) {
16479
+ const matchingPerson =
16480
+ Array.from(people.values()).find(
16481
+ (person) =>
16482
+ person.toLowerCase() === currentUserEmail.toLowerCase() ||
16483
+ personLabel(person).toLowerCase() === currentUserLabel.toLowerCase(),
16484
+ ) ?? currentUserEmail;
16485
+ choices.push({
16486
+ id: `__current_user__:${matchingPerson}`,
16487
+ name: "Me",
16488
+ color: "gray" as const,
16489
+ filterValue: matchingPerson,
16490
+ });
16491
+ }
16492
+ choices.push(
16493
+ ...Array.from(people.values()).map((person) => ({
16494
+ id: person,
16495
+ name: personLabel(person),
16496
+ color: "gray" as const,
16497
+ })),
16498
+ );
16499
+ return choices;
14530
16500
  }
14531
16501
 
14532
16502
  export function databaseFilterOptionPropertyForKey(
@@ -14538,7 +16508,8 @@ export function databaseFilterOptionPropertyForKey(
14538
16508
  if (
14539
16509
  property.definition.type !== "select" &&
14540
16510
  property.definition.type !== "status" &&
14541
- property.definition.type !== "multi_select"
16511
+ property.definition.type !== "multi_select" &&
16512
+ property.definition.type !== "person"
14542
16513
  ) {
14543
16514
  return null;
14544
16515
  }
@@ -14549,18 +16520,105 @@ function databaseFilterValueLabel(
14549
16520
  filter: DatabaseFilter,
14550
16521
  properties: DocumentProperty[],
14551
16522
  ) {
14552
- const option = databaseFilterOptionChoices(filter.key, properties).find(
14553
- (candidate) =>
14554
- candidate.id === filter.value || candidate.name === filter.value,
16523
+ if (filter.operator === "between") {
16524
+ const [start, end] = databaseFilterDateRangeValues(filter.value);
16525
+ if (start && end) return `${start} to ${end}`;
16526
+ return start || end || "Choose dates";
16527
+ }
16528
+ const values = databaseFilterSelectedValues(filter.value);
16529
+ const options = databaseFilterOptionChoices(filter.key, properties);
16530
+ if (options.length > 0) {
16531
+ const labels = values.map((value) => {
16532
+ const option = options.find(
16533
+ (candidate) =>
16534
+ candidate.id === value ||
16535
+ candidate.name.trim().toLowerCase() === value.trim().toLowerCase(),
16536
+ );
16537
+ return option?.name ?? value;
16538
+ });
16539
+ return labels.length > 0 ? labels.join(", ") : "Choose option";
16540
+ }
16541
+ return values[0] || "Choose option";
16542
+ }
16543
+
16544
+ function databaseFilterSelectedValues(value: string) {
16545
+ const trimmed = value.trim();
16546
+ if (!trimmed) return [];
16547
+ try {
16548
+ const parsed = JSON.parse(trimmed) as unknown;
16549
+ if (!Array.isArray(parsed)) return [trimmed];
16550
+ return [
16551
+ ...new Set(
16552
+ parsed
16553
+ .filter((item): item is string => typeof item === "string")
16554
+ .map((item) => item.trim())
16555
+ .filter(Boolean),
16556
+ ),
16557
+ ];
16558
+ } catch {
16559
+ return [trimmed];
16560
+ }
16561
+ }
16562
+
16563
+ function databaseFilterDateRangeValues(value: string): [string, string] {
16564
+ const trimmed = value.trim();
16565
+ if (!trimmed) return ["", ""];
16566
+ try {
16567
+ const parsed = JSON.parse(trimmed) as unknown;
16568
+ if (!Array.isArray(parsed)) return [trimmed, ""];
16569
+ const start = typeof parsed[0] === "string" ? parsed[0].trim() : "";
16570
+ const end = typeof parsed[1] === "string" ? parsed[1].trim() : "";
16571
+ return [start, end];
16572
+ } catch {
16573
+ return [trimmed, ""];
16574
+ }
16575
+ }
16576
+
16577
+ function databaseFilterEncodeDateRangeValues(start: string, end: string) {
16578
+ const normalizedStart = start.trim();
16579
+ const normalizedEnd = end.trim();
16580
+ if (!normalizedStart && !normalizedEnd) return "";
16581
+ return JSON.stringify([normalizedStart, normalizedEnd]);
16582
+ }
16583
+
16584
+ function databaseFilterDateRangeValue(value: string): [number, number] | null {
16585
+ const [startValue, endValue] = databaseFilterDateRangeValues(value);
16586
+ const start = new Date(startValue).getTime();
16587
+ const end = new Date(endValue).getTime();
16588
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
16589
+ return start <= end ? [start, end] : [end, start];
16590
+ }
16591
+
16592
+ function databaseFilterEncodeSelectedValues(values: string[]) {
16593
+ const normalized = [
16594
+ ...new Set(values.map((value) => value.trim()).filter(Boolean)),
16595
+ ];
16596
+ if (normalized.length === 0) return "";
16597
+ if (normalized.length === 1) return normalized[0];
16598
+ return JSON.stringify(normalized);
16599
+ }
16600
+
16601
+ function databaseFilterOptionIsSelected(
16602
+ option: DatabaseFilterChoice,
16603
+ values: string[],
16604
+ ) {
16605
+ const optionValue = databaseFilterChoiceValue(option);
16606
+ const optionName = option.name.trim().toLowerCase();
16607
+ return values.some(
16608
+ (value) =>
16609
+ value === optionValue || value.trim().toLowerCase() === optionName,
14555
16610
  );
14556
- return (option?.name ?? filter.value) || "Choose option";
14557
16611
  }
14558
16612
 
14559
16613
  function databaseFilterChipLabel(
14560
16614
  filter: DatabaseFilter,
14561
16615
  properties: DocumentProperty[],
14562
16616
  ) {
14563
- const operator = FILTER_OPERATOR_LABELS[filter.operator] ?? "Contains";
16617
+ const operator = databaseFilterOperatorLabel(
16618
+ filter.operator,
16619
+ filter.key,
16620
+ properties,
16621
+ );
14564
16622
  if (!filterOperatorNeedsValue(filter.operator)) {
14565
16623
  return `${filter.label} ${operator.toLowerCase()}`;
14566
16624
  }
@@ -14570,6 +16628,23 @@ function databaseFilterChipLabel(
14570
16628
  )}`;
14571
16629
  }
14572
16630
 
16631
+ export function databaseInlineFilterLabel(
16632
+ filter: DatabaseFilter,
16633
+ properties: DocumentProperty[],
16634
+ ) {
16635
+ if (!filterOperatorNeedsValue(filter.operator)) {
16636
+ return `${filter.label}: ${databaseFilterOperatorLabel(
16637
+ filter.operator,
16638
+ filter.key,
16639
+ properties,
16640
+ )}`;
16641
+ }
16642
+ if (databaseFilterSelectedValues(filter.value).length === 0) {
16643
+ return filter.label;
16644
+ }
16645
+ return `${filter.label}: ${databaseFilterValueLabel(filter, properties)}`;
16646
+ }
16647
+
14573
16648
  function databaseFilterPropertyForKey(
14574
16649
  key: string,
14575
16650
  properties: DocumentProperty[],
@@ -14827,6 +16902,9 @@ function DatabaseTableRow({
14827
16902
  item.properties.find(
14828
16903
  (candidate) => candidate.definition.id === property.definition.id,
14829
16904
  ) ?? property;
16905
+ const bodyCellHydrationPending =
16906
+ itemProperty.definition.type === "blocks" &&
16907
+ databaseItemBodyHydrationIsPending(item);
14830
16908
 
14831
16909
  const value = (
14832
16910
  <div
@@ -14835,10 +16913,12 @@ function DatabaseTableRow({
14835
16913
  wrapCells
14836
16914
  ? "whitespace-normal break-words"
14837
16915
  : "truncate whitespace-nowrap",
14838
- isEmptyPropertyValue(itemProperty.value) && "text-transparent",
16916
+ isEmptyPropertyValue(itemProperty.value) &&
16917
+ !bodyCellHydrationPending &&
16918
+ "text-transparent",
14839
16919
  )}
14840
16920
  >
14841
- {databaseTableCellDisplayValue(itemProperty)}
16921
+ {databaseTableCellDisplayValue(itemProperty, item)}
14842
16922
  </div>
14843
16923
  );
14844
16924
  const isEditableCheckbox =