From bd10825e741930bdffc1a6763da61d8f39ecc8b1 Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 27 Feb 2026 16:36:45 +0100 Subject: [PATCH] feat: wiki like linkage for posts --- src/renderer/components/Editor/Editor.tsx | 4 + .../components/InsertModal/InsertModal.css | 55 ++++++ .../components/InsertModal/InsertModal.tsx | 90 +++++++-- .../MilkdownEditor/MilkdownEditor.tsx | 12 +- src/renderer/i18n/locales/de.json | 2 + src/renderer/i18n/locales/en.json | 2 + src/renderer/i18n/locales/es.json | 2 + src/renderer/i18n/locales/fr.json | 2 + src/renderer/i18n/locales/it.json | 2 + src/renderer/store/appStore.ts | 19 +- .../renderer/components/InsertModal.test.tsx | 172 +++++++++++++++++- tests/renderer/store/appStore.test.ts | 46 +++++ 12 files changed, 390 insertions(+), 18 deletions(-) diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index c7b27db..d24e50b 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -847,6 +847,8 @@ export const PostEditor: React.FC = ({ postId }) => { content={content} onChange={setContent} placeholder={tr('editor.placeholder.startWriting')} + currentPostTags={tags} + currentPostCategories={selectedCategories} /> )} @@ -921,6 +923,8 @@ export const PostEditor: React.FC = ({ postId }) => { onInsertLink={handleInsertLink} onInsertImage={() => {}} onClose={() => setShowPostSearch(false)} + currentPostTags={tags} + currentPostCategories={selectedCategories} /> )} diff --git a/src/renderer/components/InsertModal/InsertModal.css b/src/renderer/components/InsertModal/InsertModal.css index 99456ff..ade5ff2 100644 --- a/src/renderer/components/InsertModal/InsertModal.css +++ b/src/renderer/components/InsertModal/InsertModal.css @@ -240,3 +240,58 @@ .insert-modal-results::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted, #555); } + +/* Create post option */ +.insert-modal-result-create { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 12px 16px; + border: none; + border-top: 1px solid var(--color-border, #3c3c3c); + margin-top: 4px; + padding-top: 16px; + background: transparent; + color: var(--vscode-notificationsInfoIcon-foreground, #75beff); + font-family: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; + border-radius: 0 0 4px 4px; + transition: background-color 0.15s ease; +} + +.insert-modal-result-create:first-child { + border-top: none; + margin-top: 0; + padding-top: 12px; +} + +.insert-modal-result-create:hover, +.insert-modal-result-create.selected { + background: var(--color-bg-tertiary, #2a2a2a); +} + +.insert-modal-result-create.selected { + border-left: 3px solid var(--vscode-notificationsInfoIcon-foreground, #75beff); + padding-left: 13px; +} + +.insert-modal-result-create:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.insert-modal-create-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1px dashed currentColor; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + flex-shrink: 0; +} diff --git a/src/renderer/components/InsertModal/InsertModal.tsx b/src/renderer/components/InsertModal/InsertModal.tsx index 40e8a92..09fad95 100644 --- a/src/renderer/components/InsertModal/InsertModal.tsx +++ b/src/renderer/components/InsertModal/InsertModal.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useI18n } from '../../i18n'; +import { useAppStore } from '../../store/appStore'; +import { showToast } from '../Toast'; import './InsertModal.css'; interface PostSearchResult { @@ -20,8 +22,8 @@ interface MediaSearchResult { /** Get display name for media: title (truncated to 60 chars) or fallback to filename */ function getMediaDisplayName(media: MediaSearchResult): string { if (media.title) { - return media.title.length > 60 - ? media.title.substring(0, 60) + '...' + return media.title.length > 60 + ? media.title.substring(0, 60) + '...' : media.title; } return media.originalName; @@ -38,6 +40,8 @@ interface InsertModalProps { onInsertImage: (url: string, alt: string, mediaId?: string) => void; onClose: () => void; initialText?: string; // Selected text in editor + currentPostTags?: string[]; + currentPostCategories?: string[]; } function isPostResult(result: SearchResult): result is PostSearchResult { @@ -54,8 +58,11 @@ export const InsertModal: React.FC = ({ onInsertImage, onClose, initialText = '', + currentPostTags, + currentPostCategories, }) => { const { t: tr } = useI18n(); + const openTabInBackground = useAppStore((s) => s.openTabInBackground); const [activeTab, setActiveTab] = useState('internal'); const [query, setQuery] = useState(''); const [externalUrl, setExternalUrl] = useState(''); @@ -64,9 +71,20 @@ export const InsertModal: React.FC = ({ const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [isSearching, setIsSearching] = useState(false); + const [isCreating, setIsCreating] = useState(false); const inputRef = useRef(null); const externalUrlRef = useRef(null); + // Whether to show the "Create post" option + const showCreateOption = mode === 'link' && + activeTab === 'internal' && + query.trim().length >= 2 && + !isSearching && + !results.some(r => isPostResult(r) && r.title.toLowerCase() === query.trim().toLowerCase()); + + // Total selectable items count (results + optional create option) + const totalItems = results.length + (showCreateOption ? 1 : 0); + // Focus appropriate input on mount and tab change useEffect(() => { if (activeTab === 'internal') { @@ -106,6 +124,34 @@ export const InsertModal: React.FC = ({ return () => clearTimeout(timeoutId); }, [query, mode, activeTab]); + // Handle creating a new post from the search query + const handleCreatePost = useCallback(async () => { + const title = query.trim(); + if (!title || isCreating) return; + + setIsCreating(true); + try { + const newPost = await window.electronAPI.posts.create({ + title, + tags: currentPostTags || [], + categories: currentPostCategories || [], + }); + + if (newPost) { + openTabInBackground({ type: 'post', id: newPost.id, isTransient: false }); + const linkUrl = `/posts/${newPost.slug}`; + onInsertLink(linkUrl, title); + showToast.success(tr('insert.createdPost', { title })); + onClose(); + } + } catch (error) { + const err = error as Error; + showToast.error(err.message); + } finally { + setIsCreating(false); + } + }, [query, isCreating, currentPostTags, currentPostCategories, openTabInBackground, onInsertLink, onClose, tr]); + // Keyboard navigation handler const handleKeyDown = useCallback((e: React.KeyboardEvent) => { switch (e.key) { @@ -116,7 +162,7 @@ export const InsertModal: React.FC = ({ case 'ArrowDown': if (activeTab === 'internal') { e.preventDefault(); - setSelectedIndex(prev => Math.min(prev + 1, results.length - 1)); + setSelectedIndex(prev => Math.min(prev + 1, totalItems - 1)); } break; case 'ArrowUp': @@ -127,9 +173,13 @@ export const InsertModal: React.FC = ({ break; case 'Enter': e.preventDefault(); - if (activeTab === 'internal' && results[selectedIndex]) { - handleSelectResult(results[selectedIndex]); - } else if (activeTab === 'external' && externalUrl) { + if (activeTab === 'internal') { + if (selectedIndex < results.length && results[selectedIndex]) { + handleSelectResult(results[selectedIndex]); + } else if (showCreateOption && selectedIndex === results.length) { + handleCreatePost(); + } + } else if (externalUrl) { handleExternalSubmit(); } break; @@ -137,7 +187,7 @@ export const InsertModal: React.FC = ({ // Allow tab switching with Tab key when on the tab buttons break; } - }, [activeTab, results, selectedIndex, externalUrl, onClose]); + }, [activeTab, results, selectedIndex, totalItems, showCreateOption, externalUrl, onClose, handleCreatePost]); // Handle selecting a search result const handleSelectResult = useCallback(async (result: SearchResult) => { @@ -161,7 +211,7 @@ export const InsertModal: React.FC = ({ // Handle external URL submission const handleExternalSubmit = useCallback(() => { if (!externalUrl) return; - + if (mode === 'link') { onInsertLink(externalUrl, externalText || undefined); } else { @@ -180,7 +230,7 @@ export const InsertModal: React.FC = ({ // Scroll selected item into view useEffect(() => { - const selectedElement = document.querySelector('.insert-modal-result-item.selected'); + const selectedElement = document.querySelector('.insert-modal-result-item.selected, .insert-modal-result-create.selected'); if (selectedElement) { selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } @@ -189,7 +239,7 @@ export const InsertModal: React.FC = ({ const title = mode === 'link' ? tr('insert.title.link') : tr('insert.title.image'); const internalLabel = mode === 'link' ? tr('insert.tab.linkInternal') : tr('insert.tab.imageInternal'); const externalLabel = mode === 'link' ? tr('insert.tab.linkExternal') : tr('insert.tab.imageExternal'); - const searchPlaceholder = mode === 'link' + const searchPlaceholder = mode === 'link' ? tr('insert.searchPlaceholder.link') : tr('insert.searchPlaceholder.image'); @@ -224,6 +274,7 @@ export const InsertModal: React.FC = ({ placeholder={searchPlaceholder} value={query} onChange={(e) => setQuery(e.target.value)} + onInput={(e) => setQuery((e.target as HTMLInputElement).value)} autoComplete="off" /> @@ -239,7 +290,7 @@ export const InsertModal: React.FC = ({ )} - {!isSearching && query.length >= 2 && results.length === 0 && ( + {!isSearching && query.length >= 2 && results.length === 0 && !showCreateOption && (
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
@@ -274,6 +325,19 @@ export const InsertModal: React.FC = ({ )} ))} + + {showCreateOption && ( + + )} ) : ( @@ -290,7 +354,7 @@ export const InsertModal: React.FC = ({ autoComplete="off" /> - + {mode === 'link' ? (
@@ -328,7 +392,7 @@ export const InsertModal: React.FC = ({
- {activeTab === 'internal' + {activeTab === 'internal' ? tr('insert.hint.internal') : tr('insert.hint.external')} diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index 2caec2d..532166b 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -54,6 +54,8 @@ interface MilkdownEditorProps { content: string; onChange: (markdown: string) => void; placeholder?: string; + currentPostTags?: string[]; + currentPostCategories?: string[]; } interface MilkdownChangePropagationInput { @@ -86,10 +88,12 @@ export const shouldPropagateMilkdownChange = ({ interface EditorToolbarProps { onUserInteraction: () => void; + currentPostTags?: string[]; + currentPostCategories?: string[]; } // Toolbar component that uses the editor instance -const EditorToolbar: React.FC = ({ onUserInteraction }) => { +const EditorToolbar: React.FC = ({ onUserInteraction, currentPostTags, currentPostCategories }) => { const { t: tr } = useI18n(); const [loading, getEditor] = useInstance(); const [insertMode, setInsertMode] = useState(null); @@ -269,6 +273,8 @@ const EditorToolbar: React.FC = ({ onUserInteraction }) => { onInsertImage={handleInsertImage} onClose={() => setInsertMode(null)} initialText={selectedText} + currentPostTags={currentPostTags} + currentPostCategories={currentPostCategories} /> )} @@ -289,6 +295,8 @@ const MilkdownProviderInner: React.FC = ({ content, onChange, placeholder, + currentPostTags, + currentPostCategories, }) => { const { t: tr } = useI18n(); const resolvedPlaceholder = placeholder || tr('editor.placeholder'); @@ -376,7 +384,7 @@ const MilkdownProviderInner: React.FC = ({ onPasteCapture={markUserInteraction} onInputCapture={markUserInteraction} > - +
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 38f1b52..45d9afd 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -248,6 +248,8 @@ "insert.hint.external": "URL eingeben und Enter drücken oder auf die Schaltfläche klicken, Esc zum Schließen", "insert.hint.canonicalPost": "Kanonisch: /YYYY/MM/DD/slug", "insert.hint.canonicalMedia": "Kanonisch: /media/YYYY/MM/datei.ext", + "insert.createPost": "Beitrag \"{title}\" erstellen", + "insert.createdPost": "Beitrag \"{title}\" erstellt", "postLinks.loading": "Links werden geladen...", "postLinks.link": "Link", "postLinks.links": "Links", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 3c304a5..35b24c1 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -248,6 +248,8 @@ "insert.hint.external": "Enter URL and press Enter or click button, Esc to close", "insert.hint.canonicalPost": "Canonical: /YYYY/MM/DD/slug", "insert.hint.canonicalMedia": "Canonical: /media/YYYY/MM/file.ext", + "insert.createPost": "Create post \"{title}\"", + "insert.createdPost": "Post \"{title}\" created", "postLinks.loading": "Loading links...", "postLinks.link": "link", "postLinks.links": "links", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index f1c5325..3afaad8 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -248,6 +248,8 @@ "insert.hint.external": "Introduce la URL y pulsa Enter o haz clic en el botón, Esc para cerrar", "insert.hint.canonicalPost": "Canónico: /YYYY/MM/DD/slug", "insert.hint.canonicalMedia": "Canónico: /media/YYYY/MM/archivo.ext", + "insert.createPost": "Crear artículo \"{title}\"", + "insert.createdPost": "Artículo \"{title}\" creado", "postLinks.loading": "Cargando enlaces...", "postLinks.link": "enlace", "postLinks.links": "enlaces", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 5115801..9638a0b 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -246,6 +246,8 @@ "insert.hint.external": "Entrez l’URL et appuyez sur Entrée ou cliquez sur le bouton, Esc pour fermer", "insert.hint.canonicalPost": "Canonique : /YYYY/MM/DD/slug", "insert.hint.canonicalMedia": "Canonique : /media/YYYY/MM/fichier.ext", + "insert.createPost": "Créer l'article « {title} »", + "insert.createdPost": "Article « {title} » créé", "postLinks.loading": "Chargement des liens...", "postLinks.link": "lien", "postLinks.links": "liens", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 5cc6d52..aba7a37 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -246,6 +246,8 @@ "insert.hint.external": "Inserisci URL e premi Invio o clicca il pulsante, Esc per chiudere", "insert.hint.canonicalPost": "Canonico: /YYYY/MM/DD/slug", "insert.hint.canonicalMedia": "Canonico: /media/YYYY/MM/file.ext", + "insert.createPost": "Crea articolo \"{title}\"", + "insert.createdPost": "Articolo \"{title}\" creato", "postLinks.loading": "Caricamento link...", "postLinks.link": "collegamento", "postLinks.links": "link", diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index f394b05..23faa8c 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -118,6 +118,7 @@ interface AppState { // Tab Actions openTab: (tab: { type: TabType; id: string; isTransient: boolean }) => void; + openTabInBackground: (tab: { type: TabType; id: string; isTransient: boolean }) => void; closeTab: (id: string) => void; setActiveTab: (id: string) => void; pinTab: (id: string) => void; @@ -274,7 +275,23 @@ export const useAppStore = create()( const newTab: Tab = { type, id, isTransient }; return { tabs: [...state.tabs, newTab], activeTabId: id }; }), - + + openTabInBackground: ({ type, id, isTransient }) => set((state) => { + const existingTabIndex = state.tabs.findIndex((t) => t.id === id && t.type === type); + + if (existingTabIndex >= 0) { + if (!isTransient) { + const updatedTabs = [...state.tabs]; + updatedTabs[existingTabIndex] = { ...updatedTabs[existingTabIndex], isTransient: false }; + return { tabs: updatedTabs }; + } + return state; + } + + const newTab: Tab = { type, id, isTransient }; + return { tabs: [...state.tabs, newTab] }; + }), + closeTab: (id) => set((state) => { const tabIndex = state.tabs.findIndex((t) => t.id === id); if (tabIndex === -1) return state; diff --git a/tests/renderer/components/InsertModal.test.tsx b/tests/renderer/components/InsertModal.test.tsx index cfed947..984fe20 100644 --- a/tests/renderer/components/InsertModal.test.tsx +++ b/tests/renderer/components/InsertModal.test.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import { InsertModal } from '../../../src/renderer/components/InsertModal/InsertModal'; +import { useAppStore } from '../../../src/renderer/store/appStore'; describe('InsertModal format hints', () => { it('shows canonical post link format hint in internal link mode', () => { @@ -30,3 +31,170 @@ describe('InsertModal format hints', () => { expect(screen.getByText('Canonical: /media/YYYY/MM/file.ext')).toBeInTheDocument(); }); }); + +describe('InsertModal create post', () => { + const mockOnInsertLink = vi.fn(); + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + useAppStore.setState({ tabs: [], activeTabId: 'current-post' }); + }); + + it('does not show create option when query is shorter than 2 characters', () => { + render( + + ); + + const input = screen.getByPlaceholderText('Search posts by title or content...'); + fireEvent.input(input, { target: { value: 'a' } }); + + expect(screen.queryByText(/Create post/)).not.toBeInTheDocument(); + }); + + it('does not show create option in image mode', async () => { + (window.electronAPI.media.search as ReturnType).mockResolvedValue([]); + + render( + + ); + + const input = screen.getByPlaceholderText('Search media by name, title, or alt text...'); + fireEvent.input(input, { target: { value: 'test query' } }); + + // Wait for search to complete by finding the no-results message + await screen.findByText(/No.*found/i); + + expect(screen.queryByText(/Create post/)).not.toBeInTheDocument(); + }); + + it('shows create option when search has no exact title match', async () => { + (window.electronAPI.posts.search as ReturnType).mockResolvedValue([ + { id: 'p1', title: 'Different Title', slug: 'different-title', excerpt: 'Some text' }, + ]); + + render( + + ); + + const input = screen.getByPlaceholderText('Search posts by title or content...'); + fireEvent.input(input, { target: { value: 'My New Post' } }); + + // Wait for search results to render + await screen.findByText('Different Title'); + + expect(screen.getByText('Create post "My New Post"')).toBeInTheDocument(); + }); + + it('does not show create option when an exact title match exists', async () => { + (window.electronAPI.posts.search as ReturnType).mockResolvedValue([ + { id: 'p1', title: 'My New Post', slug: 'my-new-post', excerpt: '' }, + ]); + + render( + + ); + + const input = screen.getByPlaceholderText('Search posts by title or content...'); + fireEvent.input(input, { target: { value: 'My New Post' } }); + + // Wait for results to render (slug appears in the result path) + await screen.findByText(/my-new-post/); + + expect(screen.queryByText('Create post "My New Post"')).not.toBeInTheDocument(); + }); + + it('creates post and inserts link when create option is clicked', async () => { + (window.electronAPI.posts.search as ReturnType).mockResolvedValue([]); + (window.electronAPI.posts.create as ReturnType).mockResolvedValue({ + id: 'new-post-id', + title: 'New Post Title', + slug: 'new-post-title', + content: '', + status: 'draft', + tags: ['tag1'], + categories: ['article'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + render( + + ); + + const input = screen.getByPlaceholderText('Search posts by title or content...'); + fireEvent.input(input, { target: { value: 'New Post Title' } }); + + // Wait for the create option to appear after debounced search completes + const createButton = await screen.findByText('Create post "New Post Title"'); + + await act(async () => { + fireEvent.click(createButton); + }); + + expect(window.electronAPI.posts.create).toHaveBeenCalledWith({ + title: 'New Post Title', + tags: ['tag1'], + categories: ['article'], + }); + + expect(mockOnInsertLink).toHaveBeenCalledWith('/posts/new-post-title', 'New Post Title'); + expect(mockOnClose).toHaveBeenCalled(); + + // Check that the tab was opened in the background + const storeState = useAppStore.getState(); + expect(storeState.tabs).toContainEqual( + expect.objectContaining({ type: 'post', id: 'new-post-id', isTransient: false }) + ); + expect(storeState.activeTabId).toBe('current-post'); + }); + + it('shows create option when no results exist (standalone)', async () => { + (window.electronAPI.posts.search as ReturnType).mockResolvedValue([]); + + render( + + ); + + const input = screen.getByPlaceholderText('Search posts by title or content...'); + fireEvent.input(input, { target: { value: 'Nonexistent Post' } }); + + // Wait for create option to appear (replaces no-results message) + expect(await screen.findByText('Create post "Nonexistent Post"')).toBeInTheDocument(); + + // The "no results" message should not appear when create option is shown + expect(screen.queryByText(/No posts found/i)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/renderer/store/appStore.test.ts b/tests/renderer/store/appStore.test.ts index 4a48dae..e51ce37 100644 --- a/tests/renderer/store/appStore.test.ts +++ b/tests/renderer/store/appStore.test.ts @@ -215,4 +215,50 @@ describe('AppStore', () => { expectTypeOf().toEqualTypeOf(); }); }); + + describe('Tab Management', () => { + beforeEach(() => { + setState({ + tabs: [], + activeTabId: null, + }); + }); + + it('should open a tab in the background without changing activeTabId', () => { + // Set up an existing active tab + getStore().openTab({ type: 'post', id: 'existing-post', isTransient: false }); + expect(getStore().activeTabId).toBe('existing-post'); + + // Open a new tab in the background + getStore().openTabInBackground({ type: 'post', id: 'background-post', isTransient: false }); + + expect(getStore().tabs).toHaveLength(2); + expect(getStore().tabs[1].id).toBe('background-post'); + expect(getStore().activeTabId).toBe('existing-post'); + }); + + it('should not duplicate a tab when opening in background if it already exists', () => { + getStore().openTab({ type: 'post', id: 'post-1', isTransient: false }); + getStore().openTabInBackground({ type: 'post', id: 'post-1', isTransient: false }); + + expect(getStore().tabs).toHaveLength(1); + }); + + it('should pin an existing transient tab when opening in background as non-transient', () => { + getStore().openTab({ type: 'post', id: 'post-1', isTransient: true }); + getStore().openTabInBackground({ type: 'post', id: 'post-1', isTransient: false }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().tabs[0].isTransient).toBe(false); + }); + + it('should preserve null activeTabId when opening background tab with no prior active tab', () => { + setState({ activeTabId: null, tabs: [] }); + + getStore().openTabInBackground({ type: 'post', id: 'bg-post', isTransient: false }); + + expect(getStore().tabs).toHaveLength(1); + expect(getStore().activeTabId).toBeNull(); + }); + }); });