1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

refactor(gitops): migrate git form to react [EE-4849] (#8268)

This commit is contained in:
Chaim Lev-Ari 2023-02-23 01:43:33 +05:30 committed by GitHub
parent afe6cd6df0
commit 273a3f9a10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
130 changed files with 3194 additions and 1190 deletions

View file

@ -0,0 +1,56 @@
import { FormikErrors } from 'formik';
import { FormError } from '@@/form-components/FormError';
import { InputGroup } from '@@/form-components/InputGroup';
import { InputList, ItemProps } from '@@/form-components/InputList';
import { useCaretPosition } from '@@/form-components/useCaretPosition';
interface Props {
value: Array<string>;
onChange: (value: Array<string>) => void;
errors?: FormikErrors<string>[] | string | string[];
}
export function AdditionalFileField({ onChange, value, errors }: Props) {
return (
<InputList
errors={errors}
label="Additional paths"
onChange={onChange}
value={value}
addLabel="Add file"
item={Item}
itemBuilder={() => ''}
/>
);
}
function Item({
item,
onChange,
disabled,
error,
readOnly,
}: ItemProps<string>) {
const { ref, updateCaret } = useCaretPosition();
return (
<>
<InputGroup size="small" className="col-sm-5">
<InputGroup.Addon>path</InputGroup.Addon>
<InputGroup.Input
mRef={ref}
required
disabled={disabled}
readOnly={readOnly}
value={item}
onChange={(e) => {
onChange(e.target.value);
updateCaret();
}}
/>
</InputGroup>
{error && <FormError>{error}</FormError>}
</>
);
}

View file

@ -0,0 +1,181 @@
import { FormikErrors } from 'formik';
import { boolean, number, object, SchemaOf, string } from 'yup';
import { GitAuthModel } from '@/react/portainer/gitops/types';
import { useDebounce } from '@/react/hooks/useDebounce';
import { GitCredential } from '@/portainer/views/account/git-credential/types';
import { SwitchField } from '@@/form-components/SwitchField';
import { Input } from '@@/form-components/Input';
import { FormControl } from '@@/form-components/FormControl';
import { TextTip } from '@@/Tip/TextTip';
import { isBE } from '../../feature-flags/feature-flags.service';
import { CredentialSelector } from './CredentialSelector';
import { NewCredentialForm } from './NewCredentialForm';
interface Props {
value: GitAuthModel;
onChange: (value: Partial<GitAuthModel>) => void;
isExplanationVisible?: boolean;
errors?: FormikErrors<GitAuthModel>;
}
export function AuthFieldset({
value,
onChange,
isExplanationVisible,
errors,
}: Props) {
const [username, setUsername] = useDebounce(
value.RepositoryUsername || '',
(username) => handleChange({ RepositoryUsername: username })
);
const [password, setPassword] = useDebounce(
value.RepositoryPassword || '',
(password) => handleChange({ RepositoryPassword: password })
);
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}
onChange={(value) =>
handleChange({ RepositoryAuthentication: value })
}
data-cy="component-gitAuthToggle"
/>
</div>
</div>
{value.RepositoryAuthentication && (
<>
{isExplanationVisible && (
<TextTip color="orange">
Enabling authentication will store the credentials and it is
advisable to use a git service account
</TextTip>
)}
{isBE && (
<CredentialSelector
onChange={handleChangeGitCredential}
value={value.RepositoryGitCredentialID}
/>
)}
<div className="form-group">
<div className="col-sm-12">
<FormControl label="Username" errors={errors?.RepositoryUsername}>
<Input
value={username}
name="repository_username"
placeholder={
value.RepositoryGitCredentialID ? '' : 'git username'
}
onChange={(e) => setUsername(e.target.value)}
data-cy="component-gitUsernameInput"
readOnly={!!value.RepositoryGitCredentialID}
/>
</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"
readOnly={!!value.RepositoryGitCredentialID}
/>
</FormControl>
</div>
</div>
{!value.RepositoryGitCredentialID &&
value.RepositoryPassword &&
isBE && (
<NewCredentialForm
value={value}
onChange={handleChange}
errors={errors}
/>
)}
</>
)}
</>
);
function handleChangeGitCredential(gitCredential?: GitCredential | null) {
handleChange(
gitCredential
? {
RepositoryGitCredentialID: gitCredential.id,
RepositoryUsername: gitCredential?.username,
RepositoryPassword: '',
SaveCredential: false,
NewCredentialName: '',
}
: {
RepositoryGitCredentialID: 0,
RepositoryUsername: '',
RepositoryPassword: '',
}
);
}
function handleChange(partialValue: Partial<GitAuthModel>) {
onChange(partialValue);
}
}
export function gitAuthValidation(
gitCredentials: Array<GitCredential>
): SchemaOf<GitAuthModel> {
return object({
RepositoryAuthentication: boolean().default(false),
RepositoryGitCredentialID: number().default(0),
RepositoryUsername: string()
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
is: (auth: boolean, id: number) => auth && !id,
then: string().required('Username is required'),
})
.default(''),
RepositoryPassword: string()
.when(['RepositoryAuthentication', 'RepositoryGitCredentialID'], {
is: (auth: boolean, id: number) => auth && !id,
then: string().required('Password is required'),
})
.default(''),
SaveCredential: boolean().default(false),
NewCredentialName: string()
.default('')
.when(['RepositoryAuthentication', 'SaveCredential'], {
is: true,
then: string()
.required('Name is required')
.test(
'is-unique',
'This name is already been used, please try another one',
(name) => !!name && !gitCredentials.find((x) => x.name === name)
)
.matches(
/^[-_a-z0-9]+$/,
"This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123')."
),
}),
});
}

