@agent-native/core 0.91.0 → 0.91.2

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 (219) 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 +1 -1
  6. package/corpus/core/CHANGELOG.md +26 -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/package.json +6 -5
  12. package/corpus/core/src/agent/run-manager.ts +23 -2
  13. package/corpus/core/src/agent/run-store.ts +2 -2
  14. package/corpus/core/src/cli/add.ts +1 -1
  15. package/corpus/core/src/cli/create.ts +8 -3
  16. package/corpus/core/src/cli/design-connect.ts +53 -9
  17. package/corpus/core/src/cli/index.ts +5 -4
  18. package/corpus/core/src/client/extensions/ExtensionSlot.tsx +7 -0
  19. package/corpus/core/src/client/session-replay.ts +97 -3
  20. package/corpus/core/src/jobs/scheduler.ts +30 -1
  21. package/corpus/core/src/localization/default-messages.ts +21 -0
  22. package/corpus/core/src/templates/default/app/i18n/ar-SA.ts +21 -0
  23. package/corpus/core/src/templates/default/app/i18n/de-DE.ts +21 -0
  24. package/corpus/core/src/templates/default/app/i18n/en-US.ts +21 -0
  25. package/corpus/core/src/templates/default/app/i18n/es-ES.ts +21 -0
  26. package/corpus/core/src/templates/default/app/i18n/fr-FR.ts +21 -0
  27. package/corpus/core/src/templates/default/app/i18n/hi-IN.ts +20 -0
  28. package/corpus/core/src/templates/default/app/i18n/ja-JP.ts +21 -0
  29. package/corpus/core/src/templates/default/app/i18n/ko-KR.ts +20 -0
  30. package/corpus/core/src/templates/default/app/i18n/pt-BR.ts +21 -0
  31. package/corpus/core/src/templates/default/app/i18n/zh-CN.ts +20 -0
  32. package/corpus/core/src/templates/default/app/i18n/zh-TW.ts +19 -0
  33. package/corpus/core/src/templates/default/package.json +2 -2
  34. package/corpus/core/src/templates/headless/package.json +3 -3
  35. package/corpus/core/src/templates/workspace-core/package.json +6 -6
  36. package/corpus/core/src/templates/workspace-root/package.json +2 -2
  37. package/corpus/core/src/triggers/dispatcher.ts +30 -2
  38. package/corpus/core/src/vite/client.ts +96 -1
  39. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +21 -2
  40. package/corpus/templates/analytics/app/global.css +26 -10
  41. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  42. package/corpus/templates/analytics/app/i18n-data.ts +25 -0
  43. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +548 -237
  44. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +285 -294
  45. package/corpus/templates/analytics/changelog/2026-07-08-console-details-expand-inline-under-each-row-and-the-event-t.md +6 -0
  46. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-clamps-ultra-wide-viewports-while-keeping-the.md +6 -0
  47. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-keeps-a-usable-stage-when-dev-tools-is-open-a.md +6 -0
  48. package/corpus/templates/analytics/changelog/2026-07-08-session-replay-resumes-after-scrubbing-and-expands-console-r.md +6 -0
  49. package/corpus/templates/analytics/changelog/2026-07-08-session-replays-wait-for-full-load-keep-a-normal-viewport-an.md +6 -0
  50. package/corpus/templates/analytics/package.json +2 -2
  51. package/corpus/templates/assets/package.json +2 -2
  52. package/corpus/templates/brain/package.json +2 -2
  53. package/corpus/templates/calendar/AGENTS.md +10 -0
  54. package/corpus/templates/calendar/actions/create-event.ts +5 -1
  55. package/corpus/templates/calendar/actions/event-action-helpers.ts +41 -0
  56. package/corpus/templates/calendar/actions/get-attendee-timezones.ts +28 -0
  57. package/corpus/templates/calendar/actions/set-attendee-timezone.ts +68 -0
  58. package/corpus/templates/calendar/app/components/calendar/EventAttendeesSection.tsx +150 -11
  59. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +36 -1
  60. package/corpus/templates/calendar/app/components/calendar/EventDetailPopover.tsx +32 -0
  61. package/corpus/templates/calendar/app/hooks/use-attendee-timezones.ts +24 -0
  62. package/corpus/templates/calendar/app/i18n/zh-TW.ts +2 -0
  63. package/corpus/templates/calendar/app/i18n-data.ts +20 -0
  64. package/corpus/templates/calendar/app/lib/attendee-local-time.ts +122 -0
  65. package/corpus/templates/calendar/changelog/2026-07-08-event-details-now-support-extension-widgets-and-you-can-see-.md +6 -0
  66. package/corpus/templates/calendar/changelog/2026-07-08-when-you-invite-guests-to-a-new-event-you-now-appear-in.md +6 -0
  67. package/corpus/templates/calendar/package.json +2 -2
  68. package/corpus/templates/calendar/server/handlers/events.ts +5 -1
  69. package/corpus/templates/calendar/shared/api.ts +6 -0
  70. package/corpus/templates/chat/package.json +2 -2
  71. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +6 -2
  72. package/corpus/templates/clips/actions/create-meeting.ts +8 -3
  73. package/corpus/templates/clips/actions/list-meetings.ts +31 -3
  74. package/corpus/templates/clips/changelog/2026-07-08-after-finishing-a-desktop-recording-reopening-clips-restor.md +6 -0
  75. package/corpus/templates/clips/changelog/2026-07-08-clips-now-pops-up-to-take-notes-when-you-join-an-adhoc-zoom-.md +6 -0
  76. package/corpus/templates/clips/changelog/2026-07-08-desktop-clip-recordings-are-louder-after-capture-mic-only.md +6 -0
  77. package/corpus/templates/clips/changelog/2026-07-08-meeting-reminders-now-appear-1-minute-before-start-stay-unti.md +6 -0
  78. package/corpus/templates/clips/changelog/2026-07-08-the-desktop-meetings-panel-has-room-for-its-drop-shadow-and-.md +6 -0
  79. package/corpus/templates/clips/chrome-extension/package.json +2 -2
  80. package/corpus/templates/clips/desktop/package.json +2 -2
  81. package/corpus/templates/clips/desktop/src/app.tsx +91 -34
  82. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +15 -5
  83. package/corpus/templates/clips/desktop/src/lib/meeting-notification-timing.ts +83 -0
  84. package/corpus/templates/clips/desktop/src/lib/native-recording-warm.ts +21 -0
  85. package/corpus/templates/clips/desktop/src/lib/recorder.ts +98 -20
  86. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +174 -50
  87. package/corpus/templates/clips/desktop/src/styles.css +128 -53
  88. package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +364 -0
  89. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +1 -1
  90. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +5 -0
  91. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +91 -11
  92. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +69 -19
  93. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +36 -1
  94. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +62 -10
  95. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +1 -24
  96. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +36 -1
  97. package/corpus/templates/clips/package.json +2 -2
  98. package/corpus/templates/content/actions/_content-database-personal-view.ts +80 -0
  99. package/corpus/templates/content/actions/get-content-database-personal-view.ts +27 -0
  100. package/corpus/templates/content/actions/update-content-database-personal-view.ts +31 -0
  101. package/corpus/templates/content/actions/update-content-database-view.ts +3 -0
  102. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +1 -1
  103. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +2584 -546
  104. package/corpus/templates/content/app/components/editor/database/filter-sort.ts +220 -18
  105. package/corpus/templates/content/app/hooks/use-content-database.ts +32 -0
  106. package/corpus/templates/content/app/i18n/zh-TW.ts +16 -0
  107. package/corpus/templates/content/app/i18n-data.ts +154 -0
  108. package/corpus/templates/content/changelog/2026-07-08-database-filters-now-support-notion-style-reset-save-for-eve.md +6 -0
  109. package/corpus/templates/content/changelog/2026-07-08-date-filters-can-now-target-a-between-range.md +6 -0
  110. package/corpus/templates/content/package.json +2 -2
  111. package/corpus/templates/content/parity/matrix.md +1 -1
  112. package/corpus/templates/content/parity/matrix.ts +2 -0
  113. package/corpus/templates/content/shared/api.ts +24 -0
  114. package/corpus/templates/design/AGENTS.md +9 -0
  115. package/corpus/templates/design/actions/request-localhost-write-consent.ts +141 -0
  116. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +13 -19
  117. package/corpus/templates/design/package.json +2 -2
  118. package/corpus/templates/design/server/lib/verify-write-grant.ts +4 -2
  119. package/corpus/templates/dispatch/app/i18n/zh-TW.ts +34 -1
  120. package/corpus/templates/dispatch/app/i18n-data.ts +387 -10
  121. package/corpus/templates/dispatch/app/routes/automations.tsx +1 -0
  122. package/corpus/templates/dispatch/changelog/2026-07-08-added-an-automations-page-to-review-pause-and-create.md +6 -0
  123. package/corpus/templates/dispatch/changelog/2026-07-08-destinations-now-shows-outbound-delivery-queue-health.md +6 -0
  124. package/corpus/templates/dispatch/package.json +2 -2
  125. package/corpus/templates/forms/package.json +2 -2
  126. package/corpus/templates/macros/package.json +2 -2
  127. package/corpus/templates/mail/app/hooks/use-emails.ts +56 -44
  128. package/corpus/templates/mail/app/lib/gmail-mutation-queue.ts +325 -0
  129. package/corpus/templates/mail/changelog/2026-07-08-rapid-archive-and-mark-read-now-batch-gmail-updates-so-the-i.md +6 -0
  130. package/corpus/templates/mail/package.json +2 -2
  131. package/corpus/templates/plan/package.json +2 -2
  132. package/corpus/templates/slides/app/components/editor/ImageDropPromptPopover.tsx +46 -29
  133. package/corpus/templates/slides/app/context/DeckContext.tsx +45 -18
  134. package/corpus/templates/slides/app/i18n/ar-SA.ts +4 -0
  135. package/corpus/templates/slides/app/i18n/de-DE.ts +4 -0
  136. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  137. package/corpus/templates/slides/app/i18n/es-ES.ts +4 -0
  138. package/corpus/templates/slides/app/i18n/fr-FR.ts +4 -0
  139. package/corpus/templates/slides/app/i18n/hi-IN.ts +4 -0
  140. package/corpus/templates/slides/app/i18n/ja-JP.ts +4 -0
  141. package/corpus/templates/slides/app/i18n/ko-KR.ts +4 -0
  142. package/corpus/templates/slides/app/i18n/pt-BR.ts +4 -0
  143. package/corpus/templates/slides/app/i18n/zh-CN.ts +4 -0
  144. package/corpus/templates/slides/app/i18n/zh-TW.ts +4 -0
  145. package/corpus/templates/slides/app/lib/image-drop-to-agent.ts +115 -0
  146. package/corpus/templates/slides/app/pages/DeckEditor.tsx +22 -13
  147. package/corpus/templates/slides/changelog/2026-07-08-dropping-an-image-onto-a-slide-and-sending-it-to-the-agent-w.md +6 -0
  148. package/corpus/templates/slides/changelog/2026-07-08-undo-works-after-the-agent-edits-a-slide-so-you-can-reverse.md +6 -0
  149. package/corpus/templates/slides/package.json +2 -2
  150. package/dist/agent/run-manager.d.ts.map +1 -1
  151. package/dist/agent/run-manager.js +23 -2
  152. package/dist/agent/run-manager.js.map +1 -1
  153. package/dist/agent/run-store.js +2 -2
  154. package/dist/agent/run-store.js.map +1 -1
  155. package/dist/catalog.json +2 -2
  156. package/dist/cli/add.js +1 -1
  157. package/dist/cli/add.js.map +1 -1
  158. package/dist/cli/create.d.ts.map +1 -1
  159. package/dist/cli/create.js +8 -3
  160. package/dist/cli/create.js.map +1 -1
  161. package/dist/cli/design-connect.d.ts.map +1 -1
  162. package/dist/cli/design-connect.js +40 -10
  163. package/dist/cli/design-connect.js.map +1 -1
  164. package/dist/cli/index.js +5 -4
  165. package/dist/cli/index.js.map +1 -1
  166. package/dist/client/extensions/ExtensionSlot.js +6 -0
  167. package/dist/client/extensions/ExtensionSlot.js.map +1 -1
  168. package/dist/client/session-replay.d.ts +8 -0
  169. package/dist/client/session-replay.d.ts.map +1 -1
  170. package/dist/client/session-replay.js +80 -3
  171. package/dist/client/session-replay.js.map +1 -1
  172. package/dist/collab/routes.d.ts +1 -1
  173. package/dist/jobs/scheduler.js +26 -1
  174. package/dist/jobs/scheduler.js.map +1 -1
  175. package/dist/localization/default-messages.d.ts +19 -0
  176. package/dist/localization/default-messages.d.ts.map +1 -1
  177. package/dist/localization/default-messages.js +19 -0
  178. package/dist/localization/default-messages.js.map +1 -1
  179. package/dist/notifications/routes.d.ts +1 -1
  180. package/dist/observability/routes.d.ts +1 -1
  181. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  182. package/dist/resources/handlers.d.ts +1 -1
  183. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  184. package/dist/templates/default/app/i18n/ar-SA.ts +21 -0
  185. package/dist/templates/default/app/i18n/de-DE.ts +21 -0
  186. package/dist/templates/default/app/i18n/en-US.ts +21 -0
  187. package/dist/templates/default/app/i18n/es-ES.ts +21 -0
  188. package/dist/templates/default/app/i18n/fr-FR.ts +21 -0
  189. package/dist/templates/default/app/i18n/hi-IN.ts +20 -0
  190. package/dist/templates/default/app/i18n/ja-JP.ts +21 -0
  191. package/dist/templates/default/app/i18n/ko-KR.ts +20 -0
  192. package/dist/templates/default/app/i18n/pt-BR.ts +21 -0
  193. package/dist/templates/default/app/i18n/zh-CN.ts +20 -0
  194. package/dist/templates/default/app/i18n/zh-TW.ts +19 -0
  195. package/dist/templates/default/package.json +2 -2
  196. package/dist/templates/headless/package.json +3 -3
  197. package/dist/templates/workspace-core/package.json +6 -6
  198. package/dist/templates/workspace-root/package.json +2 -2
  199. package/dist/triggers/dispatcher.js +27 -2
  200. package/dist/triggers/dispatcher.js.map +1 -1
  201. package/dist/vite/client.d.ts.map +1 -1
  202. package/dist/vite/client.js +70 -1
  203. package/dist/vite/client.js.map +1 -1
  204. package/package.json +7 -6
  205. package/src/templates/default/app/i18n/ar-SA.ts +21 -0
  206. package/src/templates/default/app/i18n/de-DE.ts +21 -0
  207. package/src/templates/default/app/i18n/en-US.ts +21 -0
  208. package/src/templates/default/app/i18n/es-ES.ts +21 -0
  209. package/src/templates/default/app/i18n/fr-FR.ts +21 -0
  210. package/src/templates/default/app/i18n/hi-IN.ts +20 -0
  211. package/src/templates/default/app/i18n/ja-JP.ts +21 -0
  212. package/src/templates/default/app/i18n/ko-KR.ts +20 -0
  213. package/src/templates/default/app/i18n/pt-BR.ts +21 -0
  214. package/src/templates/default/app/i18n/zh-CN.ts +20 -0
  215. package/src/templates/default/app/i18n/zh-TW.ts +19 -0
  216. package/src/templates/default/package.json +2 -2
  217. package/src/templates/headless/package.json +3 -3
  218. package/src/templates/workspace-core/package.json +6 -6
  219. package/src/templates/workspace-root/package.json +2 -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"
