Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/collection/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func NewCollectionCommand(client client.API, w io.Writer) *cobra.Command {
NewCreateCollectionCommand(client, w),
NewUpdateCommand(client, w),
NewPublishCommand(client, w),
NewPreviewCommand(client, w),
)
return cmd
}
92 changes: 92 additions & 0 deletions cmd/collection/preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package collection

import (
"errors"
"fmt"
"io"

"github.com/pkg/browser"
"github.com/prolific-oss/cli/client"
"github.com/prolific-oss/cli/cmd/shared"
"github.com/prolific-oss/cli/ui"
collectionui "github.com/prolific-oss/cli/ui/collection"
"github.com/spf13/cobra"
)

// BrowserOpener is a function type for opening URLs in a browser.
// This allows for dependency injection in tests.
type BrowserOpener func(url string) error

// DefaultBrowserOpener uses the system browser to open URLs.
var DefaultBrowserOpener BrowserOpener = browser.OpenURL

// PreviewOptions is the options for the preview collection command.
type PreviewOptions struct {
Args []string
BrowserOpener BrowserOpener
}

// NewPreviewCommand creates a new `collection preview` command to open a collection
// preview in the browser.
func NewPreviewCommand(c client.API, w io.Writer) *cobra.Command {
return NewPreviewCommandWithOpener(c, w, DefaultBrowserOpener)
}

// NewPreviewCommandWithOpener creates a new `collection preview` command with a custom browser opener.
// This is useful for testing to avoid opening actual browser windows.
func NewPreviewCommandWithOpener(c client.API, w io.Writer, browserOpener BrowserOpener) *cobra.Command {
var opts PreviewOptions
opts.BrowserOpener = browserOpener

cmd := &cobra.Command{
Use: "preview <collection-id>",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 || args[0] == "" {
return errors.New("please provide a collection ID")
}
return nil
},
Short: "Preview a collection in the browser",
Long: `Preview a collection in the browser

Opens the collection in your default web browser so you can preview
it before publishing.`,
Example: `
Preview a collection in the browser

$ prolific collection preview 123456789
`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Args = args
collectionID := opts.Args[0]

// Fetch collection to validate access
_, err := c.GetCollection(collectionID)
if err != nil {
if shared.IsFeatureNotEnabledError(err) {
ui.RenderFeatureAccessMessage(FeatureNameAITBCollection, FeatureContactURLAITBCollection)
return nil
}
return fmt.Errorf("failed to get collection: %s", err.Error())
}

// Build the preview URL and display it
previewURL := collectionui.GetCollectionPreviewURL(collectionID)
fmt.Fprintln(w, "Opening collection preview in browser...")
fmt.Fprintln(w)
fmt.Fprintln(w, previewURL)

// Attempt to open the browser - don't fail if it doesn't work
// (e.g., in headless/CI environments)
if opts.BrowserOpener != nil {
if err := opts.BrowserOpener(previewURL); err != nil {
fmt.Fprintln(w, "(Browser did not open automatically - use the URL above)")
}
}

return nil
},
}

return cmd
}
213 changes: 213 additions & 0 deletions cmd/collection/preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package collection_test

import (
"bufio"
"bytes"
"errors"
"strings"
"testing"
"time"

"github.com/golang/mock/gomock"
"github.com/prolific-oss/cli/cmd/collection"
"github.com/prolific-oss/cli/mock_client"
"github.com/prolific-oss/cli/model"
)

// noOpBrowserOpener is a no-op browser opener for testing
func noOpBrowserOpener(url string) error {
return nil
}

func TestNewPreviewCommand(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

var buf bytes.Buffer
cmd := collection.NewPreviewCommandWithOpener(mockClient, &buf, noOpBrowserOpener)

use := "preview <collection-id>"
short := "Preview a collection in the browser"

if cmd.Use != use {
t.Fatalf("expected use: %s; got %s", use, cmd.Use)
}

if cmd.Short != short {
t.Fatalf("expected short: %s; got %s", short, cmd.Short)
}
}

func TestPreviewCommandRequiresCollectionID(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

var buf bytes.Buffer
cmd := collection.NewPreviewCommandWithOpener(mockClient, &buf, noOpBrowserOpener)

// Test the Args validator directly
err := cmd.Args(cmd, []string{})
if err == nil {
t.Fatalf("expected error for missing collection ID, got nil")
}

expected := "please provide a collection ID"
if err.Error() != expected {
t.Fatalf("expected error %q, got %q", expected, err.Error())
}
}

func TestPreviewCommandRequiresNonEmptyCollectionID(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

var buf bytes.Buffer
cmd := collection.NewPreviewCommandWithOpener(mockClient, &buf, noOpBrowserOpener)

// Test the Args validator directly
err := cmd.Args(cmd, []string{""})
if err == nil {
t.Fatalf("expected error for empty collection ID, got nil")
}

expected := "please provide a collection ID"
if err.Error() != expected {
t.Fatalf("expected error %q, got %q", expected, err.Error())
}
}

