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
17 changes: 17 additions & 0 deletions api/dashboard/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,23 @@ func TestGetApplication_Success(t *testing.T) {
assert.Equal(t, "api-key-123", app.APIKey)
}

func TestGetApplication_ParsesPlanLabel(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/application/APP1", func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte(
`{"data":{"id":"APP1","type":"application","attributes":{"name":"My App","application_id":"APP1","plan":{"name":"v8.5-plg-grow-plus","version":9,"label":"Grow Plus","pay_as_you_go":true}}}}`,
))
require.NoError(t, err)
})

ts, client := newTestClient(mux)
defer ts.Close()

app, err := client.GetApplication("test-token", "APP1")
require.NoError(t, err)
assert.Equal(t, "Grow Plus", app.PlanLabel)
}

func TestCreateApplication_Success(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications", func(w http.ResponseWriter, r *http.Request) {
Expand Down
30 changes: 21 additions & 9 deletions api/dashboard/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,27 @@ type ApplicationResource struct {

// ApplicationAttributes contains the actual application fields.
type ApplicationAttributes struct {
Name string `json:"name"`
ApplicationID string `json:"application_id"`
APIKey string `json:"api_key"`
Name string `json:"name"`
ApplicationID string `json:"application_id"`
APIKey string `json:"api_key"`
Plan ApplicationPlan `json:"plan"`
}

// ApplicationPlan is the plan applied to an application (attributes.plan).
// Label (e.g. "Grow Plus") matches a self-serve plan template's Name.
type ApplicationPlan struct {
Name string `json:"name"`
Label string `json:"label"`
Version int `json:"version"`
PayAsYouGo bool `json:"pay_as_you_go"`
}

// Application is a flattened view of an Algolia application for CLI consumption.
type Application struct {
ID string `json:"id"`
Name string `json:"name"`
APIKey string `json:"api_key,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
APIKey string `json:"api_key,omitempty"`
PlanLabel string `json:"plan_label,omitempty"` // current plan label, e.g. "Grow Plus"
}

// PaginationMeta contains page-based pagination metadata.
Expand Down Expand Up @@ -160,9 +171,10 @@ type DashboardCrawlerError struct {
// toApplication flattens a JSON:API resource into a simple Application.
func (r *ApplicationResource) toApplication() Application {
return Application{
ID: r.Attributes.ApplicationID,
Name: r.Attributes.Name,
APIKey: r.Attributes.APIKey,
ID: r.Attributes.ApplicationID,
Name: r.Attributes.Name,
APIKey: r.Attributes.APIKey,
PlanLabel: r.Attributes.Plan.Label,
}
}

Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/application/downgrade/downgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func NewDowngradeCmd(f *cmdutil.Factory) *cobra.Command {
opts := &planchange.Options{
IO: f.IOStreams,
Config: f.Config,
Direction: planchange.DirectionDowngrade,
PrintFlags: cmdutil.NewPrintFlags(),
NewDashboardClient: func(clientID string) *dashboard.Client {
return dashboard.NewClient(clientID)
Expand Down
174 changes: 150 additions & 24 deletions pkg/cmd/application/planchange/planchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,25 @@ import (
"github.com/algolia/cli/pkg/prompt"
)

// Direction selects whether the flow offers higher-tier (upgrade) or
// lower-tier (downgrade) plans.
type Direction int

const (
DirectionUpgrade Direction = iota
DirectionDowngrade
)

// Options carries everything the shared plan-change flow needs. The upgrade and
// downgrade commands populate it the same way.
type Options struct {
IO *iostreams.IOStreams
Config config.IConfig

Plan string // --plan (optional): target plan, e.g. "free", "grow", "grow-plus"
DryRun bool // --dry-run: preview without calling the API
AcceptTerms bool // --accept-terms: accept ToS in non-interactive mode
Direction Direction // upgrade or downgrade
Plan string // --plan (optional): target plan, e.g. "free", "grow", "grow-plus"
DryRun bool // --dry-run: preview without calling the API
AcceptTerms bool // --accept-terms: accept ToS in non-interactive mode

PrintFlags *cmdutil.PrintFlags

Expand Down Expand Up @@ -85,10 +95,26 @@ func Run(opts *Options) error {
user = nil
}

target, err := selectTarget(opts, client, &token, appID, plans)
app := fetchApplication(opts, client, &token, appID)

target, err := resolveTarget(opts, appID, app, plans)
if err != nil {
return err
}
if target == nil {
return nil
}

if isCurrentPlan(app, *target) {
fmt.Fprintf(
opts.IO.Out,
"%s Application %s is already on the %s plan; no change needed.\n",
cs.WarningIcon(),
cs.Bold(appID),
cs.Bold(target.Name),
)
return nil
}

// Paid plans require a payment method that the CLI cannot collect. Only
// block when we positively know there is none (user fetched, flag false);
Expand Down Expand Up @@ -195,49 +221,149 @@ func offerCostManagementBudget(opts *Options, dashboardURL, appID string) error
return browser(url)
}

// selectTarget resolves the target plan from the --plan flag or, when
// interactive and no flag is set, an interactive picker over the available
// plans.
func selectTarget(
// resolveTarget picks the target plan: --plan overrides the direction filter,
// otherwise candidates are filtered by direction and chosen interactively. A
// nil plan with a nil error means there is nothing to switch to.
func resolveTarget(
opts *Options,
client *dashboard.Client,
token *string,
appID string,
app *dashboard.Application,
plans []dashboard.Plan,
) (*dashboard.Plan, error) {
if opts.Plan != "" {
return resolvePlan(plans, opts.Plan)
}

candidates := filterByDirection(plans, app, opts.Direction)

if len(candidates) == 0 {
reportNoCandidates(opts, appID, app, opts.Direction)
return nil, nil
}

if !opts.IO.CanPrompt() {
return nil, cmdutil.FlagErrorf(
"--plan is required in non-interactive mode (one of: %s)",
strings.Join(planChoices(plans), ", "),
strings.Join(planChoices(candidates), ", "),
)
}

cs := opts.IO.ColorScheme()
appLabel := cs.Bold(appID)
if name := currentAppName(opts, client, token, appID); name != "" {
appLabel = fmt.Sprintf("%s (%s)", cs.Bold(appID), name)
}
fmt.Fprintf(opts.IO.Out, "Current application: %s\n\n", appLabel)
printCurrentApplication(opts, appID, app)

return pickPlan(plans)
return pickPlan(candidates)
}

// currentAppName resolves the current application's display name, best-effort.
// It returns "" when the name can't be fetched so callers fall back to the ID.
func currentAppName(opts *Options, client *dashboard.Client, token *string, appID string) string {
// fetchApplication returns the current application, or nil if it can't be fetched.
func fetchApplication(
opts *Options,
client *dashboard.Client,
token *string,
appID string,
) *dashboard.Application {
var app *dashboard.Application
if err := callWithReauth(opts.IO, client, token, "Fetching application", func(t string) error {
var e error
app, e = client.GetApplication(t, appID)
return e
}); err != nil || app == nil {
return ""
}); err != nil {
return nil
}
return app
}

// filterByDirection returns plans above (upgrade) or below (downgrade) the
// current plan in the API's tier order, or all plans when it isn't found.
func filterByDirection(
plans []dashboard.Plan,
app *dashboard.Application,
dir Direction,
) []dashboard.Plan {
idx := currentPlanIndex(plans, app)
if idx < 0 {
return plans
}
if dir == DirectionDowngrade {
return plans[:idx]
}
return plans[idx+1:]
}

// normalizePlanKey normalizes a plan label/name for comparison; the CLI joins
// the current plan to the self-serve list by matching label against Name.
func normalizePlanKey(s string) string {
return strings.TrimSpace(strings.ToLower(s))
}

// currentPlanIndex returns the index of the app's current plan in plans, or -1.
func currentPlanIndex(plans []dashboard.Plan, app *dashboard.Application) int {
if app == nil {
return -1
}
label := normalizePlanKey(app.PlanLabel)
if label == "" {
return -1
}
for i := range plans {
if normalizePlanKey(plans[i].Name) == label {
return i
}
}
return -1
}

// isCurrentPlan reports whether target is the plan the application is already on.
func isCurrentPlan(app *dashboard.Application, target dashboard.Plan) bool {
if app == nil {
return false
}
label := normalizePlanKey(app.PlanLabel)
return label != "" && label == normalizePlanKey(target.Name)
}

// reportNoCandidates tells the user they're already at the highest/lowest plan.
func reportNoCandidates(
opts *Options,
appID string,
app *dashboard.Application,
dir Direction,
) {
cs := opts.IO.ColorScheme()
current := ""
if app != nil && app.PlanLabel != "" {
current = fmt.Sprintf(" (%s)", app.PlanLabel)
}
tier, verb := "highest", "upgrade"
if dir == DirectionDowngrade {
tier, verb = "lowest", "downgrade"
}
fmt.Fprintf(
opts.IO.Out,
"%s Application %s is already on the %s self-serve plan%s; nothing to %s to.\n",
cs.WarningIcon(),
cs.Bold(appID),
tier,
current,
verb,
)
}

// printCurrentApplication prints the current app and plan before the picker.
func printCurrentApplication(opts *Options, appID string, app *dashboard.Application) {
cs := opts.IO.ColorScheme()
label := cs.Bold(appID)
if app != nil && app.Name != "" {
label = fmt.Sprintf("%s (%s)", cs.Bold(appID), app.Name)
}
if app != nil && app.PlanLabel != "" {
fmt.Fprintf(
opts.IO.Out,
"Current application: %s — current plan: %s\n\n",
label,
cs.Bold(app.PlanLabel),
)
return
}
return app.Name
fmt.Fprintf(opts.IO.Out, "Current application: %s\n\n", label)
}

// resolvePlan maps a --plan value to one of the fetched plans.
Expand Down
Loading
Loading