feat(custom-templates): filter templates by edge [EE-6565] (#10979)

pull/11031/head
Chaim Lev-Ari 2024-01-28 15:54:34 +02:00 committed by GitHub
parent 441a8bbbbf
commit 2826a4ce39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 229 additions and 21 deletions

View File

@ -8,8 +8,11 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/slices"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
) )
// @id CustomTemplateList // @id CustomTemplateList
@ -21,6 +24,7 @@ import (
// @security jwt // @security jwt
// @produce json // @produce json
// @param type query []int true "Template types" Enums(1,2,3) // @param type query []int true "Template types" Enums(1,2,3)
// @param edge query boolean false "Filter by edge templates"
// @success 200 {array} portainer.CustomTemplate "Success" // @success 200 {array} portainer.CustomTemplate "Success"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /custom_templates [get] // @router /custom_templates [get]
@ -30,6 +34,8 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
return httperror.BadRequest("Invalid Custom template type", err) return httperror.BadRequest("Invalid Custom template type", err)
} }
edge := retrieveEdgeParam(r)
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll() customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err) return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
@ -63,9 +69,37 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
customTemplates = filterByType(customTemplates, templateTypes) customTemplates = filterByType(customTemplates, templateTypes)
if edge != nil {
customTemplates = slices.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
return customTemplate.EdgeTemplate == *edge
})
}
for i := range customTemplates {
customTemplate := &customTemplates[i]
if customTemplate.GitConfig != nil && customTemplate.GitConfig.Authentication != nil {
customTemplate.GitConfig.Authentication.Password = ""
}
}
return response.JSON(w, customTemplates) return response.JSON(w, customTemplates)
} }
func retrieveEdgeParam(r *http.Request) *bool {
var edge *bool
edgeParam, _ := request.RetrieveQueryParameter(r, "edge", true)
if edgeParam != "" {
edgeVal, err := strconv.ParseBool(edgeParam)
if err != nil {
log.Warn().Err(err).Msg("failed parsing edge param")
return nil
}
edge = &edgeVal
}
return edge
}
func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) { func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) {
err := r.ParseForm() err := r.ParseForm()
if err != nil { if err != nil {

View File

@ -8,3 +8,16 @@ func Map[T, U any](s []T, f func(T) U) []U {
} }
return result return result
} }
// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true
func Filter[T any](s []T, predicate func(T) bool) []T {
n := 0
for _, v := range s {
if predicate(v) {
s[n] = v
n++
}
}
return s[:n]
}

View File

@ -0,0 +1,131 @@
package slices
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
)
type filterTestCase[T any] struct {
name string
input []T
expected []T
predicate func(T) bool
}
func TestFilter(t *testing.T) {
intTestCases := []filterTestCase[int]{
{
name: "Filter even numbers",
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
expected: []int{2, 4, 6, 8},
predicate: func(n int) bool {
return n%2 == 0
},
},
{
name: "Filter odd numbers",
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
expected: []int{1, 3, 5, 7, 9},
predicate: func(n int) bool {
return n%2 != 0
},
},
}
runTestCases(t, intTestCases)
stringTestCases := []filterTestCase[string]{
{
name: "Filter strings starting with 'A'",
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
expected: []string{"Apple", "Avocado", "Apricot"},
predicate: func(s string) bool {
return s[0] == 'A'
},
},
{
name: "Filter strings longer than 5 characters",
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
expected: []string{"Banana", "Avocado", "Grapes", "Apricot"},
predicate: func(s string) bool {
return len(s) > 5
},
},
}
runTestCases(t, stringTestCases)
}
func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
is := assert.New(t)
result := Filter(testCase.input, testCase.predicate)
is.Equal(len(testCase.expected), len(result))
is.ElementsMatch(testCase.expected, result)
})
}
}
func TestMap(t *testing.T) {
intTestCases := []struct {
name string
input []int
expected []string
mapper func(int) string
}{
{
name: "Map integers to strings",
input: []int{1, 2, 3, 4, 5},
expected: []string{"1", "2", "3", "4", "5"},
mapper: func(n int) string {
return strconv.Itoa(n)
},
},
}
runMapTestCases(t, intTestCases)
stringTestCases := []struct {
name string
input []string
expected []int
mapper func(string) int
}{
{
name: "Map strings to integers",
input: []string{"1", "2", "3", "4", "5"},
expected: []int{1, 2, 3, 4, 5},
mapper: func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
},
}
runMapTestCases(t, stringTestCases)
}
func runMapTestCases[T, U any](t *testing.T, testCases []struct {
name string
input []T
expected []U
mapper func(T) U
}) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
is := assert.New(t)
result := Map(testCase.input, testCase.mapper)
is.Equal(len(testCase.expected), len(result))
is.ElementsMatch(testCase.expected, result)
})
}
}

View File

