refactor(gitops): remove manual credential entry from git form [BE-13047] (#2951)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chaim Lev-Ari
2026-06-22 15:42:09 +03:00
committed by GitHub
parent f596c862b3
commit a5cacd712d
77 changed files with 214 additions and 2104 deletions
@@ -1,76 +0,0 @@
import { IFormController } from 'angular';
import { FormikErrors } from 'formik';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { gitAuthValidation } from '@/react/portainer/gitops/AuthFieldset';
import { validateForm } from '@@/form-components/validate-form';
export default class GitFormAuthFieldsetController {
errors?: FormikErrors<GitAuthModel> = {};
$async: <T>(fn: () => Promise<T>) => Promise<T>;
gitFormAuthFieldset?: IFormController;
value?: GitAuthModel;
isAuthEdit: boolean;
onChange?: (value: GitAuthModel) => void;
/* @ngInject */
constructor($async: <T>(fn: () => Promise<T>) => Promise<T>) {
this.$async = $async;
this.isAuthEdit = false;
this.handleChange = this.handleChange.bind(this);
this.runGitValidation = this.runGitValidation.bind(this);
}
async handleChange(newValues: Partial<GitAuthModel>) {
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
const value = {
...this.value,
...newValues,
};
this.onChange?.(value);
await this.runGitValidation(value, this.isAuthEdit);
}
async runGitValidation(value: GitAuthModel, isAuthEdit: boolean) {
return this.$async(async () => {
this.errors = {};
this.gitFormAuthFieldset?.$setValidity(
'gitFormAuth',
true,
this.gitFormAuthFieldset
);
this.errors = await validateForm<GitAuthModel>(
() => gitAuthValidation(isAuthEdit, false),
value
);
if (this.errors && Object.keys(this.errors).length > 0) {
this.gitFormAuthFieldset?.$setValidity(
'gitFormAuth',
false,
this.gitFormAuthFieldset
);
}
});
}
async $onInit() {
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
await this.runGitValidation(this.value, this.isAuthEdit);
}
}
@@ -1,23 +0,0 @@
import { IComponentOptions } from 'angular';
import controller from './git-form-auth-fieldset.controller';
export const gitFormAuthFieldset: IComponentOptions = {
controller,
template: `
<ng-form name="$ctrl.gitFormAuthFieldset">
<react-git-form-auth-fieldset
value="$ctrl.value"
on-change="$ctrl.handleChange"
is-auth-explanation-visible="$ctrl.isAuthExplanationVisible"
errors="$ctrl.errors"
is-auth-edit="$ctrl.isAuthEdit">
</react-git-form-auth-fieldset>
</ng-form>`,
bindings: {
value: '<',
onChange: '<',
isAuthExplanationVisible: '<',
isAuthEdit: '<',
},
};
@@ -1,80 +0,0 @@
import { IFormController } from 'angular';
import { FormikErrors } from 'formik';
import { IAuthenticationService } from '@/portainer/services/types';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
import { autoUpdateValidation } from '@/react/portainer/gitops/AutoUpdateFieldset/validation';
import { validateForm } from '@@/form-components/validate-form';
export default class GitFormAutoUpdateFieldsetController {
errors?: FormikErrors<AutoUpdateModel> = {};
$async: <T>(fn: () => Promise<T>) => Promise<T>;
gitFormAutoUpdate?: IFormController;
Authentication: IAuthenticationService;
value?: AutoUpdateModel;
onChange?: (value: AutoUpdateModel) => void;
/* @ngInject */
constructor(
$async: <T>(fn: () => Promise<T>) => Promise<T>,
Authentication: IAuthenticationService
) {
this.$async = $async;
this.Authentication = Authentication;
this.handleChange = this.handleChange.bind(this);
this.runGitValidation = this.runGitValidation.bind(this);
}
async handleChange(newValues: Partial<AutoUpdateModel>) {
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
const value = {
...this.value,
...newValues,
};
this.onChange?.(value);
await this.runGitValidation(value);
}
async runGitValidation(value: AutoUpdateModel) {
return this.$async(async () => {
this.errors = {};
this.gitFormAutoUpdate?.$setValidity(
'gitFormAuth',
true,
this.gitFormAutoUpdate
);
this.errors = await validateForm<AutoUpdateModel>(
() => autoUpdateValidation(),
value
);
if (this.errors && Object.keys(this.errors).length > 0) {
this.gitFormAutoUpdate?.$setValidity(
'gitFormAuth',
false,
this.gitFormAutoUpdate
);
}
});
}
async $onInit() {
// this should never happen, but just in case
if (!this.value) {
throw new Error('GitFormController: value is required');
}
await this.runGitValidation(this.value);
}
}
@@ -1,28 +0,0 @@
import { IComponentOptions } from 'angular';
import controller from './git-form-auto-update-fieldset.controller';
export const gitFormAutoUpdate: IComponentOptions = {
template: `<ng-form name="$ctrl.gitFormAutoUpdate">
<react-git-form-auto-update-fieldset
value="$ctrl.value"
on-change="$ctrl.handleChange"
environment-type="$ctrl.environmentType"
is-force-pull-visible="$ctrl.isForcePullVisible"
base-webhook-url="$ctrl.baseWebhookUrl"
webhook-id="$ctrl.webhookId"
webhooks-docs="$ctrl.webhooksDocs"
errors="$ctrl.errors">
</react-git-form-auto-update-fieldset>
</ng-form>`,
bindings: {
value: '<',
onChange: '<',
environmentType: '@',
isForcePullVisible: '<',
baseWebhookUrl: '@',
webhookId: '@',
webhooksDocs: '@',
},
controller,
};
@@ -1,83 +0,0 @@
import { IComponentOptions, IFormController } from 'angular';
import { GitFormModel } from '@/react/portainer/gitops/types';
import { AsyncService } from '@/portainer/services/types';
import { refFieldValidation } from '@/react/portainer/gitops/RefField/RefField';
import { validateForm } from '@@/form-components/validate-form';
class GitFormRefFieldController {
$async: AsyncService;
value?: string;
onChange?: (value: string) => void;
gitFormRefField?: IFormController;
error?: string = '';
model?: GitFormModel;
stackId?: number = 0;
/* @ngInject */
constructor($async: AsyncService) {
this.$async = $async;
this.handleChange = this.handleChange.bind(this);
this.runValidation = this.runValidation.bind(this);
}
async handleChange(value: string) {
return this.$async(async () => {
this.onChange?.(value);
await this.runValidation(value);
});
}
async runValidation(value: string) {
return this.$async(async () => {
this.error = '';
this.gitFormRefField?.$setValidity(
'gitFormRefField',
true,
this.gitFormRefField
);
this.error = await validateForm<string>(
() => refFieldValidation(),
value
);
if (this.error) {
this.gitFormRefField?.$setValidity(
'gitFormRefField',
false,
this.gitFormRefField
);
}
});
}
}
export const gitFormRefField: IComponentOptions = {
controller: GitFormRefFieldController,
template: `
<ng-form name="$ctrl.gitFormRefField">
<react-git-form-ref-field
is-url-valid="$ctrl.isUrlValid"
model="$ctrl.model"
value="$ctrl.value"
on-change="$ctrl.handleChange"
stack-id="$ctrl.stackId"
error="$ctrl.error">
</react-git-form-ref-field>
</ng-form>`,
bindings: {
isUrlValid: '<',
value: '<',
onChange: '<',
model: '<',
stackId: '<',
},
};
@@ -19,8 +19,6 @@ export default class GitFormController {
deployMethod?: DeployMethod;
isSourceSelectionVisible?: boolean;
/* @ngInject */
constructor($async: <T>(fn: () => Promise<T>) => Promise<T>) {
this.$async = $async;
@@ -41,26 +39,15 @@ export default class GitFormController {
};
this.onChange?.(value);
const isCreatedFromCustomTemplate =
!!this.createdFromCustomTemplateId &&
this.createdFromCustomTemplateId > 0;
await this.runGitFormValidation(value, isCreatedFromCustomTemplate);
await this.runGitFormValidation(value);
}
async runGitFormValidation(
value: GitFormModel,
isCreatedFromCustomTemplate: boolean
) {
async runGitFormValidation(value: GitFormModel) {
return this.$async(async () => {
this.errors = {};
this.gitForm?.$setValidity('gitForm', true, this.gitForm);
this.errors = await validateGitForm(
value,
isCreatedFromCustomTemplate,
this.deployMethod,
this.isSourceSelectionVisible
);
this.errors = await validateGitForm(value, this.deployMethod);
if (this.errors && Object.keys(this.errors).length > 0) {
this.gitForm?.$setValidity('gitForm', false, this.gitForm);
}
@@ -73,9 +60,6 @@ export default class GitFormController {
throw new Error('GitFormController: value is required');
}
const isCreatedFromCustomTemplate =
!!this.createdFromCustomTemplateId &&
this.createdFromCustomTemplateId > 0;
await this.runGitFormValidation(this.value, isCreatedFromCustomTemplate);
await this.runGitFormValidation(this.value);
}
}
@@ -18,7 +18,6 @@ export const gitForm: IComponentOptions = {
webhook-id="$ctrl.webhookId"
webhooks-docs="$ctrl.webhooksDocs"
created-from-custom-template-id="$ctrl.createdFromCustomTemplateId"
is-source-selection-visible="$ctrl.isSourceSelectionVisible"
errors="$ctrl.errors">
</react-git-form>
</ng-form>`,
@@ -35,7 +34,6 @@ export const gitForm: IComponentOptions = {
webhookId: '@',
webhooksDocs: '@',
createdFromCustomTemplateId: '<',
isSourceSelectionVisible: '<',
},
controller,
};
@@ -1,13 +1,7 @@
import angular from 'angular';
import { gitForm } from './git-form';
import { gitFormAuthFieldset } from './git-form-auth-fieldset';
import { gitFormAutoUpdate } from './git-form-auto-update-fieldset';
import { gitFormRefField } from './git-form-ref-field';
export const gitFormModule = angular
.module('portainer.app.components.git-form', [])
.component('gitForm', gitForm) // kube deploy + docker stack create
.component('gitFormAuthFieldset', gitFormAuthFieldset)
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdate)
.component('gitFormRefField', gitFormRefField).name;
.component('gitForm', gitForm).name;
@@ -4,12 +4,7 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
import { GitForm } from '@/react/portainer/gitops/GitForm';
import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
import { RefField } from '@/react/portainer/gitops/RefField';
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
import { GitReferenceCard } from '@/react/portainer/gitops/GitReferenceCard';
export const gitFormModule = angular
.module('portainer.app.components.forms.git', [])
@@ -23,63 +18,10 @@ export const gitFormModule = angular
'deployMethod',
'isAdditionalFilesFieldVisible',
'isForcePullVisible',
'isAuthExplanationVisible',
'errors',
'baseWebhookUrl',
'webhookId',
'webhooksDocs',
'createdFromCustomTemplateId',
'isAutoUpdateVisible',
'isSourceSelectionVisible',
])
)
.component(
'gitFormGitReferenceCard',
r2a(withReactQuery(GitReferenceCard), [
'stackId',
'stackType',
'gitConfig',
'autoUpdate',
'currentDeploymentInfo',
'sourceId',
])
)
.component(
'reactGitFormAutoUpdateFieldset',
r2a(withUIRouter(withReactQuery(AutoUpdateFieldset)), [
'value',
'onChange',
'environmentType',
'isForcePullVisible',
'errors',
'baseWebhookUrl',
'webhookId',
'webhooksDocs',
])
)
.component(
'reactGitFormAuthFieldset',
r2a(withUIRouter(withReactQuery(withCurrentUser(AuthFieldset))), [
'value',
'isAuthExplanationVisible',
'onChange',
'errors',
])
)
.component(
'reactGitFormRefField',
r2a(withUIRouter(withReactQuery(withCurrentUser(RefField))), [
'error',
'model',
'onChange',
'stackId',
'createdFromCustomTemplateId',
'value',
'isUrlValid',
])
)
.component(
'timeWindowDisplay',
r2a(withReactQuery(withUIRouter(TimeWindowDisplay)), [])
).name;
@@ -29,6 +29,7 @@ vi.mock('@/portainer/services/notifications', () => ({
const mockStack = createMockStack({
Id: 1,
EndpointId: 1,
GitSourceId: 1,
GitConfig: {
URL: 'https://github.com/test/repo',
ReferenceName: 'main',
@@ -146,6 +147,20 @@ describe('EditGitSettingsModal', () => {
function renderComponent(onClose = vi.fn()) {
server.use(
http.get('/api/gitops/sources', () =>
HttpResponse.json([
{
id: 1,
name: 'test-source',
type: 'git',
url: 'https://github.com/test/repo',
status: 'valid',
usedBy: 0,
environments: 0,
lastSync: 0,
},
])
),
http.post('/api/gitops/repo/refs', () => HttpResponse.json([])),
http.post('/api/gitops/repo/files/search', () => HttpResponse.json([]))
);
@@ -19,13 +19,19 @@ interface Props {
}
export function EditGitSettingsModal({ stack, onClose }: Props) {
const validationSchema = useValidationSchema(stack.Type, !!stack.GitSourceId);
const validationSchema = useValidationSchema(stack.Type);
const [webhookId] = useState(
() => stack.AutoUpdate?.Webhook || createWebhookId()
);
const mutation = useUpdateGitStack(stack);
const gitModel = toGitFormModel(
stack.GitConfig,
stack.GitSourceId,
{
ReferenceName: stack.GitConfig?.ReferenceName ?? '',
ConfigFilePath: stack.GitConfig?.ConfigFilePath ?? '',
},
parseAutoUpdateResponse(stack.AutoUpdate)
);
@@ -35,15 +41,12 @@ export function EditGitSettingsModal({ stack, onClose }: Props) {
...gitModel,
AdditionalFiles: stack.AdditionalFiles || [],
SourceId: stack.GitSourceId,
RepositoryURLValid: !!gitModel.RepositoryURL,
},
env: stack.Env || [],
prune: stack.Option?.Prune || false,
redeployNow: false,
};
const mutation = useUpdateGitStack(stack);
return (
<Formik
initialValues={initialValues}
@@ -53,7 +56,6 @@ export function EditGitSettingsModal({ stack, onClose }: Props) {
<InnerForm
stackName={stack.Name}
stackType={stack.Type}
gitSourceId={stack.GitSourceId}
webhookId={webhookId}
onDismiss={onClose}
isSubmitting={mutation.isLoading}
@@ -24,14 +24,12 @@ import { FormValues } from './types';
export function InnerForm({
stackName,
stackType,
gitSourceId,
onDismiss,
isSubmitting,
webhookId,
}: {
stackName: string;
stackType: StackType;
gitSourceId?: number;
onDismiss: () => void;
isSubmitting: boolean;
webhookId: string;
@@ -99,7 +97,6 @@ export function InnerForm({
baseWebhookUrl={baseStackWebhookUrl()}
webhookId={webhookId}
webhooksDocs="/user/docker/stacks/webhooks"
isAuthExplanationVisible
isAdditionalFilesFieldVisible
isAutoUpdateVisible
errors={errors.git}
@@ -107,7 +104,6 @@ export function InnerForm({
stackType === StackType.Kubernetes ? 'manifest' : 'compose'
}
isDockerStandalone={isDockerStandalone}
isSourceSelectionVisible={!!gitSourceId}
/>
<StackEnvironmentVariablesPanel
@@ -29,13 +29,8 @@ export function useUpdateGitStack(stack: Stack) {
);
await updateGitStackSettings(stack.Id, stack.EndpointId, {
RepositoryURL: values.git.RepositoryURL,
ConfigFilePath: values.git.ComposeFilePathInRepository,
RepositoryReferenceName: values.git.RepositoryReferenceName,
RepositoryAuthentication: values.git.RepositoryAuthentication,
RepositoryUsername: values.git.RepositoryUsername,
RepositoryPassword: values.git.RepositoryPassword || undefined,
TLSSkipVerify: values.git.TLSSkipVerify,
AutoUpdate: autoUpdate,
AdditionalFiles: values.git.AdditionalFiles,
env: values.env,
@@ -52,9 +47,6 @@ export function useUpdateGitStack(stack: Stack) {
Env: values.env,
Prune: values.prune,
StackName: values.kube.name.trim() || undefined,
RepositoryAuthentication: values.git.RepositoryAuthentication,
RepositoryUsername: values.git.RepositoryUsername,
RepositoryPassword: values.git.RepositoryPassword || undefined,
RepullImageAndRedeploy: repullImageAndRedeploy,
});
return { redeployAttempted: true, redeployFailed: false };
@@ -9,8 +9,7 @@ import { envVarValidation } from '@@/form-components/EnvironmentVariablesFieldse
import { FormValues } from './types';
export function useValidationSchema(
stackType: StackType,
isSourceSelection: boolean
stackType: StackType
): SchemaOf<FormValues> {
const isKubernetes = stackType === StackType.Kubernetes;
@@ -22,17 +21,12 @@ export function useValidationSchema(
name: string().default(''),
}).required()
: object({ name: string().default('') }).optional(),
git: buildGitValidationSchema(
false,
isKubernetes ? 'manifest' : 'compose',
true,
isSourceSelection
),
git: buildGitValidationSchema(isKubernetes ? 'manifest' : 'compose'),
env: envVarValidation(),
prune: boolean().default(false),
redeployNow: boolean().default(false),
}),
[isKubernetes, isSourceSelection]
[isKubernetes]
);
}
@@ -46,12 +46,8 @@ export function GitPullButton({ stack }: { stack: Stack }) {
mutation.mutate(
{
RepullImageAndRedeploy: result.repullImageAndRedeploy,
RepositoryAuthentication: !!stack.GitConfig?.Authentication,
Env: stack.Env || [],
Prune: stack.Option?.Prune,
RepositoryAuthorizationType:
stack.GitConfig?.Authentication?.AuthorizationType,
RepositoryUsername: stack.GitConfig?.Authentication?.Username,
},
{
onSuccess: () => {
@@ -15,22 +15,14 @@ export type KubernetesGitRepositoryPayload = {
/** When set, URL and auth are resolved from the stored Source record */
sourceId?: number;
/** URL of a Git repository hosting the Stack file */
repositoryUrl: string;
/** Reference name of a Git repository hosting the Stack file */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** Path to the Stack file inside the Git repository */
manifestFile?: string;
additionalFiles?: Array<string>;
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
tlsSkipVerify?: boolean;
/** Optional GitOps update configuration */
autoUpdate?: AutoUpdateResponse | null;
environmentId: EnvironmentId;
@@ -16,16 +16,11 @@ export type StandaloneGitRepositoryPayload = {
/** Whether the stack is from an app template */
fromAppTemplate?: boolean;
/** URL of a Git repository hosting the Stack file */
repositoryUrl: string;
/** URL of a Git repository hosting the Stack file (used for app templates) */
repositoryUrl?: string;
/** Reference name of a Git repository hosting the Stack file */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** Path to the Stack file inside the Git repository */
composeFile?: string;
@@ -38,8 +33,7 @@ export type StandaloneGitRepositoryPayload = {
supportRelativePath?: boolean;
/** Local filesystem path */
filesystemPath?: string;
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
tlsSkipVerify?: boolean;
/** ID of an existing Source. When set, repositoryUrl and authentication fields are ignored. */
sourceId?: number;
environmentId: EnvironmentId;
@@ -18,16 +18,11 @@ export type SwarmGitRepositoryPayload = {
/** Swarm cluster identifier */
swarmID: string;
/** URL of a Git repository hosting the Stack file */
repositoryUrl: string;
/** URL of a Git repository hosting the Stack file (used for app templates) */
repositoryUrl?: string;
/** Reference name of a Git repository hosting the Stack file */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** Path to the Stack file inside the Git repository */
composeFile?: string;
@@ -40,8 +35,7 @@ export type SwarmGitRepositoryPayload = {
supportRelativePath?: boolean;
/** Local filesystem path */
filesystemPath?: string;
/** TLSSkipVerify skips SSL verification when cloning the Git repository */
tlsSkipVerify?: boolean;
/** ID of an existing Source. When set, repositoryUrl and authentication fields are ignored. */
sourceId?: number;
environmentId: EnvironmentId;
@@ -196,12 +196,8 @@ function createSwarmStack({ method, payload }: SwarmCreatePayload) {
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
composeFile: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
sourceId: payload.git.SourceId,
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
@@ -247,12 +243,8 @@ function createStandaloneStack({ method, payload }: StandaloneCreatePayload) {
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
composeFile: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
tlsSkipVerify: payload.git.TLSSkipVerify,
sourceId: payload.git.SourceId,
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
@@ -294,14 +286,9 @@ function createKubernetesStack({ method, payload }: KubernetesCreatePayload) {
stackName: payload.name,
sourceId: payload.git.SourceId,
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
manifestFile: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: transformAutoUpdateViewModel(
payload.git.AutoUpdate,
payload.webhook
@@ -325,7 +325,7 @@ describe('CreateStackForm', () => {
expect(requestBody).toMatchObject(
expect.objectContaining({
name: 'test-stack',
repositoryUrl: 'https://github.com/test/repo',
sourceId: 1,
repositoryReferenceName: 'refs/heads/main',
composeFile: 'docker-compose.yml',
})
@@ -96,13 +96,8 @@ describe('CreateStackForm - Webhook ID Integration', () => {
method: 'repository',
name: 'test-stack',
git: {
RepositoryURL: 'https://github.com/test/repo',
RepositoryReferenceName: 'main',
ComposeFilePathInRepository: 'docker-compose.yml',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: {
RepositoryAutomaticUpdates: true,
@@ -111,7 +106,6 @@ describe('CreateStackForm - Webhook ID Integration', () => {
ForcePullImage: false,
RepositoryAutomaticUpdatesForce: false,
},
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
},
@@ -59,10 +59,9 @@ function renderComponent({
const values = mockFormValues({
method: 'repository',
git: {
RepositoryURL: '',
SourceId: 0,
RepositoryReferenceName: 'refs/heads/main',
ComposeFilePathInRepository: 'docker-compose.yml',
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: undefined,
SupportRelativePath: false,
@@ -33,9 +33,7 @@ export function GitSection({ webhookId, isDockerStandalone = false }: Props) {
deployMethod="compose"
isDockerStandalone={isDockerStandalone}
isAdditionalFilesFieldVisible
isAuthExplanationVisible
isForcePullVisible
isSourceSelectionVisible
errors={errors.git}
baseWebhookUrl={baseStackWebhookUrl()}
webhookId={webhookId}
@@ -7,16 +7,10 @@ describe('Git validation', () => {
const validData: GitFormValues = {
SourceId: 1,
RepositoryURL: 'https://github.com/user/repo',
RepositoryReferenceName: 'refs/heads/main',
ComposeFilePathInRepository: 'docker-compose.yml',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: undefined,
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
};
@@ -24,11 +18,10 @@ describe('Git validation', () => {
await expect(schema.validate(validData)).resolves.toBeDefined();
});
it('should fail validation when repository URL is empty', async () => {
it('should fail validation when SourceId is missing', async () => {
const schema = getGitValidationSchema();
const invalidData = {
RepositoryURL: '',
RepositoryReferenceName: 'refs/heads/main',
ComposeFilePathInRepository: 'docker-compose.yml',
};
@@ -5,7 +5,7 @@ import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
import { GitFormValues } from './types';
export function getGitValidationSchema(): SchemaOf<GitFormValues> {
return buildGitValidationSchema(false, 'compose', false, true).concat(
return buildGitValidationSchema('compose').concat(
object({
SupportRelativePath: boolean().default(false),
FilesystemPath: string()
@@ -21,16 +21,11 @@ export function mockFormValues(overrides: DeepPartial<FormValues>): FormValues {
file: null,
},
git: {
RepositoryURL: '',
SourceId: 0,
RepositoryReferenceName: '',
ComposeFilePathInRepository: '',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
TLSSkipVerify: false,
AdditionalFiles: [],
AutoUpdate: undefined,
RepositoryAuthorizationType: undefined,
SupportRelativePath: false,
FilesystemPath: '',
},
@@ -158,6 +158,7 @@ describe('conditional form rendering', () => {
ConfigHash: '',
TLSSkipVerify: false,
},
GitSourceId: 1,
FromAppTemplate: false,
});
renderComponent({
@@ -266,6 +267,7 @@ describe('git and duplication form combination', () => {
ConfigHash: '',
TLSSkipVerify: false,
},
GitSourceId: 1,
FromAppTemplate: false,
});
renderComponent({
@@ -94,16 +94,17 @@ export function StackInfoTab({
/>
) : (
<div className="space-y-4">
{stack.GitConfig && !stack.FromAppTemplate && (
<GitReferenceCard
stackId={stack.Id}
gitConfig={stack.GitConfig}
autoUpdate={stack.AutoUpdate}
currentDeploymentInfo={stack.CurrentDeploymentInfo}
stackType="docker"
sourceId={stack.GitSourceId}
/>
)}
{!!stack.GitConfig &&
!stack.FromAppTemplate &&
!!stack.GitSourceId && (
<GitReferenceCard
gitConfig={stack.GitConfig}
autoUpdate={stack.AutoUpdate}
currentDeploymentInfo={stack.CurrentDeploymentInfo}
stackType="docker"
sourceId={stack.GitSourceId}
/>
)}
{isRegular && !!stackFileContent && (
<StackDuplicationForm
@@ -4,7 +4,10 @@ import { useState, useMemo } from 'react';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { getDefaultRelativePathModel } from '@/react/portainer/gitops/RelativePathFieldset/types';
import { createWebhookId } from '@/portainer/helpers/webhookHelper';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import {
CustomTemplate,
getTemplateSourceId,
} from '@/react/portainer/templates/custom-templates/types';
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { useAppTemplate } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
@@ -145,6 +148,7 @@ function useInitialValues(
getDefaultStaggerConfig(),
method: templateParams.templateId ? 'template' : 'editor',
git: toGitFormModel(
getTemplateSourceId(templateQuery.customTemplate),
templateQuery.customTemplate?.GitConfig,
parseAutoUpdateResponse()
),
@@ -126,12 +126,7 @@ export function useValidation({
values.deploymentType === DeploymentType.Compose
? 'compose'
: 'manifest';
return buildGitValidationSchema(
!!customTemplate,
deploymentMethod,
false,
true
);
return buildGitValidationSchema(deploymentMethod);
},
}) as SchemaOf<GitFormModel>,
relativePath: mixed().when('method', {
@@ -140,7 +140,6 @@ export function DockerComposeForm({ webhookId, onChangeTemplate }: Props) {
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
isAutoUpdateVisible={isBE}
isSourceSelectionVisible
/>
{isBE && (
@@ -111,7 +111,6 @@ export function KubeManifestForm({
baseWebhookUrl={baseEdgeStackWebhookUrl()}
webhookId={webhookId}
isAutoUpdateVisible={isBE}
isSourceSelectionVisible
/>
)}
</>
@@ -47,18 +47,13 @@ const expectedCustomTemplatePayload = {
UpdateFailureAction: 3,
},
useManifestNamespaces: false,
repositoryUrl: 'https://github.com/testA113/nginx-public',
repositoryUsername: '',
repositoryReferenceName: 'refs/heads/main',
filePathInRepository: 'docker/voting.yaml',
repositoryAuthentication: false,
repositoryPassword: '',
filesystemPath: '/test',
supportRelativePath: true,
perDeviceConfigsGroupMatchType: 'file',
perDeviceConfigsMatchType: 'file',
perDeviceConfigsPath: 'test',
tlsSkipVerify: false,
autoUpdate: null,
};
@@ -3,7 +3,10 @@ import { SetStateAction, useEffect, useState } from 'react';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { useCustomTemplateFile } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplateFile';
import { useCustomTemplate } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplate';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import {
CustomTemplate,
getTemplateSourceId,
} from '@/react/portainer/templates/custom-templates/types';
import { StackType } from '@/react/common/stacks/types';
import { toGitFormModel } from '@/react/portainer/gitops/types';
@@ -79,7 +82,7 @@ function getValuesFromTemplate(
template.Type === StackType.Kubernetes
? DeploymentType.Kubernetes
: DeploymentType.Compose,
git: toGitFormModel(template.GitConfig),
git: toGitFormModel(getTemplateSourceId(template), template.GitConfig),
...(template.EdgeSettings
? {
prePullImage: template.EdgeSettings.PrePullImage || false,
@@ -48,6 +48,7 @@ describe('GitForm', () => {
Password: '',
},
},
GitSourceId: 1,
PrePullImage: false,
RetryDeploy: false,
RetryPeriod: 0,
@@ -1,10 +1,10 @@
import { useState } from 'react';
import { Form, Formik, useFormikContext } from 'formik';
import { useRouter } from '@uirouter/react';
import { array, number, object } from 'yup';
import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { GitSourceSelector } from '@/react/portainer/gitops/sources/GitSourceSelector';
import {
parseAutoUpdateResponse,
transformAutoUpdateViewModel,
@@ -12,17 +12,12 @@ import {
import { RefField } from '@/react/portainer/gitops/RefField';
import {
AutoUpdateModel,
GitAuthModel,
RelativePathModel,
} from '@/react/portainer/gitops/types';
import {
baseEdgeStackWebhookUrl,
createWebhookId,
} from '@/portainer/helpers/webhookHelper';
import {
parseAuthResponse,
transformGitAuthenticationViewModel,
} from '@/react/portainer/gitops/AuthFieldset/utils';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
@@ -33,6 +28,7 @@ import { Registry } from '@/react/portainer/registries/types/registry';
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
import { RelativePathFieldset } from '@/react/portainer/gitops/RelativePathFieldset/RelativePathFieldset';
import { parseRelativePathResponse } from '@/react/portainer/gitops/RelativePathFieldset/utils';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { GitReferenceCard } from '@/react/portainer/gitops/GitReferenceCard';
import { LoadingButton } from '@@/buttons';
@@ -41,6 +37,7 @@ import { TextTip } from '@@/Tip/TextTip';
import { FormError } from '@@/form-components/FormError';
import { EnvironmentVariablesPanel } from '@@/form-components/EnvironmentVariablesFieldset';
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
import { Link } from '@@/Link';
import { useEdgeGroupHasType } from '../useEdgeGroupHasType';
import { PrivateRegistryFieldset } from '../../../components/PrivateRegistryFieldset';
@@ -55,7 +52,6 @@ interface FormValues {
deploymentType: DeploymentType;
autoUpdate: AutoUpdateModel;
refName: string;
authentication: GitAuthModel;
envVars: EnvVar[];
privateRegistryId?: Registry['Id'];
relativePath: RelativePathModel;
@@ -73,26 +69,26 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
return null;
}
const gitConfig = stack.GitConfig;
const initialValues: FormValues = {
groupIds: stack.EdgeGroups,
deploymentType: stack.DeploymentType,
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
refName: stack.GitConfig.ReferenceName,
authentication: parseAuthResponse(stack.GitConfig.Authentication),
relativePath: parseRelativePathResponse(stack),
envVars: stack.EnvVars || [],
};
return (
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={formValidation()}
>
{({ values, isValid }) => {
return (
<InnerForm
webhookId={webhookId}
onUpdateSettingsClick={handleUpdateSettings}
gitUrl={gitConfig.URL}
isLoading={updateStackMutation.isLoading}
isUpdateVersion={!!updateStackMutation.variables?.updateVersion}
stack={stack}
@@ -125,13 +121,12 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
}
function getPayload(
{ authentication, autoUpdate, privateRegistryId, ...values }: FormValues,
{ autoUpdate, privateRegistryId, ...values }: FormValues,
updateVersion: boolean
): UpdateEdgeStackGitPayload {
return {
updateVersion,
id: stack.Id,
authentication: transformGitAuthenticationViewModel(authentication),
autoUpdate: transformAutoUpdateViewModel(autoUpdate, webhookId),
registries:
typeof privateRegistryId !== 'undefined'
@@ -143,14 +138,12 @@ export function GitForm({ stack }: { stack: EdgeStack }) {
}
function InnerForm({
gitUrl,
isLoading,
isUpdateVersion,
onUpdateSettingsClick,
webhookId,
stack,
}: {
gitUrl: string;
isLoading: boolean;
isUpdateVersion: boolean;
onUpdateSettingsClick(): void;
@@ -166,6 +159,10 @@ function InnerForm({
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
if (!stack.GitConfig || !stack.GitSourceId) {
return null;
}
return (
<Form className="form-horizontal" onSubmit={handleSubmit}>
<EdgeGroupsSelector
@@ -199,14 +196,12 @@ function InnerForm({
}}
/>
{!!stack.GitConfig && (
<GitReferenceCard
stackId={stack.Id}
stackType="edge"
autoUpdate={stack.AutoUpdate}
gitConfig={stack.GitConfig}
/>
)}
<GitReferenceCard
stackType="edge"
autoUpdate={stack.AutoUpdate}
gitConfig={stack.GitConfig}
sourceId={stack.GitSourceId}
/>
<FormSection title="Update from git repository">
<AutoUpdateFieldset
@@ -227,21 +222,21 @@ function InnerForm({
<RefField
value={values.refName}
onChange={(value) => setFieldValue('refName', value)}
model={{ ...values.authentication, RepositoryURL: gitUrl }}
sourceId={stack.GitSourceId}
error={errors.refName}
isUrlValid
/>
<AuthFieldset
value={values.authentication}
isAuthExplanationVisible
onChange={(value) =>
Object.entries(value).forEach(([key, value]) => {
setFieldValue(`authentication.${key}`, value);
})
}
errors={errors.authentication}
/>
<GitSourceSelector value={stack.GitSourceId} readOnly />
<TextTip>
Credentials are managed by the source.{' '}
<Link
to="portainer.gitops.sources.item"
params={{ sourceId: stack.GitSourceId }}
data-cy="source-item-link"
>
Edit source
</Link>
</TextTip>
{isBE && (
<RelativePathFieldset
@@ -291,3 +286,12 @@ function InnerForm({
</Form>
);
}
function formValidation() {
return object({
groupIds: array()
.of(number().required())
.required()
.min(1, 'At least one edge group is required'),
});
}
@@ -6,10 +6,7 @@ import {
withError,
withInvalidate,
} from '@/react-tools/react-query';
import {
AutoUpdateResponse,
GitAuthenticationResponse,
} from '@/react/portainer/gitops/types';
import { AutoUpdateResponse } from '@/react/portainer/gitops/types';
import { buildUrl } from '@/react/edge/edge-stacks/queries/buildUrl';
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
@@ -21,7 +18,6 @@ export interface UpdateEdgeStackGitPayload {
id: EdgeStack['Id'];
autoUpdate: AutoUpdateResponse | null;
refName: string;
authentication: GitAuthenticationResponse | null;
groupIds: EdgeGroup['Id'][];
deploymentType: DeploymentType;
updateVersion: boolean;
@@ -19,12 +19,6 @@ export type GitRepositoryPayload = {
repositoryUrl?: string;
/** Reference name of a Git repository hosting the Stack file */
repositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository */
repositoryAuthentication?: boolean;
/** Username used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryUsername?: string;
/** Password used in basic authentication. Required when RepositoryAuthentication is true. */
repositoryPassword?: string;
/** Path to the Stack file inside the Git repository */
filePathInRepository?: string;
/** List of identifiers of EdgeGroups */
@@ -131,12 +131,8 @@ function createEdgeStackFromGit(
staggerConfig: payload.staggerConfig,
useManifestNamespaces: payload.useManifestNamespaces,
sourceId: payload.git.SourceId,
repositoryUrl: payload.git.RepositoryURL,
repositoryReferenceName: payload.git.RepositoryReferenceName,
filePathInRepository: payload.git.ComposeFilePathInRepository,
repositoryAuthentication: payload.git.RepositoryAuthentication,
repositoryUsername: payload.git.RepositoryUsername,
repositoryPassword: payload.git.RepositoryPassword,
filesystemPath: payload.relativePathSettings?.FilesystemPath,
supportRelativePath: payload.relativePathSettings?.SupportRelativePath,
perDeviceConfigsGroupMatchType:
@@ -144,7 +140,6 @@ function createEdgeStackFromGit(
perDeviceConfigsMatchType:
payload.relativePathSettings?.PerDeviceConfigsMatchType,
perDeviceConfigsPath: payload.relativePathSettings?.PerDeviceConfigsPath,
tlsSkipVerify: payload.git.TLSSkipVerify,
autoUpdate: payload.autoUpdate,
});
}
+1
View File
@@ -88,6 +88,7 @@ export type EdgeStack = Partial<RelativePathModel> & {
ManifestPath: string;
DeploymentType: DeploymentType;
UseManifestNamespaces: boolean;
GitSourceId?: number;
} & Partial<{
// EE
Registries: RegistryId[];
@@ -68,10 +68,9 @@ export function ApplicationDetailsWidget() {
<WidgetBody>
{!isSystemNamespace && (
<>
{!!stack?.GitConfig && (
{!!stack?.GitConfig && !!stack.GitSourceId && (
<div className="mb-4">
<GitReferenceCard
stackId={stack.Id}
autoUpdate={stack.AutoUpdate}
gitConfig={stack.GitConfig}
currentDeploymentInfo={stack.CurrentDeploymentInfo}
@@ -1,392 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { AuthFieldset, gitAuthValidation } from './AuthFieldset';
vi.mock('../../feature-flags/feature-flags.service', () => ({
isBE: true,
isLimitedToBE: () => false,
}));
vi.mock('@/react/hooks/useDebounce', () => ({
useDebounce: (value: unknown, callback: (value: unknown) => void) => [
value,
callback,
],
}));
const defaultGitAuthModel: GitAuthModel = {
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
function renderAuthFieldset({
value = defaultGitAuthModel,
onChange = vi.fn(),
isAuthExplanationVisible = false,
errors = {},
}: {
value?: GitAuthModel;
onChange?: (value: Partial<GitAuthModel>) => void;
isAuthExplanationVisible?: boolean;
errors?: Record<string, string>;
} = {}) {
return render(
<AuthFieldset
value={value}
onChange={onChange}
isAuthExplanationVisible={isAuthExplanationVisible}
errors={errors}
/>
);
}
describe('AuthFieldset', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('key component rendering', () => {
it('should render authentication toggle', () => {
renderAuthFieldset();
expect(screen.getByTestId('component-gitAuthToggle')).toBeInTheDocument();
});
it('should render username input when authentication is enabled', () => {
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
});
expect(
screen.getByTestId('component-gitUsernameInput')
).toBeInTheDocument();
});
it('should render password input when authentication is enabled', () => {
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
});
expect(
screen.getByTestId('component-gitPasswordInput')
).toBeInTheDocument();
});
it('should not render interactive fields when authentication is disabled', () => {
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: false },
});
expect(
screen.queryByTestId('component-gitUsernameInput')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('component-gitPasswordInput')
).not.toBeInTheDocument();
});
});
describe('props handling', () => {
it('should handle onChange prop', () => {
const onChange = vi.fn();
renderAuthFieldset({ onChange });
expect(onChange).toBeDefined();
});
it('should handle errors prop for username', () => {
const errors = { RepositoryUsername: 'Username is required' };
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
errors,
});
expect(screen.getByText('Username is required')).toBeInTheDocument();
});
it('should handle errors prop for password', () => {
const errors = { RepositoryPassword: 'Password is required' };
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
errors,
});
expect(screen.getByText('Password is required')).toBeInTheDocument();
});
it('should handle multiple errors', () => {
const errors = {
RepositoryUsername: 'Username is required',
RepositoryPassword: 'Password is required',
};
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
errors,
});
expect(screen.getByText('Username is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
});
it('should handle empty errors object', () => {
const errors = {};
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
errors,
});
expect(
screen.getByTestId('component-gitUsernameInput')
).toBeInTheDocument();
expect(
screen.getByTestId('component-gitPasswordInput')
).toBeInTheDocument();
});
it('should handle isAuthExplanationVisible prop when true', () => {
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
isAuthExplanationVisible: true,
});
expect(
screen.getByText(
'Enabling authentication will store the credentials and it is advisable to use a git service account'
)
).toBeInTheDocument();
});
it('should handle isAuthExplanationVisible prop when false', () => {
renderAuthFieldset({
value: { ...defaultGitAuthModel, RepositoryAuthentication: true },
isAuthExplanationVisible: false,
});
expect(
screen.queryByText(
'Enabling authentication will store the credentials and it is advisable to use a git service account'
)
).not.toBeInTheDocument();
});
it('should handle value prop with all fields populated', () => {
const value: GitAuthModel = {
RepositoryAuthentication: true,
RepositoryUsername: 'testuser',
RepositoryPassword: 'testpass',
RepositoryAuthorizationType: AuthTypeOption.Token,
};
renderAuthFieldset({ value });
expect(
screen.getByTestId('component-gitUsernameInput')
).toBeInTheDocument();
expect(
screen.getByTestId('component-gitPasswordInput')
).toBeInTheDocument();
});
});
});
describe('gitAuthValidation', () => {
describe('default values', () => {
it('should provide correct default values', async () => {
const schema = gitAuthValidation(false, false);
const result = await schema.validate({});
expect(result).toEqual({
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
});
});
});
describe('authentication disabled', () => {
it('should allow empty values when authentication is disabled', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
const result = await schema.validate(data);
expect(result.RepositoryAuthentication).toBe(false);
});
});
describe('authentication enabled', () => {
it('should require username when authentication is enabled', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: '',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
await expect(schema.validate(data)).rejects.toThrow(
'Username is required'
);
});
it('should require password when authentication is enabled, not auth edit, and not from custom template', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'username',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
await expect(schema.validate(data)).rejects.toThrow(
'Personal Access Token is required'
);
});
it('should set default authorization type when authentication is enabled', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: undefined,
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic);
});
it('should accept valid authorization types', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: AuthTypeOption.Token,
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Token);
});
it('should reject invalid authorization types', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: 999,
};
await expect(schema.validate(data)).rejects.toThrow();
});
});
describe('auth edit mode', () => {
it('should not require password when in auth edit mode', async () => {
const schema = gitAuthValidation(true, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'username',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
const result = await schema.validate(data);
expect(result.RepositoryPassword).toBe('');
});
it('should not require authorization type when in auth edit mode', async () => {
const schema = gitAuthValidation(true, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: undefined,
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic);
});
});
describe('created from custom template', () => {
it('should not require password when created from custom template', async () => {
const schema = gitAuthValidation(false, true);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'username',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
const result = await schema.validate(data);
expect(result.RepositoryPassword).toBe('');
});
it('should not require authorization type when created from custom template', async () => {
const schema = gitAuthValidation(false, true);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'username',
RepositoryPassword: 'password',
RepositoryAuthorizationType: undefined,
};
const result = await schema.validate(data);
expect(result.RepositoryAuthorizationType).toBe(AuthTypeOption.Basic);
});
});
describe('complex scenarios', () => {
it('should handle complete valid data', async () => {
const schema = gitAuthValidation(false, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'testuser',
RepositoryPassword: 'testpassword',
RepositoryAuthorizationType: AuthTypeOption.Token,
};
const result = await schema.validate(data);
expect(result).toEqual(data);
});
it('should handle auth edit mode', async () => {
const schema = gitAuthValidation(true, false);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'testuser',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
const result = await schema.validate(data);
expect(result.RepositoryPassword).toBe('');
});
it('should handle custom template creation', async () => {
const schema = gitAuthValidation(false, true);
const data = {
RepositoryAuthentication: true,
RepositoryUsername: 'testuser',
RepositoryPassword: '',
RepositoryAuthorizationType: AuthTypeOption.Basic,
};
const result = await schema.validate(data);
expect(result.RepositoryPassword).toBe('');
});
});
});
@@ -1,104 +0,0 @@
import { FormikErrors } from 'formik';
import { boolean, mixed, object, SchemaOf, string } from 'yup';
import { useState } from 'react';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { AuthTypeOption } from '@/react/portainer/account/git-credentials/types';
import { SwitchField } from '@@/form-components/SwitchField';
import { TextTip } from '@@/Tip/TextTip';
import { isBE } from '../../feature-flags/feature-flags.service';
import { CredentialsSection } from './CredentialsSection';
interface Props {
value: GitAuthModel;
onChange: (value: Partial<GitAuthModel>) => void;
isAuthExplanationVisible?: boolean;
errors?: FormikErrors<GitAuthModel>;
}
export function AuthFieldset({
value: initialValue,
onChange,
isAuthExplanationVisible,
errors,
}: Props) {
const [value, setValue] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
return (
<>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Authentication"
labelClass="col-sm-3 col-lg-2"
name="authentication"
checked={value.RepositoryAuthentication || false}
onChange={(value) =>
handleChange({ RepositoryAuthentication: value })
}
data-cy="component-gitAuthToggle"
/>
</div>
</div>
{value.RepositoryAuthentication && (
<>
{isAuthExplanationVisible && (
<TextTip color="orange" className="mb-2">
Enabling authentication will store the credentials and it is
advisable to use a git service account
</TextTip>
)}
<CredentialsSection
value={value}
onChange={handleChange}
errors={errors}
/>
</>
)}
</>
);
function handleChange(partialValue: Partial<GitAuthModel>) {
onChange(partialValue);
setValue((value) => ({ ...value, ...partialValue }));
}
}
export function gitAuthValidation(
isAuthEdit: boolean,
isCreatedFromCustomTemplate: boolean
): SchemaOf<GitAuthModel> {
return object({
RepositoryAuthentication: boolean().default(false),
RepositoryUsername: string()
.when(['RepositoryAuthentication', 'SourceId'], {
is: (auth: boolean, sourceId?: number) => auth && !sourceId,
then: string().required('Username is required'),
})
.default(''),
RepositoryPassword: string()
.when(['RepositoryAuthentication', 'SourceId'], {
is: (auth: boolean, sourceId?: number) =>
auth && !sourceId && !isAuthEdit && !isCreatedFromCustomTemplate,
then: string().required('Personal Access Token is required'),
})
.default(''),
RepositoryAuthorizationType: mixed()
.oneOf(Object.values(AuthTypeOption))
.when(['RepositoryAuthentication', 'SourceId'], {
is: (auth: boolean, sourceId?: number) =>
isBE &&
auth &&
!sourceId &&
!isAuthEdit &&
!isCreatedFromCustomTemplate,
then: mixed().required('Authorization type is required'),
})
.default(AuthTypeOption.Basic),
});
}
@@ -1,99 +0,0 @@
import { FormikErrors } from 'formik';
import { useDebounce } from '@/react/hooks/useDebounce';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { RadioGroup } from '@@/RadioGroup/RadioGroup';
import { AuthTypeOption } from '../../account/git-credentials/types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { GitAuthModel } from '../types';
export const defaultAuthTypeOptions = [
{
value: AuthTypeOption.Basic,
label: 'Basic',
},
{
value: AuthTypeOption.Token,
label: 'Token',
},
] as const;
export function CredentialsSection({
value,
onChange,
errors,
}: {
value: GitAuthModel;
onChange: (value: Partial<GitAuthModel>) => void;
errors?: FormikErrors<GitAuthModel>;
}) {
const [username, setUsername] = useDebounce(
value.RepositoryUsername || '',
(username) => onChange({ RepositoryUsername: username })
);
const [password, setPassword] = useDebounce(
value.RepositoryPassword || '',
(password) => onChange({ RepositoryPassword: password })
);
const [authType, setAuthType] = useDebounce(
value.RepositoryAuthorizationType || AuthTypeOption.Basic,
(authType) => onChange({ RepositoryAuthorizationType: authType })
);
return (
<>
{isBE && (
<div className="form-group">
<div className="col-sm-12">
<FormControl
label="Authorization type"
tooltip="GitHub, GitLab, and Bitbucket Cloud expect Basic Auth, even when using an API or access token."
>
<RadioGroup
options={defaultAuthTypeOptions}
selectedOption={authType}
onOptionChange={(value) => setAuthType(value)}
name="AuthorizationType"
/>
</FormControl>
</div>
</div>
)}
<div className="form-group">
<div className="col-sm-12">
<FormControl label="Username" errors={errors?.RepositoryUsername}>
<Input
value={username}
name="repository_username"
placeholder="git username"
onChange={(e) => setUsername(e.target.value)}
data-cy="component-gitUsernameInput"
/>
</FormControl>
</div>
</div>
<div className="form-group !mb-0">
<div className="col-sm-12">
<FormControl
label="Personal Access Token"
tooltip="Provide a personal access token or password"
errors={errors?.RepositoryPassword}
>
<Input
type="password"
value={password}
name="repository_password"
placeholder="*******"
onChange={(e) => setPassword(e.target.value)}
data-cy="component-gitPasswordInput"
/>
</FormControl>
</div>
</div>
</>
);
}
@@ -1 +0,0 @@
export { AuthFieldset, gitAuthValidation } from './AuthFieldset';
@@ -1,36 +0,0 @@
import { GitAuthenticationResponse, GitAuthModel } from '../types';
export function parseAuthResponse(
auth?: GitAuthenticationResponse
): GitAuthModel {
if (!auth) {
return {
RepositoryAuthentication: false,
RepositoryPassword: '',
RepositoryUsername: '',
};
}
return {
RepositoryAuthentication: true,
RepositoryPassword: '',
RepositoryUsername: auth.Username,
};
}
export function transformGitAuthenticationViewModel(
auth?: GitAuthModel
): GitAuthenticationResponse | null {
if (!auth || !auth.RepositoryAuthentication) {
return null;
}
if (!auth.RepositoryUsername && !auth.RepositoryPassword) {
return null;
}
return {
Username: auth.RepositoryUsername,
Password: auth.RepositoryPassword,
};
}
@@ -29,10 +29,8 @@ const defaultProps = {
onChange: vi.fn(),
isCompose: true,
model: {
RepositoryURL: 'https://github.com/example/repo',
SourceId: 1,
ComposeFilePathInRepository: 'docker-compose.yml',
RepositoryAuthentication: false,
TLSSkipVerify: false,
} as GitFormModel,
isDockerStandalone: false,
};
@@ -16,7 +16,6 @@ interface Props {
isCompose: boolean;
model: GitFormModel;
isDockerStandalone: boolean;
createdFromCustomTemplateId?: number;
}
export function ComposePathField({
@@ -26,7 +25,6 @@ export function ComposePathField({
model,
isDockerStandalone,
errors,
createdFromCustomTemplateId,
}: Props) {
const [inputValue, updateInputValue] = useStateWrapper(value, onChange);
@@ -77,7 +75,6 @@ export function ComposePathField({
placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'}
model={model}
inputId="stack_repository_path"
createdFromCustomTemplateId={createdFromCustomTemplateId}
/>
) : (
<Input
@@ -2,20 +2,11 @@ import { useSearch } from '@/react/portainer/gitops/queries/useSearch';
import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect';
import { getAuthentication } from '../utils';
import { GitFormModel } from '../types';
export type PathSelectorGitModel = Pick<
GitFormModel,
| 'RepositoryAuthentication'
| 'RepositoryPassword'
| 'RepositoryUsername'
| 'RepositoryAuthorizationType'
| 'RepositoryURL'
| 'RepositoryReferenceName'
| 'TLSSkipVerify'
| 'RepositoryURLValid'
| 'SourceId'
'RepositoryReferenceName' | 'SourceId'
>;
export function PathSelector({
@@ -26,7 +17,6 @@ export function PathSelector({
dirOnly,
readOnly,
inputId,
createdFromCustomTemplateId,
}: {
value: string;
onChange(value: string): void;
@@ -35,24 +25,15 @@ export function PathSelector({
dirOnly?: boolean;
readOnly?: boolean;
inputId: string;
createdFromCustomTemplateId?: number;
}) {
const creds = getAuthentication(model);
const payload = {
repository: model.RepositoryURL,
keyword: value,
reference: model.RepositoryReferenceName,
tlsSkipVerify: model.TLSSkipVerify,
dirOnly,
createdFromCustomTemplateId,
sourceId: model.SourceId,
...creds,
};
const enabled = Boolean(
((model.RepositoryURL && model.RepositoryURLValid) || model.SourceId) &&
value
);
const enabled = !!(model.SourceId && value);
const { data: searchResults } = useSearch(payload, enabled);
return (
+2 -11
View File
@@ -15,7 +15,6 @@ const WrappedComponent = withUserProvider(GitForm);
interface Args {
isAdditionalFilesFieldVisible: boolean;
isAuthExplanationVisible: boolean;
isDockerStandalone: boolean;
deployMethod: DeployMethod;
isForcePullVisible: boolean;
@@ -24,26 +23,20 @@ interface Args {
export function Primary({
deployMethod,
isAdditionalFilesFieldVisible,
isAuthExplanationVisible,
isDockerStandalone,
isForcePullVisible,
}: Args) {
const initialValues: GitFormModel = {
RepositoryURL: '',
RepositoryURLValid: false,
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
SourceId: 0,
AdditionalFiles: [],
RepositoryReferenceName: '',
ComposeFilePathInRepository: '',
TLSSkipVerify: false,
};
return (
<Formik
initialValues={initialValues}
validationSchema={() => buildGitValidationSchema(false, 'compose')}
validationSchema={() => buildGitValidationSchema(deployMethod)}
onSubmit={() => {}}
>
{({ values, errors, setValues }) => (
@@ -53,7 +46,6 @@ export function Primary({
errors={errors}
onChange={(value) => setValues({ ...values, ...value })}
isAdditionalFilesFieldVisible={isAdditionalFilesFieldVisible}
isAuthExplanationVisible={isAuthExplanationVisible}
isDockerStandalone={isDockerStandalone}
isForcePullVisible={isForcePullVisible}
deployMethod={deployMethod}
@@ -68,7 +60,6 @@ export function Primary({
Primary.args = {
isAdditionalFilesFieldVisible: true,
isAuthExplanationVisible: true,
isAutoUpdateVisible: true,
isDockerStandalone: true,
isForcePullVisible: true,
+22 -117
View File
@@ -1,20 +1,17 @@
import { useState } from 'react';
import { array, boolean, number, object, SchemaOf, string } from 'yup';
import { array, number, object, SchemaOf, string } from 'yup';
import { FormikErrors } from 'formik';
import { ComposePathField } from '@/react/portainer/gitops/ComposePathField';
import { RefField } from '@/react/portainer/gitops/RefField';
import { GitFormUrlField } from '@/react/portainer/gitops/GitFormUrlField';
import { DeployMethod, GitFormModel } from '@/react/portainer/gitops/types';
import { TimeWindowDisplay } from '@/react/portainer/gitops/TimeWindowDisplay';
import { GitSourceSelector } from '@/react/portainer/gitops/sources/GitSourceSelector';
import { FormSection } from '@@/form-components/FormSection';
import { validateForm } from '@@/form-components/validate-form';
import { SwitchField } from '@@/form-components/SwitchField';
import { AdditionalFileField } from './AdditionalFilesField';
import { gitAuthValidation, AuthFieldset } from './AuthFieldset';
import { AutoUpdateFieldset } from './AutoUpdateFieldset';
import { autoUpdateValidation } from './AutoUpdateFieldset/validation';
import { refFieldValidation } from './RefField/RefField';
@@ -27,15 +24,11 @@ interface Props {
isDockerStandalone?: boolean;
isAdditionalFilesFieldVisible?: boolean;
isForcePullVisible?: boolean;
isAuthExplanationVisible?: boolean;
errors?: FormikErrors<GitFormModel>;
baseWebhookUrl?: string;
webhookId?: string;
webhooksDocs?: string;
createdFromCustomTemplateId?: number;
isAutoUpdateVisible?: boolean;
/** When true, shows a SourceSelector instead of the manual git fields. The manual git fields are deprecated and will be removed (BE-13047). */
isSourceSelectionVisible?: boolean;
}
export function GitForm({
@@ -46,87 +39,34 @@ export function GitForm({
isDockerStandalone = false,
isAdditionalFilesFieldVisible,
isForcePullVisible,
isAuthExplanationVisible,
errors = {},
baseWebhookUrl,
webhookId,
webhooksDocs,
createdFromCustomTemplateId,
isAutoUpdateVisible = true,
isSourceSelectionVisible = false,
}: Props) {
const [value, setValue] = useState(initialValue); // TODO: remove this state when form is not inside angularjs
return (
<FormSection title="Git repository">
{isSourceSelectionVisible ? (
<GitSourceSelector
value={value.SourceId}
onChange={(source) =>
handleChange({
SourceId: source?.id,
RepositoryURL: source?.url ?? '',
RepositoryReferenceName: initialValue.RepositoryReferenceName,
ComposeFilePathInRepository:
initialValue.ComposeFilePathInRepository,
RepositoryURLValid: !!source,
})
}
error={errors.SourceId as string | undefined}
/>
) : (
<>
<AuthFieldset
value={value}
onChange={handleChange}
isAuthExplanationVisible={isAuthExplanationVisible}
errors={errors}
/>
<GitFormUrlField
value={value.RepositoryURL}
onChange={(value) => {
handleChange({
RepositoryURL: value,
RepositoryReferenceName: initialValue.RepositoryReferenceName,
ComposeFilePathInRepository:
initialValue.ComposeFilePathInRepository,
RepositoryURLValid: false,
});
}}
onChangeRepositoryValid={(isValid) =>
handleChange({
RepositoryURLValid: isValid,
})
}
model={value}
createdFromCustomTemplateId={createdFromCustomTemplateId}
errors={errors.RepositoryURL}
/>
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Skip TLS Verification"
data-cy="gitops-skip-tls-verification-switch"
checked={value.TLSSkipVerify || false}
onChange={(value) => handleChange({ TLSSkipVerify: value })}
name="TLSSkipVerify"
tooltip="Enabling this will allow skipping TLS validation for any self-signed certificate."
labelClass="col-sm-3 col-lg-2"
/>
</div>
</div>
</>
)}
<GitSourceSelector
value={value.SourceId}
onChange={(source) =>
handleChange({
SourceId: source?.id,
RepositoryReferenceName: initialValue.RepositoryReferenceName,
ComposeFilePathInRepository:
initialValue.ComposeFilePathInRepository,
})
}
error={errors.SourceId}
/>
<RefField
value={value.RepositoryReferenceName || ''}
onChange={(value) => handleChange({ RepositoryReferenceName: value })}
model={value}
sourceId={value.SourceId}
error={errors.RepositoryReferenceName}
isUrlValid={value.RepositoryURLValid}
createdFromCustomTemplateId={createdFromCustomTemplateId}
/>
<ComposePathField
@@ -138,7 +78,6 @@ export function GitForm({
model={value}
isDockerStandalone={isDockerStandalone}
errors={errors.ComposeFilePathInRepository}
createdFromCustomTemplateId={createdFromCustomTemplateId}
/>
{isAdditionalFilesFieldVisible && (
@@ -174,37 +113,19 @@ export function GitForm({
export async function validateGitForm(
formValues: GitFormModel,
isCreatedFromCustomTemplate: boolean,
deployMethod: DeployMethod = 'compose',
isSourceSelection = false
deployMethod: DeployMethod = 'compose'
) {
return validateForm<GitFormModel>(
() =>
buildGitValidationSchema(
isCreatedFromCustomTemplate,
deployMethod,
false,
isSourceSelection
),
() => buildGitValidationSchema(deployMethod),
formValues
);
}
export function buildGitValidationSchema(
isCreatedFromCustomTemplate: boolean,
deployMethod: DeployMethod,
isEdit = false,
isSourceSelection = false
deployMethod: DeployMethod
): SchemaOf<GitFormModel> {
return object({
// In source-selection mode the repository URL is derived from the selected
// source (not user-editable), so the user provides a SourceId instead and
// the URL itself needs no validation.
RepositoryURL: isSourceSelection
? string()
: string()
.test('valid URL', 'The URL must be a valid URL', isValidGitUrl)
.required('Repository URL is required'),
RepositoryURL: string().optional(),
RepositoryReferenceName: refFieldValidation(),
ComposeFilePathInRepository: string().required(
deployMethod === 'compose'
@@ -212,25 +133,9 @@ export function buildGitValidationSchema(
: 'Manifest file path is required'
),
AdditionalFiles: array(string().required('Path is required')).default([]),
RepositoryURLValid: boolean().default(false),
AutoUpdate: autoUpdateValidation().nullable(),
TLSSkipVerify: boolean().default(false),
SourceId: isSourceSelection
? number().min(1, 'Source is required').required('Source is required')
: number().optional().nullable(),
}).concat(
gitAuthValidation(isEdit, isCreatedFromCustomTemplate)
) as SchemaOf<GitFormModel>;
}
function isValidGitUrl(value?: string) {
if (!value) {
return true;
}
try {
return !!new URL(value).hostname;
} catch {
return false;
}
SourceId: number()
.min(1, 'Source is required')
.required('Source is required'),
}) as SchemaOf<GitFormModel>;
}
@@ -1,358 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { HttpResponse } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { useDebounce } from '@/react/hooks/useDebounce';
import { server, http } from '@/setup-tests/server';
import { suppressConsoleLogs } from '@/setup-tests/suppress-console';
import { GitFormModel } from './types';
import { GitFormUrlField } from './GitFormUrlField';
import { getAuthentication } from './utils';
vi.mock('@/react/hooks/useDebounce', () => ({
useDebounce: vi.fn(),
}));
vi.mock('../feature-flags/feature-flags.service', () => ({
isBE: true,
}));
vi.mock('./utils', async (importActual) => ({
...(await importActual()),
getAuthentication: vi.fn(),
}));
const mockUseDebounce = vi.mocked(useDebounce);
const mockGetAuthentication = vi.mocked(getAuthentication);
describe('GitFormUrlField', () => {
const defaultModel: GitFormModel = {
RepositoryURL: '',
ComposeFilePathInRepository: '',
RepositoryAuthentication: false,
RepositoryURLValid: false,
TLSSkipVerify: false,
};
const defaultProps = {
value: '',
onChange: vi.fn(),
onChangeRepositoryValid: vi.fn(),
model: defaultModel,
};
beforeEach(() => {
vi.clearAllMocks();
mockUseDebounce.mockImplementation((value, onChange) => [value, onChange]);
mockGetAuthentication.mockReturnValue(undefined);
});
function renderComponent(props = {}) {
const Component = withTestQueryProvider(() => (
<GitFormUrlField {...defaultProps} {...props} />
));
return render(<Component />);
}
describe('Basic rendering', () => {
it('should render with correct structure', () => {
renderComponent();
expect(screen.getByText(/repository url/i)).toBeInTheDocument();
expect(
screen.getByPlaceholderText(
'e.g. https://github.com/portainer/portainer-compose'
)
).toBeInTheDocument();
expect(screen.getByTestId('component-gitUrlInput')).toBeInTheDocument();
expect(
screen.getByTestId('component-gitUrlRefreshButton')
).toBeInTheDocument();
});
it('should display the current value in the input', () => {
const testUrl = 'https://github.com/test/repo';
renderComponent({ value: testUrl });
expect(screen.getByDisplayValue(testUrl)).toBeInTheDocument();
});
it('should mark input as required', () => {
renderComponent();
const input = screen.getByTestId('component-gitUrlInput');
expect(input).toHaveAttribute('required');
});
it('should have correct input name and type', () => {
renderComponent();
const input = screen.getByTestId('component-gitUrlInput');
expect(input).toHaveAttribute('name', 'repoUrlField');
expect(input).toHaveAttribute('type', 'text');
});
});
describe('Input handling', () => {
it('should call onChange when input value changes', async () => {
const user = userEvent.setup();
const mockOnChange = vi.fn();
renderComponent({ onChange: mockOnChange });
const input = screen.getByTestId('component-gitUrlInput');
await user.clear(input);
await user.type(input, 'test');
expect(mockOnChange).toHaveBeenCalledWith('t');
expect(mockOnChange).toHaveBeenCalledWith('e');
expect(mockOnChange).toHaveBeenCalledWith('s');
expect(mockOnChange).toHaveBeenLastCalledWith('t');
});
it('should use debounced value and onChange', () => {
const debouncedValue = 'debounced-value';
const debouncedOnChange = vi.fn();
mockUseDebounce.mockReturnValue([debouncedValue, debouncedOnChange]);
renderComponent();
expect(screen.getByDisplayValue(debouncedValue)).toBeInTheDocument();
});
});
describe('Repository validation', () => {
it('should display error message when repo check fails with Portainer error', async () => {
const restoreConsole = suppressConsoleLogs();
const errorMessage = 'Repository not found';
server.use(
http.post('/api/gitops/repo/refs', () =>
HttpResponse.json(
{ message: errorMessage, details: errorMessage },
{ status: 422 }
)
)
);
renderComponent({ value: 'https://github.com/test/repo' });
await waitFor(() =>
expect(screen.getByText(errorMessage)).toBeInTheDocument()
);
restoreConsole();
});
it('should not display error message when repo check fails with non-Portainer error', async () => {
const restoreConsole = suppressConsoleLogs();
server.use(
http.post('/api/gitops/repo/refs', () =>
HttpResponse.json('Network error', { status: 500 })
)
);
renderComponent({ value: 'https://github.com/test/repo' });
await waitFor(() =>
expect(
screen.queryByLabelText('Checking repository')
).not.toBeInTheDocument()
);
expect(screen.queryByText('Network error')).not.toBeInTheDocument();
restoreConsole();
});
it('should transform "Authentication required" error when no creds', async () => {
const restoreConsole = suppressConsoleLogs();
server.use(
http.post('/api/gitops/repo/refs', () =>
HttpResponse.json(
{
message: 'Authentication required: Repository not found.',
details: 'Authentication required: Repository not found.',
},
{ status: 422 }
)
)
);
renderComponent({ value: 'https://github.com/private/repo' });
await waitFor(() =>
expect(
screen.getByText(
'Git repository could not be found or is private, please ensure that the URL is correct or credentials are provided.'
)
).toBeInTheDocument()
);
restoreConsole();
});
it('should display custom errors prop', () => {
const customError = 'Custom validation error';
renderComponent({ errors: customError });
expect(screen.getByText(customError)).toBeInTheDocument();
});
it('should prioritize repo error message over custom errors', async () => {
const restoreConsole = suppressConsoleLogs();
const repoError = 'Repository error';
const customError = 'Custom validation error';
server.use(
http.post('/api/gitops/repo/refs', () =>
HttpResponse.json(
{ message: repoError, details: repoError },
{ status: 422 }
)
)
);
renderComponent({
value: 'https://github.com/test/repo',
errors: customError,
});
await waitFor(() =>
expect(screen.getByText(repoError)).toBeInTheDocument()
);
expect(screen.queryByText(customError)).not.toBeInTheDocument();
restoreConsole();
});
});
describe('Status icons', () => {
it('should show no status when URL is empty', () => {
renderComponent({ value: '' });
expect(
screen.queryByLabelText('Checking repository')
).not.toBeInTheDocument();
expect(
screen.queryByLabelText('Repository detected')
).not.toBeInTheDocument();
expect(
screen.queryByLabelText(
'Repository does not exist, or is not accessible'
)
).not.toBeInTheDocument();
});
it('should announce repository detected when repo is valid', async () => {
renderComponent({ value: 'https://github.com/test/repo' });
await waitFor(() =>
expect(screen.getByLabelText('Repository detected')).toBeInTheDocument()
);
});
it('should announce inaccessible when repo check fails and is fetched', async () => {
const restoreConsole = suppressConsoleLogs();
server.use(
http.post('/api/gitops/repo/refs', () =>
HttpResponse.json(
{ message: 'not found', details: '' },
{ status: 422 }
)
)
);
renderComponent({ value: 'https://github.com/test/repo' });
await waitFor(() =>
expect(
screen.getByLabelText(
'Repository does not exist, or is not accessible'
)
).toBeInTheDocument()
);
restoreConsole();
});
it('should not announce inaccessible while still loading', async () => {
let resolveRequest!: () => void;
const requestPending = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
server.use(
http.post('/api/gitops/repo/refs', async () => {
await requestPending;
return HttpResponse.json(['refs/heads/main']);
})
);
renderComponent({ value: 'https://github.com/test/repo' });
await waitFor(() =>
expect(screen.getByLabelText('Checking repository')).toBeInTheDocument()
);
expect(
screen.queryByLabelText(
'Repository does not exist, or is not accessible'
)
).not.toBeInTheDocument();
resolveRequest();
});
});
describe('Refresh functionality', () => {
it('should disable refresh button when repository is not valid', () => {
renderComponent({
model: { ...defaultModel, RepositoryURLValid: false },
});
expect(
screen.getByTestId('component-gitUrlRefreshButton')
).toBeDisabled();
});
it('should enable refresh button when repository is valid', () => {
renderComponent({ model: { ...defaultModel, RepositoryURLValid: true } });
expect(
screen.getByTestId('component-gitUrlRefreshButton')
).not.toBeDisabled();
});
it('should send force=true as query param when refresh is clicked', async () => {
const user = userEvent.setup();
const requestUrls: string[] = [];
server.use(
http.post('/api/gitops/repo/refs', ({ request }) => {
requestUrls.push(request.url);
return HttpResponse.json(['refs/heads/main']);
})
);
renderComponent({
value: 'https://github.com/test/repo',
model: { ...defaultModel, RepositoryURLValid: true },
});
await waitFor(() => expect(requestUrls).toHaveLength(1));
await user.click(screen.getByRole('button', { name: /Refresh/ }));
await waitFor(() => expect(requestUrls).toHaveLength(2));
expect(new URL(requestUrls[1]).searchParams.get('force')).toBe('true');
});
});
});
@@ -1,143 +0,0 @@
import { ChangeEvent, useState } from 'react';
import { RefreshCcw, Loader2, X, Check } from 'lucide-react';
import { useDebounce } from '@/react/hooks/useDebounce';
import { useGitRepoValidity } from '@/react/portainer/gitops/hooks/useGitRepoValidity';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { Button } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { isBE } from '../feature-flags/feature-flags.service';
import { GitFormModel } from './types';
import { getAuthentication } from './utils';
interface Props {
value: string;
onChange(value: string): void;
onChangeRepositoryValid(value: boolean): void;
model: GitFormModel;
createdFromCustomTemplateId?: number;
errors?: string;
placeholder?: string;
}
export function GitFormUrlField({
value,
onChange,
onChangeRepositoryValid,
model,
createdFromCustomTemplateId,
errors,
placeholder = 'e.g. https://github.com/portainer/portainer-compose',
}: Props) {
const creds = getAuthentication(model);
const [force, setForce] = useState(false);
const { errorMessage, isChecking, isValid, query } = useGitRepoValidity({
url: value,
creds,
force,
tlsSkipVerify: model.TLSSkipVerify,
createdFromCustomTemplateId,
enabled: isBE,
onSettled: onChangeRepositoryValid,
onAfterSettle: () => setForce(false),
});
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
const fieldErrorMessage = errorMessage || errors;
return (
<div className="form-group">
<div className="col-sm-12">
<FormControl
label="Repository URL"
inputId="stack_repository_url"
errors={fieldErrorMessage}
required
>
<span className="flex">
<div className="relative flex-1">
<Input
value={debouncedValue}
type="text"
name="repoUrlField"
className="form-control pr-8"
placeholder={placeholder}
data-cy="component-gitUrlInput"
required
onChange={handleChange}
id="stack_repository_url"
/>
{debouncedValue !== '' && (
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center">
{isChecking && (
<span
className="inline-flex items-center"
aria-live="polite"
aria-label="Checking repository"
>
<Loader2
className="h-4 w-4 animate-spin stroke-gray-6"
aria-hidden="true"
/>
</span>
)}
{!isChecking && isValid === false && query.isFetched && (
<TooltipWithChildren message="Repository does not exist, or is not accessible">
<span
className="inline-flex items-center"
aria-label="Repository does not exist, or is not accessible"
>
<X
className="h-4 w-4 stroke-error-6"
aria-hidden="true"
/>
</span>
</TooltipWithChildren>
)}
{!isChecking && isValid === true && (
<TooltipWithChildren message="Repository detected">
<span
className="inline-flex items-center"
aria-label="Repository detected"
>
<Check
className="h-4 w-4 stroke-green-6"
aria-hidden="true"
/>
</span>
</TooltipWithChildren>
)}
</div>
)}
</div>
<Button
onClick={onRefresh}
data-cy="component-gitUrlRefreshButton"
size="medium"
className="vertical-center"
color="light"
icon={RefreshCcw}
title="Refresh Git Repository"
aria-label="Refresh Git Repository"
disabled={!model.RepositoryURLValid}
/>
</span>
</FormControl>
</div>
</div>
);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
debouncedOnChange(e.target.value);
}
function onRefresh() {
setForce(true);
}
}
@@ -3,6 +3,7 @@ import { HttpResponse } from 'msw';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { server, http } from '@/setup-tests/server';
import { withTestRouter } from '@/react/test-utils/withRouter';
import { RepoConfigResponse } from './types';
import { GitReferenceCard } from './GitReferenceCard';
@@ -22,14 +23,16 @@ const defaultGitConfig: RepoConfigResponse = {
function renderCard(
overrides: Partial<Parameters<typeof GitReferenceCard>[0]> = {}
) {
const Component = withTestQueryProvider(() => (
<GitReferenceCard
stackId={1}
gitConfig={defaultGitConfig}
stackType="docker"
{...overrides}
/>
));
const Component = withTestQueryProvider(
withTestRouter(() => (
<GitReferenceCard
gitConfig={defaultGitConfig}
stackType="docker"
sourceId={1}
{...overrides}
/>
))
);
return render(<Component />);
}
@@ -24,19 +24,17 @@ import { Link } from '@@/Link';
import { getGitValidityError } from './hooks/useGitRepoValidity';
export function GitReferenceCard({
stackId,
stackType,
gitConfig,
autoUpdate,
currentDeploymentInfo,
sourceId,
}: {
stackId: number;
stackType: 'docker' | 'helm' | 'edge' | 'edge-helm' | 'kubernetes';
gitConfig: RepoConfigResponse;
autoUpdate?: AutoUpdateResponse | null;
currentDeploymentInfo?: StackDeploymentInfo | null;
sourceId?: number;
sourceId: number;
}) {
const hasDivergence = isGitConfigDiverged(gitConfig, currentDeploymentInfo);
@@ -47,16 +45,11 @@ export function GitReferenceCard({
const commitId = deployed?.ConfigHash ?? gitConfig.ConfigHash;
const sourceIdToShow = deployed?.SourceID ?? sourceId;
const fromEdgeStack = stackType === 'edge' || stackType === 'edge-helm';
const refCheckQuery = useGitRefs(
{
repository: url || '',
stackId,
fromEdgeStack,
sourceId: sourceIdToShow,
},
{ enabled: !!url, suppressError: true }
{ enabled: !!sourceIdToShow, suppressError: true }
);
const repoError = getGitValidityError(
@@ -76,15 +69,12 @@ export function GitReferenceCard({
const enableFileCheck = stackType !== 'helm' && stackType !== 'edge-helm';
const fileCheckQuery = useSearch(
{
repository: url || '',
keyword: configFilePath || '',
stackId,
fromEdgeStack,
reference,
sourceId: sourceIdToShow,
},
enableFileCheck &&
!!url &&
!!sourceIdToShow &&
!!reference &&
!!configFilePath &&
!hasRepoError &&
@@ -1,7 +1,6 @@
import { PropsWithChildren, ReactNode } from 'react';
import { SchemaOf, string } from 'yup';
import { StackId } from '@/react/common/stacks/types';
import { useStateWrapper } from '@/react/hooks/useStateWrapper';
import { FormControl } from '@@/form-components/FormControl';
@@ -11,27 +10,15 @@ import { TextTip } from '@@/Tip/TextTip';
import { isBE } from '../../feature-flags/feature-flags.service';
import { RefSelector } from './RefSelector';
import { RefFieldModel } from './types';
interface Props {
value: string;
onChange(value: string): void;
model: RefFieldModel;
sourceId?: number;
error?: string;
isUrlValid?: boolean;
stackId?: StackId;
createdFromCustomTemplateId?: number;
}
export function RefField({
value,
onChange,
model,
error,
isUrlValid,
stackId,
createdFromCustomTemplateId,
}: Props) {
export function RefField({ value, onChange, sourceId, error }: Props) {
const [inputValue, updateInputValue] = useStateWrapper(value, onChange);
const inputId = 'repository-reference-field';
return isBE ? (
@@ -50,10 +37,7 @@ export function RefField({
inputId={inputId}
value={value}
onChange={onChange}
model={model}
isUrlValid={isUrlValid}
stackId={stackId}
createdFromCustomTemplateId={createdFromCustomTemplateId}
sourceId={sourceId}
/>
</Wrapper>
) : (
@@ -1,43 +1,24 @@
import { StackId } from '@/react/common/stacks/types';
import { useGitRefs } from '@/react/portainer/gitops/queries/useGitRefs';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { getAuthentication } from '../utils';
import { RefFieldModel } from './types';
export function RefSelector({
model,
sourceId,
value,
onChange,
isUrlValid,
stackId,
createdFromCustomTemplateId,
inputId,
}: {
model: RefFieldModel;
sourceId?: number;
value: string;
stackId?: StackId;
createdFromCustomTemplateId?: number;
onChange: (value: string) => void;
isUrlValid?: boolean;
inputId: string;
}) {
const creds = getAuthentication(model);
const payload = {
repository: model.RepositoryURL,
stackId,
createdFromCustomTemplateId,
tlsSkipVerify: model.TLSSkipVerify,
sourceId: model.SourceId,
...creds,
};
const { data: refs } = useGitRefs<Array<{ label: string; value: string }>>(
payload,
{
enabled: !!((model.RepositoryURL && isUrlValid) || model.SourceId),
sourceId: sourceId!,
},
{
enabled: !!sourceId,
select: (refs) => {
if (refs.length === 0) {
return [{ value: 'refs/heads/main', label: 'refs/heads/main' }];
@@ -1,7 +0,0 @@
import { GitAuthModel } from '../types';
export interface RefFieldModel extends GitAuthModel {
RepositoryURL: string;
TLSSkipVerify?: boolean;
SourceId?: number;
}
@@ -14,13 +14,8 @@ export function parseRelativePathResponse(stack: EdgeStack): RelativePathModel {
}
export const dummyGitForm: GitFormModel = {
RepositoryURL: '',
RepositoryURLValid: false,
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
SourceId: 0,
AdditionalFiles: [],
RepositoryReferenceName: '',
ComposeFilePathInRepository: '',
TLSSkipVerify: false,
};
@@ -1,57 +1,30 @@
import { isAxiosError } from '@/portainer/services/axios/utils/isAxiosError';
import { isDefaultResponse } from '../../services/axios/utils/parseAxiosError';
import { AuthTypeOption } from '../../account/git-credentials/types';
import { useGitRefs } from '../queries/useGitRefs';
interface Creds {
username?: string;
password?: string;
authorizationType?: AuthTypeOption;
}
interface Params {
url: string;
creds?: Creds;
force?: boolean;
tlsSkipVerify?: boolean;
createdFromCustomTemplateId?: number;
fromEdgeStack?: boolean;
stackId?: number;
/** When set, the refs check will use credentials from the stored Source record */
sourceId?: number;
enabled?: boolean;
onSettled?(isValid?: boolean): void;
// run after onSettled, useful for clearing local flags like force
onAfterSettle?(): void;
}
export function useGitRepoValidity({
url,
creds,
force,
tlsSkipVerify,
fromEdgeStack,
createdFromCustomTemplateId,
stackId,
sourceId,
force,
enabled,
onSettled,
onAfterSettle,
}: Params) {
const query = useGitRefs(
{
repository: url,
...creds,
tlsSkipVerify,
createdFromCustomTemplateID: createdFromCustomTemplateId,
stackId,
sourceId: sourceId!,
force,
fromEdgeStack,
sourceId,
},
{
enabled: (!!url || !!sourceId) && enabled,
enabled: !!sourceId && enabled,
select: () => true,
suppressError: true,
onSettled(isValid) {
@@ -65,9 +38,7 @@ export function useGitRepoValidity({
}
);
const hasCreds = !!(creds?.username && creds?.password) || !!sourceId;
const errorMessage = getGitValidityError(query.error, hasCreds);
const errorMessage = getGitValidityError(query.error, !!sourceId);
const isChecking = query.isInitialLoading || query.isFetching;
@@ -2,17 +2,9 @@ import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { AuthTypeOption } from '../../account/git-credentials/types';
import { omitPassword } from '../utils';
export interface GitFilePreviewParams {
repository: string;
targetFile: string;
reference?: string;
username?: string;
password?: string;
authorizationType?: AuthTypeOption;
tlsSkipVerify?: boolean;
/** When set, resolves URL and auth from the stored Source record */
sourceId?: number;
}
@@ -35,12 +27,9 @@ export function useGitFilePreview<TData = string>(
) {
const { enabled = true, select } = options;
return useQuery({
queryKey: ['gitops', 'file-preview', omitPassword(params)],
queryKey: ['gitops', 'file-preview', params],
queryFn: () => getFilePreview(params),
enabled:
enabled &&
(!!params.repository || !!params.sourceId) &&
!!params.targetFile,
enabled: enabled && !!params.sourceId && !!params.targetFile,
select,
retry: false,
});
@@ -4,20 +4,9 @@ import axios from '@/portainer/services/axios/axios';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { withError } from '@/react-tools/react-query';
import { AuthTypeOption } from '../../account/git-credentials/types';
import { omitPassword } from '../utils';
interface RefsPayload {
repository: string;
username?: string;
password?: string;
authorizationType?: AuthTypeOption;
stackId?: number;
fromEdgeStack?: boolean;
createdFromCustomTemplateID?: number;
tlsSkipVerify?: boolean;
force?: boolean;
sourceId?: number;
sourceId: number;
}
export function useGitRefs<T = string[]>(
@@ -39,7 +28,7 @@ export function useGitRefs<T = string[]>(
} = {}
) {
return useQuery({
queryKey: ['gitops', 'refs', omitPassword(payload)],
queryKey: ['gitops', 'refs', payload],
queryFn: () => listRefs(payload),
enabled: isBE && enabled,
retry: false,
@@ -2,30 +2,19 @@ import { useQuery } from '@tanstack/react-query';
import axios from '@/portainer/services/axios/axios';
import { AuthTypeOption } from '../../account/git-credentials/types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { omitPassword } from '../utils';
interface SearchPayload {
repository: string;
keyword: string;
reference?: string;
username?: string;
password?: string;
authorizationType?: AuthTypeOption;
tlsSkipVerify?: boolean;
dirOnly?: boolean;
createdFromCustomTemplateId?: number;
stackId?: number;
fromEdgeStack?: boolean;
sourceId?: number;
}
export function useSearch(payload: SearchPayload, enabled: boolean) {
return useQuery({
queryKey: ['gitops', 'search', omitPassword(payload)],
queryKey: ['gitops', 'search', payload],
queryFn: () => searchRepo(payload),
enabled: isBE && enabled,
retry: false,
cacheTime: 0,
@@ -6,14 +6,8 @@ import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
import { AuthTypeOption } from '../../account/git-credentials/types';
interface DeployGitPayload {
RepositoryReferenceName?: string;
RepositoryAuthentication?: boolean;
RepositoryUsername?: string;
RepositoryPassword?: string;
RepositoryAuthorizationType?: AuthTypeOption;
Env?: EnvVarValues;
Prune?: boolean;
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
@@ -8,7 +8,6 @@ import { withError } from '@/react-tools/react-query';
import { EnvVar } from '@@/form-components/EnvironmentVariablesFieldset/types';
import { AutoUpdateResponse } from '../types';
import { AuthTypeOption } from '../../account/git-credentials/types';
export interface GitStackPayload {
env: Array<EnvVar>;
@@ -16,10 +15,6 @@ export interface GitStackPayload {
RepositoryURL?: string;
ConfigFilePath?: string;
RepositoryReferenceName?: string;
RepositoryAuthentication?: boolean;
RepositoryUsername?: string;
RepositoryPassword?: string;
RepositoryAuthorizationType?: AuthTypeOption;
AutoUpdate?: AutoUpdateResponse | null;
TLSSkipVerify?: boolean;
Registries?: number[];
+13 -25
View File
@@ -28,65 +28,53 @@ export interface RepoConfigResponse {
TLSSkipVerify: boolean;
}
export type GitAuthModel = {
RepositoryAuthentication?: boolean;
RepositoryUsername?: string;
RepositoryPassword?: string;
RepositoryAuthorizationType?: AuthTypeOption;
};
export type DeployMethod = 'compose' | 'manifest' | 'helm';
export interface GitFormModel extends GitAuthModel {
RepositoryURL: string;
RepositoryURLValid?: boolean;
export interface GitFormModel {
SourceId?: number;
ComposeFilePathInRepository?: string;
RepositoryReferenceName?: string;
AdditionalFiles?: string[];
TLSSkipVerify?: boolean;
/**
* Auto update
*
* if undefined, GitForm won't show the AutoUpdate fieldset
*/
AutoUpdate?: AutoUpdateModel;
/** ID of an existing Source. When set, inline URL and credentials are ignored. */
SourceId?: number;
/** Used to create stacks from app templates */
RepositoryURL?: string;
}
export function getDefaultModel(
autoUpdate: AutoUpdateModel = getDefaultAutoUpdateValues()
): GitFormModel {
return {
RepositoryURL: '',
ComposeFilePathInRepository: 'docker-compose.yml',
RepositoryReferenceName: 'refs/heads/main',
RepositoryAuthentication: false,
TLSSkipVerify: false,
AutoUpdate: autoUpdate,
SourceId: 0,
};
}
export function toGitFormModel(
response?: RepoConfigResponse,
sourceId?: number,
response?: Omit<
RepoConfigResponse,
'URL' | 'TLSSkipVerify' | 'ConfigHash' | 'Authentication'
>,
autoUpdate?: AutoUpdateModel
): GitFormModel {
if (!response) {
return getDefaultModel(autoUpdate);
}
const { URL, ReferenceName, ConfigFilePath, Authentication, TLSSkipVerify } =
response;
const { ReferenceName, ConfigFilePath } = response;
return {
RepositoryURL: URL,
ComposeFilePathInRepository: ConfigFilePath,
RepositoryReferenceName: ReferenceName,
RepositoryAuthentication: !!Authentication?.Username,
RepositoryUsername: Authentication?.Username,
RepositoryPassword: Authentication?.Password,
RepositoryAuthorizationType: Authentication?.AuthorizationType,
TLSSkipVerify,
AutoUpdate: autoUpdate,
SourceId: sourceId,
};
}
+1 -25
View File
@@ -2,31 +2,7 @@ import { StackDeploymentInfo } from '@/react/common/stacks/types';
import { confirm } from '@@/modals/confirm';
import { GitFormModel, RepoConfigResponse } from './types';
export function getAuthentication(
model: Pick<
GitFormModel,
'RepositoryAuthentication' | 'RepositoryPassword' | 'RepositoryUsername'
>
) {
if (!model.RepositoryAuthentication) {
return undefined;
}
return {
username: model.RepositoryUsername,
password: model.RepositoryPassword,
};
}
/** Returns a copy of the object without `password` to keep it out of query keys and devtools. */
export function omitPassword<T extends { password?: unknown }>(
obj: T
): Omit<T, 'password'> {
const { password, ...rest } = obj;
return rest;
}
import { RepoConfigResponse } from './types';
export function confirmEnableTLSVerify() {
return confirm({
@@ -131,7 +131,6 @@ export function InnerForm({
}))
}
errors={errors.Git}
isSourceSelectionVisible
/>
)}
@@ -48,15 +48,10 @@ export function useInitialValues({
Logo: '',
Variables: [],
Git: {
RepositoryURL: '',
SourceId: 0,
RepositoryReferenceName: '',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
ComposeFilePathInRepository: initialFilePathInRepository,
AdditionalFiles: [],
RepositoryURLValid: true,
TLSSkipVerify: false,
},
AccessControl: isEdge
? undefined
@@ -56,8 +56,7 @@ export function useValidation({
}),
Git: mixed().when('Method', {
is: git.value,
then: () =>
buildGitValidationSchema(false, deployMethod, false, true),
then: () => buildGitValidationSchema(deployMethod),
}),
Variables: variablesValidation(),
EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(),
@@ -41,7 +41,6 @@ export function EditForm({
isGit,
templateId: template.Id,
deployMethod,
isSourceSelection: isGit,
});
const fileContentQuery = useCustomTemplateFile(template.Id);
@@ -119,7 +119,6 @@ export function InnerForm({
values.Type === StackType.Kubernetes ? 'manifest' : 'compose'
}
errors={typeof errors.Git === 'object' ? errors.Git : undefined}
isSourceSelectionVisible={!!values.Git.SourceId}
/>
<div className="form-group">
<div className="col-sm-12">
@@ -3,7 +3,7 @@ import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
import { CustomTemplate } from '../types';
import { CustomTemplate, getTemplateSourceId } from '../types';
import { FormValues } from './types';
@@ -34,10 +34,7 @@ export function useInitialValues({
Logo: template.Logo,
Variables: template.Variables,
Git: template.GitConfig
? {
...toGitFormModel(template.GitConfig),
SourceId: template.artifact?.files?.[0]?.sourceId,
}
? toGitFormModel(getTemplateSourceId(template), template.GitConfig)
: undefined,
AccessControl:
!isEdge && template.ResourceControl
@@ -18,13 +18,11 @@ export function useValidation({
templateId,
viewType,
deployMethod,
isSourceSelection,
}: {
isGit: boolean;
templateId: CustomTemplate['Id'];
viewType: TemplateViewType;
deployMethod: DeployMethod;
isSourceSelection?: boolean;
}) {
const customTemplatesQuery = useCustomTemplates({
params: {
@@ -47,14 +45,7 @@ export function useValidation({
.default(StackType.DockerCompose),
FileContent: string().required('Template is required.'),
Git: isGit
? buildGitValidationSchema(
false,
deployMethod,
false,
isSourceSelection ?? false
)
: mixed(),
Git: isGit ? buildGitValidationSchema(deployMethod) : mixed(),
Variables: variablesValidation(),
EdgeSettings: viewType === 'edge' ? edgeFieldsetValidation() : mixed(),
}).concat(
@@ -63,13 +54,6 @@ export function useValidation({
currentTemplateId: templateId,
})
),
[
customTemplatesQuery.data,
isGit,
isSourceSelection,
templateId,
viewType,
deployMethod,
]
[customTemplatesQuery.data, isGit, templateId, viewType, deployMethod]
);
}
@@ -11,7 +11,10 @@ import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control';
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { NameField } from '@/react/docker/stacks/common/NameField';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import {
CustomTemplate,
getTemplateSourceId,
} from '@/react/portainer/templates/custom-templates/types';
import {
isTemplateVariablesEnabled,
renderTemplate,
@@ -195,7 +198,10 @@ export function DeployForm({
payload: {
name: values.name,
environmentId,
git: toGitFormModel(template.GitConfig),
git: toGitFormModel(
getTemplateSourceId(template),
template.GitConfig
),
accessControl: values.accessControl,
},
}
@@ -206,7 +212,10 @@ export function DeployForm({
name: values.name,
environmentId,
swarmId: swarmIdQuery.data || '',
git: toGitFormModel(template.GitConfig),
git: toGitFormModel(
getTemplateSourceId(template),
template.GitConfig
),
accessControl: values.accessControl,
},
};
@@ -201,24 +201,14 @@ interface CustomTemplateFromGitRepositoryPayload {
Platform: Platform;
/** Type of created stack. Required. */
Type: StackType;
/** References an existing Source for git credentials/URL. When set, inline URL and auth are ignored. */
/** References an existing Source for git credentials/URL. */
SourceId?: number;
/** URL of a Git repository hosting the Stack file. Required. */
RepositoryURL: string;
/** Reference name of a Git repository hosting the Stack file. */
RepositoryReferenceName?: string;
/** Use basic authentication to clone the Git repository. */
RepositoryAuthentication?: boolean;
/** Username used in basic authentication when RepositoryAuthentication is true. */
RepositoryUsername?: string;
/** Password used in basic authentication when RepositoryAuthentication is true. */
RepositoryPassword?: string;
/** Path to the Stack file inside the Git repository. */
ComposeFilePathInRepository?: string;
/** Definitions of variables in the stack file. */
Variables: VariableDefinition[];
/** Indicates whether to skip SSL verification when cloning the Git repository. */
TLSSkipVerify?: boolean;
/** Indicates if the Kubernetes template is created from a Docker Compose file. */
IsComposeFormat?: boolean;
/** Indicates if this template is for Edge Stack. */
@@ -103,6 +103,12 @@ export type CustomTemplate = {
};
};
export function getTemplateSourceId(
template?: Pick<CustomTemplate, 'artifact'>
) {
return template?.artifact?.files?.[0]?.sourceId;
}
/**
* EdgeTemplateSettings represents the configuration of a custom template for Edge
*/