View file

@ -0,0 +1,48 @@
import { useGitCredentials } from '@/portainer/views/account/git-credential/gitCredential.service';
import { GitCredential } from '@/portainer/views/account/git-credential/types';
import { useUser } from '@/react/hooks/useUser';
import { FormControl } from '@@/form-components/FormControl';
import { Select } from '@@/form-components/ReactSelect';
export function CredentialSelector({
value,
onChange,
error,
}: {
value?: number;
onChange(gitCredential?: GitCredential | null): void;
error?: string;
}) {
const { user } = useUser();
const gitCredentialsQuery = useGitCredentials(user.Id);
const gitCredentials = gitCredentialsQuery.data ?? [];
return (
<div className="form-group">
<div className="col-sm-12">
<FormControl
label="Git Credentials"
inputId="git-creds-selector"
errors={error}
>
<Select
placeholder="select git credential or fill in below"
value={gitCredentials.find(
(gitCredential) => gitCredential.id === value
)}
options={gitCredentials}
getOptionLabel={(gitCredential) => gitCredential.name}
getOptionValue={(gitCredential) => gitCredential.id.toString()}
onChange={onChange}
isClearable
noOptionsMessage={() => 'no saved credentials'}
inputId="git-creds-selector"
/>
</FormControl>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { FormikErrors } from 'formik';
import { Checkbox } from '@@/form-components/Checkbox';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { TextTip } from '@@/Tip/TextTip';
import { GitAuthModel } from '../types';
export function NewCredentialForm({
value,
onChange,
errors,
}: {
value: GitAuthModel;
onChange: (value: Partial<GitAuthModel>) => void;
errors?: FormikErrors<GitAuthModel>;
}) {
return (
<div className="form-group">
<div className="col-sm-12">
<FormControl label="">
<div className="flex items-center gap-2">
<Checkbox
id="repository-save-credential"
label="save credential"
checked={value.SaveCredential}
className="[&+label]:mb-0"
onChange={(e) => onChange({ SaveCredential: e.target.checked })}
/>
<Input
value={value.NewCredentialName}
name="new_credential_name"
placeholder="credential name"
className="ml-4 w-48"
onChange={(e) => onChange({ NewCredentialName: e.target.value })}
disabled={!value.SaveCredential}
/>
{errors?.NewCredentialName && (
<div className="small text-danger">
{errors.NewCredentialName}
</div>
)}
{value.SaveCredential && (
<TextTip color="blue" className="!mb-0">
This git credential can be managed through your account page
</TextTip>
)}
</div>
</FormControl>
</div>
</div>
);
}

View file

@ -0,0 +1 @@
export { AuthFieldset, gitAuthValidation } from './AuthFieldset';

View file

@ -0,0 +1,24 @@
import { GitAuthenticationResponse, GitAuthModel } from '../types';
export function parseAuthResponse(
auth?: GitAuthenticationResponse
): GitAuthModel {
if (!auth) {
return {
RepositoryAuthentication: false,
NewCredentialName: '',
RepositoryGitCredentialID: 0,
RepositoryPassword: '',
RepositoryUsername: '',
SaveCredential: false,
};
}
return {
RepositoryAuthentication: true,
NewCredentialName: '',
RepositoryGitCredentialID: auth.GitCredentialID,
RepositoryPassword: '',
RepositoryUsername: auth.Username,
};
}

View file

@ -0,0 +1,62 @@
import { FormikErrors } from 'formik';
import { AutoUpdateModel } from '@/react/portainer/gitops/types';
import { SwitchField } from '@@/form-components/SwitchField';
import { TextTip } from '@@/Tip/TextTip';
import { AutoUpdateSettings } from './AutoUpdateSettings';
export function AutoUpdateFieldset({
value,
onChange,
environmentType,
isForcePullVisible = true,
errors,
baseWebhookUrl,
}: {
value: AutoUpdateModel;
onChange: (value: AutoUpdateModel) => void;
environmentType?: 'DOCKER' | 'KUBERNETES';
isForcePullVisible?: boolean;
errors?: FormikErrors<AutoUpdateModel>;
baseWebhookUrl: string;
}) {
return (
<>
<div className="form-group">
<div className="col-sm-12">
<TextTip color="blue">
When enabled, at each polling interval or webhook invocation, if the
git repo differs from what was stored locally on the last git pull,
the changes are deployed.
</TextTip>
<SwitchField
name="autoUpdate"
checked={!!value.RepositoryAutomaticUpdates}
label="Automatic updates"
labelClass="col-sm-3 col-lg-2"
onChange={(value) =>
handleChange({ RepositoryAutomaticUpdates: value })
}
/>
</div>
</div>
{value.RepositoryAutomaticUpdates && (
<AutoUpdateSettings
baseWebhookUrl={baseWebhookUrl}
value={value}
onChange={handleChange}
environmentType={environmentType}
showForcePullImage={isForcePullVisible}
errors={errors}
/>
)}
</>
);
function handleChange(newValues: Partial<AutoUpdateModel>) {
onChange({ ...value, ...newValues });
}
}

View file

@ -0,0 +1,114 @@
import { FormikErrors } from 'formik';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { type AutoUpdateModel } from '@/react/portainer/gitops/types';
import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector';
import { FormControl } from '@@/form-components/FormControl';
import { SwitchField } from '@@/form-components/SwitchField';
import { TextTip } from '@@/Tip/TextTip';
import { ForceDeploymentSwitch } from './ForceDeploymentSwitch';
import { IntervalField } from './IntervalField';
import { WebhookSettings } from './WebhookSettings';
export function AutoUpdateSettings({
value,
onChange,
environmentType,
showForcePullImage,
errors,
baseWebhookUrl,
}: {
value: AutoUpdateModel;
onChange: (value: Partial<AutoUpdateModel>) => void;
environmentType?: 'DOCKER' | 'KUBERNETES';
showForcePullImage: boolean;
errors?: FormikErrors<AutoUpdateModel>;
baseWebhookUrl: string;
}) {
return (
<>
<TextTip color="orange">
Any changes to this stack or application that have been made locally via
Portainer or directly in the cluster will be overwritten by the git
repository content, which may cause service interruption.
</TextTip>
<FormControl label="Mechanism">
<ButtonSelector
size="small"
options={[
{ value: 'Interval', label: 'Polling' },
{ value: 'Webhook', label: 'Webhook' },
]}
value={value.RepositoryMechanism || 'Interval'}
onChange={(value) => onChange({ RepositoryMechanism: value })}
/>
</FormControl>
{value.RepositoryMechanism === 'Webhook' && (
<WebhookSettings
baseUrl={baseWebhookUrl}
value={value.RepositoryWebhookId || ''}
docsLink={
environmentType === 'KUBERNETES'
? 'https://docs.portainer.io/user/kubernetes/applications/webhooks'
: 'https://docs.portainer.io/user/docker/stacks/webhooks'
}
/>
)}
{value.RepositoryMechanism === 'Interval' && (
<IntervalField
value={value.RepositoryFetchInterval || ''}
onChange={(value) => onChange({ RepositoryFetchInterval: value })}
errors={errors?.RepositoryFetchInterval}
/>
)}
{showForcePullImage && (
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="forcePullImage"
featureId={FeatureId.STACK_PULL_IMAGE}
checked={value.ForcePullImage || false}
label="Re-pull image"
labelClass="col-sm-3 col-lg-2"
tooltip="If enabled, then when redeploy is triggered via the webhook or polling, if there's a newer image with the tag that you've specified (e.g. changeable development builds), it's pulled and redeployed. If you haven't specified a tag, or have specified 'latest' as the tag, then the image with the tag 'latest' is pulled and redeployed."
onChange={(value) => onChange({ ForcePullImage: value })}
/>
</div>
</div>
)}
<ForceDeploymentSwitch
checked={value.RepositoryAutomaticUpdatesForce || false}
onChange={(value) =>
onChange({ RepositoryAutomaticUpdatesForce: value })
}
label={
environmentType === 'KUBERNETES' ? 'Always apply manifest' : undefined
}
tooltip={
environmentType === 'KUBERNETES' ? (
<>
<p>
If enabled, then when redeploy is triggered via the webhook or
polling, kubectl apply is always performed, even if Portainer
detects no difference between the git repo and what was stored
locally on last git pull.
</p>
<p>
This is useful if you want your git repo to be the source of
truth and are fine with changes made directly to resources in
the cluster being overwritten.
</p>
</>
) : undefined
}
/>
</>
);
}

View file

@ -0,0 +1,33 @@
import { ReactNode } from 'react';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { SwitchField } from '@@/form-components/SwitchField';
export function ForceDeploymentSwitch({
checked,
onChange,
tooltip = '',
label = 'Force redeployment',
}: {
checked: boolean;
onChange: (value: boolean) => void;
tooltip?: ReactNode;
label?: string;
}) {
return (
<div className="form-group">
<div className="col-sm-12">
<SwitchField
name="forceUpdate"
featureId={FeatureId.FORCE_REDEPLOYMENT}
checked={checked}
label={label}
tooltip={tooltip}
labelClass="col-sm-3 col-lg-2"
onChange={onChange}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,60 @@
import { string } from 'yup';
import parse from 'parse-duration';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { useCaretPosition } from '@@/form-components/useCaretPosition';
export function IntervalField({
onChange,
value,
errors,
}: {
value: string;
onChange: (value: string) => void;
errors?: string;
}) {
const { ref, updateCaret } = useCaretPosition();
return (
<FormControl
label="Fetch interval"
inputId="repository_fetch_interval"
tooltip="Specify how frequently polling occurs using syntax such as, 5m = 5 minutes, 24h = 24 hours, 6h40m = 6 hours and 40 minutes."
required
errors={errors}
>
<Input
mRef={ref}
id="repository_fetch_interval"
name="repository_fetch_interval"
placeholder="5m"
required
value={value}
onChange={(e) => {
onChange(e.target.value);
updateCaret();
}}
/>
</FormControl>
);
}
export function intervalValidation() {
return (
string()
.required('This field is required.')
// TODO: find a regex that validates time.Duration
// .matches(
// // validate golang time.Duration format
// // https://cs.opensource.google/go/go/+/master:src/time/format.go;l=1590
// /[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+/g,
// 'Please enter a valid time interval.'
// )
.test(
'minimumInterval',
'Minimum interval is 1m',
(value) => !!value && parse(value, 'minute') >= 1
)
);
}

View file

@ -0,0 +1,38 @@
import { truncateLeftRight } from '@/portainer/filters/filters';
import { CopyButton } from '@@/buttons';
import { FormControl } from '@@/form-components/FormControl';
export function WebhookSettings({
value,
baseUrl,
docsLink,
}: {
docsLink: string;
value: string;
baseUrl: string;
}) {
const url = `${baseUrl}/${value}`;
return (
<FormControl
label="Webhook"
tooltip={
<>
See{' '}
<a href={docsLink} target="_blank" rel="noreferrer">
Portainer documentation on webhook usage
</a>
.
</>
}
>
<div className="flex items-center gap-2">
<span className="text-muted">{truncateLeftRight(url)}</span>
<CopyButton copyText={url} color="light">
Copy link
</CopyButton>
</div>
</FormControl>
);
}

View file

@ -0,0 +1 @@
export { AutoUpdateFieldset } from './AutoUpdateFieldset';

View file

@ -0,0 +1,48 @@
import { v4 as uuid } from 'uuid';
import { AutoUpdateResponse, AutoUpdateModel } from '../types';
export function parseAutoUpdateResponse(
response?: AutoUpdateResponse
): AutoUpdateModel {
if (!response || (!response?.Interval && !response?.Webhook)) {
return {
RepositoryAutomaticUpdates: false,
RepositoryAutomaticUpdatesForce: false,
RepositoryMechanism: 'Interval',
RepositoryFetchInterval: '5m',
RepositoryWebhookId: uuid(),
ForcePullImage: false,
};
}
return {
RepositoryAutomaticUpdates: true,
RepositoryMechanism: response.Interval ? 'Interval' : 'Webhook',
RepositoryFetchInterval: response.Interval || '',
RepositoryWebhookId: response.Webhook || uuid(),
RepositoryAutomaticUpdatesForce: response.ForceUpdate,
ForcePullImage: response.ForcePullImage,
};
}
export function transformAutoUpdateViewModel(
viewModel?: AutoUpdateModel
): AutoUpdateResponse | null {
if (!viewModel || !viewModel.RepositoryAutomaticUpdates) {
return null;
}
return {
Interval:
viewModel.RepositoryMechanism === 'Interval'
? viewModel.RepositoryFetchInterval
: '',
Webhook:
viewModel.RepositoryMechanism === 'Webhook'
? viewModel.RepositoryWebhookId
: '',
ForceUpdate: viewModel.RepositoryAutomaticUpdatesForce,
ForcePullImage: viewModel.ForcePullImage,
};
}

View file

@ -0,0 +1,28 @@
import { string, boolean, object, SchemaOf, mixed } from 'yup';
import { AutoUpdateMechanism, AutoUpdateModel } from '../types';
import { intervalValidation } from './IntervalField';
export function autoUpdateValidation(): SchemaOf<AutoUpdateModel> {
return object({
RepositoryAutomaticUpdates: boolean().default(false),
RepositoryAutomaticUpdatesForce: boolean().default(false),
RepositoryMechanism: mixed<AutoUpdateMechanism>()
.oneOf(['Interval', 'Webhook'])
.when('RepositoryAutomaticUpdates', {
is: true,
then: string().required(),
})
.default('Interval'),
RepositoryFetchInterval: string()
.default('')
.when(['RepositoryAutomaticUpdates', 'RepositoryMechanism'], {
is: (autoUpdates: boolean, mechanism: AutoUpdateMechanism) =>
autoUpdates && mechanism === 'Interval',
then: intervalValidation(),
}),
RepositoryWebhookId: string().default(''),
ForcePullImage: boolean().default(false),
});
}

View file

@ -0,0 +1,81 @@
import { FormControl } from '@@/form-components/FormControl';
import { TextTip } from '@@/Tip/TextTip';
import { Input } from '@@/form-components/Input';
import { useCaretPosition } from '@@/form-components/useCaretPosition';
import { GitFormModel } from '../types';
import { isBE } from '../../feature-flags/feature-flags.service';
import { PathSelector } from './PathSelector';
interface Props {
errors?: string;
value: string;
onChange(value: string): void;
isCompose: boolean;
model: GitFormModel;
isDockerStandalone: boolean;
}
export function ComposePathField({
value,
onChange,
isCompose,
model,
isDockerStandalone,
errors,
}: Props) {
const { ref, updateCaret } = useCaretPosition();
return (
<div className="form-group">
<span className="col-sm-12">
<TextTip color="blue">
<span>
Indicate the path to the {isCompose ? 'Compose' : 'Manifest'} file
from the root of your repository (requires a yaml, yml, json, or hcl
file extension).
</span>
{isDockerStandalone && (
<span className="ml-2">
To enable rebuilding of an image if already present on Docker
standalone environments, include
<code>pull_policy: build</code> in your compose file as per{' '}
<a href="https://docs.docker.com/compose/compose-file/#pull_policy">
Docker documentation
</a>
.
</span>
)}
</TextTip>
</span>
<div className="col-sm-12">
<FormControl
label={isCompose ? 'Compose path' : 'Manifest path'}
inputId="stack_repository_path"
required
errors={errors}
>
{isBE ? (
<PathSelector
value={value}
onChange={onChange}
placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'}
model={model}
/>
) : (
<Input
mRef={ref}
value={value}
onChange={(e) => {
onChange(e.target.value);
updateCaret();
}}
placeholder={isCompose ? 'docker-compose.yml' : 'manifest.yml'}
/>
)}
</FormControl>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
.root [data-reach-combobox-popover] {
border-radius: 5px;
border: 1px solid var(--border-form-control-color);
background-color: var(--bg-dropdown-menu-color);
color: var(--text-form-control-color);
z-index: 9999;
}
.root [data-reach-combobox-option]:hover {
background-color: var(--bg-dropdown-hover);
}
.root [data-suggested-value] {
font-weight: normal;
}
.root [data-user-value] {
font-weight: bold;
}

View file

@ -0,0 +1,87 @@
import {
Combobox,
ComboboxInput,
ComboboxList,
ComboboxOption,
ComboboxPopover,
} from '@reach/combobox';
import '@reach/combobox/styles.css';
import { ChangeEvent } from 'react';
import { useSearch } from '@/react/portainer/gitops/queries/useSearch';
import { useDebounce } from '@/react/hooks/useDebounce';
import { useCaretPosition } from '@@/form-components/useCaretPosition';
import { getAuthentication } from '../utils';
import { GitFormModel } from '../types';
import styles from './PathSelector.module.css';
export function PathSelector({
value,
onChange,
placeholder,
model,
}: {
value: string;
onChange(value: string): void;
placeholder: string;
model: GitFormModel;
}) {
const [searchTerm, setSearchTerm] = useDebounce('', () => {});
const creds = getAuthentication(model);
const payload = {
repository: model.RepositoryURL,
keyword: searchTerm,
reference: model.RepositoryReferenceName,
...creds,
};
const enabled = Boolean(
model.RepositoryURL && model.RepositoryURLValid && searchTerm
);
const { data: searchResult } = useSearch(payload, enabled);
const { ref, updateCaret } = useCaretPosition();
return (
<Combobox
className={styles.root}
aria-label="compose"
onSelect={onSelect}
data-cy="component-gitComposeInput"
>
<ComboboxInput
ref={ref}
className="form-control"
onChange={handleChange}
placeholder={placeholder}
value={value}
/>
{searchResult && searchResult.length > 0 && searchTerm !== '' && (
<ComboboxPopover>
<ComboboxList>
{searchResult.map((result: string, index: number) => (
<ComboboxOption
key={index}
value={result}
className={`[&[aria-selected="true"]]:th-highcontrast:!bg-black [&[aria-selected="true"]]:th-dark:!bg-black`}
/>
))}
</ComboboxList>
</ComboboxPopover>
)}
</Combobox>
);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
setSearchTerm(e.target.value);
onChange(e.target.value);
updateCaret();
}
function onSelect(value: string) {
setSearchTerm('');
onChange(value);
}
}

View file

@ -0,0 +1 @@
export { ComposePathField } from './ComposePathField';

View file

@ -0,0 +1,107 @@
import { Meta } from '@storybook/react';
import { Form, Formik } from 'formik';
import { rest } from 'msw';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { GitCredential } from '@/portainer/views/account/git-credential/types';
import { GitForm, buildGitValidationSchema } from './GitForm';
import { GitFormModel } from './types';
export default {
component: GitForm,
title: 'Forms/GitForm',
parameters: {
msw: {
handlers: [
rest.get<Array<GitCredential>, { userId: string }>(
'/api/users/:userId/gitcredentials',
(req, res, ctx) =>
res(
ctx.status(200),
ctx.json<Array<GitCredential>>([
{
id: 1,
name: 'credential-1',
username: 'username-1',
userId: parseInt(req.params.userId, 10),
creationDate: 0,
},
{
id: 2,
name: 'credential-2',
username: 'username-2',
userId: parseInt(req.params.userId, 10),
creationDate: 0,
},
])
)
),
],
},
},
} as Meta;
const WrappedComponent = withUserProvider(GitForm);
interface Args {
isAdditionalFilesFieldVisible: boolean;
isAuthExplanationVisible: boolean;
isDockerStandalone: boolean;
deployMethod: 'compose' | 'nomad' | 'manifest';
isForcePullVisible: boolean;
}
export function Primary({
deployMethod,
isAdditionalFilesFieldVisible,
isAuthExplanationVisible,
isDockerStandalone,
isForcePullVisible,
}: Args) {
const initialValues: GitFormModel = {
RepositoryURL: '',
RepositoryURLValid: false,
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
AdditionalFiles: [],
RepositoryReferenceName: '',
ComposeFilePathInRepository: '',
NewCredentialName: '',
SaveCredential: false,
};
return (
<Formik
initialValues={initialValues}
validationSchema={() => buildGitValidationSchema([])}
onSubmit={() => {}}
>
{({ values, errors, setValues }) => (
<Form className="form-horizontal">
<WrappedComponent
value={values}
errors={errors}
onChange={(value) => setValues({ ...values, ...value })}
isAdditionalFilesFieldVisible={isAdditionalFilesFieldVisible}
isAuthExplanationVisible={isAuthExplanationVisible}
isDockerStandalone={isDockerStandalone}
isForcePullVisible={isForcePullVisible}
deployMethod={deployMethod}
baseWebhookUrl="ws://localhost:9000"
/>
</Form>
)}
</Formik>
);
}
Primary.args = {
isAdditionalFilesFieldVisible: true,
isAuthExplanationVisible: true,
isAutoUpdateVisible: true,
isDockerStandalone: true,
isForcePullVisible: true,
deployMethod: 'compose',
};

View file

@ -0,0 +1,133 @@
import { array, boolean, 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 { GitFormModel } from '@/react/portainer/gitops/types';
import { GitCredential } from '@/portainer/views/account/git-credential/types';
import { FormSection } from '@@/form-components/FormSection';
import { TimeWindowDisplay } from '@@/TimeWindowDisplay';
import { validateForm } from '@@/form-components/validate-form';
import { AdditionalFileField } from './AdditionalFilesField';
import { gitAuthValidation, AuthFieldset } from './AuthFieldset';
import { AutoUpdateFieldset } from './AutoUpdateFieldset';
import { autoUpdateValidation } from './AutoUpdateFieldset/validation';
import { refFieldValidation } from './RefField/RefField';
interface Props {
value: GitFormModel;
onChange: (value: Partial<GitFormModel>) => void;
deployMethod?: 'compose' | 'nomad' | 'manifest';
isDockerStandalone?: boolean;
isAdditionalFilesFieldVisible?: boolean;
isForcePullVisible?: boolean;
isAuthExplanationVisible?: boolean;
errors: FormikErrors<GitFormModel>;
baseWebhookUrl: string;
}
export function GitForm({
value,
onChange,
deployMethod = 'compose',
isDockerStandalone = false,
isAdditionalFilesFieldVisible,
isForcePullVisible,
isAuthExplanationVisible,
errors = {},
baseWebhookUrl,
}: Props) {
return (
<FormSection title="Git repository">
<AuthFieldset
value={value}
onChange={handleChange}
isExplanationVisible={isAuthExplanationVisible}
errors={errors}
/>
<GitFormUrlField
value={value.RepositoryURL}
onChange={(value) => handleChange({ RepositoryURL: value })}
onChangeRepositoryValid={(value) =>
handleChange({ RepositoryURLValid: value })
}
model={value}
errors={errors.RepositoryURL}
/>
<RefField
value={value.RepositoryReferenceName || ''}
onChange={(value) => handleChange({ RepositoryReferenceName: value })}
model={value}
error={errors.RepositoryReferenceName}
isUrlValid={value.RepositoryURLValid}
/>
<ComposePathField
value={value.ComposeFilePathInRepository}
onChange={(value) =>
handleChange({ ComposeFilePathInRepository: value })
}
isCompose={deployMethod === 'compose'}
model={value}
isDockerStandalone={isDockerStandalone}
errors={errors.ComposeFilePathInRepository}
/>
{isAdditionalFilesFieldVisible && (
<AdditionalFileField
value={value.AdditionalFiles}
onChange={(value) => handleChange({ AdditionalFiles: value })}
errors={errors.AdditionalFiles}
/>
)}
{value.AutoUpdate && (
<AutoUpdateFieldset
baseWebhookUrl={baseWebhookUrl}
value={value.AutoUpdate}
onChange={(value) => handleChange({ AutoUpdate: value })}
isForcePullVisible={isForcePullVisible}
errors={errors.AutoUpdate as FormikErrors<GitFormModel['AutoUpdate']>}
/>
)}
<TimeWindowDisplay />
</FormSection>
);
function handleChange(partialValue: Partial<GitFormModel>) {
onChange(partialValue);
}
}
export async function validateGitForm(
gitCredentials: Array<GitCredential>,
formValues: GitFormModel
) {
return validateForm<GitFormModel>(
() => buildGitValidationSchema(gitCredentials),
formValues
);
}
export function buildGitValidationSchema(
gitCredentials: Array<GitCredential>
): SchemaOf<GitFormModel> {
return object({
RepositoryURL: string()
.url('Invalid Url')
.required('Repository URL is required'),
RepositoryReferenceName: refFieldValidation(),
ComposeFilePathInRepository: string().required(
'Compose file path is required'
),
AdditionalFiles: array(string().required()).default([]),
RepositoryURLValid: boolean().default(false),
AutoUpdate: autoUpdateValidation().nullable(),
}).concat(gitAuthValidation(gitCredentials));
}

View file

@ -0,0 +1,122 @@
import { ChangeEvent, useState } from 'react';
import { RefreshCcw } from 'lucide-react';
import { useQueryClient } from 'react-query';
import { string, StringSchema } from 'yup';
import {
checkRepo,
useCheckRepo,
} from '@/react/portainer/gitops/queries/useCheckRepo';
import { useDebounce } from '@/react/hooks/useDebounce';
import { isPortainerError } from '@/portainer/error';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { TextTip } from '@@/Tip/TextTip';
import { Button } from '@@/buttons';
import { useCachedValidation } from '@@/form-components/useCachedTest';
import { GitFormModel } from './types';
import { getAuthentication } from './utils';
interface Props {
value: string;
onChange(value: string): void;
onChangeRepositoryValid(value: boolean): void;
model: GitFormModel;
errors?: string;
}
export function GitFormUrlField({
value,
onChange,
onChangeRepositoryValid,
model,
errors,
}: Props) {
const queryClient = useQueryClient();
const creds = getAuthentication(model);
const [force, setForce] = useState(false);
const repoStatusQuery = useCheckRepo(value, creds, force, {
onSettled(isValid) {
onChangeRepositoryValid(!!isValid);
setForce(false);
},
});
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
const errorMessage = isPortainerError(repoStatusQuery.error)
? repoStatusQuery.error.message
: undefined;
return (
<div className="form-group">
<span className="col-sm-12">
<TextTip color="blue">You can use the URL of a git repository.</TextTip>
</span>
<div className="col-sm-12">
<FormControl
label="Repository URL"
inputId="stack_repository_url"
errors={errorMessage || errors}
required
>
<span className="flex">
<Input
value={debouncedValue}
type="text"
name="repoUrlField"
className="form-control"
placeholder="https://github.com/portainer/portainer-compose"
data-cy="component-gitUrlInput"
required
onChange={handleChange}
/>
<Button
onClick={onRefresh}
size="medium"
className="vertical-center"
color="light"
icon={RefreshCcw}
title="refreshGitRepo"
disabled={!model.RepositoryURLValid}
/>
</span>
</FormControl>
</div>
</div>
);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
debouncedOnChange(e.target.value);
}
function onRefresh() {
setForce(true);
queryClient.invalidateQueries(['git_repo_refs', 'git_repo_search_results']);
}
}
// Todo: once git form is used only in react, it should be used for validation instead of L40-52
export function useUrlValidation(force: boolean) {
const existenceTest = useCachedValidation<string, GitFormModel>(
(url, context) => {
if (!url) {
return Promise.resolve(true);
}
const model = context.parent as GitFormModel;
const creds = getAuthentication(model);
return checkRepo(url, creds, force);
}
);
return (string() as StringSchema<string, GitFormModel>)
.url('Invalid Url')
.required('Repository URL is required')
.test('repo-exists', 'Repository does not exist', existenceTest);
}

View file

@ -0,0 +1,32 @@
import clsx from 'clsx';
interface Props {
url: string;
configFilePath: string;
additionalFiles?: string[];
className: string;
type: string;
}
export function InfoPanel({
url,
configFilePath,
additionalFiles = [],
className,
type,
}: Props) {
return (
<div className={clsx('form-group', className)}>
<div className="col-sm-12">
<p>
This {type} was deployed from the git repository <code>{url}</code>.
</p>
<p>
Update
<code>{[configFilePath, ...additionalFiles].join(', ')}</code>
in git and pull from here to update the {type}.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
import { PropsWithChildren, ReactNode } from 'react';
import { SchemaOf, string } from 'yup';
import { StackId } from '@/react/docker/stacks/types';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
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;
error?: string;
isUrlValid: boolean;
stackId?: StackId;
}
export function RefField({
value,
onChange,
model,
error,
isUrlValid,
stackId,
}: Props) {
return isBE ? (
<Wrapper
errors={error}
tip={
<>
Specify a reference of the repository using the following syntax:
branches with <code>refs/heads/branch_name</code> or tags with{' '}
<code>refs/tags/tag_name</code>.
</>
}
>
<RefSelector
value={value}
onChange={onChange}
model={model}
isUrlValid={isUrlValid}
stackId={stackId}
/>
</Wrapper>
) : (
<Wrapper
errors={error}
tip={
<>
Specify a reference of the repository using the following syntax:
branches with <code>refs/heads/branch_name</code> or tags with{' '}
<code>refs/tags/tag_name</code>. If not specified, will use the
default <code>HEAD</code> reference normally the <code>main</code>{' '}
branch.
</>
}
>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="refs/heads/main"
/>
</Wrapper>
);
}
function Wrapper({
tip,
children,
errors,
}: PropsWithChildren<{ tip: ReactNode; errors?: string }>) {
return (
<div className="form-group">
<span className="col-sm-12">
<TextTip color="blue">{tip}</TextTip>
</span>
<div className="col-sm-12">
<FormControl
label="Repository reference"
inputId="stack_repository_reference_name"
required
errors={errors}
>
{children}
</FormControl>
</div>
</div>
);
}
export function refFieldValidation(): SchemaOf<string> {
return string()
.when({
is: isBE,
then: string().required('Repository reference name is required'),
})
.default('');
}

View file

@ -0,0 +1,68 @@
import { StackId } from '@/react/docker/stacks/types';
import { useGitRefs } from '@/react/portainer/gitops/queries/useGitRefs';
import { Select } from '@@/form-components/Input';
import { getAuthentication } from '../utils';
import { RefFieldModel } from './types';
export function RefSelector({
model,
value,
onChange,
isUrlValid,
stackId,
}: {
model: RefFieldModel;
value: string;
stackId?: StackId;
onChange: (value: string) => void;
isUrlValid: boolean;
}) {
const creds = getAuthentication(model);
const payload = {
repository: model.RepositoryURL,
stackId,
...creds,
};
const { data: refs } = useGitRefs<Array<{ label: string; value: string }>>(
payload,
{
enabled: !!(model.RepositoryURL && isUrlValid),
select: (refs) => {
if (refs.length === 0) {
return [{ value: 'refs/heads/main', label: 'refs/heads/main' }];
}
// put refs/heads/main first if it is present in repository
if (refs.includes('refs/heads/main')) {
refs.splice(refs.indexOf('refs/heads/main'), 1);
refs.unshift('refs/heads/main');
}
return refs.map((t: string) => ({
value: t,
label: t,
}));
},
onSuccess(refs) {
if (refs && !refs.some((ref) => ref.value === value)) {
onChange(refs[0].value);
}
},
}
);
return (
<Select
value={value}
options={refs || [{ value: 'refs/heads/main', label: 'refs/heads/main' }]}
onChange={(e) => onChange(e.target.value)}
data-cy="component-gitRefInput"
required
/>
);
}

View file

@ -0,0 +1 @@
export { RefField } from './RefField';

View file

@ -0,0 +1,5 @@
import { GitCredentialsModel } from '../types';
export interface RefFieldModel extends GitCredentialsModel {
RepositoryURL: string;
}

View file

@ -0,0 +1,62 @@
import { AxiosError } from 'axios';
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
interface Creds {
username?: string;
password?: string;
gitCredentialId?: number;
}
export function useCheckRepo(
url: string,
creds: Creds,
force: boolean,
{
enabled,
onSettled,
}: { enabled?: boolean; onSettled?(isValid?: boolean): void } = {}
) {
return useQuery(
['git_repo_valid', url, creds, force],
() => checkRepo(url, creds, force),
{
enabled: !!url && enabled,
onSettled,
retry: false,
}
);
}
export async function checkRepo(
repository: string,
creds: Creds,
force: boolean
): Promise<boolean> {
try {
await axios.post<string[]>(
'/gitops/repo/refs',
{ repository, ...creds },
force ? { params: { force } } : {}
);
return true;
} catch (error) {
throw parseAxiosError(error as Error, '', (axiosError: AxiosError) => {
let details = axiosError.response?.data.details;
// If no credentials were provided alter error from git to indicate repository is not found or is private
if (
!(creds.username && creds.password) &&
details ===
'Authentication failed, please ensure that the git credentials are correct.'
) {
details =
'Git repository could not be found or is private, please ensure that the URL is correct or credentials are provided.';
}
const error = new Error(details);
return { error, details };
});
}
}

View file

@ -0,0 +1,39 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
interface RefsPayload {
repository: string;
username?: string;
password?: string;
}
export function useGitRefs<T = string[]>(
payload: RefsPayload,
{
enabled,
select,
onSuccess,
}: {
enabled?: boolean;
select?: (data: string[]) => T;
onSuccess?(data: T): void;
} = {}
) {
return useQuery(['git_repo_refs', { payload }], () => listRefs(payload), {
enabled,
retry: false,
cacheTime: 0,
select,
onSuccess,
});
}
export async function listRefs(payload: RefsPayload) {
try {
const { data } = await axios.post<string[]>('/gitops/repo/refs', payload);
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}

View file

@ -0,0 +1,35 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
interface SearchPayload {
repository: string;
keyword: string;
reference?: string;
username?: string;
password?: string;
}
export function useSearch(payload: SearchPayload, enabled: boolean) {
return useQuery(
['git_repo_search_results', { payload }],
() => searchRepo(payload),
{
enabled,
retry: false,
cacheTime: 0,
}
);
}
export async function searchRepo(payload: SearchPayload) {
try {
const { data } = await axios.post<string[] | null>(
'/gitops/repo/files/search',
payload
);
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}

View file

@ -0,0 +1,71 @@
export type AutoUpdateMechanism = 'Webhook' | 'Interval';
export interface AutoUpdateResponse {
/* Auto update interval */
Interval: string;
/* A UUID generated from client */
Webhook: string;
/* Force update ignores repo changes */
ForceUpdate: boolean;
/* Pull latest image */
ForcePullImage: boolean;
}
export interface GitAuthenticationResponse {
Username: string;
Password: string;
GitCredentialID: number;
}
export interface RepoConfigResponse {
URL: string;
ReferenceName: string;
ConfigFilePath: string;
Authentication?: GitAuthenticationResponse;
ConfigHash: string;
}
export type AutoUpdateModel = {
RepositoryAutomaticUpdates: boolean;
RepositoryMechanism: AutoUpdateMechanism;
RepositoryWebhookId: string;
RepositoryFetchInterval: string;
ForcePullImage: boolean;
RepositoryAutomaticUpdatesForce: boolean;
};
export type GitCredentialsModel = {
RepositoryAuthentication: boolean;
RepositoryUsername?: string;
RepositoryPassword?: string;
RepositoryGitCredentialID?: number;
};
export type GitNewCredentialModel = {
NewCredentialName?: string;
SaveCredential?: boolean;
};
export type GitAuthModel = GitCredentialsModel & GitNewCredentialModel;
export interface GitFormModel extends GitAuthModel {
RepositoryURL: string;
RepositoryURLValid: boolean;
ComposeFilePathInRepository: string;
RepositoryAuthentication: boolean;
RepositoryReferenceName?: string;
AdditionalFiles: string[];
SaveCredential?: boolean;
NewCredentialName?: string;
/**
* Auto update
*
* if undefined, GitForm won't show the AutoUpdate fieldset
*/
AutoUpdate?: AutoUpdateModel;
}

View file

@ -0,0 +1,27 @@
import { GitFormModel } from './types';
export function getAuthentication(
model: Pick<
GitFormModel,
| 'RepositoryAuthentication'
| 'RepositoryPassword'
| 'RepositoryUsername'
| 'RepositoryGitCredentialID'
>
) {
if (model.RepositoryAuthentication) {
return {
username: model.RepositoryUsername,
password: model.RepositoryPassword,
};
}
if (model.RepositoryGitCredentialID) {
return { gitCredentialId: model.RepositoryGitCredentialID };
}
return {
username: model.RepositoryUsername,
password: model.RepositoryPassword,
};
}