mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 10:14:48 +00:00
refactor(gitops): remove manual credential entry from git form [BE-13047] (#2951)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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'),
|
||||
});
|
||||
}
|
||||
|
||||
+1
-5
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export type EdgeStack = Partial<RelativePathModel> & {
|
||||
ManifestPath: string;
|
||||
DeploymentType: DeploymentType;
|
||||
UseManifestNamespaces: boolean;
|
||||
GitSourceId?: number;
|
||||
} & Partial<{
|
||||
// EE
|
||||
Registries: RegistryId[];
|
||||
|
||||
+1
-2
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
+12
-3
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
+1
-11
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user