@@ -7081,7 +8533,10 @@ type DatabasePropertyPickerOption = {
7081
8533
  export function databasePropertyPickerItems(
7082
8534
  properties: DocumentProperty[],
7083
8535
  query: string,
7084
- { includeName = true }: { includeName?: boolean } = {},
8536
+ {
8537
+ includeName = true,
8538
+ excludeKeys,
8539
+ }: { includeName?: boolean; excludeKeys?: ReadonlySet<string> } = {},
7085
8540
  ): DatabasePropertyPickerOption[] {
7086
8541
  const normalizedQuery = query.trim().toLowerCase();
7087
8542
  const items: DatabasePropertyPickerOption[] = [
@@ -7093,7 +8548,7 @@ export function databasePropertyPickerItems(
7093
8548
  label: property.definition.name,
7094
8549
  type: property.definition.type,
7095
8550
  })),
7096
- ];
8551
+ ].filter((item) => !excludeKeys?.has(item.key));
7097
8552
 
7098
8553
  if (!normalizedQuery) return items;
7099
8554
  return items.filter((item) =>
@@ -7106,9 +8561,11 @@ export function databasePropertyPickerItems(
7106
8561
  function DatabasePropertyPickerSearch({
7107
8562
  value,
7108
8563
  onChange,
8564
+ placeholder = dbText("searchProperties"),
7109
8565
  }: {
7110
8566
  value: string;
7111
8567
  onChange: (value: string) => void;
8568
+ placeholder?: string;
7112
8569
  }) {
7113
8570
  return (
7114
8571
  <div className="border-b border-border/70 p-1">
@@ -7118,8 +8575,8 @@ function DatabasePropertyPickerSearch({
7118
8575
  value={value}
7119
8576
  onChange={(event) => onChange(event.target.value)}
7120
8577
  onKeyDown={(event) => event.stopPropagation()}
7121
- placeholder={dbText("searchProperties")}
7122
- aria-label={dbText("searchProperties")}
8578
+ placeholder={placeholder}
8579
+ aria-label={placeholder}
7123
8580
  className="h-6 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
7124
8581
  />
7125
8582
  </div>
@@ -7131,16 +8588,18 @@ function DatabasePropertyPickerItem({
7131
8588
  item,
7132
8589
  selected,
7133
8590
  onSelect,
8591
+ closeOnSelect = false,
7134
8592
  }: {
7135
8593
  item: DatabasePropertyPickerOption;
7136
8594
  selected: boolean;
7137
8595
  onSelect: (key: string, label: string) => void;
8596
+ closeOnSelect?: boolean;
7138
8597
  }) {
7139
8598
  const Icon = item.type === "name" ? IconFileText : TYPE_ICONS[item.type];
7140
8599
  return (
7141
8600
  <DropdownMenuItem
7142
8601
  onSelect={(event) => {
7143
- event.preventDefault();
8602
+ if (!closeOnSelect) event.preventDefault();
7144
8603
  onSelect(item.key, item.label);
7145
8604
  }}
7146
8605
  >
@@ -7151,23 +8610,84 @@ function DatabasePropertyPickerItem({
7151
8610
  );
7152
8611
  }
7153
8612
 
8613
+ function DatabasePropertyPickerMenuItems({
8614
+ properties,
8615
+ selectedKey,
8616
+ includeName,
8617
+ excludeKeys,
8618
+ placeholder,
8619
+ closeOnSelect,
8620
+ onSelect,
8621
+ }: {
8622
+ properties: DocumentProperty[];
8623
+ selectedKey?: string;
8624
+ includeName?: boolean;
8625
+ excludeKeys?: ReadonlySet<string>;
8626
+ placeholder?: string;
8627
+ closeOnSelect?: boolean;
8628
+ onSelect: (key: string, label: string) => void;
8629
+ }) {
8630
+ const [query, setQuery] = useState("");
8631
+ const items = databasePropertyPickerItems(properties, query, {
8632
+ includeName,
8633
+ excludeKeys,
8634
+ });
8635
+
8636
+ return (
8637
+ <>
8638
+ <DatabasePropertyPickerSearch
8639
+ value={query}
8640
+ onChange={setQuery}
8641
+ placeholder={placeholder}
8642
+ />
8643
+ <div className="max-h-72 overflow-auto p-1">
8644
+ {items.map((item) => (
8645
+ <DatabasePropertyPickerItem
8646
+ key={item.key}
8647
+ item={item}
8648
+ selected={selectedKey === item.key}
8649
+ closeOnSelect={closeOnSelect}
8650
+ onSelect={onSelect}
8651
+ />
8652
+ ))}
8653
+ {items.length === 0 ? (
8654
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">
8655
+ {dbText("noPropertiesFound")}
8656
+ </div>
8657
+ ) : null}
8658
+ </div>
8659
+ </>
8660
+ );
8661
+ }
8662
+
7154
8663
  function DatabasePropertyPickerSubContent({
7155
8664
  properties,
7156
8665
  selectedKey,
7157
8666
  includeName,
8667
+ excludeKeys,
8668
+ placeholder,
7158
8669
  onSelect,
7159
8670
  }: {
7160
8671
  properties: DocumentProperty[];
7161
- selectedKey: string;
8672
+ selectedKey?: string;
7162
8673
  includeName?: boolean;
8674
+ excludeKeys?: ReadonlySet<string>;
8675
+ placeholder?: string;
7163
8676
  onSelect: (key: string, label: string) => void;
7164
8677
  }) {
7165
8678
  const [query, setQuery] = useState("");
7166
- const items = databasePropertyPickerItems(properties, query, { includeName });
8679
+ const items = databasePropertyPickerItems(properties, query, {
8680
+ includeName,
8681
+ excludeKeys,
8682
+ });
7167
8683
 
7168
8684
  return (
7169
8685
  <DropdownMenuSubContent className="max-h-80 w-64 overflow-auto">
7170
- <DatabasePropertyPickerSearch value={query} onChange={setQuery} />
8686
+ <DatabasePropertyPickerSearch
8687
+ value={query}
8688
+ onChange={setQuery}
8689
+ placeholder={placeholder}
8690
+ />
7171
8691
  {items.map((item) => (
7172
8692
  <DatabasePropertyPickerItem
7173
8693
  key={item.key}
@@ -7332,8 +8852,8 @@ function databaseFilterModeLabel(filterMode: DatabaseFilterMode) {
7332
8852
  return filterMode === "or" ? "Any" : "All";
7333
8853
  }
7334
8854
 
7335
- function databaseFilterModePhrase(filterMode: DatabaseFilterMode) {
7336
- return filterMode === "or" ? "any filter" : "all filters";
8855
+ function databaseFilterModeConjunctionLabel(filterMode: DatabaseFilterMode) {
8856
+ return filterMode === "or" ? "Or" : "And";
7337
8857
  }
7338
8858
 
7339
8859
  function DatabaseResultCountFooter({
@@ -10753,6 +12273,143 @@ function databaseViewStateKey(
10753
12273
  return JSON.stringify({ databaseId, viewConfig });
10754
12274
  }
10755
12275
 
12276
+ function normalizePersonalDatabaseViewOverrides(
12277
+ value: ContentDatabasePersonalViewOverrides | null | undefined,
12278
+ ): PersonalDatabaseViewOverrides | null {
12279
+ try {
12280
+ if (!value) return null;
12281
+ const parsed = value as Partial<PersonalDatabaseViewOverrides>;
12282
+ if (
12283
+ parsed.version !== PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION ||
12284
+ !Array.isArray(parsed.views)
12285
+ ) {
12286
+ return null;
12287
+ }
12288
+
12289
+ return {
12290
+ version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
12291
+ activeViewId:
12292
+ typeof parsed.activeViewId === "string"
12293
+ ? parsed.activeViewId
12294
+ : undefined,
12295
+ views: parsed.views
12296
+ .filter((view) => typeof view?.id === "string")
12297
+ .map((view) => ({
12298
+ id: view.id,
12299
+ sorts: Array.isArray(view.sorts)
12300
+ ? view.sorts.filter(isDatabaseSort)
12301
+ : [],
12302
+ filters: Array.isArray(view.filters)
12303
+ ? view.filters.filter(isDatabaseFilter)
12304
+ : [],
12305
+ filterMode: normalizeClientDatabaseFilterMode(view.filterMode),
12306
+ })),
12307
+ };
12308
+ } catch {
12309
+ return null;
12310
+ }
12311
+ }
12312
+
12313
+ function personalDatabaseViewOverridesFromConfig(
12314
+ viewConfig: ContentDatabaseViewConfig,
12315
+ ): PersonalDatabaseViewOverrides {
12316
+ const normalized = normalizeClientDatabaseViewConfig(viewConfig);
12317
+ return {
12318
+ version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
12319
+ activeViewId: normalized.activeViewId,
12320
+ views: normalized.views.map((view) => ({
12321
+ id: view.id,
12322
+ sorts: view.sorts,
12323
+ filters: view.filters,
12324
+ filterMode: view.filterMode ?? "and",
12325
+ })),
12326
+ };
12327
+ }
12328
+
12329
+ export function applyPersonalDatabaseViewOverrides(
12330
+ savedViewConfig: ContentDatabaseViewConfig,
12331
+ overrides: PersonalDatabaseViewOverrides | null,
12332
+ ) {
12333
+ const saved = normalizeClientDatabaseViewConfig(savedViewConfig);
12334
+ if (!overrides) return saved;
12335
+
12336
+ const overridesByViewId = new Map(
12337
+ overrides.views.map((view) => [view.id, view]),
12338
+ );
12339
+ return normalizeClientDatabaseViewConfig({
12340
+ ...saved,
12341
+ activeViewId: saved.views.some((view) => view.id === overrides.activeViewId)
12342
+ ? overrides.activeViewId
12343
+ : saved.activeViewId,
12344
+ views: saved.views.map((view) => {
12345
+ const override = overridesByViewId.get(view.id);
12346
+ if (!override) return view;
12347
+ return {
12348
+ ...view,
12349
+ sorts: override.sorts,
12350
+ filters: override.filters,
12351
+ filterMode: override.filterMode,
12352
+ };
12353
+ }),
12354
+ });
12355
+ }
12356
+
12357
+ export function databaseViewConfigWithSavedQueryState(
12358
+ currentViewConfig: ContentDatabaseViewConfig,
12359
+ savedViewConfig: ContentDatabaseViewConfig,
12360
+ ) {
12361
+ const current = normalizeClientDatabaseViewConfig(currentViewConfig);
12362
+ const saved = normalizeClientDatabaseViewConfig(savedViewConfig);
12363
+ const savedViewsById = new Map(saved.views.map((view) => [view.id, view]));
12364
+
12365
+ return normalizeClientDatabaseViewConfig({
12366
+ ...current,
12367
+ activeViewId: saved.activeViewId,
12368
+ views: current.views.map((view) => {
12369
+ const savedView = savedViewsById.get(view.id);
12370
+ if (!savedView) return view;
12371
+ return {
12372
+ ...view,
12373
+ sorts: savedView.sorts,
12374
+ filters: savedView.filters,
12375
+ filterMode: savedView.filterMode,
12376
+ };
12377
+ }),
12378
+ });
12379
+ }
12380
+
12381
+ export function databaseViewConfigWithClearedActiveFilters(
12382
+ viewConfig: ContentDatabaseViewConfig,
12383
+ ) {
12384
+ return updateActiveDatabaseView(
12385
+ normalizeClientDatabaseViewConfig(viewConfig),
12386
+ (view) => ({ ...view, filters: [], filterMode: "and" }),
12387
+ );
12388
+ }
12389
+
12390
+ export function databaseViewHasPersonalQueryChanges(
12391
+ currentViewConfig: ContentDatabaseViewConfig,
12392
+ savedViewConfig: ContentDatabaseViewConfig,
12393
+ ) {
12394
+ return (
12395
+ databaseViewQueryStateKey(currentViewConfig) !==
12396
+ databaseViewQueryStateKey(savedViewConfig)
12397
+ );
12398
+ }
12399
+
12400
+ function databaseViewQueryStateKey(viewConfig: ContentDatabaseViewConfig) {
12401
+ const normalized = normalizeClientDatabaseViewConfig(viewConfig);
12402
+ return JSON.stringify({
12403
+ activeViewId: normalized.activeViewId,
12404
+ views: normalized.views.map((view) => ({
12405
+ id: view.id,
12406
+ sorts: view.sorts,
12407
+ filters: view.filters,
12408
+ filterMode: view.filterMode ?? "and",
12409
+ })),
12410
+ });
12411
+ }
12412
+
10756
12413
  function isTablePropertyVisible(
10757
12414
  property: DocumentProperty,
10758
12415
  items: ContentDatabaseItem[],
@@ -13355,13 +15012,12 @@ export function applyDatabaseView(
13355
15012
  const activeFilters = filters.filter(isActiveFilter);
13356
15013
  const filtered = activeFilters.length
13357
15014
  ? 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
- ),
15015
+ databaseItemMatchesFilterTree(
15016
+ item,
15017
+ properties,
15018
+ activeFilters,
15019
+ filterMode,
15020
+ ),
13365
15021
  )
13366
15022
  : searched;
13367
15023
 
@@ -13377,25 +15033,137 @@ export function applyDatabaseView(
13377
15033
  return sort.direction === "asc" ? comparison : -comparison;
13378
15034
  }
13379
15035
  }
13380
- return 0;
13381
- });
15036
+ return 0;
15037
+ });
15038
+ }
15039
+
15040
+ function databaseItemMatchesFilterTree(
15041
+ item: ContentDatabaseItem,
15042
+ properties: DocumentProperty[],
15043
+ filters: DatabaseFilter[],
15044
+ filterMode: DatabaseFilterMode,
15045
+ ) {
15046
+ const rootFilters = filters.filter((filter) => !filter.parentFilterGroupId);
15047
+ const nestedGroups = nestedDatabaseFilterGroups(filters);
15048
+ const matches = [
15049
+ ...rootFilters.map((filter) =>
15050
+ databaseItemMatchesFilter(item, properties, filter),
15051
+ ),
15052
+ ...nestedGroups.map((group) =>
15053
+ combineDatabaseFilterMatches(
15054
+ group.map((filter) =>
15055
+ databaseItemMatchesFilter(item, properties, filter),
15056
+ ),
15057
+ filterMode,
15058
+ ),
15059
+ ),
15060
+ ];
15061
+
15062
+ return combineDatabaseFilterMatches(matches, filterMode);
15063
+ }
15064
+
15065
+ function nestedDatabaseFilterGroups(filters: DatabaseFilter[]) {
15066
+ const groups = new Map<string, DatabaseFilter[]>();
15067
+ for (const filter of filters) {
15068
+ if (!filter.parentFilterGroupId || !filter.filterGroupId) continue;
15069
+ groups.set(filter.filterGroupId, [
15070
+ ...(groups.get(filter.filterGroupId) ?? []),
15071
+ filter,
15072
+ ]);
15073
+ }
15074
+ return [...groups.values()].filter((group) => group.length > 0);
15075
+ }
15076
+
15077
+ function combineDatabaseFilterMatches(
15078
+ matches: boolean[],
15079
+ filterMode: DatabaseFilterMode,
15080
+ ) {
15081
+ if (matches.length === 0) return true;
15082
+ return filterMode === "or"
15083
+ ? matches.some((matched) => matched)
15084
+ : matches.every((matched) => matched);
15085
+ }
15086
+
15087
+ function defaultDatabaseSort(): DatabaseSort {
15088
+ return {
15089
+ key: "name",
15090
+ label: "Name",
15091
+ direction: "asc",
15092
+ };
15093
+ }
15094
+
15095
+ function defaultDatabaseFilter(): DatabaseFilter {
15096
+ return {
15097
+ key: "name",
15098
+ label: "Name",
15099
+ operator: "contains",
15100
+ value: "",
15101
+ };
15102
+ }
15103
+
15104
+ function createDatabaseFilterForField(
15105
+ key: ColumnKey,
15106
+ label: string,
15107
+ properties: DocumentProperty[],
15108
+ advanced:
15109
+ | boolean
15110
+ | { filterGroupId: string; parentFilterGroupId?: string } = false,
15111
+ ): DatabaseFilter {
15112
+ const group =
15113
+ typeof advanced === "object"
15114
+ ? advanced
15115
+ : advanced
15116
+ ? { filterGroupId: DATABASE_ADVANCED_FILTER_GROUP_ID }
15117
+ : null;
15118
+ return {
15119
+ key,
15120
+ label,
15121
+ operator: defaultFilterOperatorForKey(key, properties),
15122
+ value: "",
15123
+ ...(group ? { filterGroupId: group.filterGroupId } : {}),
15124
+ ...(group?.parentFilterGroupId
15125
+ ? { parentFilterGroupId: group.parentFilterGroupId }
15126
+ : {}),
15127
+ };
15128
+ }
15129
+
15130
+ function isAdvancedDatabaseFilter(filter: DatabaseFilter) {
15131
+ return (
15132
+ filter.filterGroupId === DATABASE_ADVANCED_FILTER_GROUP_ID ||
15133
+ filter.parentFilterGroupId === DATABASE_ADVANCED_FILTER_GROUP_ID
15134
+ );
15135
+ }
15136
+
15137
+ function advancedNestedFilterGroups(
15138
+ entries: DatabaseFilterEntry[],
15139
+ ): DatabaseNestedFilterGroupEntry[] {
15140
+ const groups = new Map<string, DatabaseFilterEntry[]>();
15141
+ for (const entry of entries) {
15142
+ if (
15143
+ entry.filter.parentFilterGroupId !== DATABASE_ADVANCED_FILTER_GROUP_ID ||
15144
+ !entry.filter.filterGroupId
15145
+ ) {
15146
+ continue;
15147
+ }
15148
+ groups.set(entry.filter.filterGroupId, [
15149
+ ...(groups.get(entry.filter.filterGroupId) ?? []),
15150
+ entry,
15151
+ ]);
15152
+ }
15153
+ return [...groups.entries()].map(([id, groupEntries]) => ({
15154
+ id,
15155
+ entries: groupEntries,
15156
+ }));
13382
15157
  }
13383
15158
 
13384
- function defaultDatabaseSort(): DatabaseSort {
13385
- return {
13386
- key: "name",
13387
- label: "Name",
13388
- direction: "asc",
13389
- };
15159
+ function createAdvancedNestedFilterGroupId() {
15160
+ return `advanced-${Date.now().toString(36)}-${Math.random()
15161
+ .toString(36)
15162
+ .slice(2, 8)}`;
13390
15163
  }
13391
15164
 
13392
- function defaultDatabaseFilter(): DatabaseFilter {
13393
- return {
13394
- key: "name",
13395
- label: "Name",
13396
- operator: "contains",
13397
- value: "",
13398
- };
15165
+ function advancedFilterGroupLabel(ruleCount: number) {
15166
+ return `${ruleCount} rule${ruleCount === 1 ? "" : "s"}`;
13399
15167
  }
13400
15168
 
13401
15169
  export function upsertDatabaseSort(
@@ -13553,7 +15321,7 @@ function isActiveFilter(
13553
15321
  ): filter is DatabaseFilter {
13554
15322
  if (!filter) return false;
13555
15323
  if (filterOperatorNeedsValue(filter.operator)) {
13556
- return filter.value.trim().length > 0;
15324
+ return databaseFilterSelectedValues(filter.value).length > 0;
13557
15325
  }
13558
15326
  return true;
13559
15327
  }
@@ -13598,21 +15366,42 @@ function databaseFilterDefaultValueForNewItem(
13598
15366
  if (filter.operator === "is_unchecked") {
13599
15367
  return property.definition.type === "checkbox" ? false : undefined;
13600
15368
  }
13601
- if (filter.operator !== "equals") return undefined;
15369
+ if (property.definition.type === "multi_select") {
15370
+ if (filter.operator !== "equals" && filter.operator !== "contains") {
15371
+ return undefined;
15372
+ }
15373
+ const values = databaseFilterSelectedValues(filter.value);
15374
+ if (values.length === 0) return undefined;
15375
+ return values.map(
15376
+ (value) =>
15377
+ databasePropertyOptionIdForFilterValue(property, value) ?? value,
15378
+ );
15379
+ }
15380
+ if (property.definition.type === "person" && filter.operator === "contains") {
15381
+ const values = databaseFilterSelectedValues(filter.value);
15382
+ return values.length > 0 ? values : undefined;
15383
+ }
13602
15384
 
13603
15385
  const value = filter.value.trim();
13604
15386
  if (!value) return undefined;
13605
15387
 
13606
- const optionValue = databasePropertyOptionIdForFilterValue(property, value);
13607
- if (property.definition.type === "multi_select") {
13608
- return [optionValue ?? value];
13609
- }
13610
15388
  if (
13611
15389
  property.definition.type === "select" ||
13612
15390
  property.definition.type === "status"
13613
15391
  ) {
13614
- return optionValue ?? value;
15392
+ if (filter.operator !== "equals" && filter.operator !== "contains") {
15393
+ return undefined;
15394
+ }
15395
+ const values = databaseFilterSelectedValues(filter.value);
15396
+ const firstValue = values[0]?.trim() ?? "";
15397
+ if (!firstValue) return undefined;
15398
+ return (
15399
+ databasePropertyOptionIdForFilterValue(property, firstValue) ?? firstValue
15400
+ );
13615
15401
  }
15402
+
15403
+ if (filter.operator !== "equals") return undefined;
15404
+
13616
15405
  if (property.definition.type === "date") {
13617
15406
  return /^\d{4}-\d{2}-\d{2}$/.test(value)
13618
15407
  ? { start: value, includeTime: false }
@@ -13657,10 +15446,20 @@ function databaseItemMatchesFilter(
13657
15446
  : current < target;
13658
15447
  }
13659
15448
 
13660
- if (filter.operator === "before" || filter.operator === "after") {
15449
+ if (
15450
+ filter.operator === "before" ||
15451
+ filter.operator === "after" ||
15452
+ filter.operator === "between"
15453
+ ) {
13661
15454
  const current = propertyDateValue(property);
15455
+ if (!Number.isFinite(current)) return false;
15456
+ if (filter.operator === "between") {
15457
+ const range = databaseFilterDateRangeValue(filter.value);
15458
+ if (!range) return false;
15459
+ return current >= range[0] && current <= range[1];
15460
+ }
13662
15461
  const target = new Date(filter.value.trim()).getTime();
13663
- if (!Number.isFinite(current) || !Number.isFinite(target)) return false;
15462
+ if (!Number.isFinite(target)) return false;
13664
15463
  return filter.operator === "before" ? current < target : current > target;
13665
15464
  }
13666
15465
 
@@ -13669,8 +15468,31 @@ function databaseItemMatchesFilter(
13669
15468
  properties,
13670
15469
  filter.key,
13671
15470
  ).map((candidate) => candidate.trim().toLowerCase());
15471
+ const selectedFilterValues = databaseFilterSelectedValues(filter.value).map(
15472
+ (candidate) => candidate.trim().toLowerCase(),
15473
+ );
13672
15474
  const normalizedValue = value.trim().toLowerCase();
13673
- const normalizedFilter = filter.value.trim().toLowerCase();
15475
+ const normalizedFilter = selectedFilterValues[0] ?? "";
15476
+ const usesDiscreteValues =
15477
+ property?.definition.type === "select" ||
15478
+ property?.definition.type === "status" ||
15479
+ property?.definition.type === "multi_select" ||
15480
+ property?.definition.type === "person";
15481
+
15482
+ if (
15483
+ usesDiscreteValues &&
15484
+ (filter.operator === "equals" || filter.operator === "contains")
15485
+ ) {
15486
+ return selectedFilterValues.some((filterValue) =>
15487
+ candidateValues.includes(filterValue),
15488
+ );
15489
+ }
15490
+ if (usesDiscreteValues && filter.operator === "does_not_equal") {
15491
+ return selectedFilterValues.every(
15492
+ (filterValue) => !candidateValues.includes(filterValue),
15493
+ );
15494
+ }
15495
+
13674
15496
  if (filter.operator === "equals") {
13675
15497
  return candidateValues.includes(normalizedFilter);
13676
15498
  }
@@ -13844,6 +15666,10 @@ function propertyDateValue(property: DocumentProperty | null | undefined) {
13844
15666
  return Number.isFinite(value) ? value : Number.NaN;
13845
15667
  }
13846
15668
 
15669
+ function databaseSortDirectionLabel(direction: SortDirection) {
15670
+ return direction === "asc" ? "Ascending" : "Descending";
15671
+ }
15672
+
13847
15673
  function SortMenu({
13848
15674
  properties,
13849
15675
  sorts,
@@ -13879,6 +15705,10 @@ function SortMenu({
13879
15705
  onSortsChange([...sorts, defaultDatabaseSort()]);
13880
15706
  }
13881
15707
 
15708
+ function addSortForField(key: "name" | string, label: string) {
15709
+ onSortsChange([...sorts, { key, label, direction: "asc" }]);
15710
+ }
15711
+
13882
15712
  function removeSort(index: number) {
13883
15713
  onSortsChange(sorts.filter((_, sortIndex) => sortIndex !== index));
13884
15714
  }
@@ -13911,363 +15741,210 @@ function SortMenu({
13911
15741
  ) : null}
13912
15742
  </Button>
13913
15743
  </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")}
15744
+ <DropdownMenuContent
15745
+ align="end"
15746
+ className={cn("w-[340px]", sorts.length === 0 && "w-64 p-0")}
15747
+ >
15748
+ {sorts.length === 0 ? (
15749
+ <div onKeyDown={(event) => event.stopPropagation()}>
15750
+ <DatabasePropertyPickerMenuItems
15751
+ properties={properties}
15752
+ includeName
15753
+ placeholder={dbText("sortBy")}
15754
+ closeOnSelect
15755
+ onSelect={addSortForField}
15756
+ />
14117
15757
  </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
- }}
15758
+ ) : null}
15759
+ {sorts.length > 0 ? (
15760
+ <div className="grid gap-2 p-2">
15761
+ <div className="text-xs font-medium text-muted-foreground">
15762
+ {dbText("sortRowsBy")}
15763
+ </div>
15764
+ <div className="grid gap-2">
15765
+ {displayedSorts.map((sort, index) => (
15766
+ <div
15767
+ key={`${index}-${sort.key}`}
15768
+ className="grid grid-cols-[minmax(0,1fr)_auto_auto_auto] gap-1 rounded border border-border/70 bg-background p-1.5"
14132
15769
  >
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
15770
  <DropdownMenuSub>
14151
15771
  <DropdownMenuSubTrigger className="min-w-0">
14152
- <FilterFieldIcon
14153
- filter={currentFilter}
14154
- properties={properties}
14155
- />
15772
+ <SortFieldIcon sort={sort} properties={properties} />
14156
15773
  <span className="min-w-0 flex-1 truncate">
14157
- {currentFilter.label}
15774
+ {sort.label}
14158
15775
  </span>
14159
15776
  </DropdownMenuSubTrigger>
14160
15777
  <DatabasePropertyPickerSubContent
14161
15778
  properties={properties}
14162
- selectedKey={currentFilter.key}
15779
+ selectedKey={sort.key}
14163
15780
  includeName
14164
- onSelect={(key, label) => selectField(index, key, label)}
15781
+ onSelect={(key, label) => selectSort(index, key, label)}
14165
15782
  />
14166
15783
  </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
-
15784
+ <button
15785
+ type="button"
15786
+ className="flex h-8 items-center rounded px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
15787
+ onClick={() => toggleDirection(index)}
15788
+ >
15789
+ {sort.direction === "asc" ? "Asc" : "Desc"}
15790
+ </button>
14198
15791
  <div className="flex items-center">
14199
15792
  <button
14200
15793
  type="button"
14201
- aria-label={`Move filter ${index + 1} earlier`}
15794
+ aria-label={`Move sort ${index + 1} earlier`}
14202
15795
  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")}
15796
+ disabled={sorts.length <= 1 || index === 0}
15797
+ onClick={() => moveSort(index, "up")}
14205
15798
  >
14206
15799
  <IconArrowUp className="size-3.5" />
14207
15800
  </button>
14208
15801
  <button
14209
15802
  type="button"
14210
- aria-label={`Move filter ${index + 1} later`}
15803
+ aria-label={`Move sort ${index + 1} later`}
14211
15804
  className="flex size-8 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
14212
15805
  disabled={
14213
- filters.length <= 1 ||
14214
- index >= displayedFilters.length - 1
15806
+ sorts.length <= 1 || index >= displayedSorts.length - 1
14215
15807
  }
14216
- onClick={() => moveFilter(index, "down")}
15808
+ onClick={() => moveSort(index, "down")}
14217
15809
  >
14218
15810
  <IconArrowDown className="size-3.5" />
14219
15811
  </button>
14220
15812
  </div>
14221
-
14222
15813
  <button
14223
15814
  type="button"
14224
- aria-label={`Remove filter ${index + 1}`}
15815
+ aria-label={`Remove sort ${index + 1}`}
14225
15816
  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)}
15817
+ disabled={sorts.length === 0}
15818
+ onClick={() => removeSort(index)}
14228
15819
  >
14229
15820
  <IconX className="size-4" />
14230
15821
  </button>
14231
15822
  </div>
15823
+ ))}
15824
+ </div>
15825
+
15826
+ <div className="flex justify-between gap-2 border-t pt-2">
15827
+ <Button
15828
+ type="button"
15829
+ size="sm"
15830
+ variant="ghost"
15831
+ className="h-8 px-2 text-xs"
15832
+ onClick={addSort}
15833
+ >
15834
+ <IconPlus className="mr-1 size-3.5" />
15835
+ {dbText("addSort")}
15836
+ </Button>
15837
+ <Button
15838
+ type="button"
15839
+ size="sm"
15840
+ variant="ghost"
15841
+ className="h-8 px-2 text-xs"
15842
+ disabled={sorts.length === 0}
15843
+ onClick={() => onSortsChange([])}
15844
+ >
15845
+ {dbText("clearSorts")}
15846
+ </Button>
15847
+ </div>
15848
+ </div>
15849
+ ) : null}
15850
+ </DropdownMenuContent>
15851
+ </DropdownMenu>
15852
+ );
15853
+ }
15854
+
15855
+ function SortFieldIcon({
15856
+ sort,
15857
+ properties,
15858
+ }: {
15859
+ sort: DatabaseSort;
15860
+ properties: DocumentProperty[];
15861
+ }) {
15862
+ if (sort.key === "name") {
15863
+ return <IconFileText className="mr-2 size-4 text-muted-foreground" />;
15864
+ }
15865
+ const property = properties.find(
15866
+ (candidate) => candidate.definition.id === sort.key,
15867
+ );
15868
+ const Icon = property ? TYPE_ICONS[property.definition.type] : IconFileText;
15869
+ return <Icon className="mr-2 size-4 text-muted-foreground" />;
15870
+ }
14232
15871
 
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>
15872
+ function FilterMenu({
15873
+ filters,
15874
+ properties,
15875
+ inlineOpen,
15876
+ open,
15877
+ onOpenChange,
15878
+ onAddFilter,
15879
+ onAddAdvancedFilter,
15880
+ }: {
15881
+ filters: DatabaseFilter[];
15882
+ properties: DocumentProperty[];
15883
+ inlineOpen: boolean;
15884
+ open: boolean;
15885
+ onOpenChange: (open: boolean) => void;
15886
+ onAddFilter: (key: "name" | string, label: string) => void;
15887
+ onAddAdvancedFilter: (key: "name" | string, label: string) => void;
15888
+ }) {
15889
+ const activeFilters = filters.filter(isActiveFilter);
15890
+ const active = activeFilters.length > 0 || inlineOpen;
15891
+ const excludedFilterKeys = useMemo(
15892
+ () => new Set(activeFilters.map((filter) => filter.key)),
15893
+ [activeFilters],
15894
+ );
15895
+ const advancedFilterCandidate =
15896
+ databasePropertyPickerItems(properties, "", {
15897
+ includeName: false,
15898
+ excludeKeys: excludedFilterKeys,
15899
+ })[0] ??
15900
+ databasePropertyPickerItems(properties, "", {
15901
+ includeName: true,
15902
+ excludeKeys: excludedFilterKeys,
15903
+ })[0];
14245
15904
 
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>
15905
+ return (
15906
+ <DropdownMenu open={open} onOpenChange={onOpenChange}>
15907
+ <DropdownMenuTrigger asChild>
15908
+ <Button
15909
+ type="button"
15910
+ variant="ghost"
15911
+ size="sm"
15912
+ aria-label={
15913
+ activeFilters.length > 0
15914
+ ? `${activeFilters.length} active filters`
15915
+ : "Filter"
15916
+ }
15917
+ title="Filter"
15918
+ className={databaseToolbarIconButtonClass(active || open)}
15919
+ >
15920
+ <IconFilter className="size-3.5" />
15921
+ </Button>
15922
+ </DropdownMenuTrigger>
15923
+ <DropdownMenuContent align="end" className="w-64">
15924
+ <div onKeyDown={(event) => event.stopPropagation()}>
15925
+ <DatabasePropertyPickerMenuItems
15926
+ properties={properties}
15927
+ includeName
15928
+ excludeKeys={excludedFilterKeys}
15929
+ placeholder={dbText("filterBy")}
15930
+ closeOnSelect
15931
+ onSelect={onAddFilter}
15932
+ />
15933
+ <DropdownMenuSeparator />
15934
+ <DropdownMenuItem
15935
+ disabled={!advancedFilterCandidate}
15936
+ onSelect={(event) => {
15937
+ event.preventDefault();
15938
+ if (!advancedFilterCandidate) return;
15939
+ onAddAdvancedFilter(
15940
+ advancedFilterCandidate.key,
15941
+ advancedFilterCandidate.label,
15942
+ );
15943
+ }}
15944
+ >
15945
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
15946
+ {dbText("addAdvancedFilter")}
15947
+ </DropdownMenuItem>
14271
15948
  </div>
14272
15949
  </DropdownMenuContent>
14273
15950
  </DropdownMenu>
@@ -14277,18 +15954,29 @@ function FilterMenu({
14277
15954
  function DatabaseFilterValueControl({
14278
15955
  documentId,
14279
15956
  filter,
15957
+ items,
14280
15958
  properties,
14281
15959
  autoFocus,
15960
+ hideOptionsUntilQuery = false,
14282
15961
  onValueChange,
14283
15962
  }: {
14284
15963
  documentId: string;
14285
15964
  filter: DatabaseFilter;
15965
+ items: ContentDatabaseItem[];
14286
15966
  properties: DocumentProperty[];
14287
15967
  autoFocus?: boolean;
15968
+ hideOptionsUntilQuery?: boolean;
14288
15969
  onValueChange: (value: string) => void;
14289
15970
  }) {
14290
15971
  const configureProperty = useConfigureDocumentProperty(documentId);
14291
- const options = databaseFilterOptionChoices(filter.key, properties);
15972
+ const { session } = useSession();
15973
+ const currentUserEmail = session?.email?.trim() ?? "";
15974
+ const options = databaseFilterOptionChoices(
15975
+ filter.key,
15976
+ properties,
15977
+ items,
15978
+ currentUserEmail,
15979
+ );
14292
15980
  const type = filterPropertyTypeForKey(filter.key, properties);
14293
15981
  const [optionQuery, setOptionQuery] = useState("");
14294
15982
  const filteredOptions = filterPropertyOptions(options, optionQuery);
@@ -14297,7 +15985,57 @@ function DatabaseFilterValueControl({
14297
15985
  properties,
14298
15986
  );
14299
15987
  const canCreateOption =
14300
- !!optionProperty && canCreatePropertyOption(options, optionQuery);
15988
+ !!optionProperty &&
15989
+ optionProperty.definition.type !== "person" &&
15990
+ canCreatePropertyOption(options, optionQuery);
15991
+ const usesOptionFilterValues =
15992
+ optionProperty?.definition.type === "select" ||
15993
+ optionProperty?.definition.type === "status" ||
15994
+ optionProperty?.definition.type === "multi_select" ||
15995
+ optionProperty?.definition.type === "person";
15996
+ const allowsMultipleValues =
15997
+ usesOptionFilterValues &&
15998
+ (filter.operator === "contains" || filter.operator === "does_not_equal");
15999
+ const selectedValues = databaseFilterSelectedValues(filter.value);
16000
+ const selectedOptions = selectedValues
16001
+ .map((value) =>
16002
+ options.find(
16003
+ (option) =>
16004
+ databaseFilterChoiceValue(option) === value ||
16005
+ option.id === value ||
16006
+ option.name.trim().toLowerCase() === value.trim().toLowerCase(),
16007
+ ),
16008
+ )
16009
+ .filter((option): option is DatabaseFilterChoice => !!option);
16010
+ const showClearValue = selectedValues.length > 0;
16011
+ const showOptionChoices =
16012
+ !hideOptionsUntilQuery ||
16013
+ optionQuery.trim().length > 0 ||
16014
+ selectedValues.length > 0;
16015
+ const showCompactOptionValueInput =
16016
+ hideOptionsUntilQuery && selectedOptions.length === 0;
16017
+
16018
+ function setSelectedValues(values: string[]) {
16019
+ onValueChange(databaseFilterEncodeSelectedValues(values));
16020
+ }
16021
+
16022
+ function chooseFilterOption(option: DatabaseFilterChoice) {
16023
+ const optionValue = databaseFilterChoiceValue(option);
16024
+ if (!allowsMultipleValues) {
16025
+ onValueChange(optionValue);
16026
+ return;
16027
+ }
16028
+ const selected = databaseFilterOptionIsSelected(option, selectedValues);
16029
+ setSelectedValues(
16030
+ selected
16031
+ ? selectedValues.filter(
16032
+ (value) =>
16033
+ value !== optionValue &&
16034
+ value.trim().toLowerCase() !== option.name.trim().toLowerCase(),
16035
+ )
16036
+ : [...selectedValues, optionValue],
16037
+ );
16038
+ }
14301
16039
 
14302
16040
  async function createFilterOption() {
14303
16041
  if (!optionProperty || !canCreateOption) return;
@@ -14311,37 +16049,82 @@ function DatabaseFilterValueControl({
14311
16049
  options: { options: [...options, option] },
14312
16050
  });
14313
16051
  setOptionQuery("");
14314
- onValueChange(option.id);
16052
+ if (allowsMultipleValues) {
16053
+ setSelectedValues([...selectedValues, option.id]);
16054
+ } else {
16055
+ onValueChange(option.id);
16056
+ }
16057
+ }
16058
+
16059
+ if (
16060
+ filter.operator === "between" &&
16061
+ (type === "date" || type === "created_time" || type === "last_edited_time")
16062
+ ) {
16063
+ return (
16064
+ <DatabaseBetweenDateFilterValueControl
16065
+ filter={filter}
16066
+ onValueChange={onValueChange}
16067
+ />
16068
+ );
14315
16069
  }
14316
16070
 
14317
16071
  if (optionProperty) {
14318
16072
  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">
16073
+ <div className="overflow-hidden rounded-sm">
16074
+ <div className="px-1 pb-1">
16075
+ <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">
16076
+ {showCompactOptionValueInput ? null : (
14331
16077
  <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>
16078
+ )}
16079
+ {selectedOptions.map((option) => (
16080
+ <button
16081
+ key={option.id}
16082
+ type="button"
16083
+ className={cn(
16084
+ "inline-flex max-w-[9rem] items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
16085
+ optionProperty?.definition.type === "person"
16086
+ ? "bg-muted text-foreground"
16087
+ : OPTION_COLOR_CLASSES[option.color ?? "gray"],
16088
+ )}
16089
+ onClick={(event) => {
16090
+ event.preventDefault();
16091
+ event.stopPropagation();
16092
+ setSelectedValues(
16093
+ selectedValues.filter(
16094
+ (value) => value !== databaseFilterChoiceValue(option),
16095
+ ),
16096
+ );
16097
+ }}
16098
+ >
16099
+ {optionProperty?.definition.type === "person" ? (
16100
+ <PersonPill value={databaseFilterChoiceValue(option)} />
16101
+ ) : (
16102
+ <span className="truncate">{option.name}</span>
16103
+ )}
16104
+ <IconX className="size-3 opacity-70" />
16105
+ </button>
16106
+ ))}
16107
+ <Input
16108
+ autoFocus={autoFocus}
16109
+ value={optionQuery}
16110
+ placeholder={
16111
+ showCompactOptionValueInput
16112
+ ? filterValuePlaceholder(filter.key, properties)
16113
+ : selectedOptions.length > 0
16114
+ ? ""
16115
+ : filterOptionSearchPlaceholder(filter.key, properties)
16116
+ }
16117
+ aria-label={`Search ${filter.label} filter options`}
16118
+ onChange={(event) => setOptionQuery(event.target.value)}
16119
+ className="h-7 min-w-[7rem] flex-1 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
16120
+ />
14341
16121
  </div>
14342
- {filter.value ? (
14343
- <>
16122
+ </div>
16123
+ {showOptionChoices ? (
16124
+ <div className="max-h-60 overflow-auto py-0.5">
16125
+ {showClearValue ? (
14344
16126
  <DropdownMenuItem
16127
+ className="text-xs"
14345
16128
  onSelect={(event) => {
14346
16129
  event.preventDefault();
14347
16130
  onValueChange("");
@@ -14350,45 +16133,56 @@ function DatabaseFilterValueControl({
14350
16133
  <IconX className="mr-2 size-4 text-muted-foreground" />
14351
16134
  {dbText("clearValue")}
14352
16135
  </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 />
16136
+ ) : null}
16137
+ {filteredOptions.length === 0 && !canCreateOption ? (
16138
+ <div className="px-2 py-3 text-xs text-muted-foreground">
16139
+ {dbText("noMatchingOptions")}
16140
+ </div>
16141
+ ) : null}
16142
+ {filteredOptions.map((option) => (
14378
16143
  <DropdownMenuItem
14379
- disabled={configureProperty.isPending}
16144
+ key={option.id}
16145
+ className="text-xs"
14380
16146
  onSelect={(event) => {
14381
16147
  event.preventDefault();
14382
- void createFilterOption();
16148
+ chooseFilterOption(option);
14383
16149
  }}
14384
16150
  >
14385
- <IconPlus className="mr-2 size-4 text-muted-foreground" />
14386
- Create &ldquo;{optionQuery.trim()}&rdquo;
16151
+ <span className="min-w-0 flex-1 truncate">
16152
+ {optionProperty?.definition.type === "person" ? (
16153
+ option.name === "Me" ? (
16154
+ "Me"
16155
+ ) : (
16156
+ <PersonPill value={databaseFilterChoiceValue(option)} />
16157
+ )
16158
+ ) : (
16159
+ option.name
16160
+ )}
16161
+ </span>
16162
+ {databaseFilterOptionIsSelected(option, selectedValues) ? (
16163
+ <IconCheck className="size-4 text-muted-foreground" />
16164
+ ) : null}
14387
16165
  </DropdownMenuItem>
14388
- </>
14389
- ) : null}
14390
- </DropdownMenuSubContent>
14391
- </DropdownMenuSub>
16166
+ ))}
16167
+ {canCreateOption ? (
16168
+ <>
16169
+ <DropdownMenuSeparator />
16170
+ <DropdownMenuItem
16171
+ disabled={configureProperty.isPending}
16172
+ className="text-xs"
16173
+ onSelect={(event) => {
16174
+ event.preventDefault();
16175
+ void createFilterOption();
16176
+ }}
16177
+ >
16178
+ <IconPlus className="mr-2 size-4 text-muted-foreground" />
16179
+ Create &ldquo;{optionQuery.trim()}&rdquo;
16180
+ </DropdownMenuItem>
16181
+ </>
16182
+ ) : null}
16183
+ </div>
16184
+ ) : null}
16185
+ </div>
14392
16186
  );
14393
16187
  }
14394
16188
 
@@ -14400,7 +16194,7 @@ function DatabaseFilterValueControl({
14400
16194
  value={filter.value}
14401
16195
  placeholder={filterValuePlaceholder(filter.key, properties)}
14402
16196
  onChange={(event) => onValueChange(event.target.value)}
14403
- className="h-8"
16197
+ className="h-9 text-sm focus-visible:border-[#2383e2] focus-visible:ring-[#2383e2]/25"
14404
16198
  />
14405
16199
  );
14406
16200
  }
@@ -14430,12 +16224,39 @@ const FILTER_OPERATOR_LABELS: Record<FilterOperator, string> = {
14430
16224
  less_than: "Less than",
14431
16225
  before: "Before",
14432
16226
  after: "After",
16227
+ between: "Between",
14433
16228
  is_checked: "Checked",
14434
16229
  is_unchecked: "Unchecked",
14435
16230
  is_empty: "Is empty",
14436
16231
  is_not_empty: "Is not empty",
14437
16232
  };
14438
16233
 
16234
+ function databaseFilterOperatorLabel(
16235
+ operator: FilterOperator,
16236
+ key: string,
16237
+ properties: DocumentProperty[],
16238
+ ) {
16239
+ const type = filterPropertyTypeForKey(key, properties);
16240
+ if (
16241
+ operator === "does_not_equal" &&
16242
+ (type === "select" ||
16243
+ type === "status" ||
16244
+ type === "multi_select" ||
16245
+ type === "person")
16246
+ ) {
16247
+ return "Does not contain";
16248
+ }
16249
+ return FILTER_OPERATOR_LABELS[operator] ?? "Contains";
16250
+ }
16251
+
16252
+ function databaseFilterOperatorInlineLabel(
16253
+ operator: FilterOperator,
16254
+ key: string,
16255
+ properties: DocumentProperty[],
16256
+ ) {
16257
+ return databaseFilterOperatorLabel(operator, key, properties).toLowerCase();
16258
+ }
16259
+
14439
16260
  function filterOperatorsForKey(
14440
16261
  key: string,
14441
16262
  properties: DocumentProperty[],
@@ -14446,8 +16267,13 @@ function filterOperatorsForKey(
14446
16267
  return ["is_checked", "is_unchecked"];
14447
16268
  }
14448
16269
 
14449
- if (type === "select" || type === "status" || type === "multi_select") {
14450
- return ["equals", "does_not_equal", "is_empty", "is_not_empty"];
16270
+ if (
16271
+ type === "select" ||
16272
+ type === "status" ||
16273
+ type === "multi_select" ||
16274
+ type === "person"
16275
+ ) {
16276
+ return ["contains", "does_not_equal", "is_empty", "is_not_empty"];
14451
16277
  }
14452
16278
 
14453
16279
  if (type === "number") {
@@ -14471,6 +16297,7 @@ function filterOperatorsForKey(
14471
16297
  "does_not_equal",
14472
16298
  "before",
14473
16299
  "after",
16300
+ "between",
14474
16301
  "is_empty",
14475
16302
  "is_not_empty",
14476
16303
  ];
@@ -14514,6 +16341,18 @@ function filterValuePlaceholder(key: string, properties: DocumentProperty[]) {
14514
16341
  return "Value";
14515
16342
  }
14516
16343
 
16344
+ function filterOptionSearchPlaceholder(
16345
+ key: string,
16346
+ properties: DocumentProperty[],
16347
+ ) {
16348
+ const type = filterPropertyTypeForKey(key, properties);
16349
+ if (type === "person") return "Search for one or more people...";
16350
+ if (type === "select" || type === "status" || type === "multi_select") {
16351
+ return "Search for one or more options...";
16352
+ }
16353
+ return "Search for an option...";
16354
+ }
16355
+
14517
16356
  function filterValueInputType(type: DocumentPropertyType) {
14518
16357
  if (type === "number") return "number";
14519
16358
  if (type === "date" || type === "created_time" || type === "last_edited_time")
@@ -14521,12 +16360,106 @@ function filterValueInputType(type: DocumentPropertyType) {
14521
16360
  return "text";
14522
16361
  }
14523
16362
 
16363
+ function DatabaseBetweenDateFilterValueControl({
16364
+ filter,
16365
+ onValueChange,
16366
+ }: {
16367
+ filter: DatabaseFilter;
16368
+ onValueChange: (value: string) => void;
16369
+ }) {
16370
+ const [start, end] = databaseFilterDateRangeValues(filter.value);
16371
+ const updateRange = (nextStart: string, nextEnd: string) => {
16372
+ onValueChange(databaseFilterEncodeDateRangeValues(nextStart, nextEnd));
16373
+ };
16374
+
16375
+ return (
16376
+ <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
16377
+ <Input
16378
+ type="date"
16379
+ value={start}
16380
+ aria-label={`Start ${filter.label} filter date`}
16381
+ onChange={(event) => updateRange(event.target.value, end)}
16382
+ className="h-9 text-sm focus-visible:border-[#2383e2] focus-visible:ring-[#2383e2]/25"
16383
+ />
16384
+ <span className="text-xs text-muted-foreground">to</span>
16385
+ <Input
16386
+ type="date"
16387
+ value={end}
16388
+ aria-label={`End ${filter.label} filter date`}
16389
+ onChange={(event) => updateRange(start, event.target.value)}
16390
+ className="h-9 text-sm focus-visible:border-[#2383e2] focus-visible:ring-[#2383e2]/25"
16391
+ />
16392
+ </div>
16393
+ );
16394
+ }
16395
+
14524
16396
  export function databaseFilterOptionChoices(
14525
16397
  key: string,
14526
16398
  properties: DocumentProperty[],
16399
+ items: ContentDatabaseItem[] = [],
16400
+ currentUserEmail = "",
16401
+ ): DatabaseFilterChoice[] {
16402
+ const property = databaseFilterPropertyForKey(key, properties);
16403
+ if (property?.definition.type === "person") {
16404
+ return databaseFilterPersonChoices(
16405
+ property.definition.id,
16406
+ items,
16407
+ currentUserEmail,
16408
+ );
16409
+ }
16410
+ const optionProperty = databaseFilterOptionPropertyForKey(key, properties);
16411
+ return optionProperty?.definition.options.options ?? [];
16412
+ }
16413
+
16414
+ type DatabaseFilterChoice = DocumentPropertyOption & {
16415
+ filterValue?: string;
16416
+ };
16417
+
16418
+ function databaseFilterChoiceValue(option: DatabaseFilterChoice) {
16419
+ return option.filterValue ?? option.id;
16420
+ }
16421
+
16422
+ function databaseFilterPersonChoices(
16423
+ propertyId: string,
16424
+ items: ContentDatabaseItem[],
16425
+ currentUserEmail = "",
14527
16426
  ) {
14528
- const property = databaseFilterOptionPropertyForKey(key, properties);
14529
- return property?.definition.options.options ?? [];
16427
+ const people = new Map<string, string>();
16428
+ for (const item of items) {
16429
+ const property = item.properties.find(
16430
+ (candidate) => candidate.definition.id === propertyId,
16431
+ );
16432
+ for (const person of personItems(property?.value ?? null)) {
16433
+ const key = person.trim().toLowerCase();
16434
+ if (!people.has(key)) people.set(key, person);
16435
+ }
16436
+ }
16437
+ const choices: DatabaseFilterChoice[] = [];
16438
+ const currentUserLabel = currentUserEmail
16439
+ ? personLabel(currentUserEmail)
16440
+ : "";
16441
+ if (currentUserEmail) {
16442
+ const matchingPerson =
16443
+ Array.from(people.values()).find(
16444
+ (person) =>
16445
+ person.toLowerCase() === currentUserEmail.toLowerCase() ||
16446
+ personLabel(person).toLowerCase() === currentUserLabel.toLowerCase(),
16447
+ ) ?? currentUserEmail;
16448
+ choices.push({
16449
+ id: `__current_user__:${matchingPerson}`,
16450
+ name: "Me",
16451
+ color: "gray" as const,
16452
+ filterValue: matchingPerson,
16453
+ });
16454
+ }
16455
+ choices.push(
16456
+ ...Array.from(people.values()).map((person) => ({
16457
+ id: person,
16458
+ name: personLabel(person),
16459
+ color: "gray" as const,
16460
+ })),
16461
+ );
16462
+ return choices;
14530
16463
  }
14531
16464
 
14532
16465
  export function databaseFilterOptionPropertyForKey(
@@ -14538,7 +16471,8 @@ export function databaseFilterOptionPropertyForKey(
14538
16471
  if (
14539
16472
  property.definition.type !== "select" &&
14540
16473
  property.definition.type !== "status" &&
14541
- property.definition.type !== "multi_select"
16474
+ property.definition.type !== "multi_select" &&
16475
+ property.definition.type !== "person"
14542
16476
  ) {
14543
16477
  return null;
14544
16478
  }
@@ -14549,18 +16483,105 @@ function databaseFilterValueLabel(
14549
16483
  filter: DatabaseFilter,
14550
16484
  properties: DocumentProperty[],
14551
16485
  ) {
14552
- const option = databaseFilterOptionChoices(filter.key, properties).find(
14553
- (candidate) =>
14554
- candidate.id === filter.value || candidate.name === filter.value,
16486
+ if (filter.operator === "between") {
16487
+ const [start, end] = databaseFilterDateRangeValues(filter.value);
16488
+ if (start && end) return `${start} to ${end}`;
16489
+ return start || end || "Choose dates";
16490
+ }
16491
+ const values = databaseFilterSelectedValues(filter.value);
16492
+ const options = databaseFilterOptionChoices(filter.key, properties);
16493
+ if (options.length > 0) {
16494
+ const labels = values.map((value) => {
16495
+ const option = options.find(
16496
+ (candidate) =>
16497
+ candidate.id === value ||
16498
+ candidate.name.trim().toLowerCase() === value.trim().toLowerCase(),
16499
+ );
16500
+ return option?.name ?? value;
16501
+ });
16502
+ return labels.length > 0 ? labels.join(", ") : "Choose option";
16503
+ }
16504
+ return values[0] || "Choose option";
16505
+ }
16506
+
16507
+ function databaseFilterSelectedValues(value: string) {
16508
+ const trimmed = value.trim();
16509
+ if (!trimmed) return [];
16510
+ try {
16511
+ const parsed = JSON.parse(trimmed) as unknown;
16512
+ if (!Array.isArray(parsed)) return [trimmed];
16513
+ return [
16514
+ ...new Set(
16515
+ parsed
16516
+ .filter((item): item is string => typeof item === "string")
16517
+ .map((item) => item.trim())
16518
+ .filter(Boolean),
16519
+ ),
16520
+ ];
16521
+ } catch {
16522
+ return [trimmed];
16523
+ }
16524
+ }
16525
+
16526
+ function databaseFilterDateRangeValues(value: string): [string, string] {
16527
+ const trimmed = value.trim();
16528
+ if (!trimmed) return ["", ""];
16529
+ try {
16530
+ const parsed = JSON.parse(trimmed) as unknown;
16531
+ if (!Array.isArray(parsed)) return [trimmed, ""];
16532
+ const start = typeof parsed[0] === "string" ? parsed[0].trim() : "";
16533
+ const end = typeof parsed[1] === "string" ? parsed[1].trim() : "";
16534
+ return [start, end];
16535
+ } catch {
16536
+ return [trimmed, ""];
16537
+ }
16538
+ }
16539
+
16540
+ function databaseFilterEncodeDateRangeValues(start: string, end: string) {
16541
+ const normalizedStart = start.trim();
16542
+ const normalizedEnd = end.trim();
16543
+ if (!normalizedStart && !normalizedEnd) return "";
16544
+ return JSON.stringify([normalizedStart, normalizedEnd]);
16545
+ }
16546
+
16547
+ function databaseFilterDateRangeValue(value: string): [number, number] | null {
16548
+ const [startValue, endValue] = databaseFilterDateRangeValues(value);
16549
+ const start = new Date(startValue).getTime();
16550
+ const end = new Date(endValue).getTime();
16551
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
16552
+ return start <= end ? [start, end] : [end, start];
16553
+ }
16554
+
16555
+ function databaseFilterEncodeSelectedValues(values: string[]) {
16556
+ const normalized = [
16557
+ ...new Set(values.map((value) => value.trim()).filter(Boolean)),
16558
+ ];
16559
+ if (normalized.length === 0) return "";
16560
+ if (normalized.length === 1) return normalized[0];
16561
+ return JSON.stringify(normalized);
16562
+ }
16563
+
16564
+ function databaseFilterOptionIsSelected(
16565
+ option: DatabaseFilterChoice,
16566
+ values: string[],
16567
+ ) {
16568
+ const optionValue = databaseFilterChoiceValue(option);
16569
+ const optionName = option.name.trim().toLowerCase();
16570
+ return values.some(
16571
+ (value) =>
16572
+ value === optionValue || value.trim().toLowerCase() === optionName,
14555
16573
  );
14556
- return (option?.name ?? filter.value) || "Choose option";
14557
16574
  }
14558
16575
 
14559
16576
  function databaseFilterChipLabel(
14560
16577
  filter: DatabaseFilter,
14561
16578
  properties: DocumentProperty[],
14562
16579
  ) {
14563
- const operator = FILTER_OPERATOR_LABELS[filter.operator] ?? "Contains";
16580
+ const operator = databaseFilterOperatorLabel(
16581
+ filter.operator,
16582
+ filter.key,
16583
+ properties,
16584
+ );
14564
16585
  if (!filterOperatorNeedsValue(filter.operator)) {
14565
16586
  return `${filter.label} ${operator.toLowerCase()}`;
14566
16587
  }
@@ -14570,6 +16591,23 @@ function databaseFilterChipLabel(
14570
16591
  )}`;
14571
16592
  }
14572
16593
 
16594
+ export function databaseInlineFilterLabel(
16595
+ filter: DatabaseFilter,
16596
+ properties: DocumentProperty[],
16597
+ ) {
16598
+ if (!filterOperatorNeedsValue(filter.operator)) {
16599
+ return `${filter.label}: ${databaseFilterOperatorLabel(
16600
+ filter.operator,
16601
+ filter.key,
16602
+ properties,
16603
+ )}`;
16604
+ }
16605
+ if (databaseFilterSelectedValues(filter.value).length === 0) {
16606
+ return filter.label;
16607
+ }
16608
+ return `${filter.label}: ${databaseFilterValueLabel(filter, properties)}`;
16609
+ }
16610
+
14573
16611
  function databaseFilterPropertyForKey(
14574
16612
  key: string,
14575
16613
  properties: DocumentProperty[],