diff --git a/internal/pkg/store/repo.go b/internal/pkg/store/repo.go index f3d65a5a..d09e5aed 100644 --- a/internal/pkg/store/repo.go +++ b/internal/pkg/store/repo.go @@ -179,13 +179,13 @@ func (s *Store) SyncRepo(ctx context.Context, r *extent.RemoteRepo) (*ent.Repo, func (s *Store) UpdateRepo(ctx context.Context, r *ent.Repo) (*ent.Repo, error) { ret, err := s.c.Repo. UpdateOne(r). + SetName(r.Name). SetConfigPath(r.ConfigPath). Save(ctx) if ent.IsValidationError(err) { - return nil, e.NewErrorWithMessage( - e.ErrorCodeEntityUnprocessable, - fmt.Sprintf("The value of \"%s\" field is invalid.", err.(*ent.ValidationError).Name), - err) + return nil, e.NewErrorWithMessage(e.ErrorCodeEntityUnprocessable, fmt.Sprintf("The value of \"%s\" field is invalid.", err.(*ent.ValidationError).Name), err) + } else if ent.IsConstraintError(err) { + return nil, e.NewError(e.ErrorRepoUniqueName, err) } else if err != nil { return nil, e.NewError(e.ErrorCodeInternalError, err) } diff --git a/internal/pkg/store/repo_test.go b/internal/pkg/store/repo_test.go index de29b55e..bca26c57 100644 --- a/internal/pkg/store/repo_test.go +++ b/internal/pkg/store/repo_test.go @@ -9,6 +9,7 @@ import ( "github.com/gitploy-io/gitploy/model/ent/enttest" "github.com/gitploy-io/gitploy/model/ent/migrate" "github.com/gitploy-io/gitploy/model/extent" + "github.com/gitploy-io/gitploy/pkg/e" ) func TestStore_ListReposOfUser(t *testing.T) { @@ -197,6 +198,72 @@ func TestStore_SyncRepo(t *testing.T) { }) } +func TestStore_UpdateRepo(t *testing.T) { + t.Run("Update the repository name.", func(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1", + enttest.WithMigrateOptions(migrate.WithForeignKeys(false)), + ) + defer client.Close() + + repo := client.Repo.Create(). + SetNamespace("gitploy-io"). + SetName("gitploy"). + SetDescription(""). + SaveX(context.Background()) + + s := NewStore(client) + + // Replace values + repo.Name = "gitploy-next" + repo.ConfigPath = "deploy-next.yml" + + var ( + ret *ent.Repo + err error + ) + ret, err = s.UpdateRepo(context.Background(), repo) + if err != nil { + t.Fatalf("UpdateRepo return an error: %s", err) + } + + if repo.Name != "gitploy-next" || + repo.ConfigPath != "deploy-next.yml" { + t.Fatalf("UpdateRepo = %s, wanted %s", repo, ret) + } + }) + + t.Run("Return an error if the same repository name exists.", func(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1", + enttest.WithMigrateOptions(migrate.WithForeignKeys(true)), + ) + defer client.Close() + + client.Repo.Create(). + SetNamespace("gitploy-io"). + SetName("gitploy-next"). + SetDescription(""). + SaveX(context.Background()) + + repo := client.Repo.Create(). + SetNamespace("gitploy-io"). + SetName("gitploy"). + SetDescription(""). + SaveX(context.Background()) + + s := NewStore(client) + + repo.Name = "gitploy-next" + + var ( + err error + ) + _, err = s.UpdateRepo(context.Background(), repo) + if !e.HasErrorCode(err, e.ErrorRepoUniqueName) { + t.Fatalf("UpdateRepo doesn't return the ErrorRepoUniqueName error: %s", err) + } + }) +} + func TestStore_Activate(t *testing.T) { t.Run("Update webhook ID and owner ID.", func(t *testing.T) { client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1", diff --git a/internal/server/api/v1/repos/repo_update.go b/internal/server/api/v1/repos/repo_update.go index aaba1996..dc7d32d3 100644 --- a/internal/server/api/v1/repos/repo_update.go +++ b/internal/server/api/v1/repos/repo_update.go @@ -14,6 +14,7 @@ import ( type ( RepoPatchPayload struct { + Name *string `json:"name"` ConfigPath *string `json:"config_path"` Active *bool `json:"active"` } @@ -57,17 +58,22 @@ func (s *RepoAPI) Update(c *gin.Context) { } } + if p.Name != nil { + s.log.Debug("Set the name field.", zap.String("value", *p.Name)) + re.Name = *p.Name + } + if p.ConfigPath != nil { - if *p.ConfigPath != re.ConfigPath { - re.ConfigPath = *p.ConfigPath + s.log.Debug("Set the config_path field.", zap.String("value", *p.ConfigPath)) + re.ConfigPath = *p.ConfigPath + } - if re, err = s.i.UpdateRepo(ctx, re); err != nil { - s.log.Check(gb.GetZapLogLevel(err), "Failed to update the repository.").Write(zap.Error(err)) - gb.ResponseWithError(c, err) - return - } - } + if re, err = s.i.UpdateRepo(ctx, re); err != nil { + s.log.Check(gb.GetZapLogLevel(err), "Failed to update the repository.").Write(zap.Error(err)) + gb.ResponseWithError(c, err) + return } + s.log.Info("Update the repository.", zap.Int64("repo_id", re.ID)) gb.Response(c, http.StatusOK, re) } diff --git a/internal/server/api/v1/repos/repo_update_test.go b/internal/server/api/v1/repos/repo_update_test.go index aad18a70..74d09138 100644 --- a/internal/server/api/v1/repos/repo_update_test.go +++ b/internal/server/api/v1/repos/repo_update_test.go @@ -93,6 +93,12 @@ func TestRepoAPI_UpdateRepo(t *testing.T) { return r, nil }) + m.EXPECT(). + UpdateRepo(gomock.Any(), gomock.AssignableToTypeOf(&ent.Repo{})). + DoAndReturn(func(ctx context.Context, r *ent.Repo) (*ent.Repo, error) { + return r, nil + }) + gin.SetMode(gin.ReleaseMode) router := gin.New() diff --git a/pkg/e/code.go b/pkg/e/code.go index b1fc2134..999497e2 100644 --- a/pkg/e/code.go +++ b/pkg/e/code.go @@ -49,6 +49,9 @@ const ( // ErrorPermissionRequired is the permission is required to access. ErrorPermissionRequired ErrorCode = "permission_required" + + // ErrorRepoUniqueName is the repository name must be unique. + ErrorRepoUniqueName ErrorCode = "repo_unique_name" ) type ( diff --git a/pkg/e/trans.go b/pkg/e/trans.go index 6d5d414b..3eb07d27 100644 --- a/pkg/e/trans.go +++ b/pkg/e/trans.go @@ -19,7 +19,8 @@ var messages = map[ErrorCode]string{ ErrorCodeLicenseDecode: "Decoding the license is failed.", ErrorCodeLicenseRequired: "The license is required.", ErrorCodeParameterInvalid: "Invalid request parameter.", - ErrorPermissionRequired: "The permission is required", + ErrorPermissionRequired: "The permission is required.", + ErrorRepoUniqueName: "The same repository name already exists.", } func GetMessage(code ErrorCode) string { @@ -49,6 +50,7 @@ var httpCodes = map[ErrorCode]int{ ErrorCodeLicenseRequired: http.StatusPaymentRequired, ErrorCodeParameterInvalid: http.StatusBadRequest, ErrorPermissionRequired: http.StatusForbidden, + ErrorRepoUniqueName: http.StatusUnprocessableEntity, } func GetHttpCode(code ErrorCode) int { diff --git a/ui/src/apis/repo.ts b/ui/src/apis/repo.ts index 120b362c..a171024f 100644 --- a/ui/src/apis/repo.ts +++ b/ui/src/apis/repo.ts @@ -4,7 +4,7 @@ import { instance, headers } from './setting' import { _fetch } from "./_base" import { DeploymentData, mapDataToDeployment } from "./deployment" -import { Repo, HttpForbiddenError, Deployment } from '../models' +import { Repo, HttpForbiddenError, Deployment, HttpUnprocessableEntityError } from '../models' export interface RepoData { id: number @@ -66,7 +66,10 @@ export const getRepo = async (namespace: string, name: string): Promise => return repo } -export const updateRepo = async (namespace: string, name: string, payload: {config_path: string}): Promise => { +export const updateRepo = async (namespace: string, name: string, payload: { + name?: string, + config_path?: string, +}): Promise => { const res = await _fetch(`${instance}/api/v1/repos/${namespace}/${name}`, { headers, credentials: 'same-origin', @@ -76,6 +79,9 @@ export const updateRepo = async (namespace: string, name: string, payload: {conf if (res.status === StatusCodes.FORBIDDEN) { const message = await res.json().then(data => data.message) throw new HttpForbiddenError(message) + } else if (res.status === StatusCodes.UNPROCESSABLE_ENTITY) { + const message = await res.json().then(data => data.message) + throw new HttpUnprocessableEntityError(message) } const ret: Repo = await res diff --git a/ui/src/redux/repoSettings.ts b/ui/src/redux/repoSettings.tsx similarity index 83% rename from ui/src/redux/repoSettings.ts rename to ui/src/redux/repoSettings.tsx index 8bdaa5bd..a16d22a8 100644 --- a/ui/src/redux/repoSettings.ts +++ b/ui/src/redux/repoSettings.tsx @@ -1,8 +1,8 @@ -import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit" +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit" import { message } from "antd" import { getRepo, updateRepo, deactivateRepo } from "../apis" -import { Repo, RequestStatus, HttpForbiddenError } from "../models" +import { Repo, RequestStatus, HttpForbiddenError, HttpUnprocessableEntityError } from "../models" interface RepoSettingsState { repo?: Repo @@ -25,9 +25,16 @@ export const init = createAsyncThunk( +export const save = createAsyncThunk< + Repo, + { + name: string, + config_path: string + }, + { state: {repoSettings: RepoSettingsState} } +>( 'repoSettings/save', - async (_, { getState, rejectWithValue, requestId } ) => { + async (values, { getState, rejectWithValue, requestId } ) => { const { repo, saveId, saving } = getState().repoSettings if (!repo) { throw new Error("There is no repo.") @@ -38,13 +45,19 @@ export const save = createAsyncThunk + It is unprocesable entity.
+ {e.message} + , 3) + } + return rejectWithValue(e) } @@ -65,7 +78,6 @@ export const deactivate = createAsyncThunk) => { - if (!state.repo) { - return - } - - state.repo.configPath = action.payload - } - }, + reducers: {}, extraReducers: builder => { builder .addCase(init.fulfilled, (state, action) => { diff --git a/ui/src/views/repoSettings/SettingsForm.tsx b/ui/src/views/repoSettings/SettingsForm.tsx index 3f5dbf1d..7460e70b 100644 --- a/ui/src/views/repoSettings/SettingsForm.tsx +++ b/ui/src/views/repoSettings/SettingsForm.tsx @@ -1,52 +1,68 @@ import { Form, Input, Button, Space, Typography } from "antd" - -import { Repo } from "../../models" +import { useState } from "react" export interface SettingFormProps { - saving: boolean - repo?: Repo - onClickFinish(values: any): void + configLink: string + initialValues?: SettingFormValues + onClickFinish(values: SettingFormValues): void onClickDeactivate(): void } +export interface SettingFormValues { + name: string + config_path: string +} + export default function SettingForm({ - saving, - repo, + configLink, + initialValues, onClickFinish, onClickDeactivate, }: SettingFormProps): JSX.Element { + const [saving, setSaving] = useState(false) + const layout = { labelCol: { span: 5}, wrapperCol: { span: 12 }, - }; + } const submitLayout = { wrapperCol: { offset: 5, span: 12 }, - }; + } - const initialValues = { - "config": repo?.configPath + const onFinish = (values: any) => { + setSaving(true) + onClickFinish(values) + setSaving(false) } return (
+ + + - + Link diff --git a/ui/src/views/repoSettings/index.tsx b/ui/src/views/repoSettings/index.tsx index 75cb7ced..17676b67 100644 --- a/ui/src/views/repoSettings/index.tsx +++ b/ui/src/views/repoSettings/index.tsx @@ -4,11 +4,10 @@ import { shallowEqual } from "react-redux"; import { PageHeader } from "antd" import { useAppSelector, useAppDispatch } from "../../redux/hooks" -import { save, deactivate, repoSettingsSlice as slice } from "../../redux/repoSettings" +import { save, deactivate } from "../../redux/repoSettings" import { init } from "../../redux/repoSettings" -import SettingsForm, { SettingFormProps } from "./SettingsForm" -import { RequestStatus } from "../../models"; +import SettingsForm, { SettingFormProps, SettingFormValues } from "./SettingsForm" export default (): JSX.Element => { const { namespace, name } = useParams<{ @@ -17,7 +16,6 @@ export default (): JSX.Element => { }>() const { - saving, repo } = useAppSelector(state => state.repoSettings, shallowEqual) @@ -31,19 +29,26 @@ export default (): JSX.Element => { // eslint-disable-next-line }, [dispatch]) - const onClickFinish = (values: any) => { - dispatch(slice.actions.setConfigPath(values.config)) - dispatch(save()) + const onClickFinish = (values: SettingFormValues) => { + const f = async () => { await dispatch(save(values)) } + f() } const onClickDeactivate = () => { dispatch(deactivate()) } + if (!repo) { + return (<>) + } + return ( @@ -53,8 +58,8 @@ export default (): JSX.Element => { interface RepoSettingsProps extends SettingFormProps {} function RepoSettings({ - saving, - repo, + configLink, + initialValues, onClickFinish, onClickDeactivate, }: RepoSettingsProps): JSX.Element { @@ -64,15 +69,12 @@ function RepoSettings({
- {(repo)? - - : - <>} +
)