func TestPreviewCommandCallsGetCollection(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

testCollection := &model.Collection{
ID: testCollectionID,
Name: "Test Collection",
CreatedAt: time.Now(),
CreatedBy: "test-user",
ItemCount: 10,
}

mockClient.
EXPECT().
GetCollection(gomock.Eq(testCollectionID)).
Return(testCollection, nil).
Times(1)

var buf bytes.Buffer
writer := bufio.NewWriter(&buf)
cmd := collection.NewPreviewCommandWithOpener(mockClient, writer, noOpBrowserOpener)

err := cmd.RunE(cmd, []string{testCollectionID})
writer.Flush()

if err != nil {
t.Fatalf("expected no error, got: %v", err)
}
}

func TestPreviewCommandReturnsErrorOnClientError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

mockClient.
EXPECT().
GetCollection(gomock.Eq(testCollectionID)).
Return(nil, errors.New("collection not found")).
Times(1)

var buf bytes.Buffer
writer := bufio.NewWriter(&buf)
cmd := collection.NewPreviewCommandWithOpener(mockClient, writer, noOpBrowserOpener)

err := cmd.RunE(cmd, []string{testCollectionID})
writer.Flush()

if err == nil {
t.Fatal("expected error, got nil")
}

expected := "failed to get collection: collection not found"
if err.Error() != expected {
t.Fatalf("expected error %q, got %q", expected, err.Error())
}
}

func TestPreviewCommandHandlesFeatureNotEnabledError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

// The feature not enabled error must contain "request failed", "permission", and "feature"
featureError := errors.New("request failed: you do not currently have permission to access this feature")

mockClient.
EXPECT().
GetCollection(gomock.Eq(testCollectionID)).
Return(nil, featureError).
Times(1)

var buf bytes.Buffer
writer := bufio.NewWriter(&buf)
cmd := collection.NewPreviewCommandWithOpener(mockClient, writer, noOpBrowserOpener)

err := cmd.RunE(cmd, []string{testCollectionID})
writer.Flush()

// When feature is not enabled, the command should not return an error
// but should display a feature access message
if err != nil {
t.Fatalf("expected no error for feature not enabled, got: %v", err)
}
}

func TestPreviewCommandOutputContainsURL(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockClient := mock_client.NewMockAPI(ctrl)

testCollection := &model.Collection{
ID: testCollectionID,
Name: "My Test Collection",
CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC),
CreatedBy: "user@example.com",
ItemCount: 25,
}

mockClient.
EXPECT().
GetCollection(gomock.Eq(testCollectionID)).
Return(testCollection, nil).
Times(1)

var buf bytes.Buffer
writer := bufio.NewWriter(&buf)
cmd := collection.NewPreviewCommandWithOpener(mockClient, writer, noOpBrowserOpener)

err := cmd.RunE(cmd, []string{testCollectionID})
writer.Flush()

if err != nil {
t.Fatalf("expected no error, got: %v", err)
}

output := buf.String()

// Check that the output contains the expected URL components
expectedStrings := []string{
"Opening collection preview in browser",
"data-collection-tool/collections/" + testCollectionID,
"preview=true",
}

for _, expected := range expectedStrings {
if !strings.Contains(output, expected) {
t.Errorf("expected output to contain %q, got: %s", expected, output)
}
}
}
16 changes: 13 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,20 @@
// including base URLs for the Prolific application and API.
package config

// GetApplicationURL will return the Application URL. This could be updated
// to understand different environments based on API URL perhaps?
import (
"strings"

"github.com/spf13/viper"
)

// DefaultApplicationURL is the default Prolific application URL.
const DefaultApplicationURL = "https://app.prolific.com"

// GetApplicationURL will return the Application URL. This can be overridden
// using the PROLIFIC_APPLICATION_URL environment variable.
func GetApplicationURL() string {
return "https://app.prolific.com"
viper.SetDefault("PROLIFIC_APPLICATION_URL", DefaultApplicationURL)
return strings.TrimRight(viper.GetString("PROLIFIC_APPLICATION_URL"), "/")
}

// GetAPIURL will return the API URL. This is the default API, but we allow
Expand Down
11 changes: 11 additions & 0 deletions ui/collection/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/prolific-oss/cli/client"
"github.com/prolific-oss/cli/config"
"github.com/prolific-oss/cli/model"
"github.com/prolific-oss/cli/ui"
)
Expand Down Expand Up @@ -93,3 +94,13 @@ func renderCollectionString(collection model.Collection) string {

return content
}

// GetCollectionPreviewPath returns the URL path to a collection preview, agnostic of domain
func GetCollectionPreviewPath(ID string) string {
return fmt.Sprintf("data-collection-tool/collections/%s?preview=true", ID)
}

// GetCollectionPreviewURL returns the full URL to a collection preview using configuration
func GetCollectionPreviewURL(ID string) string {
return fmt.Sprintf("%s/%s", config.GetApplicationURL(), GetCollectionPreviewPath(ID))
}
Loading
Loading