Skip to content
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/de/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Bereits geteilt"
},
"Option_shareButton_modified_tooltip": {
"message": "Nicht synchronisierte Änderungen. Zum Synchronisieren klicken"
},
"Option_remove_title": {
"message": "Dies löschen?"
},
Expand Down
4 changes: 4 additions & 0 deletions packages/extension/public/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -833,6 +833,10 @@
"message": "Already shared",
"description": "Tooltip for the share button when the command has already been shared to the Hub. Clicking opens the hub dashboard for this command."
},
"Option_shareButton_modified_tooltip": {
"message": "Unsynced changes. Click to sync",
"description": "Tooltip for the share button when the command has already been shared to the Hub but has local edits not yet pushed. Clicking pushes the edits to the Hub."
},
"Option_remove_title": {
"message": "Delete this?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/es/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Ya compartido"
},
"Option_shareButton_modified_tooltip": {
"message": "Cambios sin sincronizar. Haz clic para sincronizar"
},
"Option_remove_title": {
"message": "¿Eliminar esto?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/fr/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Déjà partagé"
},
"Option_shareButton_modified_tooltip": {
"message": "Modifications non synchronisées. Cliquez pour synchroniser"
},
"Option_remove_title": {
"message": "Supprimer ceci ?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/hi/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "पहले से साझा किया गया"
},
"Option_shareButton_modified_tooltip": {
"message": "बदलाव सिंक नहीं हुए। सिंक करने के लिए क्लिक करें"
},
"Option_remove_title": {
"message": "क्या आप वाकई इसे हटाना चाहते हैं?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/id/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Sudah dibagikan"
},
"Option_shareButton_modified_tooltip": {
"message": "Ada perubahan yang belum disinkronkan. Klik untuk menyinkronkan"
},
"Option_remove_title": {
"message": "Hapus ini?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/it/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Già condiviso"
},
"Option_shareButton_modified_tooltip": {
"message": "Modifiche non sincronizzate. Fai clic per sincronizzare"
},
"Option_remove_title": {
"message": "Sei sicuro di voler eliminare questo?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/ja/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "共有済み"
},
"Option_shareButton_modified_tooltip": {
"message": "未反映の変更あり。クリックして反映"
},
"Option_remove_title": {
"message": "削除しますか?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/ko/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "이미 공유됨"
},
"Option_shareButton_modified_tooltip": {
"message": "반영되지 않은 변경 사항 있음. 클릭하여 반영"
},
"Option_remove_title": {
"message": "이것을 삭제하시겠습니까?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/ms/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Sudah dikongsi"
},
"Option_shareButton_modified_tooltip": {
"message": "Ada perubahan belum disegerakkan. Klik untuk segerakkan"
},
"Option_remove_title": {
"message": "Padam ini?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/pt_BR/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Já compartilhado"
},
"Option_shareButton_modified_tooltip": {
"message": "Alterações não sincronizadas. Clique para sincronizar"
},
"Option_remove_title": {
"message": "Excluir isto?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/pt_PT/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Já partilhado"
},
"Option_shareButton_modified_tooltip": {
"message": "Alterações não sincronizadas. Clique para sincronizar"
},
"Option_remove_title": {
"message": "Eliminar isto?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/ru/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "Уже опубликовано"
},
"Option_shareButton_modified_tooltip": {
"message": "Есть несинхронизированные изменения. Нажмите, чтобы синхронизировать"
},
"Option_remove_title": {
"message": "Вы уверены, что хотите удалить это?"
},
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/public/_locales/zh_CN/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@
"Option_shareButton_shared_tooltip": {
"message": "已共享"
},
"Option_shareButton_modified_tooltip": {
"message": "有未同步的更改。点击同步"
},
"Option_remove_title": {
"message": "您确定要删除吗?"
},
Expand Down
91 changes: 62 additions & 29 deletions packages/extension/src/components/option/ShareButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useRef } from "react"
import { Share, CloudCheck } from "lucide-react"
import { CloudCheck, CloudUpload } from "lucide-react"
import { Tooltip } from "@/components/Tooltip"
import { cn, isUUIDv7, generateId } from "@/lib/utils"
import { t } from "@/services/i18n"
Expand All @@ -8,6 +8,7 @@ import {
getHubLocale,
isHubShareable,
isHubRegistered,
pushEditToHub,
} from "@/services/hubShare"
import {
sendEvent,
Expand All @@ -23,22 +24,40 @@ type Props = {
command: SelectionCommand
onCommandIdChange?: (newId: string) => void
isShared?: boolean
// Shared command with local edits not yet pushed to the Hub
isModified?: boolean
}

export const ShareButton = ({
command,
onCommandIdChange,
isShared,
isModified,
}: Props) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [status, setStatus] = useState<"idle" | "pending" | "sent" | "error">(
"idle",
)

const shareState: "unshared" | "shared" | "modified" = !isShared
? "unshared"
: isModified
? "modified"
: "shared"

const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation()

if (isShared) {
if (shareState === "modified") {
// Push the local edits to the Hub. The mark clears once the Hub
// acknowledges and the new content hash is saved.
const ok = pushEditToHub(command)
setStatus(ok ? "sent" : "error")
setTimeout(() => setStatus("idle"), 2000)
return
}

if (shareState === "shared") {
// Open the hub dashboard page for the shared command
const locale = getHubLocale()
const url = `${NEW_HUB_URL}/${locale}/dashboard/mycommands?id=${encodeURIComponent(command.id)}`
Expand Down Expand Up @@ -94,6 +113,29 @@ export const ShareButton = ({
return null
}

const iconClassName = cn(
"stroke-gray-500",
shareState === "modified"
? "group-hover/share-btn:stroke-orange-600"
: "group-hover/share-btn:stroke-green-600",
status === "error" && "stroke-red-500",
)

const { testId, tooltip } = {
unshared: {
testId: TEST_IDS.shareCommandButton,
tooltip: t("Option_shareButton_tooltip"),
},
shared: {
testId: TEST_IDS.sharedCommandButton,
tooltip: t("Option_shareButton_shared_tooltip"),
},
modified: {
testId: TEST_IDS.modifiedSharedCommandButton,
tooltip: t("Option_shareButton_modified_tooltip"),
},
}[shareState]

return (
<>
<button
Expand All @@ -102,39 +144,30 @@ export const ShareButton = ({
className={cn(
"outline-gray-200 p-2 rounded-md transition hover:bg-green-100 hover:scale-125 group/share-btn",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none",
shareState === "modified" && "bg-orange-50 hover:bg-orange-100",
)}
onClick={handleClick}
ref={buttonRef}
data-testid={
isShared ? TEST_IDS.sharedCommandButton : TEST_IDS.shareCommandButton
}
aria-label={tooltip}
data-testid={testId}
>
{!isShared ? (
<Share
className={cn(
"stroke-gray-500 group-hover/share-btn:stroke-green-600",
status === "error" && "stroke-red-500",
)}
size={16}
/>
) : (
<CloudCheck
className={cn(
"stroke-gray-500 group-hover/share-btn:stroke-green-600",
status === "error" && "stroke-red-500",
)}
size={16}
/>
{shareState === "unshared" && (
<CloudUpload className={iconClassName} size={16} />
)}
{shareState === "shared" && (
<CloudCheck className={iconClassName} size={16} />
)}
{shareState === "modified" && (
<span className="relative block">
<CloudUpload className={iconClassName} size={16} />
<span
aria-hidden="true"
className="absolute -top-1 -right-1 w-1.5 h-1.5 rounded-full bg-orange-500"
/>
</span>
)}
</button>
<Tooltip
positionElm={buttonRef.current}
text={
isShared
? t("Option_shareButton_shared_tooltip")
: t("Option_shareButton_tooltip")
}
/>
<Tooltip positionElm={buttonRef.current} text={tooltip} />
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { ShareButton } from "../ShareButton"
import { sendEvent, sendHubLinkClick } from "@/services/analytics"
import { shareCommandToHub, isHubRegistered } from "@/services/hubShare"
import {
shareCommandToHub,
isHubRegistered,
pushEditToHub,
} from "@/services/hubShare"
import { OPEN_MODE } from "@/const"
import { TEST_IDS } from "@/testIds"
import type { SearchCommand } from "@/types"
Expand All @@ -20,6 +24,7 @@ vi.mock("@/services/hubShare", () => ({
getHubLocale: () => "en",
isHubShareable: () => true,
isHubRegistered: vi.fn(),
pushEditToHub: vi.fn(),
}))

vi.mock("@/services/analytics", () => ({
Expand All @@ -39,6 +44,7 @@ const mockSendEvent = vi.mocked(sendEvent)
const mockSendHubLinkClick = vi.mocked(sendHubLinkClick)
const mockShareCommandToHub = vi.mocked(shareCommandToHub)
const mockIsHubRegistered = vi.mocked(isHubRegistered)
const mockPushEditToHub = vi.mocked(pushEditToHub)

// A UUIDv7 id, so that sharing does not regenerate the command id.
const command: SearchCommand = {
Expand Down Expand Up @@ -111,4 +117,49 @@ describe("ShareButton", () => {
expect(mockSendHubLinkClick).not.toHaveBeenCalled()
expect(mockSendEvent).not.toHaveBeenCalled()
})

it("SB-05: pushes local edits to the Hub for a modified shared command", () => {
mockPushEditToHub.mockReturnValue(true)
render(<ShareButton command={command} isShared isModified />)

fireEvent.click(screen.getByTestId(TEST_IDS.modifiedSharedCommandButton))

expect(mockPushEditToHub).toHaveBeenCalledWith(command)
expect(chrome.tabs.create).not.toHaveBeenCalled()
expect(mockShareCommandToHub).not.toHaveBeenCalled()
})

it("SB-06: keeps the unshared state when isModified is set without isShared", () => {
render(<ShareButton command={command} isModified />)

expect(screen.getByTestId(TEST_IDS.shareCommandButton)).toBeTruthy()
expect(
screen.queryByTestId(TEST_IDS.modifiedSharedCommandButton),
).toBeNull()
})

it("SB-07: shows the error state when pushing the edits fails", () => {
mockPushEditToHub.mockReturnValue(false)
render(<ShareButton command={command} isShared isModified />)

const button = screen.getByTestId(TEST_IDS.modifiedSharedCommandButton)
fireEvent.click(button)

expect(mockPushEditToHub).toHaveBeenCalledWith(command)
expect((button as HTMLButtonElement).disabled).toBe(true)
expect(button.querySelector("svg")?.getAttribute("class")).toContain(
"stroke-red-500",
)
})

it("SB-08: blocks repeated pushes while the result is shown", () => {
mockPushEditToHub.mockReturnValue(true)
render(<ShareButton command={command} isShared isModified />)

const button = screen.getByTestId(TEST_IDS.modifiedSharedCommandButton)
fireEvent.click(button)
fireEvent.click(button)

expect(mockPushEditToHub).toHaveBeenCalledOnce()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
SquareTerminal,
ChevronsUpDown,
ChevronsDownUp,
Share,
CloudUpload,
} from "lucide-react"

import {
Expand Down Expand Up @@ -908,7 +908,7 @@ const CommandEditDialogInner = ({
(err) => console.error(err),
)}
>
<Share />
<CloudUpload />
{t("labelShare")}
</Button>
)}
Expand All @@ -927,7 +927,7 @@ const CommandEditDialogInner = ({
)}
data-testid={TEST_IDS.submitCommandButton}
>
{isHubEdit ? <Share /> : <Save />}
{isHubEdit ? <CloudUpload /> : <Save />}
{isHubEdit
? t("labelShare")
: isEdit
Expand Down
Loading