@ -11,8 +11,8 @@ function CustomTemplateServiceFactory($sanitize, CustomTemplates, FileUploadServ
return CustomTemplates.get({ id }).$promise; return CustomTemplates.get({ id }).$promise;
}; };
service.customTemplates = async function customTemplates(type) { service.customTemplates = async function customTemplates(type, edge = false) {
const templates = await CustomTemplates.query({ type }).$promise; const templates = await CustomTemplates.query({ type, edge }).$promise;
templates.forEach((template) => { templates.forEach((template) => {
if (template.Note) { if (template.Note) {
template.Note = $('<p>').html($sanitize(template.Note)).find('img').remove().end().html(); template.Note = $('<p>').html($sanitize(template.Note)).find('img').remove().end().html();

View File

@ -36,8 +36,9 @@ export function TemplateFieldset({
}, [initialValues, values.template?.Id]); }, [initialValues, values.template?.Id]);
const templatesQuery = useCustomTemplates({ const templatesQuery = useCustomTemplates({
select: (templates) => params: {
templates.filter((template) => template.EdgeTemplate), edge: true,
},
}); });
return ( return (
@ -111,8 +112,9 @@ function TemplateSelector({
error?: string; error?: string;
}) { }) {
const templatesQuery = useCustomTemplates({ const templatesQuery = useCustomTemplates({
select: (templates) => params: {
templates.filter((template) => template.EdgeTemplate), edge: true,
},
}); });
if (!templatesQuery.data) { if (!templatesQuery.data) {

View File

@ -9,8 +9,8 @@ import { confirmDelete } from '@@/modals/confirm';
export function ListView() { export function ListView() {
const templatesQuery = useCustomTemplates({ const templatesQuery = useCustomTemplates({
select(templates) { params: {
return templates.filter((t) => t.EdgeTemplate); edge: true,
}, },
}); });
const deleteMutation = useDeleteTemplateMutation(); const deleteMutation = useDeleteTemplateMutation();

View File

@ -27,7 +27,11 @@ export function useValidation({
}) { }) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id); const gitCredentialsQuery = useGitCredentials(user.Id);
const customTemplatesQuery = useCustomTemplates(); const customTemplatesQuery = useCustomTemplates({
params: {
edge: undefined,
},
});
return useMemo( return useMemo(
() => () =>

View File

@ -25,7 +25,11 @@ export function useValidation({
}) { }) {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const gitCredentialsQuery = useGitCredentials(user.Id); const gitCredentialsQuery = useGitCredentials(user.Id);
const customTemplatesQuery = useCustomTemplates(); const customTemplatesQuery = useCustomTemplates({
params: {
edge: undefined,
},
});
return useMemo( return useMemo(
() => () =>

View File

@ -2,6 +2,7 @@ import { CustomTemplate } from '../types';
export const queryKeys = { export const queryKeys = {
base: () => ['custom-templates'] as const, base: () => ['custom-templates'] as const,
list: (params: unknown) => [...queryKeys.base(), { params }] as const,
item: (id: CustomTemplate['Id']) => [...queryKeys.base(), id] as const, item: (id: CustomTemplate['Id']) => [...queryKeys.base(), id] as const,
file: (id: CustomTemplate['Id'], options: { git: boolean }) => file: (id: CustomTemplate['Id'], options: { git: boolean }) =>
[...queryKeys.item(id), 'file', options] as const, [...queryKeys.item(id), 'file', options] as const,

View File

@ -2,26 +2,45 @@ import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios'; import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError } from '@/react-tools/react-query'; import { withGlobalError } from '@/react-tools/react-query';
import { StackType } from '@/react/common/stacks/types';
import { CustomTemplate } from '../types'; import { CustomTemplate } from '../types';
import { queryKeys } from './query-keys'; import { queryKeys } from './query-keys';
import { buildUrl } from './build-url'; import { buildUrl } from './build-url';
export async function getCustomTemplates() { type Params = {
type?: StackType[];
/**
* filter edge templates
* true if should show only edge templates
* false if should show only non-edge templates
* undefined if should show all templates
*/
edge?: boolean;
};
export function useCustomTemplates<T = Array<CustomTemplate>>({
select,
params,
}: { params?: Params; select?(templates: Array<CustomTemplate>): T } = {}) {
return useQuery(queryKeys.list(params), () => getCustomTemplates(params), {
select,
...withGlobalError('Unable to retrieve custom templates'),
});
}
async function getCustomTemplates({ type, edge = false }: Params = {}) {
try { try {
const { data } = await axios.get<CustomTemplate[]>(buildUrl()); const { data } = await axios.get<CustomTemplate[]>(buildUrl(), {
params: {
// deconstruct to make sure we don't pass other params
type,
edge,
},
});
return data; return data;
} catch (e) { } catch (e) {
throw parseAxiosError(e, 'Unable to get custom templates'); throw parseAxiosError(e, 'Unable to get custom templates');
} }
} }
export function useCustomTemplates<T = Array<CustomTemplate>>({
select,
}: { select?(templates: Array<CustomTemplate>): T } = {}) {
return useQuery(queryKeys.base(), () => getCustomTemplates(), {
select,
...withGlobalError('Unable to retrieve custom templates'),
});
}