diff --git a/cmd/dev_server/dev_server.go b/cmd/dev_server/dev_server.go index 5cf5847d..44fb5d97 100644 --- a/cmd/dev_server/dev_server.go +++ b/cmd/dev_server/dev_server.go @@ -7,10 +7,9 @@ import ( "github.com/spf13/viper" cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics" - "github.com/launchdarkly/ldcli/internal/analytics" - "github.com/launchdarkly/ldcli/cmd/cliflags" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/dev_server" "github.com/launchdarkly/ldcli/internal/resources" ) diff --git a/internal/dev_server/adapters/api.go b/internal/dev_server/adapters/api.go index 8be0b270..541ddc18 100644 --- a/internal/dev_server/adapters/api.go +++ b/internal/dev_server/adapters/api.go @@ -2,6 +2,7 @@ package adapters import ( "context" + "fmt" "log" "net/url" "strconv" @@ -25,7 +26,7 @@ func GetApi(ctx context.Context) Api { type Api interface { GetSdkKey(ctx context.Context, projectKey, environmentKey string) (string, error) GetAllFlags(ctx context.Context, projectKey string) ([]ldapi.FeatureFlag, error) - GetProjectEnvironments(ctx context.Context, projectKey string) ([]ldapi.Environment, error) + GetProjectEnvironments(ctx context.Context, projectKey string, query string, limit *int) ([]ldapi.Environment, error) } type apiClientApi struct { @@ -54,9 +55,9 @@ func (a apiClientApi) GetAllFlags(ctx context.Context, projectKey string) ([]lda return flags, err } -func (a apiClientApi) GetProjectEnvironments(ctx context.Context, projectKey string) ([]ldapi.Environment, error) { +func (a apiClientApi) GetProjectEnvironments(ctx context.Context, projectKey string, query string, limit *int) ([]ldapi.Environment, error) { log.Printf("Fetching all environments for project '%s'", projectKey) - environments, err := a.getEnvironments(ctx, projectKey, nil) + environments, err := a.getEnvironments(ctx, projectKey, nil, query, limit) if err != nil { err = errors.Wrap(err, "unable to get environments from LD API") } @@ -81,21 +82,28 @@ func (a apiClientApi) getFlags(ctx context.Context, projectKey string, href *str }) } -func (a apiClientApi) getEnvironments(ctx context.Context, projectKey string, href *string) ([]ldapi.Environment, error) { - return getPaginatedItems(ctx, projectKey, href, func(ctx context.Context, projectKey string, limit, offset *int64) (*ldapi.Environments, error) { - request := a.apiClient.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey) - if limit != nil { - request = request.Limit(*limit) - } +func (a apiClientApi) getEnvironments(ctx context.Context, projectKey string, href *string, query string, limit *int) ([]ldapi.Environment, error) { + request := a.apiClient.EnvironmentsApi.GetEnvironmentsByProject(ctx, projectKey) - if offset != nil { - request = request.Offset(*offset) - } + if limit != nil { + request = request.Limit(int64(*limit)) + } - envs, _, err := request. - Execute() - return envs, err - }) + if query != "" { + request = request.Sort("name").Filter(fmt.Sprintf("query:%s", query)) + } + + envs, _, err := request. + Execute() + if err != nil { + return nil, err + } + + if envs == nil { + return []ldapi.Environment{}, nil + } + + return envs.Items, nil } func getPaginatedItems[T any, R interface { diff --git a/internal/dev_server/adapters/mocks/api.go b/internal/dev_server/adapters/mocks/api.go index 58e8f847..4b0530cd 100644 --- a/internal/dev_server/adapters/mocks/api.go +++ b/internal/dev_server/adapters/mocks/api.go @@ -56,18 +56,18 @@ func (mr *MockApiMockRecorder) GetAllFlags(arg0, arg1 any) *gomock.Call { } // GetProjectEnvironments mocks base method. -func (m *MockApi) GetProjectEnvironments(arg0 context.Context, arg1 string) ([]ldapi.Environment, error) { +func (m *MockApi) GetProjectEnvironments(arg0 context.Context, arg1, arg2 string, arg3 *int) ([]ldapi.Environment, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetProjectEnvironments", arg0, arg1) + ret := m.ctrl.Call(m, "GetProjectEnvironments", arg0, arg1, arg2, arg3) ret0, _ := ret[0].([]ldapi.Environment) ret1, _ := ret[1].(error) return ret0, ret1 } // GetProjectEnvironments indicates an expected call of GetProjectEnvironments. -func (mr *MockApiMockRecorder) GetProjectEnvironments(arg0, arg1 any) *gomock.Call { +func (mr *MockApiMockRecorder) GetProjectEnvironments(arg0, arg1, arg2, arg3 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProjectEnvironments", reflect.TypeOf((*MockApi)(nil).GetProjectEnvironments), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProjectEnvironments", reflect.TypeOf((*MockApi)(nil).GetProjectEnvironments), arg0, arg1, arg2, arg3) } // GetSdkKey mocks base method. diff --git a/internal/dev_server/api/api.yaml b/internal/dev_server/api/api.yaml index 849a7f27..71bcd413 100644 --- a/internal/dev_server/api/api.yaml +++ b/internal/dev_server/api/api.yaml @@ -131,6 +131,18 @@ paths: summary: list all environments for the given project parameters: - $ref: "#/components/parameters/projectKey" + - name: name + in: query + description: filter by environment name + required: false + schema: + type: string + - name: limit + in: query + description: limit the number of environments returned + required: false + schema: + type: integer responses: 200: description: OK. List of environments diff --git a/internal/dev_server/api/get_environments.go b/internal/dev_server/api/get_environments.go index d0e4597e..777eb0ee 100644 --- a/internal/dev_server/api/get_environments.go +++ b/internal/dev_server/api/get_environments.go @@ -16,7 +16,12 @@ func (s server) GetEnvironments(ctx context.Context, request GetEnvironmentsRequ return GetEnvironments404JSONResponse{}, nil } - environments, err := model.GetEnvironmentsForProject(ctx, project.Key) + var query string + if request.Params.Name != nil { + query = *request.Params.Name + } + + environments, err := model.GetEnvironmentsForProject(ctx, project.Key, query, request.Params.Limit) if err != nil { return nil, err } diff --git a/internal/dev_server/api/server.gen.go b/internal/dev_server/api/server.gen.go index bfacd8c1..fd7f3b14 100644 --- a/internal/dev_server/api/server.gen.go +++ b/internal/dev_server/api/server.gen.go @@ -150,6 +150,15 @@ type PostAddProjectParams struct { // PostAddProjectParamsExpand defines parameters for PostAddProject. type PostAddProjectParamsExpand string +// GetEnvironmentsParams defines parameters for GetEnvironments. +type GetEnvironmentsParams struct { + // Name filter by environment name + Name *string `form:"name,omitempty" json:"name,omitempty"` + + // Limit limit the number of environments returned + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` +} + // PatchProjectJSONRequestBody defines body for PatchProject for application/json ContentType. type PatchProjectJSONRequestBody PatchProjectJSONBody @@ -178,7 +187,7 @@ type ServerInterface interface { PostAddProject(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, params PostAddProjectParams) // list all environments for the given project // (GET /dev/projects/{projectKey}/environments) - GetEnvironments(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) + GetEnvironments(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, params GetEnvironmentsParams) // remove override for flag // (DELETE /dev/projects/{projectKey}/overrides/{flagKey}) DeleteFlagOverride(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, flagKey FlagKey) @@ -363,8 +372,27 @@ func (siw *ServerInterfaceWrapper) GetEnvironments(w http.ResponseWriter, r *htt return } + // Parameter object where we will unmarshal all parameters from the context + var params GetEnvironmentsParams + + // ------------- Optional query parameter "name" ------------- + + err = runtime.BindQueryParameter("form", true, false, "name", r.URL.Query(), ¶ms.Name) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "name", Err: err}) + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameter("form", true, false, "limit", r.URL.Query(), ¶ms.Limit) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetEnvironments(w, r, projectKey) + siw.Handler.GetEnvironments(w, r, projectKey, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -733,6 +761,7 @@ func (response PostAddProject409JSONResponse) VisitPostAddProjectResponse(w http type GetEnvironmentsRequestObject struct { ProjectKey ProjectKey `json:"projectKey"` + Params GetEnvironmentsParams } type GetEnvironmentsResponseObject interface { @@ -1028,10 +1057,11 @@ func (sh *strictHandler) PostAddProject(w http.ResponseWriter, r *http.Request, } // GetEnvironments operation middleware -func (sh *strictHandler) GetEnvironments(w http.ResponseWriter, r *http.Request, projectKey ProjectKey) { +func (sh *strictHandler) GetEnvironments(w http.ResponseWriter, r *http.Request, projectKey ProjectKey, params GetEnvironmentsParams) { var request GetEnvironmentsRequestObject request.ProjectKey = projectKey + request.Params = params handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.GetEnvironments(ctx, request.(GetEnvironmentsRequestObject)) diff --git a/internal/dev_server/model/environments.go b/internal/dev_server/model/environments.go index 2287358f..43d78041 100644 --- a/internal/dev_server/model/environments.go +++ b/internal/dev_server/model/environments.go @@ -11,9 +11,9 @@ type Environment struct { Name string } -func GetEnvironmentsForProject(ctx context.Context, projectKey string) ([]Environment, error) { +func GetEnvironmentsForProject(ctx context.Context, projectKey string, query string, limit *int) ([]Environment, error) { apiAdapter := adapters.GetApi(ctx) - environments, err := apiAdapter.GetProjectEnvironments(ctx, projectKey) + environments, err := apiAdapter.GetProjectEnvironments(ctx, projectKey, query, limit) if err != nil { return nil, err } diff --git a/internal/dev_server/model/project_test.go b/internal/dev_server/model/project_test.go index 0e7bf880..eb47455c 100644 --- a/internal/dev_server/model/project_test.go +++ b/internal/dev_server/model/project_test.go @@ -5,6 +5,11 @@ import ( "errors" "testing" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + ldapi "github.com/launchdarkly/api-client-go/v14" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" @@ -12,10 +17,6 @@ import ( adapters_mocks "github.com/launchdarkly/ldcli/internal/dev_server/adapters/mocks" "github.com/launchdarkly/ldcli/internal/dev_server/model" "github.com/launchdarkly/ldcli/internal/dev_server/model/mocks" - "github.com/samber/lo" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" ) func TestCreateProject(t *testing.T) { diff --git a/internal/dev_server/sdk/go_sdk_test.go b/internal/dev_server/sdk/go_sdk_test.go index 06579661..594e0db0 100644 --- a/internal/dev_server/sdk/go_sdk_test.go +++ b/internal/dev_server/sdk/go_sdk_test.go @@ -9,6 +9,10 @@ import ( "time" "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/launchdarkly/go-sdk-common/v3/ldvalue" ldclient "github.com/launchdarkly/go-server-sdk/v7" @@ -17,9 +21,6 @@ import ( "github.com/launchdarkly/ldcli/internal/dev_server/adapters/mocks" "github.com/launchdarkly/ldcli/internal/dev_server/db" "github.com/launchdarkly/ldcli/internal/dev_server/model" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" ) // TestSdkRoutesViaGoSDK is an integration test. It hooks up a real go SDK to our SDK routes and makes changes to the diff --git a/internal/dev_server/ui/src/EnvironmentSelector.tsx b/internal/dev_server/ui/src/EnvironmentSelector.tsx index 1830d30d..1baebda7 100644 --- a/internal/dev_server/ui/src/EnvironmentSelector.tsx +++ b/internal/dev_server/ui/src/EnvironmentSelector.tsx @@ -1,8 +1,15 @@ -import { useState, useEffect } from 'react'; -import { Label, ListBox, ListBoxItem, Input } from '@launchpad-ui/components'; +import { useState, useEffect, useCallback } from 'react'; +import { + Label, + ListBox, + ListBoxItem, + Input, + ProgressBar, +} from '@launchpad-ui/components'; import { Box, Stack } from '@launchpad-ui/core'; import { fetchEnvironments } from './api'; import { Environment } from './types'; +import debounce from 'lodash/debounce'; type Props = { projectKey: string; @@ -17,50 +24,46 @@ export function EnvironmentSelector({ selectedEnvironment, setSelectedEnvironment, }: Props) { - const [environments, setEnvironments] = useState([]); - const [filteredEnvironments, setFilteredEnvironments] = useState< - Environment[] - >([]); + const [environments, setEnvironments] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); - useEffect(() => { - setIsLoading(true); - fetchEnvironments(projectKey) - .then((envs) => { - setEnvironments(envs); - setFilteredEnvironments(envs); - if (!selectedEnvironment) { - const sourceEnv = envs.find( - (env) => env.key === sourceEnvironmentKey, - ); - if (sourceEnv) { - setSelectedEnvironment(sourceEnv); - } else if (envs.length > 0) { - setSelectedEnvironment(envs[0]); + const fetchEnvironmentsDebounced = useCallback( + debounce((query: string) => { + setIsLoading(true); + fetchEnvironments(projectKey, query) + .then((envs) => { + setEnvironments(envs); + if (!selectedEnvironment) { + const sourceEnv = envs.find( + (env) => env.key === sourceEnvironmentKey, + ); + if (sourceEnv) { + setSelectedEnvironment(sourceEnv); + } else if (envs.length > 0) { + setSelectedEnvironment(envs[0]); + } } - } - }) - .catch((error) => { - console.error('Error fetching environments:', error); - }) - .finally(() => { - setIsLoading(false); - }); - }, [projectKey, sourceEnvironmentKey]); // Remove selectedEnvironment and setSelectedEnvironment from dependencies + }) + .catch((error) => { + console.error('Error fetching environments:', error); + }) + .finally(() => { + setIsLoading(false); + }); + }, 300), + [ + projectKey, + sourceEnvironmentKey, + selectedEnvironment, + setSelectedEnvironment, + ], + ); useEffect(() => { - const filtered = environments.filter( - (env) => - env.name.toLowerCase().includes(searchQuery.toLowerCase()) || - env.key.toLowerCase().includes(searchQuery.toLowerCase()), - ); - setFilteredEnvironments(filtered); - }, [searchQuery, environments]); - - if (isLoading) { - return Loading environments...; - } + fetchEnvironmentsDebounced(searchQuery); + }, [fetchEnvironmentsDebounced, searchQuery]); return ( @@ -73,7 +76,7 @@ export function EnvironmentSelector({ -
+
+ {isLoading && ( + + + + )}
{ const selectedKey = Array.from(keys)[0] as string; - const selected = environments.find( + const selected = environments?.find( (env) => env.key === selectedKey, ); if (selected) { @@ -116,7 +131,7 @@ export function EnvironmentSelector({ } }} > - {filteredEnvironments.map((env) => ( + {environments?.map((env) => ( {env.name} diff --git a/internal/dev_server/ui/src/Flag.tsx b/internal/dev_server/ui/src/Flag.tsx index 51c4da00..6ffe87d4 100644 --- a/internal/dev_server/ui/src/Flag.tsx +++ b/internal/dev_server/ui/src/Flag.tsx @@ -14,7 +14,6 @@ import { Popover, Select, SelectValue, - Switch, Text, TextArea, TextField, @@ -27,6 +26,8 @@ import { LDFlagValue } from 'launchdarkly-js-client-sdk'; import { FlagVariation } from './api.ts'; import { Box, Inline } from '@launchpad-ui/core'; import { isEqual } from 'lodash'; +import { Switch } from 'react-aria-components'; +import './Switch.css'; type VariationValuesProps = { availableVariations: FlagVariation[]; @@ -35,6 +36,7 @@ type VariationValuesProps = { flagKey: string; updateOverride: (flagKey: string, overrideValue: LDFlagValue) => void; }; + const VariationValues = ({ availableVariations, currentValue, @@ -45,12 +47,18 @@ const VariationValues = ({ switch (typeof flagValue) { case 'boolean': return ( - { - updateOverride(flagKey, newValue); - }} - /> +
+ { + updateOverride(flagKey, newValue); + }} + > + False + True + +
); default: let variations = availableVariations; diff --git a/internal/dev_server/ui/src/ProjectEditor.tsx b/internal/dev_server/ui/src/ProjectEditor.tsx index b3aa0fa0..e59c6f4e 100644 --- a/internal/dev_server/ui/src/ProjectEditor.tsx +++ b/internal/dev_server/ui/src/ProjectEditor.tsx @@ -62,18 +62,23 @@ export function ProjectEditor({ } }; + const handleClose = () => { + setTempSelectedEnvironment(selectedEnvironment); + setTempContext(context); + }; + return ( Current environment. Click to update. - + {({ close }) => ( @@ -115,7 +120,10 @@ export function ProjectEditor({