From 30100e40043f0c71690e9d3cd23bbeaa2d588176 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Mon, 22 Jul 2024 15:13:59 +0900 Subject: [PATCH 01/51] use valibot in repository form --- .../repository/components/AuthConfigForm.tsx | 101 ++++++++ .../repository/components/AuthMethodField.tsx | 195 +++++++++++++++ .../repository/components/CreateForm.tsx | 132 +++++++++++ .../repository/components/DeleteForm.tsx | 97 ++++++++ .../components/GeneralConfigForm.tsx | 100 ++++++++ .../provider/repositoryFormProvider.tsx | 5 + .../repository/schema/repositorySchema.ts | 222 ++++++++++++++++++ dashboard/src/libs/useFormContext.tsx | 40 ++++ .../repos/[id]/settings/authorization.tsx | 117 +-------- .../src/pages/repos/[id]/settings/general.tsx | 189 +-------------- dashboard/src/pages/repos/new.tsx | 132 +---------- 11 files changed, 915 insertions(+), 415 deletions(-) create mode 100644 dashboard/src/features/repository/components/AuthConfigForm.tsx create mode 100644 dashboard/src/features/repository/components/AuthMethodField.tsx create mode 100644 dashboard/src/features/repository/components/CreateForm.tsx create mode 100644 dashboard/src/features/repository/components/DeleteForm.tsx create mode 100644 dashboard/src/features/repository/components/GeneralConfigForm.tsx create mode 100644 dashboard/src/features/repository/provider/repositoryFormProvider.tsx create mode 100644 dashboard/src/features/repository/schema/repositorySchema.ts create mode 100644 dashboard/src/libs/useFormContext.tsx diff --git a/dashboard/src/features/repository/components/AuthConfigForm.tsx b/dashboard/src/features/repository/components/AuthConfigForm.tsx new file mode 100644 index 00000000..ac625a55 --- /dev/null +++ b/dashboard/src/features/repository/components/AuthConfigForm.tsx @@ -0,0 +1,101 @@ +import { Field, Form, type SubmitHandler, reset } from '@modular-forms/solid' +import { type Component, Show, createEffect, untrack } from 'solid-js' +import toast from 'solid-toast' +import type { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import { TextField } from '/@/components/UI/TextField' +import FormBox from '/@/components/layouts/FormBox' +import { useRepositoryForm } from '/@/features/repository/provider/repositoryFormProvider' +import { + type CreateOrUpdateRepositorySchema, + convertUpdateRepositoryInput, + updateRepositoryFormInitialValues, +} from '/@/features/repository/schema/repositorySchema' +import { client, handleAPIError } from '/@/libs/api' +import AuthMethodField from './AuthMethodField' + +type Props = { + repo: Repository + refetchRepo: () => void + hasPermission: boolean +} + +const AuthConfigForm: Component = (props) => { + const { formStore } = useRepositoryForm() + + // reset forms when props.repo changed + createEffect(() => { + reset( + untrack(() => formStore), + { + initialValues: updateRepositoryFormInitialValues(props.repo), + }, + ) + }) + + const handleSubmit: SubmitHandler = async (values) => { + try { + await client.updateRepository(convertUpdateRepositoryInput(values)) + toast.success('リポジトリの設定を更新しました') + props.refetchRepo() + } catch (e) { + handleAPIError(e, 'リポジトリの設定の更新に失敗しました') + } + } + + const discardChanges = () => { + reset(formStore) + } + + return ( +
+ + {() => null} + + + {() => null} + + + + + {(field, fieldProps) => ( + + )} + + + + + + + + + + +
+ ) +} + +export default AuthConfigForm diff --git a/dashboard/src/features/repository/components/AuthMethodField.tsx b/dashboard/src/features/repository/components/AuthMethodField.tsx new file mode 100644 index 00000000..ebbe6766 --- /dev/null +++ b/dashboard/src/features/repository/components/AuthMethodField.tsx @@ -0,0 +1,195 @@ +import { styled } from '@macaron-css/solid' +import { Field, type FormStore, getValue, setValues } from '@modular-forms/solid' +import { type Component, Match, Show, Suspense, Switch, createEffect, createResource, createSignal } from 'solid-js' +import { Button } from '/@/components/UI/Button' +import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' +import { TooltipInfoIcon } from '/@/components/UI/TooltipInfoIcon' +import { FormItem } from '/@/components/templates/FormItem' +import { RadioGroup, type RadioOption } from '/@/components/templates/RadioGroups' +import { client, systemInfo } from '/@/libs/api' +import { colorVars, textVars } from '/@/theme' +import { TextField } from '../../../components/UI/TextField' +import type { CreateOrUpdateRepositorySchema } from '../schema/repositorySchema' + +const SshKeyContainer = styled('div', { + base: { + width: '100%', + display: 'flex', + flexDirection: 'column', + gap: '16px', + + color: colorVars.semantic.text.grey, + ...textVars.caption.regular, + }, +}) +const RefreshButtonContainer = styled('div', { + base: { + width: '100%', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: '8px', + + color: colorVars.semantic.accent.error, + ...textVars.caption.regular, + }, +}) +const VisibilityButton = styled('button', { + base: { + width: '40px', + height: '40px', + padding: '8px', + background: 'none', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + + color: colorVars.semantic.text.black, + selectors: { + '&:hover': { + background: colorVars.semantic.transparent.primaryHover, + }, + '&:active': { + color: colorVars.semantic.primary.main, + background: colorVars.semantic.transparent.primarySelected, + }, + }, + }, +}) + +type Props = { + formStore: FormStore + readonly?: boolean +} + +const authMethods: RadioOption['method']>[] = [ + { label: '認証を使用しない', value: 'none' }, + { label: 'BASIC認証', value: 'basic' }, + { label: 'SSH公開鍵認証', value: 'ssh' }, +] + +const AuthMethodField: Component = (props) => { + const authMethod = () => getValue(props.formStore, 'auth.method') + + const [showPassword, setShowPassword] = createSignal(false) + const [useTmpKey, setUseTmpKey] = createSignal(false) + const [tmpKey] = createResource( + () => (useTmpKey() ? true : undefined), + () => client.generateKeyPair({}), + ) + createEffect(() => { + if (tmpKey.latest !== undefined) { + setValues(props.formStore, { + auth: { + value: { + ssh: { + keyId: tmpKey().keyId, + }, + }, + }, + }) + } + }) + const publicKey = () => (useTmpKey() ? tmpKey()?.publicKey : systemInfo()?.publicKey ?? '') + + return ( + <> + + {(field, fieldProps) => ( + + )} + + + + + {(field, fieldProps) => ( + + )} + + + {(field, fieldProps) => ( + setShowPassword((s) => !s)} type="button"> + visibility_off}> + visibility + + + } + /> + )} + + + + + {() => ( + + + + 以下のSSH公開鍵 + {useTmpKey() ? '(このリポジトリ専用)' : '(NeoShowcase全体共通)'} + を、リポジトリのデプロイキーとして登録してください。 +
+ 公開リポジトリの場合は、この操作は不要です。 + + + + + +
このリポジトリ専用のSSH用鍵ペアを生成します。
+
+ NeoShowcase全体で共通の公開鍵が、リポジトリに登録できない場合に生成してください。 +
+
GitHubプライベートリポジトリの場合は必ず生成が必要です。
+ + ), + }} + style="left" + /> +
+
+
+
+
+ )} +
+
+
+ + ) +} + +export default AuthMethodField diff --git a/dashboard/src/features/repository/components/CreateForm.tsx b/dashboard/src/features/repository/components/CreateForm.tsx new file mode 100644 index 00000000..f0cb3f13 --- /dev/null +++ b/dashboard/src/features/repository/components/CreateForm.tsx @@ -0,0 +1,132 @@ +import { styled } from '@macaron-css/solid' +import { Field, Form, type SubmitHandler, getValue, reset, setValue, setValues } from '@modular-forms/solid' +import { useNavigate } from '@solidjs/router' +import { type Component, createEffect, onMount } from 'solid-js' +import toast from 'solid-toast' +import { Button } from '/@/components/UI/Button' +import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' +import { TextField } from '/@/components/UI/TextField' +import { useRepositoryForm } from '/@/features/repository/provider/repositoryFormProvider' +import { + type CreateOrUpdateRepositorySchema, + convertCreateRepositoryInput, + createRepositoryFormInitialValues, +} from '/@/features/repository/schema/repositorySchema' +import { client, handleAPIError } from '/@/libs/api' +import { extractRepositoryNameFromURL } from '/@/libs/application' + +import { colorVars } from '/@/theme' +import AuthMethodField from './AuthMethodField' + +const Container = styled('div', { + base: { + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-end', + gap: '40px', + }, +}) +const InputsContainer = styled('div', { + base: { + width: '100%', + margin: '0 auto', + padding: '20px 24px', + display: 'flex', + flexDirection: 'column', + gap: '24px', + + borderRadius: '8px', + background: colorVars.semantic.ui.primary, + }, +}) + +const CreateForm: Component = () => { + const navigate = useNavigate() + const { formStore } = useRepositoryForm() + + onMount(() => { + reset(formStore, { + initialValues: createRepositoryFormInitialValues(), + }) + }) + + // URLからリポジトリ名, 認証方法を自動入力 + createEffect(() => { + const url = getValue(formStore, 'url') + if (url === undefined || url === '') return + + // リポジトリ名を自動入力 + const repositoryName = extractRepositoryNameFromURL(url) + setValue(formStore, 'name', repositoryName) + + // 認証方法を自動入力 + const isHTTPFormat = url.startsWith('http://') || url.startsWith('https://') + if (!isHTTPFormat) { + // Assume SSH or Git Protocol format + setValues(formStore, { + auth: { + method: 'ssh', + }, + }) + } + }) + + const handleSubmit: SubmitHandler = async (values) => { + try { + const res = await client.createRepository(convertCreateRepositoryInput(values)) + toast.success('リポジトリを登録しました') + // 新規アプリ作成ページに遷移 + navigate(`/apps/new?repositoryID=${res.id}`) + } catch (e) { + return handleAPIError(e, 'リポジトリの登録に失敗しました') + } + } + + return ( +
+ + {() => null} + + + + + {(field, fieldProps) => ( + + )} + + + {(field, fieldProps) => ( + + )} + + + + + +
+ ) +} + +export default CreateForm diff --git a/dashboard/src/features/repository/components/DeleteForm.tsx b/dashboard/src/features/repository/components/DeleteForm.tsx new file mode 100644 index 00000000..10239d53 --- /dev/null +++ b/dashboard/src/features/repository/components/DeleteForm.tsx @@ -0,0 +1,97 @@ +import { styled } from '@macaron-css/solid' +import { useNavigate } from '@solidjs/router' +import type { Component } from 'solid-js' +import toast from 'solid-toast' +import type { Application, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import ModalDeleteConfirm from '/@/components/UI/ModalDeleteConfirm' +import FormBox from '/@/components/layouts/FormBox' +import { FormItem } from '/@/components/templates/FormItem' +import { client, handleAPIError } from '/@/libs/api' +import { originToIcon, repositoryURLToOrigin } from '/@/libs/application' +import useModal from '/@/libs/useModal' +import { colorVars, textVars } from '/@/theme' + +const DeleteRepositoryNotice = styled('div', { + base: { + color: colorVars.semantic.text.grey, + ...textVars.caption.regular, + }, +}) + +type Props = { + repo: Repository + apps: Application[] + hasPermission: boolean +} + +const DeleteForm: Component = (props) => { + const { Modal, open, close } = useModal() + const navigate = useNavigate() + + const canDeleteRepository = () => props.apps.length === 0 + + const deleteRepository = async () => { + try { + await client.deleteRepository({ repositoryId: props.repo.id }) + toast.success('リポジトリを削除しました') + close() + navigate('/apps') + } catch (e) { + handleAPIError(e, 'リポジトリの削除に失敗しました') + } + } + + return ( + <> + + + + + リポジトリを削除するには、このリポジトリ内のすべてのアプリケーションを削除する必要があります。 + + + + + + + + + Delete Repository + + + {originToIcon(repositoryURLToOrigin(props.repo.url), 24)} + {props.repo.name} + + + + + + + + + ) +} + +export default DeleteForm diff --git a/dashboard/src/features/repository/components/GeneralConfigForm.tsx b/dashboard/src/features/repository/components/GeneralConfigForm.tsx new file mode 100644 index 00000000..f326053f --- /dev/null +++ b/dashboard/src/features/repository/components/GeneralConfigForm.tsx @@ -0,0 +1,100 @@ +import { Field, Form, type SubmitHandler, reset } from '@modular-forms/solid' +import { type Component, Show, createEffect, untrack } from 'solid-js' +import toast from 'solid-toast' +import type { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import { TextField } from '/@/components/UI/TextField' +import FormBox from '/@/components/layouts/FormBox' +import { useRepositoryForm } from '/@/features/repository/provider/repositoryFormProvider' +import { + type CreateOrUpdateRepositorySchema, + convertUpdateRepositoryInput, + updateRepositoryFormInitialValues, +} from '/@/features/repository/schema/repositorySchema' +import { client, handleAPIError } from '/@/libs/api' + +type Props = { + repo: Repository + refetchRepo: () => void + hasPermission: boolean +} + +const GeneralConfigForm: Component = (props) => { + const { formStore } = useRepositoryForm() + + // reset forms when props.repo changed + createEffect(() => { + reset( + untrack(() => formStore), + { + initialValues: updateRepositoryFormInitialValues(props.repo), + }, + ) + }) + + const handleSubmit: SubmitHandler = async (values) => { + try { + await client.updateRepository(convertUpdateRepositoryInput(values)) + toast.success('リポジトリ名を更新しました') + props.refetchRepo() + } catch (e) { + handleAPIError(e, 'リポジトリ名の更新に失敗しました') + } + } + + const discardChanges = () => { + reset(formStore) + } + + return ( +
+ + {() => null} + + + {() => null} + + + + + {(field, fieldProps) => ( + + )} + + + + + + + + + +
+ ) +} + +export default GeneralConfigForm diff --git a/dashboard/src/features/repository/provider/repositoryFormProvider.tsx b/dashboard/src/features/repository/provider/repositoryFormProvider.tsx new file mode 100644 index 00000000..5667f01b --- /dev/null +++ b/dashboard/src/features/repository/provider/repositoryFormProvider.tsx @@ -0,0 +1,5 @@ +import { useFormContext } from '../../../libs/useFormContext' +import { createOrUpdateRepositorySchema } from '../schema/repositorySchema' + +export const { FormProvider: RepositoryFormProvider, useForm: useRepositoryForm } = + useFormContext(createOrUpdateRepositorySchema) diff --git a/dashboard/src/features/repository/schema/repositorySchema.ts b/dashboard/src/features/repository/schema/repositorySchema.ts new file mode 100644 index 00000000..9fd378eb --- /dev/null +++ b/dashboard/src/features/repository/schema/repositorySchema.ts @@ -0,0 +1,222 @@ +import type { PartialMessage } from '@bufbuild/protobuf' +import * as v from 'valibot' +import { + type CreateRepositoryAuth, + type CreateRepositoryRequest, + type Repository, + Repository_AuthMethod, + type UpdateRepositoryRequest, +} from '/@/api/neoshowcase/protobuf/gateway_pb' + +// --- create repository + +const repositoryAuthSchema = v.variant('method', [ + v.object({ + method: v.literal('none'), + value: v.object({ + none: v.object({}), + }), + }), + v.object({ + method: v.literal('basic'), + value: v.object({ + basic: v.object({ + username: v.pipe(v.string(), v.nonEmpty('Enter UserName')), + password: v.pipe(v.string(), v.nonEmpty('Enter Password')), + }), + }), + }), + v.object({ + method: v.literal('ssh'), + value: v.object({ + ssh: v.object({ + keyId: v.optional(v.string()), // undefinedの場合はNeoShowcase全体共通の公開鍵が使用される + }), + }), + }), +]) + +const createRepositorySchema = v.pipe( + v.object({ + type: v.literal('create'), + name: v.pipe(v.string(), v.nonEmpty('Enter Repository Name')), + url: v.pipe(v.string(), v.nonEmpty('Enter Repository URL')), + auth: repositoryAuthSchema, + }), + // Basic認証の場合、URLはhttpsでなければならない + v.forward( + v.partialCheck( + [['url'], ['auth', 'method']], + (input) => { + if (input.auth.method === 'basic') return input.url.startsWith('https') + + // 認証方法が basic 以外の場合は常にvalid + return true + }, + 'Basic認証を使用する場合、URLはhttps://から始まる必要があります', + ), + ['url'], + ), +) +type CreateRepositorySchema = v.InferInput + +export const createRepositoryFormInitialValues = (): CreateOrUpdateRepositorySchema => + ({ + type: 'create', + name: '', + url: '', + auth: { + method: 'none', + value: { + none: {}, + }, + }, + }) satisfies CreateRepositorySchema + +/** valobot schema -> protobuf message */ +const repositoryAuthSchemaToMessage = ( + input: v.InferInput, +): PartialMessage => { + switch (input.method) { + case 'none': { + return { + auth: { + case: input.method, + value: input.value.none, + }, + } + } + case 'basic': { + return { + auth: { + case: input.method, + value: input.value.basic, + }, + } + } + case 'ssh': { + return { + auth: { + case: input.method, + value: input.value.ssh, + }, + } + } + } +} + +/** valobot schema -> protobuf message */ +export const convertCreateRepositoryInput = ( + input: CreateOrUpdateRepositorySchema, +): PartialMessage => { + if (input.type !== 'create') + throw new Error("The type of input passed to convertCreateRepositoryInput must be 'create'") + + return { + ...input, + auth: repositoryAuthSchemaToMessage(input.auth), + } +} + +// --- update repository + +const ownersSchema = v.array(v.string()) + +export const updateRepositorySchema = v.pipe( + v.object({ + type: v.literal('update'), + id: v.string(), + name: v.optional(v.pipe(v.string(), v.nonEmpty('Enter Repository Name'))), + url: v.optional(v.pipe(v.string(), v.nonEmpty('Enter Repository URL'))), + auth: v.optional(repositoryAuthSchema), + ownerIds: v.optional(ownersSchema), + }), + // Basic認証の場合、URLはhttpsでなければならない + v.forward( + v.partialCheck( + [['url'], ['auth', 'method']], + (input) => { + if (input.auth?.method === 'basic') return input.url?.startsWith('https') ?? false + + // 認証方法が basic 以外の場合は常にvalid + return true + }, + 'Basic認証を使用する場合、URLはhttps://から始まる必要があります', + ), + ['url'], + ), +) + +type UpdateRepositorySchema = v.InferInput + +/** protobuf message -> valobot schema */ +const authMethodToAuthConfig = (method: Repository_AuthMethod): v.InferInput => { + switch (method) { + case Repository_AuthMethod.NONE: { + return { + method: 'none', + value: { + none: {}, + }, + } + } + case Repository_AuthMethod.BASIC: { + return { + method: 'basic', + value: { + basic: { + username: '', + password: '', + }, + }, + } + } + case Repository_AuthMethod.SSH: { + return { + method: 'ssh', + value: { + ssh: { + keyId: '', + }, + }, + } + } + default: { + const _unreachable: never = method + throw new Error('unknown repository auth method') + } + } +} + +export const updateRepositoryFormInitialValues = (input: Repository): CreateOrUpdateRepositorySchema => { + return { + type: 'update', + id: input.id, + name: input.name, + url: input.url, + auth: authMethodToAuthConfig(input.authMethod), + ownerIds: input.ownerIds, + } satisfies UpdateRepositorySchema +} + +/** valobot schema -> protobuf message */ +export const convertUpdateRepositoryInput = ( + input: CreateOrUpdateRepositorySchema, +): PartialMessage => { + if (input.type !== 'update') + throw new Error("The type of input passed to convertCreateRepositoryInput must be 'create'") + + return { + ...input, + auth: input.auth ? repositoryAuthSchemaToMessage(input.auth) : undefined, + ownerIds: input.ownerIds + ? { + ownerIds: input.ownerIds, + } + : undefined, + } +} + +export const createOrUpdateRepositorySchema = v.variant('type', [createRepositorySchema, updateRepositorySchema]) + +export type CreateOrUpdateRepositorySchema = v.InferInput diff --git a/dashboard/src/libs/useFormContext.tsx b/dashboard/src/libs/useFormContext.tsx new file mode 100644 index 00000000..14c5f497 --- /dev/null +++ b/dashboard/src/libs/useFormContext.tsx @@ -0,0 +1,40 @@ +import { type FieldValues, type FormStore, createFormStore, valiForm } from '@modular-forms/solid' +import { type ParentComponent, createContext, useContext } from 'solid-js' +import type { BaseIssue, BaseSchema, InferInput } from 'valibot' + +type FormContextValue = { + formStore: FormStore +} + +export const useFormContext = >( + schema: BaseSchema, +) => { + const FormContext = createContext>>() + + const FormProvider: ParentComponent = (props) => { + const formStore = createFormStore>({ + validate: valiForm(schema), + }) + + return ( + + {props.children} + + ) + } + + const useForm = () => { + const c = useContext(FormContext) + if (!c) throw new Error('useRepositoryCreateForm must be used within a RepositoryCreateFormProvider') + return c + } + + return { + FormProvider, + useForm, + } +} diff --git a/dashboard/src/pages/repos/[id]/settings/authorization.tsx b/dashboard/src/pages/repos/[id]/settings/authorization.tsx index 7bf3bf88..e23f550e 100644 --- a/dashboard/src/pages/repos/[id]/settings/authorization.tsx +++ b/dashboard/src/pages/repos/[id]/settings/authorization.tsx @@ -1,121 +1,20 @@ -import type { PlainMessage } from '@bufbuild/protobuf' -import { type SubmitHandler, createForm, reset } from '@modular-forms/solid' -import { type Component, Show } from 'solid-js' -import toast from 'solid-toast' -import { - type CreateRepositoryAuth, - type Repository, - Repository_AuthMethod, -} from '/@/api/neoshowcase/protobuf/gateway_pb' -import { Button } from '/@/components/UI/Button' +import { Show } from 'solid-js' import { DataTable } from '/@/components/layouts/DataTable' -import FormBox from '/@/components/layouts/FormBox' -import { type AuthForm, RepositoryAuthSettings, formToAuth } from '/@/components/templates/repo/RepositoryAuthSettings' -import { client, handleAPIError } from '/@/libs/api' +import AuthConfigForm from '/@/features/repository/components/AuthConfigForm' +import { RepositoryFormProvider } from '/@/features/repository/provider/repositoryFormProvider' import { useRepositoryData } from '/@/routes' -const mapAuthMethod = (authMethod: Repository_AuthMethod): PlainMessage['auth']['case'] => { - switch (authMethod) { - case Repository_AuthMethod.NONE: - return 'none' - case Repository_AuthMethod.BASIC: - return 'basic' - case Repository_AuthMethod.SSH: - return 'ssh' - } -} - -const AuthConfig: Component<{ - repo: Repository - refetchRepo: () => void - hasPermission: boolean -}> = (props) => { - const [authForm, Auth] = createForm({ - initialValues: { - url: props.repo.url, - case: mapAuthMethod(props.repo.authMethod), - auth: { - basic: { - username: '', - password: '', - }, - ssh: { - keyId: '', - }, - }, - }, - }) - - const handleSubmit: SubmitHandler = async (values) => { - try { - await client.updateRepository({ - id: props.repo.id, - url: values.url, - auth: { - auth: formToAuth(values), - }, - }) - toast.success('リポジトリの設定を更新しました') - props.refetchRepo() - } catch (e) { - handleAPIError(e, 'リポジトリの設定の更新に失敗しました') - } - } - - const discardChanges = () => { - reset(authForm) - } - - const AuthSetting = RepositoryAuthSettings({ - formStore: authForm, - hasPermission: props.hasPermission, - }) - - return ( - - - - - - - - - - - - - - - - ) -} - export default () => { const { repo, refetchRepo, hasPermission } = useRepositoryData() return ( Authorization - - - + + + + + ) } diff --git a/dashboard/src/pages/repos/[id]/settings/general.tsx b/dashboard/src/pages/repos/[id]/settings/general.tsx index e8b9a2c4..e17cd2da 100644 --- a/dashboard/src/pages/repos/[id]/settings/general.tsx +++ b/dashboard/src/pages/repos/[id]/settings/general.tsx @@ -1,180 +1,9 @@ -import type { PlainMessage } from '@bufbuild/protobuf' -import { styled } from '@macaron-css/solid' -import { type SubmitHandler, createForm, required, reset } from '@modular-forms/solid' -import { useNavigate } from '@solidjs/router' -import { type Component, Show, createEffect } from 'solid-js' -import toast from 'solid-toast' -import type { Application, Repository, UpdateRepositoryRequest } from '/@/api/neoshowcase/protobuf/gateway_pb' -import { Button } from '/@/components/UI/Button' -import ModalDeleteConfirm from '/@/components/UI/ModalDeleteConfirm' -import { TextField } from '/@/components/UI/TextField' +import { Show } from 'solid-js' import { DataTable } from '/@/components/layouts/DataTable' -import FormBox from '/@/components/layouts/FormBox' -import { FormItem } from '/@/components/templates/FormItem' -import { client, handleAPIError } from '/@/libs/api' -import { originToIcon, repositoryURLToOrigin } from '/@/libs/application' -import useModal from '/@/libs/useModal' +import DeleteForm from '/@/features/repository/components/DeleteForm' +import GeneralConfigForm from '/@/features/repository/components/GeneralConfigForm' +import { RepositoryFormProvider } from '/@/features/repository/provider/repositoryFormProvider' import { useRepositoryData } from '/@/routes' -import { colorVars, textVars } from '/@/theme' - -type GeneralForm = Required, 'name'>> - -const NameConfig: Component<{ - repo: Repository - refetchRepo: () => void - hasPermission: boolean -}> = (props) => { - const [generalForm, General] = createForm({ - initialValues: { - name: props.repo.name, - }, - }) - - createEffect(() => { - reset(generalForm, 'name', { - initialValue: props.repo.name, - }) - }) - - const handleSubmit: SubmitHandler = async (values) => { - try { - await client.updateRepository({ - id: props.repo.id, - name: values.name, - }) - toast.success('リポジトリ名を更新しました') - props.refetchRepo() - } catch (e) { - handleAPIError(e, 'リポジトリ名の更新に失敗しました') - } - } - const discardChanges = () => { - reset(generalForm) - } - - return ( - - - - - {(field, fieldProps) => ( - - )} - - - - - - - - - - - ) -} - -const DeleteRepositoryNotice = styled('div', { - base: { - color: colorVars.semantic.text.grey, - ...textVars.caption.regular, - }, -}) - -const DeleteRepository: Component<{ - repo: Repository - apps: Application[] - hasPermission: boolean -}> = (props) => { - const { Modal, open, close } = useModal() - const navigate = useNavigate() - - const deleteRepository = async () => { - try { - await client.deleteRepository({ repositoryId: props.repo.id }) - toast.success('リポジトリを削除しました') - close() - navigate('/apps') - } catch (e) { - handleAPIError(e, 'リポジトリの削除に失敗しました') - } - } - const canDeleteRepository = () => props.apps.length === 0 - - return ( - <> - - - - - リポジトリを削除するには、このリポジトリ内のすべてのアプリケーションを削除する必要があります。 - - - - - - - - - Delete Repository - - - {originToIcon(repositoryURLToOrigin(props.repo.url), 24)} - {props.repo.name} - - - - - - - - - ) -} export default () => { const { repo, refetchRepo, apps, hasPermission } = useRepositoryData() @@ -183,10 +12,12 @@ export default () => { return ( General - - - - + + + + + + ) } diff --git a/dashboard/src/pages/repos/new.tsx b/dashboard/src/pages/repos/new.tsx index 6176395c..2d693def 100644 --- a/dashboard/src/pages/repos/new.tsx +++ b/dashboard/src/pages/repos/new.tsx @@ -1,106 +1,11 @@ -import { styled } from '@macaron-css/solid' -import { type SubmitHandler, createForm, getValue, required, setValue } from '@modular-forms/solid' import { Title } from '@solidjs/meta' -import { useNavigate } from '@solidjs/router' -import { createEffect } from 'solid-js' -import toast from 'solid-toast' -import { Button } from '/@/components/UI/Button' -import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' -import { TextField } from '/@/components/UI/TextField' import { MainViewContainer } from '/@/components/layouts/MainView' import { WithNav } from '/@/components/layouts/WithNav' import { Nav } from '/@/components/templates/Nav' -import { type AuthForm, RepositoryAuthSettings, formToAuth } from '/@/components/templates/repo/RepositoryAuthSettings' -import { client, handleAPIError } from '/@/libs/api' -import { extractRepositoryNameFromURL } from '/@/libs/application' -import { colorVars } from '/@/theme' - -const Container = styled('div', { - base: { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-end', - gap: '40px', - }, -}) -const InputsContainer = styled('div', { - base: { - width: '100%', - margin: '0 auto', - padding: '20px 24px', - display: 'flex', - flexDirection: 'column', - gap: '24px', - - borderRadius: '8px', - background: colorVars.semantic.ui.primary, - }, -}) - -type Config = AuthForm & { - name: string -} +import CreateForm from '/@/features/repository/components/CreateForm' +import { RepositoryFormProvider } from '/@/features/repository/provider/repositoryFormProvider' export default () => { - const navigate = useNavigate() - - const [config, Form] = createForm({ - initialValues: { - url: '', - name: '', - case: 'none', - auth: { - basic: { - username: '', - password: '', - }, - ssh: { - keyId: '', - }, - }, - }, - }) - const handleSubmit: SubmitHandler = async (values) => { - try { - const res = await client.createRepository({ - name: values.name, - url: values.url, - auth: { - auth: formToAuth(values), - }, - }) - toast.success('リポジトリを登録しました') - // 新規アプリ作成ページに遷移 - navigate(`/apps/new?repositoryID=${res.id}`) - } catch (e) { - return handleAPIError(e, 'リポジトリの登録に失敗しました') - } - } - - // URLからリポジトリ名, 認証方法を自動入力 - createEffect(() => { - const url = getValue(config, 'url') - if (url === undefined || url === '') return - - // リポジトリ名を自動入力 - const repositoryName = extractRepositoryNameFromURL(url) - setValue(config, 'name', repositoryName) - - // 認証方法を自動入力 - const isHTTPFormat = url.startsWith('http://') || url.startsWith('https://') - if (!isHTTPFormat) { - // Assume SSH or Git Protocol format - setValue(config, 'case', 'ssh') - } - }) - - const AuthSetting = RepositoryAuthSettings({ - // @ts-ignore - formStore: config, - hasPermission: true, - }) - return ( Register Repository - NeoShowcase @@ -109,36 +14,9 @@ export default () => { - - - - - - {(field, fieldProps) => ( - - )} - - - - - - - + + + From cb2a8b0715548f53fce13e2ed279c78d3a55f57c Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Mon, 22 Jul 2024 15:14:09 +0900 Subject: [PATCH 02/51] add repository schema tests --- .github/workflows/dashboard-ci.yaml | 12 + dashboard/package.json | 10 +- .../schema/repositorySchema.test.ts | 166 ++++ dashboard/yarn.lock | 800 +++++++++++++++++- 4 files changed, 980 insertions(+), 8 deletions(-) create mode 100644 dashboard/src/features/repository/schema/repositorySchema.test.ts diff --git a/.github/workflows/dashboard-ci.yaml b/.github/workflows/dashboard-ci.yaml index 77b7774b..a23387a5 100644 --- a/.github/workflows/dashboard-ci.yaml +++ b/.github/workflows/dashboard-ci.yaml @@ -49,6 +49,18 @@ jobs: - run: corepack enable - run: yarn install --immutable - run: yarn typecheck + test: + name: Test + runs-on: ubuntu-latest + needs: [packages] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: corepack enable + - run: yarn install --immutable + - run: yarn test build: name: Build runs-on: ubuntu-latest diff --git a/dashboard/package.json b/dashboard/package.json index 8e823e34..af3b2b37 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,7 +14,8 @@ "fix": "yarn fmt:apply && yarn lint:apply", "typecheck": "tsc --noEmit", "ci": "biome ci src", - "analyze": "vite build --mode analyze" + "analyze": "vite build --mode analyze", + "test": "vitest" }, "license": "MIT", "devDependencies": { @@ -24,13 +25,15 @@ "@macaron-css/vite": "1.5.1", "@tanstack/virtual-core": "3.8.1", "@types/node": "20.14.9", + "jsdom": "^24.1.1", "rollup-plugin-visualizer": "5.12.0", "typescript": "5.5.3", "unplugin-fonts": "1.1.1", "vite": "5.3.3", "vite-plugin-compression": "0.5.1", "vite-plugin-solid": "2.10.2", - "vite-plugin-solid-svg": "0.8.1" + "vite-plugin-solid-svg": "0.8.1", + "vitest": "^2.0.3" }, "dependencies": { "@bufbuild/protobuf": "1.10.0", @@ -52,7 +55,8 @@ "solid-js": "1.8.18", "solid-tippy": "0.2.1", "solid-toast": "0.5.0", - "tippy.js": "6.3.7" + "tippy.js": "6.3.7", + "valibot": "^0.36.0" }, "packageManager": "yarn@4.1.1+sha512.ec40d0639bb307441b945d9467139cbb88d14394baac760b52eca038b330d16542d66fef61574271534ace5a200518dabf3b53a85f1f9e4bfa37141b538a9590" } diff --git a/dashboard/src/features/repository/schema/repositorySchema.test.ts b/dashboard/src/features/repository/schema/repositorySchema.test.ts new file mode 100644 index 00000000..011dba08 --- /dev/null +++ b/dashboard/src/features/repository/schema/repositorySchema.test.ts @@ -0,0 +1,166 @@ +import { safeParse } from 'valibot' +import { describe, expect, test } from 'vitest' +import { createOrUpdateRepositorySchema } from './repositorySchema' + +const validator = (input: unknown) => safeParse(createOrUpdateRepositorySchema, input) + +describe('Create Repository Schema', () => { + const base = { + type: 'create', + name: 'test repository', + url: 'https://example.com/test/test.git', + auth: { + method: 'none', + value: { + none: {}, + }, + }, + } + + test('ok: valid input (auth none)', () => { + expect( + validator({ + ...base, + auth: { + method: 'none', + value: { + none: {}, + }, + }, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ok: valid input (auth basic)', () => { + expect( + validator({ + ...base, + auth: { + method: 'basic', + value: { + basic: { + username: 'test name', + password: 'test password', + }, + }, + }, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ok: valid input (auth ssh)', () => { + expect( + validator({ + ...base, + auth: { + method: 'ssh', + value: { + ssh: { + keyId: 'test key id', + }, + }, + }, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ng: empty name', () => { + expect( + validator({ + ...base, + name: '', + }).issues, + ).toEqual([ + expect.objectContaining({ + message: 'Enter Repository Name', + path: [ + expect.objectContaining({ + key: 'name', + }), + ], + }), + ]) + }) + + test('ng: empty url', () => { + expect( + validator({ + ...base, + url: '', + }).issues, + ).toEqual([ + expect.objectContaining({ + message: 'Enter Repository URL', + path: [ + expect.objectContaining({ + key: 'url', + }), + ], + }), + ]) + }) + + test("ng: auth method is basic, but the URL starts with 'http://'", () => { + expect( + validator({ + ...base, + url: 'http://example.com/test/test.git', + auth: { + method: 'basic', + value: { + basic: { + username: 'test name', + password: 'test password', + }, + }, + }, + }).issues, + ).toEqual([ + expect.objectContaining({ + message: 'Basic認証を使用する場合、URLはhttps://から始まる必要があります', + path: [ + expect.objectContaining({ + key: 'url', + }), + ], + }), + ]) + }) +}) + +describe('Update Repository Schema', () => { + const base = { + id: 'testRepositoryId', + type: 'update', + name: 'test repository', + url: 'https://example.com/test/test.git', + auth: { + method: 'none', + value: { + none: {}, + }, + }, + ownerIds: ['owner1'], + } + + test('ok: valid input', () => { + expect(validator(base)).toEqual(expect.objectContaining({ success: true })) + }) + + test('ng: empty id', () => { + expect( + validator({ + ...base, + id: undefined, + }).issues, + ).toEqual([ + expect.objectContaining({ + path: [ + expect.objectContaining({ + key: 'id', + }), + ], + }), + ]) + }) +}) diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock index 9e99bbc8..a20c8100 100644 --- a/dashboard/yarn.lock +++ b/dashboard/yarn.lock @@ -15,6 +15,16 @@ __metadata: languageName: node linkType: hard +"@ampproject/remapping@npm:^2.3.0": + version: 2.3.0 + resolution: "@ampproject/remapping@npm:2.3.0" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/81d63cca5443e0f0c72ae18b544cc28c7c0ec2cea46e7cb888bb0e0f411a1191d0d6b7af798d54e30777d8d1488b2ec0732aac2be342d3d7d3ffd271c6f489ed + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.22.13": version: 7.23.4 resolution: "@babel/code-frame@npm:7.23.4" @@ -1231,6 +1241,17 @@ __metadata: languageName: node linkType: hard +"@jridgewell/gen-mapping@npm:^0.3.5": + version: 0.3.5 + resolution: "@jridgewell/gen-mapping@npm:0.3.5" + dependencies: + "@jridgewell/set-array": "npm:^1.2.1" + "@jridgewell/sourcemap-codec": "npm:^1.4.10" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10c0/1be4fd4a6b0f41337c4f5fdf4afc3bd19e39c3691924817108b82ffcb9c9e609c273f936932b9fba4b3a298ce2eb06d9bff4eb1cc3bd81c4f4ee1b4917e25feb + languageName: node + linkType: hard + "@jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.1 resolution: "@jridgewell/resolve-uri@npm:3.1.1" @@ -1245,6 +1266,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/set-array@npm:^1.2.1": + version: 1.2.1 + resolution: "@jridgewell/set-array@npm:1.2.1" + checksum: 10c0/2a5aa7b4b5c3464c895c802d8ae3f3d2b92fcbe84ad12f8d0bfbb1f5ad006717e7577ee1fd2eac00c088abe486c7adb27976f45d2941ff6b0b92b2c3302c60f4 + languageName: node + linkType: hard + "@jridgewell/sourcemap-codec@npm:^1.4.10, @jridgewell/sourcemap-codec@npm:^1.4.14": version: 1.4.15 resolution: "@jridgewell/sourcemap-codec@npm:1.4.15" @@ -1252,6 +1280,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.4.15": + version: 1.5.0 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.0" + checksum: 10c0/2eb864f276eb1096c3c11da3e9bb518f6d9fc0023c78344cdc037abadc725172c70314bdb360f2d4b7bffec7f5d657ce006816bc5d4ecb35e61b66132db00c18 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:^0.3.17, @jridgewell/trace-mapping@npm:^0.3.9": version: 0.3.20 resolution: "@jridgewell/trace-mapping@npm:0.3.20" @@ -1262,6 +1297,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/trace-mapping@npm:^0.3.24": + version: 0.3.25 + resolution: "@jridgewell/trace-mapping@npm:0.3.25" + dependencies: + "@jridgewell/resolve-uri": "npm:^3.1.0" + "@jridgewell/sourcemap-codec": "npm:^1.4.14" + checksum: 10c0/3d1ce6ebc69df9682a5a8896b414c6537e428a1d68b02fcc8363b04284a8ca0df04d0ee3013132252ab14f2527bc13bea6526a912ecb5658f0e39fd2860b4df4 + languageName: node + linkType: hard + "@kobalte/core@npm:0.13.3": version: 0.13.3 resolution: "@kobalte/core@npm:0.13.3" @@ -1769,7 +1814,7 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:1.0.5": +"@types/estree@npm:1.0.5, @types/estree@npm:^1.0.0": version: 1.0.5 resolution: "@types/estree@npm:1.0.5" checksum: 10c0/b3b0e334288ddb407c7b3357ca67dbee75ee22db242ca7c56fe27db4e1a31989cb8af48a84dd401deb787fe10cc6b2ab1ee82dc4783be87ededbe3d53c79c70d @@ -1882,6 +1927,69 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/expect@npm:2.0.3" + dependencies: + "@vitest/spy": "npm:2.0.3" + "@vitest/utils": "npm:2.0.3" + chai: "npm:^5.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/bc8dead850a8aeb84a0d5d8620e1437752cbfe10908c2d5ec9f80fc6d9c387d70c964abfd2d6caf76da2882022c0dd05b0fa09b7c2a44d65abdde2b6c73517fe + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:2.0.3, @vitest/pretty-format@npm:^2.0.3": + version: 2.0.3 + resolution: "@vitest/pretty-format@npm:2.0.3" + dependencies: + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/217fd176fa4d1e64e04bc6a187d146381e99921f46007f98f7132d0e31e2c14b9c6d050a150331b3368ee8004bbeab5b1b7d477522a4e4d71ad822d046debc16 + languageName: node + linkType: hard + +"@vitest/runner@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/runner@npm:2.0.3" + dependencies: + "@vitest/utils": "npm:2.0.3" + pathe: "npm:^1.1.2" + checksum: 10c0/efbf646457c29268f0d370985d8cbfcfc7d181693dfc2e061dd05ce911f43592957f2c866cde1b5b2e3078ae5d74b94dc28453e1c70b80e8467440223431e863 + languageName: node + linkType: hard + +"@vitest/snapshot@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/snapshot@npm:2.0.3" + dependencies: + "@vitest/pretty-format": "npm:2.0.3" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + checksum: 10c0/dc7e2e8f60d40c308c487effe2cd94c42bffa795c2d8c740c30b880b451637763891609a052afe29f0c9872e71141d439cb03118595e4a461fe6b4877ae99878 + languageName: node + linkType: hard + +"@vitest/spy@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/spy@npm:2.0.3" + dependencies: + tinyspy: "npm:^3.0.0" + checksum: 10c0/4780aeed692c52756d70735b633ad58f201b2b8729b9e46c4cf968b8e4174e2c2cddd099de669019771bcd8e1ca32d0b9fa42d962e431fdf473b62393b9d2a0a + languageName: node + linkType: hard + +"@vitest/utils@npm:2.0.3": + version: 2.0.3 + resolution: "@vitest/utils@npm:2.0.3" + dependencies: + "@vitest/pretty-format": "npm:2.0.3" + estree-walker: "npm:^3.0.3" + loupe: "npm:^3.1.1" + tinyrainbow: "npm:^1.2.0" + checksum: 10c0/41b64c07814e7d576ebe7d11d277eb104a2aafb986497855a59f641b45fa53a30a2bfea525cd913e91b695f444a7a48b1f1e5909c27d5a989b0aea68f2242bd9 + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -1977,6 +2085,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + "async-lock@npm:1.4.1": version: 1.4.1 resolution: "async-lock@npm:1.4.1" @@ -1984,6 +2099,13 @@ __metadata: languageName: node linkType: hard +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + "babel-plugin-jsx-dom-expressions@npm:^0.37.9": version: 0.37.9 resolution: "babel-plugin-jsx-dom-expressions@npm:0.37.9" @@ -2104,6 +2226,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.1.1": + version: 5.1.1 + resolution: "chai@npm:5.1.1" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/e7f00e5881e3d5224f08fe63966ed6566bd9fdde175863c7c16dd5240416de9b34c4a0dd925f4fd64ad56256ca6507d32cf6131c49e1db65c62578eb31d4566c + languageName: node + linkType: hard + "chalk@npm:^2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -2134,6 +2269,13 @@ __metadata: languageName: node linkType: hard +"check-error@npm:^2.1.1": + version: 2.1.1 + resolution: "check-error@npm:2.1.1" + checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e + languageName: node + linkType: hard + "chokidar@npm:^3.5.3": version: 3.5.3 resolution: "chokidar@npm:3.5.3" @@ -2210,6 +2352,15 @@ __metadata: languageName: node linkType: hard +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + "commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -2224,7 +2375,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0": +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" dependencies: @@ -2293,6 +2444,15 @@ __metadata: languageName: node linkType: hard +"cssstyle@npm:^4.0.1": + version: 4.0.1 + resolution: "cssstyle@npm:4.0.1" + dependencies: + rrweb-cssom: "npm:^0.6.0" + checksum: 10c0/cadf9a8b23e11f4c6d63f21291096a0b0be868bd4ab9c799daa2c5b18330e39e5281605f01da906e901b42f742df0f3b3645af6465e83377ff7d15a88ee432a0 + languageName: node + linkType: hard + "csstype@npm:^3.0.7, csstype@npm:^3.1.0": version: 3.1.2 resolution: "csstype@npm:3.1.2" @@ -2300,6 +2460,16 @@ __metadata: languageName: node linkType: hard +"data-urls@npm:^5.0.0": + version: 5.0.0 + resolution: "data-urls@npm:5.0.0" + dependencies: + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + checksum: 10c0/1b894d7d41c861f3a4ed2ae9b1c3f0909d4575ada02e36d3d3bc584bdd84278e20709070c79c3b3bff7ac98598cb191eb3e86a89a79ea4ee1ef360e1694f92ad + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -2312,6 +2482,32 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.3.5": + version: 4.3.5 + resolution: "debug@npm:4.3.5" + dependencies: + ms: "npm:2.1.2" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/082c375a2bdc4f4469c99f325ff458adad62a3fc2c482d59923c260cb08152f34e2659f72b3767db8bb2f21ca81a60a42d1019605a412132d7b9f59363a005cc + languageName: node + linkType: hard + +"decimal.js@npm:^10.4.3": + version: 10.4.3 + resolution: "decimal.js@npm:10.4.3" + checksum: 10c0/6d60206689ff0911f0ce968d40f163304a6c1bc739927758e6efc7921cfa630130388966f16bf6ef6b838cb33679fbe8e7a78a2f3c478afce841fd55ac8fb8ee + languageName: node + linkType: hard + +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 + languageName: node + linkType: hard + "deep-object-diff@npm:^1.1.9": version: 1.1.9 resolution: "deep-object-diff@npm:1.1.9" @@ -2333,6 +2529,13 @@ __metadata: languageName: node linkType: hard +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + "dom-serializer@npm:^2.0.0": version: 2.0.0 resolution: "dom-serializer@npm:2.0.0" @@ -2415,7 +2618,7 @@ __metadata: languageName: node linkType: hard -"entities@npm:^4.2.0": +"entities@npm:^4.2.0, entities@npm:^4.4.0": version: 4.5.0 resolution: "entities@npm:4.5.0" checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 @@ -2898,6 +3101,15 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + "eval@npm:0.1.8": version: 0.1.8 resolution: "eval@npm:0.1.8" @@ -2908,6 +3120,23 @@ __metadata: languageName: node linkType: hard +"execa@npm:^8.0.1": + version: 8.0.1 + resolution: "execa@npm:8.0.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^8.0.1" + human-signals: "npm:^5.0.0" + is-stream: "npm:^3.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^5.1.0" + onetime: "npm:^6.0.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^3.0.0" + checksum: 10c0/2c52d8775f5bf103ce8eec9c7ab3059909ba350a5164744e9947ed14a53f51687c040a250bda833f906d1283aa8803975b84e6c8f7a7c42f99dc8ef80250d1af + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -2966,6 +3195,17 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.0": + version: 4.0.0 + resolution: "form-data@npm:4.0.0" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + mime-types: "npm:^2.1.12" + checksum: 10c0/cb6f3ac49180be03ff07ba3ff125f9eba2ff0b277fb33c7fc47569fc5e616882c5b1c69b9904c4c4187e97dd0419dd03b134174756f296dec62041e6527e2c6e + languageName: node + linkType: hard + "fs-extra@npm:^10.0.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -3035,6 +3275,20 @@ __metadata: languageName: node linkType: hard +"get-func-name@npm:^2.0.1": + version: 2.0.2 + resolution: "get-func-name@npm:2.0.2" + checksum: 10c0/89830fd07623fa73429a711b9daecdb304386d237c71268007f788f113505ef1d4cc2d0b9680e072c5082490aec9df5d7758bf5ac6f1c37062855e8e3dc0b9df + languageName: node + linkType: hard + +"get-stream@npm:^8.0.1": + version: 8.0.1 + resolution: "get-stream@npm:8.0.1" + checksum: 10c0/5c2181e98202b9dae0bb4a849979291043e5892eb40312b47f0c22b9414fc9b28a3b6063d2375705eb24abc41ecf97894d9a51f64ff021511b504477b27b4290 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -3087,6 +3341,15 @@ __metadata: languageName: node linkType: hard +"html-encoding-sniffer@npm:^4.0.0": + version: 4.0.0 + resolution: "html-encoding-sniffer@npm:4.0.0" + dependencies: + whatwg-encoding: "npm:^3.1.1" + checksum: 10c0/523398055dc61ac9b34718a719cb4aa691e4166f29187e211e1607de63dc25ac7af52ca7c9aead0c4b3c0415ffecb17326396e1202e2e86ff4bca4c0ee4c6140 + languageName: node + linkType: hard + "html-entities@npm:2.3.3": version: 2.3.3 resolution: "html-entities@npm:2.3.3" @@ -3111,6 +3374,16 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:^7.0.2": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10c0/4207b06a4580fb85dd6dff521f0abf6db517489e70863dca1a0291daa7f2d3d2d6015a57bd702af068ea5cf9f1f6ff72314f5f5b4228d299c0904135d2aef921 + languageName: node + linkType: hard + "https-proxy-agent@npm:^7.0.1": version: 7.0.2 resolution: "https-proxy-agent@npm:7.0.2" @@ -3121,7 +3394,24 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.6.2": +"https-proxy-agent@npm:^7.0.5": + version: 7.0.5 + resolution: "https-proxy-agent@npm:7.0.5" + dependencies: + agent-base: "npm:^7.0.2" + debug: "npm:4" + checksum: 10c0/2490e3acec397abeb88807db52cac59102d5ed758feee6df6112ab3ccd8325e8a1ce8bce6f4b66e5470eca102d31e425ace904242e4fa28dbe0c59c4bafa7b2c + languageName: node + linkType: hard + +"human-signals@npm:^5.0.0": + version: 5.0.0 + resolution: "human-signals@npm:5.0.0" + checksum: 10c0/5a9359073fe17a8b58e5a085e9a39a950366d9f00217c4ff5878bd312e09d80f460536ea6a3f260b5943a01fe55c158d1cea3fc7bee3d0520aeef04f6d915c82 + languageName: node + linkType: hard + +"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -3206,6 +3496,20 @@ __metadata: languageName: node linkType: hard +"is-potential-custom-element-name@npm:^1.0.1": + version: 1.0.1 + resolution: "is-potential-custom-element-name@npm:1.0.1" + checksum: 10c0/b73e2f22bc863b0939941d369486d308b43d7aef1f9439705e3582bfccaa4516406865e32c968a35f97a99396dac84e2624e67b0a16b0a15086a785e16ce7db9 + languageName: node + linkType: hard + +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 10c0/eb2f7127af02ee9aa2a0237b730e47ac2de0d4e76a4a905a50a11557f2339df5765eaea4ceb8029f1efa978586abe776908720bfcb1900c20c6ec5145f6f29d8 + languageName: node + linkType: hard + "is-what@npm:^4.1.8": version: 4.1.16 resolution: "is-what@npm:4.1.16" @@ -3263,6 +3567,40 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^24.1.1": + version: 24.1.1 + resolution: "jsdom@npm:24.1.1" + dependencies: + cssstyle: "npm:^4.0.1" + data-urls: "npm:^5.0.0" + decimal.js: "npm:^10.4.3" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^4.0.0" + http-proxy-agent: "npm:^7.0.2" + https-proxy-agent: "npm:^7.0.5" + is-potential-custom-element-name: "npm:^1.0.1" + nwsapi: "npm:^2.2.12" + parse5: "npm:^7.1.2" + rrweb-cssom: "npm:^0.7.1" + saxes: "npm:^6.0.0" + symbol-tree: "npm:^3.2.4" + tough-cookie: "npm:^4.1.4" + w3c-xmlserializer: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + whatwg-encoding: "npm:^3.1.1" + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + ws: "npm:^8.18.0" + xml-name-validator: "npm:^5.0.0" + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + checksum: 10c0/02d6bfe32f09f26329c0e53ad9f9883a3c671fc1f75725167d2089ca412f5b7ca85ff8aa62327d1cc6fc70ffbb3b18dfc7642c4b2096c2c8b19aaf9a48473eb3 + languageName: node + linkType: hard + "jsesc@npm:^2.5.1": version: 2.5.2 resolution: "jsesc@npm:2.5.2" @@ -3324,6 +3662,15 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^3.1.0, loupe@npm:^3.1.1": + version: 3.1.1 + resolution: "loupe@npm:3.1.1" + dependencies: + get-func-name: "npm:^2.0.1" + checksum: 10c0/99f88badc47e894016df0c403de846fedfea61154aadabbf776c8428dd59e8d8378007135d385d737de32ae47980af07d22ba7bec5ef7beebd721de9baa0a0af + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^9.1.1 || ^10.0.0": version: 10.1.0 resolution: "lru-cache@npm:10.1.0" @@ -3349,6 +3696,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.10": + version: 0.30.10 + resolution: "magic-string@npm:0.30.10" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.4.15" + checksum: 10c0/aa9ca17eae571a19bce92c8221193b6f93ee8511abb10f085e55ffd398db8e4c089a208d9eac559deee96a08b7b24d636ea4ab92f09c6cf42a7d1af51f7fd62b + languageName: node + linkType: hard + "make-fetch-happen@npm:^13.0.0": version: 13.0.0 resolution: "make-fetch-happen@npm:13.0.0" @@ -3400,6 +3756,13 @@ __metadata: languageName: node linkType: hard +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + languageName: node + linkType: hard + "merge2@npm:^1.3.0": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -3417,6 +3780,29 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 10c0/de9cc32be9996fd941e512248338e43407f63f6d497abe8441fa33447d922e927de54d4cc3c1a3c6d652857acd770389d5a3823f311a744132760ce2be15ccbf + languageName: node + linkType: hard + "minimatch@npm:^9.0.1": version: 9.0.3 resolution: "minimatch@npm:9.0.3" @@ -3585,6 +3971,7 @@ __metadata: async-lock: "npm:1.4.1" chart.js: "npm:4.4.3" fuse.js: "npm:7.0.0" + jsdom: "npm:^24.1.1" rollup-plugin-visualizer: "npm:5.12.0" solid-chartjs: "npm:1.3.10" solid-icons: "npm:1.1.0" @@ -3594,10 +3981,12 @@ __metadata: tippy.js: "npm:6.3.7" typescript: "npm:5.5.3" unplugin-fonts: "npm:1.1.1" + valibot: "npm:^0.36.0" vite: "npm:5.3.3" vite-plugin-compression: "npm:0.5.1" vite-plugin-solid: "npm:2.10.2" vite-plugin-solid-svg: "npm:0.8.1" + vitest: "npm:^2.0.3" languageName: unknown linkType: soft @@ -3646,6 +4035,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^5.1.0": + version: 5.3.0 + resolution: "npm-run-path@npm:5.3.0" + dependencies: + path-key: "npm:^4.0.0" + checksum: 10c0/124df74820c40c2eb9a8612a254ea1d557ddfab1581c3e751f825e3e366d9f00b0d76a3c94ecd8398e7f3eee193018622677e95816e8491f0797b21e30b2deba + languageName: node + linkType: hard + "nth-check@npm:^2.0.1": version: 2.1.1 resolution: "nth-check@npm:2.1.1" @@ -3655,6 +4053,22 @@ __metadata: languageName: node linkType: hard +"nwsapi@npm:^2.2.12": + version: 2.2.12 + resolution: "nwsapi@npm:2.2.12" + checksum: 10c0/95e9623d63df111405503df8c5d800e26f71675d319e2c9c70cddfa31e5ace1d3f8b6d98d354544fc156a1506d920ec291e303fab761e4f99296868e199a466e + languageName: node + linkType: hard + +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: "npm:^4.0.0" + checksum: 10c0/4eef7c6abfef697dd4479345a4100c382d73c149d2d56170a54a07418c50816937ad09500e1ed1e79d235989d073a9bade8557122aee24f0576ecde0f392bb6c + languageName: node + linkType: hard + "open@npm:^8.4.0": version: 8.4.2 resolution: "open@npm:8.4.2" @@ -3700,6 +4114,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.1.2": + version: 7.1.2 + resolution: "parse5@npm:7.1.2" + dependencies: + entities: "npm:^4.4.0" + checksum: 10c0/297d7af8224f4b5cb7f6617ecdae98eeaed7f8cbd78956c42785e230505d5a4f07cef352af10d3006fa5c1544b76b57784d3a22d861ae071bbc460c649482bf4 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -3714,6 +4137,13 @@ __metadata: languageName: node linkType: hard +"path-key@npm:^4.0.0": + version: 4.0.0 + resolution: "path-key@npm:4.0.0" + checksum: 10c0/794efeef32863a65ac312f3c0b0a99f921f3e827ff63afa5cb09a377e202c262b671f7b3832a4e64731003fa94af0263713962d317b9887bd1e0c48a342efba3 + languageName: node + linkType: hard + "path-scurry@npm:^1.10.1": version: 1.10.1 resolution: "path-scurry@npm:1.10.1" @@ -3731,6 +4161,20 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^1.1.2": + version: 1.1.2 + resolution: "pathe@npm:1.1.2" + checksum: 10c0/64ee0a4e587fb0f208d9777a6c56e4f9050039268faaaaecd50e959ef01bf847b7872785c36483fa5cdcdbdfdb31fef2ff222684d4fc21c330ab60395c681897 + languageName: node + linkType: hard + +"pathval@npm:^2.0.0": + version: 2.0.0 + resolution: "pathval@npm:2.0.0" + checksum: 10c0/602e4ee347fba8a599115af2ccd8179836a63c925c23e04bd056d0674a64b39e3a081b643cc7bc0b84390517df2d800a46fcc5598d42c155fe4977095c2f77c5 + languageName: node + linkType: hard + "picocolors@npm:^1.0.0": version: 1.0.0 resolution: "picocolors@npm:1.0.0" @@ -3820,6 +4264,27 @@ __metadata: languageName: node linkType: hard +"psl@npm:^1.1.33": + version: 1.9.0 + resolution: "psl@npm:1.9.0" + checksum: 10c0/6a3f805fdab9442f44de4ba23880c4eba26b20c8e8e0830eff1cb31007f6825dace61d17203c58bfe36946842140c97a1ba7f67bc63ca2d88a7ee052b65d97ab + languageName: node + linkType: hard + +"punycode@npm:^2.1.1, punycode@npm:^2.3.1": + version: 2.3.1 + resolution: "punycode@npm:2.3.1" + checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 + languageName: node + linkType: hard + +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10c0/3258bc3dbdf322ff2663619afe5947c7926a6ef5fb78ad7d384602974c467fadfc8272af44f5eb8cddd0d011aae8fabf3a929a8eee4b86edcc0a21e6bd10f9aa + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -3857,6 +4322,13 @@ __metadata: languageName: node linkType: hard +"requires-port@npm:^1.0.0": + version: 1.0.0 + resolution: "requires-port@npm:1.0.0" + checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -3964,6 +4436,20 @@ __metadata: languageName: node linkType: hard +"rrweb-cssom@npm:^0.6.0": + version: 0.6.0 + resolution: "rrweb-cssom@npm:0.6.0" + checksum: 10c0/3d9d90d53c2349ea9c8509c2690df5a4ef930c9cf8242aeb9425d4046f09d712bb01047e00da0e1c1dab5db35740b3d78fd45c3e7272f75d3724a563f27c30a3 + languageName: node + linkType: hard + +"rrweb-cssom@npm:^0.7.1": + version: 0.7.1 + resolution: "rrweb-cssom@npm:0.7.1" + checksum: 10c0/127b8ca6c8aac45e2755abbae6138d4a813b1bedc2caabf79466ae83ab3cfc84b5bfab513b7033f0aa4561c7753edf787d0dd01163ceacdee2e8eb1b6bf7237e + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -3980,6 +4466,15 @@ __metadata: languageName: node linkType: hard +"saxes@npm:^6.0.0": + version: 6.0.0 + resolution: "saxes@npm:6.0.0" + dependencies: + xmlchars: "npm:^2.2.0" + checksum: 10c0/3847b839f060ef3476eb8623d099aa502ad658f5c40fd60c105ebce86d244389b0d76fcae30f4d0c728d7705ceb2f7e9b34bb54717b6a7dbedaf5dad2d9a4b74 + languageName: node + linkType: hard + "semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -4032,7 +4527,14 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1": +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 @@ -4198,6 +4700,20 @@ __metadata: languageName: node linkType: hard +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^3.7.0": + version: 3.7.0 + resolution: "std-env@npm:3.7.0" + checksum: 10c0/60edf2d130a4feb7002974af3d5a5f3343558d1ccf8d9b9934d225c638606884db4a20d2fe6440a09605bca282af6b042ae8070a10490c0800d69e82e478f41e + languageName: node + linkType: hard + "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" @@ -4238,6 +4754,13 @@ __metadata: languageName: node linkType: hard +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 10c0/a771a17901427bac6293fd416db7577e2bc1c34a19d38351e9d5478c3c415f523f391003b42ed475f27e33a78233035df183525395f731d3bfb8cdcbd4da08ce + languageName: node + linkType: hard + "supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" @@ -4273,6 +4796,13 @@ __metadata: languageName: node linkType: hard +"symbol-tree@npm:^3.2.4": + version: 3.2.4 + resolution: "symbol-tree@npm:3.2.4" + checksum: 10c0/dfbe201ae09ac6053d163578778c53aa860a784147ecf95705de0cd23f42c851e1be7889241495e95c37cabb058edb1052f141387bef68f705afc8f9dd358509 + languageName: node + linkType: hard + "tar@npm:^6.1.11, tar@npm:^6.1.2": version: 6.2.0 resolution: "tar@npm:6.2.0" @@ -4287,6 +4817,34 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.8.0": + version: 2.8.0 + resolution: "tinybench@npm:2.8.0" + checksum: 10c0/5a9a642351fa3e4955e0cbf38f5674be5f3ba6730fd872fd23a5c953ad6c914234d5aba6ea41ef88820180a81829ceece5bd8d3967c490c5171bca1141c2f24d + languageName: node + linkType: hard + +"tinypool@npm:^1.0.0": + version: 1.0.0 + resolution: "tinypool@npm:1.0.0" + checksum: 10c0/71b20b9c54366393831c286a0772380c20f8cad9546d724c484edb47aea3228f274c58e98cf51d28c40869b39f5273209ef3ea94a9d2a23f8b292f4731cd3e4e + languageName: node + linkType: hard + +"tinyrainbow@npm:^1.2.0": + version: 1.2.0 + resolution: "tinyrainbow@npm:1.2.0" + checksum: 10c0/7f78a4b997e5ba0f5ecb75e7ed786f30bab9063716e7dff24dd84013fb338802e43d176cb21ed12480561f5649a82184cf31efb296601a29d38145b1cdb4c192 + languageName: node + linkType: hard + +"tinyspy@npm:^3.0.0": + version: 3.0.0 + resolution: "tinyspy@npm:3.0.0" + checksum: 10c0/eb0dec264aa5370efd3d29743825eb115ed7f1ef8a72a431e9a75d5c9e7d67e99d04b0d61d86b8cd70c79ec27863f241ad0317bc453f78762e0cbd76d2c332d0 + languageName: node + linkType: hard + "tippy.js@npm:6.3.7": version: 6.3.7 resolution: "tippy.js@npm:6.3.7" @@ -4312,6 +4870,27 @@ __metadata: languageName: node linkType: hard +"tough-cookie@npm:^4.1.4": + version: 4.1.4 + resolution: "tough-cookie@npm:4.1.4" + dependencies: + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: 10c0/aca7ff96054f367d53d1e813e62ceb7dd2eda25d7752058a74d64b7266fd07be75908f3753a32ccf866a2f997604b414cfb1916d6e7f69bc64d9d9939b0d6c45 + languageName: node + linkType: hard + +"tr46@npm:^5.0.0": + version: 5.0.0 + resolution: "tr46@npm:5.0.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10c0/1521b6e7bbc8adc825c4561480f9fe48eb2276c81335eed9fa610aa4c44a48a3221f78b10e5f18b875769eb3413e30efbf209ed556a17a42aa8d690df44b7bee + languageName: node + linkType: hard + "tslib@npm:^2.4.0": version: 2.6.2 resolution: "tslib@npm:2.6.2" @@ -4371,6 +4950,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.2.0": + version: 0.2.0 + resolution: "universalify@npm:0.2.0" + checksum: 10c0/cedbe4d4ca3967edf24c0800cfc161c5a15e240dac28e3ce575c689abc11f2c81ccc6532c8752af3b40f9120fb5e454abecd359e164f4f6aa44c29cd37e194fe + languageName: node + linkType: hard + "universalify@npm:^2.0.0": version: 2.0.1 resolution: "universalify@npm:2.0.1" @@ -4420,6 +5006,16 @@ __metadata: languageName: node linkType: hard +"url-parse@npm:^1.5.3": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10c0/bd5aa9389f896974beb851c112f63b466505a04b4807cea2e5a3b7092f6fbb75316f0491ea84e44f66fed55f1b440df5195d7e3a8203f64fcefa19d182f5be87 + languageName: node + linkType: hard + "valibot@npm:>=0.33.0 <1": version: 0.35.0 resolution: "valibot@npm:0.35.0" @@ -4427,6 +5023,13 @@ __metadata: languageName: node linkType: hard +"valibot@npm:^0.36.0": + version: 0.36.0 + resolution: "valibot@npm:0.36.0" + checksum: 10c0/deff84cdcdc324d5010c2087e553cd26b07752ac18912c9152eccd241b507a49a9ad77fed57501d45bcbef9bec6a7a6707b17d9bef8d35e681d45f098a70e466 + languageName: node + linkType: hard + "validate-html-nesting@npm:^1.2.1": version: 1.2.2 resolution: "validate-html-nesting@npm:1.2.2" @@ -4434,6 +5037,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:2.0.3": + version: 2.0.3 + resolution: "vite-node@npm:2.0.3" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.3.5" + pathe: "npm:^1.1.2" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/a1bcc110aeb49e79a50ae0df41ca692d39e0d992702f7c5b095c969f622eb72636543bed79efb7131fdedaa4c44a6c9c19daf6fca909240acc1f27f79b978c11 + languageName: node + linkType: hard + "vite-node@npm:^0.28.5": version: 0.28.5 resolution: "vite-node@npm:0.28.5" @@ -4578,6 +5196,46 @@ __metadata: languageName: node linkType: hard +"vite@npm:^5.0.0": + version: 5.3.4 + resolution: "vite@npm:5.3.4" + dependencies: + esbuild: "npm:^0.21.3" + fsevents: "npm:~2.3.3" + postcss: "npm:^8.4.39" + rollup: "npm:^4.13.0" + peerDependencies: + "@types/node": ^18.0.0 || >=20.0.0 + less: "*" + lightningcss: ^1.21.0 + sass: "*" + stylus: "*" + sugarss: "*" + terser: ^5.4.0 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/604a1c8698bcf09d6889533c552f20137c80cb5027e9e7ddf6215d51e3df763414f8712168c22b3c8c16383aff9447094c05f21d7cca3c115874ff9d12e1538e + languageName: node + linkType: hard + "vitefu@npm:^0.2.5": version: 0.2.5 resolution: "vitefu@npm:0.2.5" @@ -4590,6 +5248,71 @@ __metadata: languageName: node linkType: hard +"vitest@npm:^2.0.3": + version: 2.0.3 + resolution: "vitest@npm:2.0.3" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@vitest/expect": "npm:2.0.3" + "@vitest/pretty-format": "npm:^2.0.3" + "@vitest/runner": "npm:2.0.3" + "@vitest/snapshot": "npm:2.0.3" + "@vitest/spy": "npm:2.0.3" + "@vitest/utils": "npm:2.0.3" + chai: "npm:^5.1.1" + debug: "npm:^4.3.5" + execa: "npm:^8.0.1" + magic-string: "npm:^0.30.10" + pathe: "npm:^1.1.2" + std-env: "npm:^3.7.0" + tinybench: "npm:^2.8.0" + tinypool: "npm:^1.0.0" + tinyrainbow: "npm:^1.2.0" + vite: "npm:^5.0.0" + vite-node: "npm:2.0.3" + why-is-node-running: "npm:^2.2.2" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/node": ^18.0.0 || >=20.0.0 + "@vitest/browser": 2.0.3 + "@vitest/ui": 2.0.3 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/1801ec31eb144063d14a03d054ff573869732dcaf69abd4fefdabe011d183599a7493e49d8e180b29808675309814421c4a12271fb140c708e7c9f68c4a37a3c + languageName: node + linkType: hard + +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" + dependencies: + xml-name-validator: "npm:^5.0.0" + checksum: 10c0/8712774c1aeb62dec22928bf1cdfd11426c2c9383a1a63f2bcae18db87ca574165a0fbe96b312b73652149167ac6c7f4cf5409f2eb101d9c805efe0e4bae798b + languageName: node + linkType: hard + +"webidl-conversions@npm:^7.0.0": + version: 7.0.0 + resolution: "webidl-conversions@npm:7.0.0" + checksum: 10c0/228d8cb6d270c23b0720cb2d95c579202db3aaf8f633b4e9dd94ec2000a04e7e6e43b76a94509cdb30479bd00ae253ab2371a2da9f81446cc313f89a4213a2c4 + languageName: node + linkType: hard + "webpack-sources@npm:^3.2.3": version: 3.2.3 resolution: "webpack-sources@npm:3.2.3" @@ -4604,6 +5327,32 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^3.1.1": + version: 3.1.1 + resolution: "whatwg-encoding@npm:3.1.1" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10c0/273b5f441c2f7fda3368a496c3009edbaa5e43b71b09728f90425e7f487e5cef9eb2b846a31bd760dd8077739c26faf6b5ca43a5f24033172b003b72cf61a93e + languageName: node + linkType: hard + +"whatwg-mimetype@npm:^4.0.0": + version: 4.0.0 + resolution: "whatwg-mimetype@npm:4.0.0" + checksum: 10c0/a773cdc8126b514d790bdae7052e8bf242970cebd84af62fb2f35a33411e78e981f6c0ab9ed1fe6ec5071b09d5340ac9178e05b52d35a9c4bcf558ba1b1551df + languageName: node + linkType: hard + +"whatwg-url@npm:^14.0.0": + version: 14.0.0 + resolution: "whatwg-url@npm:14.0.0" + dependencies: + tr46: "npm:^5.0.0" + webidl-conversions: "npm:^7.0.0" + checksum: 10c0/ac32e9ba9d08744605519bbe9e1371174d36229689ecc099157b6ba102d4251a95e81d81f3d80271eb8da182eccfa65653f07f0ab43ea66a6934e643fd091ba9 + languageName: node + linkType: hard + "which@npm:^2.0.1": version: 2.0.2 resolution: "which@npm:2.0.2" @@ -4626,6 +5375,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.2.2": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" @@ -4648,6 +5409,35 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.18.0 + resolution: "ws@npm:8.18.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/25eb33aff17edcb90721ed6b0eb250976328533ad3cd1a28a274bd263682e7296a6591ff1436d6cbc50fa67463158b062f9d1122013b361cec99a05f84680e06 + languageName: node + linkType: hard + +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10c0/3fcf44e7b73fb18be917fdd4ccffff3639373c7cb83f8fc35df6001fecba7942f1dbead29d91ebb8315e2f2ff786b508f0c9dc0215b6353f9983c6b7d62cb1f5 + languageName: node + linkType: hard + +"xmlchars@npm:^2.2.0": + version: 2.2.0 + resolution: "xmlchars@npm:2.2.0" + checksum: 10c0/b64b535861a6f310c5d9bfa10834cf49127c71922c297da9d4d1b45eeaae40bf9b4363275876088fbe2667e5db028d2cd4f8ee72eed9bede840a67d57dab7593 + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" From 7139c267d4f7d7c7bd1fbae4dfd2b5db7cf75c9a Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Mon, 22 Jul 2024 16:44:11 +0900 Subject: [PATCH 03/51] omit destructuring --- .../src/features/repository/schema/repositorySchema.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dashboard/src/features/repository/schema/repositorySchema.ts b/dashboard/src/features/repository/schema/repositorySchema.ts index 9fd378eb..d922f1fd 100644 --- a/dashboard/src/features/repository/schema/repositorySchema.ts +++ b/dashboard/src/features/repository/schema/repositorySchema.ts @@ -113,7 +113,8 @@ export const convertCreateRepositoryInput = ( throw new Error("The type of input passed to convertCreateRepositoryInput must be 'create'") return { - ...input, + name: input.name, + url: input.url, auth: repositoryAuthSchemaToMessage(input.auth), } } @@ -207,7 +208,9 @@ export const convertUpdateRepositoryInput = ( throw new Error("The type of input passed to convertCreateRepositoryInput must be 'create'") return { - ...input, + id: input.id, + name: input.name, + url: input.url, auth: input.auth ? repositoryAuthSchemaToMessage(input.auth) : undefined, ownerIds: input.ownerIds ? { From bf0a6f1427694dc1ffd14652a32ab838ce50f35f Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Mon, 22 Jul 2024 17:35:11 +0900 Subject: [PATCH 04/51] rename schema types -> input --- .../repository/components/AuthConfigForm.tsx | 4 ++-- .../repository/components/AuthMethodField.tsx | 6 +++--- .../repository/components/CreateForm.tsx | 4 ++-- .../components/GeneralConfigForm.tsx | 4 ++-- .../repository/schema/repositorySchema.ts | 18 +++++++++--------- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/dashboard/src/features/repository/components/AuthConfigForm.tsx b/dashboard/src/features/repository/components/AuthConfigForm.tsx index ac625a55..341bbf19 100644 --- a/dashboard/src/features/repository/components/AuthConfigForm.tsx +++ b/dashboard/src/features/repository/components/AuthConfigForm.tsx @@ -7,7 +7,7 @@ import { TextField } from '/@/components/UI/TextField' import FormBox from '/@/components/layouts/FormBox' import { useRepositoryForm } from '/@/features/repository/provider/repositoryFormProvider' import { - type CreateOrUpdateRepositorySchema, + type CreateOrUpdateRepositoryInput, convertUpdateRepositoryInput, updateRepositoryFormInitialValues, } from '/@/features/repository/schema/repositorySchema' @@ -33,7 +33,7 @@ const AuthConfigForm: Component = (props) => { ) }) - const handleSubmit: SubmitHandler = async (values) => { + const handleSubmit: SubmitHandler = async (values) => { try { await client.updateRepository(convertUpdateRepositoryInput(values)) toast.success('リポジトリの設定を更新しました') diff --git a/dashboard/src/features/repository/components/AuthMethodField.tsx b/dashboard/src/features/repository/components/AuthMethodField.tsx index ebbe6766..09afd8eb 100644 --- a/dashboard/src/features/repository/components/AuthMethodField.tsx +++ b/dashboard/src/features/repository/components/AuthMethodField.tsx @@ -9,7 +9,7 @@ import { RadioGroup, type RadioOption } from '/@/components/templates/RadioGroup import { client, systemInfo } from '/@/libs/api' import { colorVars, textVars } from '/@/theme' import { TextField } from '../../../components/UI/TextField' -import type { CreateOrUpdateRepositorySchema } from '../schema/repositorySchema' +import type { CreateOrUpdateRepositoryInput } from '../schema/repositorySchema' const SshKeyContainer = styled('div', { base: { @@ -58,11 +58,11 @@ const VisibilityButton = styled('button', { }) type Props = { - formStore: FormStore + formStore: FormStore readonly?: boolean } -const authMethods: RadioOption['method']>[] = [ +const authMethods: RadioOption['method']>[] = [ { label: '認証を使用しない', value: 'none' }, { label: 'BASIC認証', value: 'basic' }, { label: 'SSH公開鍵認証', value: 'ssh' }, diff --git a/dashboard/src/features/repository/components/CreateForm.tsx b/dashboard/src/features/repository/components/CreateForm.tsx index f0cb3f13..52b8b1e2 100644 --- a/dashboard/src/features/repository/components/CreateForm.tsx +++ b/dashboard/src/features/repository/components/CreateForm.tsx @@ -8,7 +8,7 @@ import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' import { TextField } from '/@/components/UI/TextField' import { useRepositoryForm } from '/@/features/repository/provider/repositoryFormProvider' import { - type CreateOrUpdateRepositorySchema, + type CreateOrUpdateRepositoryInput, convertCreateRepositoryInput, createRepositoryFormInitialValues, } from '/@/features/repository/schema/repositorySchema' @@ -72,7 +72,7 @@ const CreateForm: Component = () => { } }) - const handleSubmit: SubmitHandler = async (values) => { + const handleSubmit: SubmitHandler = async (values) => { try { const res = await client.createRepository(convertCreateRepositoryInput(values)) toast.success('リポジトリを登録しました') diff --git a/dashboard/src/features/repository/components/GeneralConfigForm.tsx b/dashboard/src/features/repository/components/GeneralConfigForm.tsx index f326053f..98c6e9d1 100644 --- a/dashboard/src/features/repository/components/GeneralConfigForm.tsx +++ b/dashboard/src/features/repository/components/GeneralConfigForm.tsx @@ -7,7 +7,7 @@ import { TextField } from '/@/components/UI/TextField' import FormBox from '/@/components/layouts/FormBox' import { useRepositoryForm } from '/@/features/repository/provider/repositoryFormProvider' import { - type CreateOrUpdateRepositorySchema, + type CreateOrUpdateRepositoryInput, convertUpdateRepositoryInput, updateRepositoryFormInitialValues, } from '/@/features/repository/schema/repositorySchema' @@ -32,7 +32,7 @@ const GeneralConfigForm: Component = (props) => { ) }) - const handleSubmit: SubmitHandler = async (values) => { + const handleSubmit: SubmitHandler = async (values) => { try { await client.updateRepository(convertUpdateRepositoryInput(values)) toast.success('リポジトリ名を更新しました') diff --git a/dashboard/src/features/repository/schema/repositorySchema.ts b/dashboard/src/features/repository/schema/repositorySchema.ts index d922f1fd..c07bb280 100644 --- a/dashboard/src/features/repository/schema/repositorySchema.ts +++ b/dashboard/src/features/repository/schema/repositorySchema.ts @@ -58,9 +58,9 @@ const createRepositorySchema = v.pipe( ['url'], ), ) -type CreateRepositorySchema = v.InferInput +type CreateRepositoryInput = v.InferInput -export const createRepositoryFormInitialValues = (): CreateOrUpdateRepositorySchema => +export const createRepositoryFormInitialValues = (): CreateOrUpdateRepositoryInput => ({ type: 'create', name: '', @@ -71,7 +71,7 @@ export const createRepositoryFormInitialValues = (): CreateOrUpdateRepositorySch none: {}, }, }, - }) satisfies CreateRepositorySchema + }) satisfies CreateRepositoryInput /** valobot schema -> protobuf message */ const repositoryAuthSchemaToMessage = ( @@ -107,7 +107,7 @@ const repositoryAuthSchemaToMessage = ( /** valobot schema -> protobuf message */ export const convertCreateRepositoryInput = ( - input: CreateOrUpdateRepositorySchema, + input: CreateOrUpdateRepositoryInput, ): PartialMessage => { if (input.type !== 'create') throw new Error("The type of input passed to convertCreateRepositoryInput must be 'create'") @@ -148,7 +148,7 @@ export const updateRepositorySchema = v.pipe( ), ) -type UpdateRepositorySchema = v.InferInput +type UpdateRepositoryInput = v.InferInput /** protobuf message -> valobot schema */ const authMethodToAuthConfig = (method: Repository_AuthMethod): v.InferInput => { @@ -189,7 +189,7 @@ const authMethodToAuthConfig = (method: Repository_AuthMethod): v.InferInput { +export const updateRepositoryFormInitialValues = (input: Repository): CreateOrUpdateRepositoryInput => { return { type: 'update', id: input.id, @@ -197,12 +197,12 @@ export const updateRepositoryFormInitialValues = (input: Repository): CreateOrUp url: input.url, auth: authMethodToAuthConfig(input.authMethod), ownerIds: input.ownerIds, - } satisfies UpdateRepositorySchema + } satisfies UpdateRepositoryInput } /** valobot schema -> protobuf message */ export const convertUpdateRepositoryInput = ( - input: CreateOrUpdateRepositorySchema, + input: CreateOrUpdateRepositoryInput, ): PartialMessage => { if (input.type !== 'update') throw new Error("The type of input passed to convertCreateRepositoryInput must be 'create'") @@ -222,4 +222,4 @@ export const convertUpdateRepositoryInput = ( export const createOrUpdateRepositorySchema = v.variant('type', [createRepositorySchema, updateRepositorySchema]) -export type CreateOrUpdateRepositorySchema = v.InferInput +export type CreateOrUpdateRepositoryInput = v.InferInput From c6a39fe6142c33b614969a38788af5dfb75ab308 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Mon, 22 Jul 2024 20:13:22 +0900 Subject: [PATCH 05/51] add test case --- .../schema/repositorySchema.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/dashboard/src/features/repository/schema/repositorySchema.test.ts b/dashboard/src/features/repository/schema/repositorySchema.test.ts index 011dba08..13c218c8 100644 --- a/dashboard/src/features/repository/schema/repositorySchema.test.ts +++ b/dashboard/src/features/repository/schema/repositorySchema.test.ts @@ -147,6 +147,37 @@ describe('Update Repository Schema', () => { expect(validator(base)).toEqual(expect.objectContaining({ success: true })) }) + test('ok: update name', () => { + expect( + validator({ + id: base.id, + type: base.type, + name: base.name, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ok: update auth config', () => { + expect( + validator({ + id: base.id, + type: base.type, + url: base.url, + auth: base.auth, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ok: update ownerIds', () => { + expect( + validator({ + id: base.id, + type: base.type, + ownerIds: base.ownerIds, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + test('ng: empty id', () => { expect( validator({ @@ -163,4 +194,31 @@ describe('Update Repository Schema', () => { }), ]) }) + + test("ng: auth method is basic, but the URL starts with 'http://'", () => { + expect( + validator({ + ...base, + url: 'http://example.com/test/test.git', + auth: { + method: 'basic', + value: { + basic: { + username: 'test name', + password: 'test password', + }, + }, + }, + }).issues, + ).toEqual([ + expect.objectContaining({ + message: 'Basic認証を使用する場合、URLはhttps://から始まる必要があります', + path: [ + expect.objectContaining({ + key: 'url', + }), + ], + }), + ]) + }) }) From f1f71d0d10355cad93809683ddb4cab8b466c015 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Tue, 23 Jul 2024 01:38:23 +0900 Subject: [PATCH 06/51] =?UTF-8?q?wip=20application=20form=E3=81=AEvalibot?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/package.json | 1 + .../application/components/BranchField.tsx | 47 ++ .../components/GeneralConfigForm.tsx | 122 +++++ .../provider/applicationFormProvider.tsx | 6 + .../schema/applicationSchema.test.ts | 364 +++++++++++++ .../application/schema/applicationSchema.ts | 505 ++++++++++++++++++ .../repository/components/AuthConfigForm.tsx | 4 +- .../components/GeneralConfigForm.tsx | 4 +- dashboard/src/libs/useFormContext.tsx | 7 +- .../src/pages/apps/[id]/settings/general.tsx | 80 +-- dashboard/yarn.lock | 8 + 11 files changed, 1071 insertions(+), 77 deletions(-) create mode 100644 dashboard/src/features/application/components/BranchField.tsx create mode 100644 dashboard/src/features/application/components/GeneralConfigForm.tsx create mode 100644 dashboard/src/features/application/provider/applicationFormProvider.tsx create mode 100644 dashboard/src/features/application/schema/applicationSchema.test.ts create mode 100644 dashboard/src/features/application/schema/applicationSchema.ts diff --git a/dashboard/package.json b/dashboard/package.json index af3b2b37..85e72d11 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -56,6 +56,7 @@ "solid-tippy": "0.2.1", "solid-toast": "0.5.0", "tippy.js": "6.3.7", + "ts-pattern": "^5.2.0", "valibot": "^0.36.0" }, "packageManager": "yarn@4.1.1+sha512.ec40d0639bb307441b945d9467139cbb88d14394baac760b52eca038b330d16542d66fef61574271534ace5a200518dabf3b53a85f1f9e4bfa37141b538a9590" diff --git a/dashboard/src/features/application/components/BranchField.tsx b/dashboard/src/features/application/components/BranchField.tsx new file mode 100644 index 00000000..44066d29 --- /dev/null +++ b/dashboard/src/features/application/components/BranchField.tsx @@ -0,0 +1,47 @@ +import { Field } from '@modular-forms/solid' +import type { Component } from 'solid-js' +import type { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { ComboBox } from '/@/components/templates/Select' +import { useBranches } from '/@/libs/branchesSuggestion' +import { useApplicationForm } from '../provider/applicationFormProvider' + +type Props = { + repo: Repository + hasPermission: boolean +} + +const BranchField: Component = (props) => { + const { formStore } = useApplicationForm() + const branches = useBranches(() => props.repo.id) + + return ( + + {(field, fieldProps) => ( + +
Gitブランチ名またはRef
+
入力欄をクリックして候補を表示
+ + ), + }, + }} + {...fieldProps} + options={branches().map((branch) => ({ + label: branch, + value: branch, + }))} + value={field.value} + error={field.error} + readOnly={!props.hasPermission} + /> + )} +
+ ) +} + +export default BranchField diff --git a/dashboard/src/features/application/components/GeneralConfigForm.tsx b/dashboard/src/features/application/components/GeneralConfigForm.tsx new file mode 100644 index 00000000..2b45e6a2 --- /dev/null +++ b/dashboard/src/features/application/components/GeneralConfigForm.tsx @@ -0,0 +1,122 @@ +import { Field, Form, type SubmitHandler, reset } from '@modular-forms/solid' +import { type Component, Show, createEffect, untrack } from 'solid-js' +import toast from 'solid-toast' +import type { Application, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import { TextField } from '/@/components/UI/TextField' +import FormBox from '/@/components/layouts/FormBox' +import { client, handleAPIError } from '/@/libs/api' +import { useApplicationForm } from '../provider/applicationFormProvider' +import { + type CreateOrUpdateApplicationSchema, + convertUpdateApplicationInput, + updateApplicationFormInitialValues, +} from '../schema/applicationSchema' +import BranchField from './BranchField' + +type Props = { + app: Application + repo: Repository + refetchApp: () => Promise + hasPermission: boolean +} + +const GeneralConfigForm: Component = (props) => { + const { formStore } = useApplicationForm() + + // reset forms when props.app changed + createEffect(() => { + reset( + untrack(() => formStore), + { + initialValues: updateApplicationFormInitialValues(props.app), + }, + ) + }) + + const handleSubmit: SubmitHandler = async (values) => { + try { + await client.updateRepository(convertUpdateApplicationInput(values)) + toast.success('アプリケーション設定を更新しました') + props.refetchApp() + // 非同期でビルドが開始されるので1秒程度待ってから再度リロード + setTimeout(props.refetchApp, 1000) + } catch (e) { + handleAPIError(e, 'アプリケーション設定の更新に失敗しました') + } + } + + const discardChanges = () => { + reset(formStore) + } + + return ( +
+ + {() => null} + + + {() => null} + + + + + {(field, fieldProps) => ( + + )} + + + {(field, fieldProps) => ( + + )} + + + + + + + + + + +
+ ) +} + +export default GeneralConfigForm diff --git a/dashboard/src/features/application/provider/applicationFormProvider.tsx b/dashboard/src/features/application/provider/applicationFormProvider.tsx new file mode 100644 index 00000000..5a18181c --- /dev/null +++ b/dashboard/src/features/application/provider/applicationFormProvider.tsx @@ -0,0 +1,6 @@ +import { useFormContext } from '../../../libs/useFormContext' +import { createOrUpdateApplicationSchema } from '../schema/applicationSchema' + +export const { FormProvider: ApplicationFormProvider, useForm: useApplicationForm } = useFormContext( + createOrUpdateApplicationSchema, +) diff --git a/dashboard/src/features/application/schema/applicationSchema.test.ts b/dashboard/src/features/application/schema/applicationSchema.test.ts new file mode 100644 index 00000000..c32b703b --- /dev/null +++ b/dashboard/src/features/application/schema/applicationSchema.test.ts @@ -0,0 +1,364 @@ +import { safeParse } from 'valibot' +import { describe, expect, test } from 'vitest' +import { AuthenticationType, PortPublicationProtocol } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { createOrUpdateApplicationSchema } from './applicationSchema' + +const validator = (input: unknown) => safeParse(createOrUpdateApplicationSchema, input) + +describe('Create Application Schema', () => { + const baseConfig = { + deployConfig: { + type: 'runtime', + value: { + runtime: { + useMariadb: false, + useMongodb: false, + entrypoint: '.', + command: "echo 'test'", + }, + }, + }, + buildConfig: { + type: 'buildpack', + value: { + buildpack: { + context: '', + }, + }, + }, + } + + const baseWebsite = { + state: 'added', + subdomain: 'example', + domain: 'example.com', + pathPrefix: '', + stripPrefix: false, + https: true, + h2c: false, + httpPort: 80, + authentication: AuthenticationType.OFF, + } + + const basePortPublication = { + internetPort: 80, + applicationPort: 3000, + protocol: PortPublicationProtocol.TCP, + } + + const base = { + type: 'create', + name: 'test application', + repositoryId: 'testRepoId', + refName: 'main', + config: baseConfig, + websites: [baseWebsite], + portPublications: [basePortPublication], + startOnCreate: true, + } + + test('ok: valid input (runtime config)', () => { + expect( + validator({ + ...base, + config: { + deployConfig: { + type: 'runtime', + value: { + runtime: { + useMariadb: false, + useMongodb: false, + entrypoint: '.', + command: "echo 'test'", + }, + }, + }, + buildConfig: { + type: 'buildpack', + value: { + buildpack: { + context: '', + }, + }, + }, + }, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ok: valid input (static config)', () => { + expect( + validator({ + ...base, + config: { + deployConfig: { + type: 'static', + value: { + static: { + artifactPath: '.', + spa: false, + }, + }, + }, + buildConfig: { + type: 'buildpack', + value: { + buildpack: { + context: '', + }, + }, + }, + }, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ok: valid input (buildpack config)', () => { + expect( + validator({ + ...base, + config: { + deployConfig: { + type: 'runtime', + value: { + runtime: { + useMariadb: false, + useMongodb: false, + entrypoint: '.', + command: "echo 'test'", + }, + }, + }, + buildConfig: { + type: 'buildpack', + value: { + buildpack: { + context: '', + }, + }, + }, + }, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ok: valid input (dockerfile config)', () => { + expect( + validator({ + ...base, + config: { + deployConfig: { + type: 'runtime', + value: { + runtime: { + useMariadb: false, + useMongodb: false, + entrypoint: '.', + command: "echo 'test'", + }, + }, + }, + buildConfig: { + type: 'dockerfile', + value: { + dockerfile: { + dockerfileName: 'Dockerfile', + context: '', + }, + }, + }, + }, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + test('ok: valid input (cmd config)', () => { + expect( + validator({ + ...base, + config: { + deployConfig: { + type: 'runtime', + value: { + runtime: { + useMariadb: false, + useMongodb: false, + entrypoint: '.', + command: "echo 'test'", + }, + }, + }, + buildConfig: { + type: 'cmd', + value: { + cmd: { + baseImage: 'node:22-alpine', + buildCmd: 'npm run build', + }, + }, + }, + }, + }), + ).toEqual(expect.objectContaining({ success: true })) + }) + + // test("ng: empty name", () => { + // expect( + // validator({ + // ...base, + // name: "", + // }).issues, + // ).toEqual([ + // expect.objectContaining({ + // message: "Enter Repository Name", + // path: [ + // expect.objectContaining({ + // key: "name", + // }), + // ], + // }), + // ]); + // }); + + // test("ng: empty url", () => { + // expect( + // validator({ + // ...base, + // url: "", + // }).issues, + // ).toEqual([ + // expect.objectContaining({ + // message: "Enter Repository URL", + // path: [ + // expect.objectContaining({ + // key: "url", + // }), + // ], + // }), + // ]); + // }); + + // test("ng: auth method is basic, but the URL starts with 'http://'", () => { + // expect( + // validator({ + // ...base, + // url: "http://example.com/test/test.git", + // auth: { + // method: "basic", + // value: { + // basic: { + // username: "test name", + // password: "test password", + // }, + // }, + // }, + // }).issues, + // ).toEqual([ + // expect.objectContaining({ + // message: + // "Basic認証を使用する場合、URLはhttps://から始まる必要があります", + // path: [ + // expect.objectContaining({ + // key: "url", + // }), + // ], + // }), + // ]); + // }); +}) + +// describe("Update Repository Schema", () => { +// const base = { +// id: "testRepositoryId", +// type: "update", +// name: "test repository", +// url: "https://example.com/test/test.git", +// auth: { +// method: "none", +// value: { +// none: {}, +// }, +// }, +// ownerIds: ["owner1"], +// }; + +// test("ok: valid input", () => { +// expect(validator(base)).toEqual(expect.objectContaining({ success: true })); +// }); + +// test("ok: update name", () => { +// expect( +// validator({ +// id: base.id, +// type: base.type, +// name: base.name, +// }), +// ).toEqual(expect.objectContaining({ success: true })); +// }); + +// test("ok: update auth config", () => { +// expect( +// validator({ +// id: base.id, +// type: base.type, +// url: base.url, +// auth: base.auth, +// }), +// ).toEqual(expect.objectContaining({ success: true })); +// }); + +// test("ok: update ownerIds", () => { +// expect( +// validator({ +// id: base.id, +// type: base.type, +// ownerIds: base.ownerIds, +// }), +// ).toEqual(expect.objectContaining({ success: true })); +// }); + +// test("ng: empty id", () => { +// expect( +// validator({ +// ...base, +// id: undefined, +// }).issues, +// ).toEqual([ +// expect.objectContaining({ +// path: [ +// expect.objectContaining({ +// key: "id", +// }), +// ], +// }), +// ]); +// }); + +// test("ng: auth method is basic, but the URL starts with 'http://'", () => { +// expect( +// validator({ +// ...base, +// url: "http://example.com/test/test.git", +// auth: { +// method: "basic", +// value: { +// basic: { +// username: "test name", +// password: "test password", +// }, +// }, +// }, +// }).issues, +// ).toEqual([ +// expect.objectContaining({ +// message: +// "Basic認証を使用する場合、URLはhttps://から始まる必要があります", +// path: [ +// expect.objectContaining({ +// key: "url", +// }), +// ], +// }), +// ]); +// }); +// }); diff --git a/dashboard/src/features/application/schema/applicationSchema.ts b/dashboard/src/features/application/schema/applicationSchema.ts new file mode 100644 index 00000000..ded26aa9 --- /dev/null +++ b/dashboard/src/features/application/schema/applicationSchema.ts @@ -0,0 +1,505 @@ +import type { PartialMessage } from '@bufbuild/protobuf' +import { match } from 'ts-pattern' +import * as v from 'valibot' +import { + type Application, + type ApplicationConfig, + AuthenticationType, + type AvailableDomain, + type CreateApplicationRequest, + type CreateWebsiteRequest, + PortPublicationProtocol, + type UpdateApplicationRequest, + type Website, +} from '/@/api/neoshowcase/protobuf/gateway_pb' +import { systemInfo } from '/@/libs/api' + +const runtimeConfigSchema = v.object({ + useMariadb: v.boolean(), + useMongodb: v.boolean(), + entrypoint: v.string(), + command: v.string(), +}) +const staticConfigSchema = v.object({ + artifactPath: v.pipe(v.string(), v.nonEmpty('Enter Artifact Path')), + spa: v.boolean(), +}) + +const buildpackConfigSchema = v.object({ + context: v.string(), +}) +const cmdConfigSchema = v.object({ + baseImage: v.string(), + buildCmd: v.string(), +}) +const dockerfileConfigSchema = v.object({ + dockerfileName: v.pipe(v.string(), v.nonEmpty('Enter Dockerfile Name')), + context: v.string(), +}) + +const applicationConfigSchema = v.object({ + deployConfig: v.pipe( + v.optional( + v.variant('type', [ + v.object({ + type: v.literal('runtime'), + value: v.object({ + runtime: runtimeConfigSchema, + }), + }), + v.object({ + type: v.literal('static'), + value: v.object({ + static: staticConfigSchema, + }), + }), + ]), + ), + // アプリ作成時には最初undefinedになっているが、submit時にはundefinedで無い必要がある + v.check((input) => !!input, 'Select Deploy Type'), + ), + buildConfig: v.pipe( + v.optional( + v.variant('type', [ + v.object({ + type: v.literal('buildpack'), + value: v.object({ + buildpack: buildpackConfigSchema, + }), + }), + v.object({ + type: v.literal('cmd'), + value: v.object({ + cmd: cmdConfigSchema, + }), + }), + v.object({ + type: v.literal('dockerfile'), + value: v.object({ + dockerfile: dockerfileConfigSchema, + }), + }), + ]), + ), + // アプリ作成時には最初undefinedになっているが、submit時にはundefinedで無い必要がある + v.check((input) => !!input, 'Select Build Type'), + ), +}) + +type ApplicationConfigInput = v.InferInput + +const createWebsiteSchema = v.object({ + state: v.union([v.literal('noChange'), v.literal('readyToChange'), v.literal('readyToDelete'), v.literal('added')]), + subdomain: v.string(), + domain: v.string(), + pathPrefix: v.string(), + stripPrefix: v.boolean(), + https: v.boolean(), + h2c: v.boolean(), + httpPort: v.pipe(v.number(), v.integer()), + authentication: v.enum(AuthenticationType), +}) + +const portPublicationSchema = v.object({ + internetPort: v.pipe(v.number(), v.integer()), + applicationPort: v.pipe(v.number(), v.integer()), + protocol: v.enum(PortPublicationProtocol), +}) + +// --- create application + +const createApplicationSchema = v.object({ + type: v.literal('create'), + name: v.pipe(v.string(), v.nonEmpty('Enter Application Name')), + repositoryId: v.string(), + refName: v.pipe(v.string(), v.nonEmpty('Enter Branch Name')), + config: applicationConfigSchema, + websites: v.array(createWebsiteSchema), + portPublications: v.array(portPublicationSchema), + startOnCreate: v.boolean(), +}) + +type CreateApplicationSchema = v.InferInput + +export const createApplicationFormInitialValues = (): CreateOrUpdateApplicationSchema => + ({ + type: 'create', + name: '', + repositoryId: '', + refName: '', + config: {}, + websites: [], + portPublications: [], + startOnCreate: false, + }) satisfies CreateApplicationSchema + +/** valobot schema -> protobuf message */ +const configSchemaToMessage = ( + input: v.InferInput, +): PartialMessage => { + return match([input.deployConfig, input.buildConfig]) + .returnType>() + .with( + [ + { + type: 'runtime', + }, + { + type: 'buildpack', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'runtimeBuildpack', + value: { + ...buildConfig.value.buildpack, + runtimeConfig: deployConfig.value.runtime, + }, + }, + } + }, + ) + .with( + [ + { + type: 'runtime', + }, + { + type: 'cmd', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'runtimeCmd', + value: { + ...buildConfig.value.cmd, + runtimeConfig: deployConfig.value.runtime, + }, + }, + } + }, + ) + .with( + [ + { + type: 'runtime', + }, + { + type: 'dockerfile', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'runtimeDockerfile', + value: { + ...buildConfig.value.dockerfile, + runtimeConfig: deployConfig.value.runtime, + }, + }, + } + }, + ) + .with( + [ + { + type: 'static', + }, + { + type: 'buildpack', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'staticBuildpack', + value: { + ...buildConfig.value.buildpack, + staticConfig: deployConfig.value.static, + }, + }, + } + }, + ) + .with( + [ + { + type: 'static', + }, + { + type: 'cmd', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'staticCmd', + value: { + ...buildConfig.value.cmd, + staticConfig: deployConfig.value.static, + }, + }, + } + }, + ) + .with( + [ + { + type: 'static', + }, + { + type: 'dockerfile', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'staticDockerfile', + value: { + ...buildConfig.value.dockerfile, + staticConfig: deployConfig.value.static, + }, + }, + } + }, + ) + .otherwise(() => ({ + buildConfig: { case: undefined }, + })) +} + +const createWebsiteSchemaToMessage = ( + input: v.InferInput, +): PartialMessage => { + const { domain, subdomain, ...rest } = input + + // wildcard domainならsubdomainとdomainを結合 + const fqdn = input.domain.startsWith('*') + ? `${input.subdomain}${input.domain.replace(/\*/g, '')}` + : // non-wildcard domainならdomainをそのまま使う + input.domain + + return { + ...rest, + fqdn, + } +} + +/** valobot schema -> protobuf message */ +export const convertCreateApplicationInput = ( + input: CreateOrUpdateApplicationSchema, +): PartialMessage => { + if (input.type !== 'create') + throw new Error("The type of input passed to convertCreateApplicationInput must be 'create'") + + const { type: _type, config, websites, ...rest } = input + + return { + ...rest, + config: configSchemaToMessage(input.config), + websites: input.websites.map((w) => createWebsiteSchemaToMessage(w)), + } +} + +// --- update application + +const ownersSchema = v.array(v.string()) + +export const updateApplicationSchema = v.object({ + type: v.literal('update'), + id: v.string(), + name: v.optional(v.pipe(v.string(), v.nonEmpty('Enter Application Name'))), + repositoryId: v.optional(v.pipe(v.string(), v.nonEmpty('Enter Repository ID'))), + refName: v.optional(v.pipe(v.string(), v.nonEmpty('Enter Branch Name'))), + config: v.optional(applicationConfigSchema), + websites: v.array(createWebsiteSchema), + portPublications: v.array(portPublicationSchema), + ownerIds: v.optional(ownersSchema), +}) + +type UpdateApplicationSchema = v.InferInput + +/** protobuf message -> valobot schema */ +const configMessageToSchema = (config: ApplicationConfig): ApplicationConfigInput => { + let deployConfig: ApplicationConfigInput['deployConfig'] + const _case = config.buildConfig.case + switch (_case) { + case 'runtimeBuildpack': + case 'runtimeDockerfile': + case 'runtimeCmd': { + deployConfig = { + type: 'runtime', + value: { + runtime: config.buildConfig.value.runtimeConfig ?? { + command: '', + entrypoint: '', + useMariadb: false, + useMongodb: false, + }, + }, + } + break + } + case 'staticBuildpack': + case 'staticDockerfile': + case 'staticCmd': { + deployConfig = { + type: 'static', + value: { + static: config.buildConfig.value.staticConfig ?? { + spa: false, + artifactPath: '', + }, + }, + } + break + } + case undefined: { + break + } + default: { + const _unreachable: never = _case + throw new Error('unknown application build config case') + } + } + + let buildConfig: ApplicationConfigInput['buildConfig'] + switch (_case) { + case 'runtimeBuildpack': + case 'staticBuildpack': { + buildConfig = { + type: 'buildpack', + value: { + buildpack: config.buildConfig.value, + }, + } + break + } + case 'runtimeCmd': + case 'staticCmd': { + buildConfig = { + type: 'cmd', + value: { + cmd: config.buildConfig.value, + }, + } + break + } + case 'runtimeDockerfile': + case 'staticDockerfile': { + buildConfig = { + type: 'dockerfile', + value: { + dockerfile: config.buildConfig.value, + }, + } + break + } + case undefined: { + break + } + default: { + const _unreachable: never = _case + throw new Error('unknown application build config case') + } + } + + return { + deployConfig, + buildConfig, + } +} + +const extractSubdomain = ( + fqdn: string, + availableDomains: AvailableDomain[], +): { + subdomain: string + domain: string +} => { + const nonWildcardDomains = availableDomains.filter((d) => !d.domain.startsWith('*')) + const wildcardDomains = availableDomains.filter((d) => d.domain.startsWith('*')) + + const matchNonWildcardDomain = nonWildcardDomains.find((d) => fqdn === d.domain) + if (matchNonWildcardDomain !== undefined) { + return { + subdomain: '', + domain: matchNonWildcardDomain.domain, + } + } + + const matchDomain = wildcardDomains.find((d) => fqdn.endsWith(d.domain.replace(/\*/g, ''))) + if (matchDomain === undefined) { + const fallbackDomain = availableDomains.at(0) + if (fallbackDomain === undefined) throw new Error('No domain available') + return { + subdomain: '', + domain: fallbackDomain.domain, + } + } + return { + subdomain: fqdn.slice(0, -matchDomain.domain.length + 1), + domain: matchDomain.domain, + } +} + +const websiteMessageToSchema = (website: Website): v.InferInput => { + const availableDomains = systemInfo()?.domains ?? [] + + const { domain, subdomain } = extractSubdomain(website.fqdn, availableDomains) + + return { + state: 'noChange', + domain, + subdomain, + pathPrefix: website.pathPrefix, + stripPrefix: website.stripPrefix, + https: website.https, + h2c: website.h2c, + httpPort: website.httpPort, + authentication: website.authentication, + } +} + +export const updateApplicationFormInitialValues = (input: Application): CreateOrUpdateApplicationSchema => { + return { + type: 'update', + id: input.id, + name: input.name, + repositoryId: input.repositoryId, + refName: input.refName, + config: input.config ? configMessageToSchema(input.config) : undefined, + websites: input.websites.map((w) => websiteMessageToSchema(w)), + portPublications: input.portPublications, + ownerIds: input.ownerIds, + } satisfies UpdateApplicationSchema +} + +/** valobot schema -> protobuf message */ +export const convertUpdateApplicationInput = ( + input: CreateOrUpdateApplicationSchema, +): PartialMessage => { + if (input.type !== 'update') + throw new Error("The type of input passed to convertUpdateApplicationInput must be 'create'") + + return { + id: input.id, + name: input.name, + repositoryId: input.repositoryId, + refName: input.refName, + config: input.config ? configSchemaToMessage(input.config) : undefined, + websites: { + websites: input.websites.map((w) => createWebsiteSchemaToMessage(w)), + }, + portPublications: { portPublications: input.portPublications }, + ownerIds: { + ownerIds: input.ownerIds, + }, + } +} + +export const createOrUpdateApplicationSchema = v.variant('type', [createApplicationSchema, updateApplicationSchema]) + +export type CreateOrUpdateApplicationSchema = v.InferInput diff --git a/dashboard/src/features/repository/components/AuthConfigForm.tsx b/dashboard/src/features/repository/components/AuthConfigForm.tsx index 341bbf19..0fd6eada 100644 --- a/dashboard/src/features/repository/components/AuthConfigForm.tsx +++ b/dashboard/src/features/repository/components/AuthConfigForm.tsx @@ -16,7 +16,7 @@ import AuthMethodField from './AuthMethodField' type Props = { repo: Repository - refetchRepo: () => void + refetchRepo: () => Promise hasPermission: boolean } @@ -37,7 +37,7 @@ const AuthConfigForm: Component = (props) => { try { await client.updateRepository(convertUpdateRepositoryInput(values)) toast.success('リポジトリの設定を更新しました') - props.refetchRepo() + await props.refetchRepo() } catch (e) { handleAPIError(e, 'リポジトリの設定の更新に失敗しました') } diff --git a/dashboard/src/features/repository/components/GeneralConfigForm.tsx b/dashboard/src/features/repository/components/GeneralConfigForm.tsx index 98c6e9d1..cd251ad1 100644 --- a/dashboard/src/features/repository/components/GeneralConfigForm.tsx +++ b/dashboard/src/features/repository/components/GeneralConfigForm.tsx @@ -15,7 +15,7 @@ import { client, handleAPIError } from '/@/libs/api' type Props = { repo: Repository - refetchRepo: () => void + refetchRepo: () => Promise hasPermission: boolean } @@ -36,7 +36,7 @@ const GeneralConfigForm: Component = (props) => { try { await client.updateRepository(convertUpdateRepositoryInput(values)) toast.success('リポジトリ名を更新しました') - props.refetchRepo() + await props.refetchRepo() } catch (e) { handleAPIError(e, 'リポジトリ名の更新に失敗しました') } diff --git a/dashboard/src/libs/useFormContext.tsx b/dashboard/src/libs/useFormContext.tsx index 14c5f497..1a83f15c 100644 --- a/dashboard/src/libs/useFormContext.tsx +++ b/dashboard/src/libs/useFormContext.tsx @@ -13,7 +13,12 @@ export const useFormContext = { const formStore = createFormStore>({ - validate: valiForm(schema), + validate: async (input) => { + console.log(input) + console.log(await valiForm(schema)(input)) + return valiForm(schema)(input) + }, + // validate: valiForm(schema), }) return ( diff --git a/dashboard/src/pages/apps/[id]/settings/general.tsx b/dashboard/src/pages/apps/[id]/settings/general.tsx index d107fb55..8446a796 100644 --- a/dashboard/src/pages/apps/[id]/settings/general.tsx +++ b/dashboard/src/pages/apps/[id]/settings/general.tsx @@ -13,6 +13,8 @@ import FormBox from '/@/components/layouts/FormBox' import { FormItem } from '/@/components/templates/FormItem' import { List } from '/@/components/templates/List' import { AppGeneralConfig, type AppGeneralForm } from '/@/components/templates/app/AppGeneralConfig' +import GeneralConfigForm from '/@/features/application/components/GeneralConfigForm' +import { ApplicationFormProvider } from '/@/features/application/provider/applicationFormProvider' import { client, handleAPIError } from '/@/libs/api' import { diffHuman } from '/@/libs/format' import useModal from '/@/libs/useModal' @@ -122,81 +124,15 @@ export default () => { const { app, refetch, repo, hasPermission } = useApplicationData() const loaded = () => !!(app() && repo()) - const [generalForm, General] = createForm({ - initialValues: { - name: app()?.name, - repositoryId: app()?.repositoryId, - refName: app()?.refName, - }, - }) - - createEffect( - on(app, (app) => { - reset(generalForm, { - initialValues: { - name: app?.name, - repositoryId: app?.repositoryId, - refName: app?.refName, - }, - }) - }), - ) - - const handleSubmit: SubmitHandler = async (values) => { - try { - await client.updateApplication({ - id: app()?.id, - ...values, - }) - toast.success('アプリケーション設定を更新しました') - void refetch() - // 非同期でビルドが開始されるので1秒程度待ってから再度リロード - setTimeout(refetch, 1000) - } catch (e) { - handleAPIError(e, 'アプリケーション設定の更新に失敗しました') - } - } - const discardChanges = () => { - reset(generalForm) - } - return ( General - - - - - - - - - - - - - - - - - + + + + + + ) } diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock index a20c8100..684815ff 100644 --- a/dashboard/yarn.lock +++ b/dashboard/yarn.lock @@ -3979,6 +3979,7 @@ __metadata: solid-tippy: "npm:0.2.1" solid-toast: "npm:0.5.0" tippy.js: "npm:6.3.7" + ts-pattern: "npm:^5.2.0" typescript: "npm:5.5.3" unplugin-fonts: "npm:1.1.1" valibot: "npm:^0.36.0" @@ -4891,6 +4892,13 @@ __metadata: languageName: node linkType: hard +"ts-pattern@npm:^5.2.0": + version: 5.2.0 + resolution: "ts-pattern@npm:5.2.0" + checksum: 10c0/99d0bc0d2f3aa19d8ff50b028f4e1c3da35b21f4503f329f8818863b614df3850c0b3709c703e18f5902e68b9682d29719c82b709e07907e87e374e5c70a01f5 + languageName: node + linkType: hard + "tslib@npm:^2.4.0": version: 2.6.2 resolution: "tslib@npm:2.6.2" From c690e0431a195e0063672d9db82d25fc15c20b6e Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Thu, 25 Jul 2024 16:57:18 +0900 Subject: [PATCH 07/51] =?UTF-8?q?wip:=20application=E3=81=AEform=E4=BD=9C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/GeneralConfigForm.tsx | 2 +- .../schema/applicationConfigSchema.ts | 306 ++++++++++++++++ .../schema/applicationSchema.test.ts | 204 +++-------- .../application/schema/applicationSchema.ts | 327 +----------------- 4 files changed, 368 insertions(+), 471 deletions(-) create mode 100644 dashboard/src/features/application/schema/applicationConfigSchema.ts diff --git a/dashboard/src/features/application/components/GeneralConfigForm.tsx b/dashboard/src/features/application/components/GeneralConfigForm.tsx index 2b45e6a2..f220baf9 100644 --- a/dashboard/src/features/application/components/GeneralConfigForm.tsx +++ b/dashboard/src/features/application/components/GeneralConfigForm.tsx @@ -36,7 +36,7 @@ const GeneralConfigForm: Component = (props) => { const handleSubmit: SubmitHandler = async (values) => { try { - await client.updateRepository(convertUpdateApplicationInput(values)) + await client.updateApplication(convertUpdateApplicationInput(values)) toast.success('アプリケーション設定を更新しました') props.refetchApp() // 非同期でビルドが開始されるので1秒程度待ってから再度リロード diff --git a/dashboard/src/features/application/schema/applicationConfigSchema.ts b/dashboard/src/features/application/schema/applicationConfigSchema.ts new file mode 100644 index 00000000..bae4be41 --- /dev/null +++ b/dashboard/src/features/application/schema/applicationConfigSchema.ts @@ -0,0 +1,306 @@ +import type { PartialMessage } from '@bufbuild/protobuf' +import { match } from 'ts-pattern' +import * as v from 'valibot' +import type { ApplicationConfig } from '/@/api/neoshowcase/protobuf/gateway_pb' + +const runtimeConfigSchema = v.object({ + useMariadb: v.boolean(), + useMongodb: v.boolean(), + entrypoint: v.string(), + command: v.string(), +}) +const staticConfigSchema = v.object({ + artifactPath: v.pipe(v.string(), v.nonEmpty('Enter Artifact Path')), + spa: v.boolean(), +}) + +const buildpackConfigSchema = v.object({ + context: v.string(), +}) +const cmdConfigSchema = v.object({ + baseImage: v.string(), + buildCmd: v.string(), +}) +const dockerfileConfigSchema = v.object({ + dockerfileName: v.pipe(v.string(), v.nonEmpty('Enter Dockerfile Name')), + context: v.string(), +}) + +export const applicationConfigSchema = v.object({ + deployConfig: v.pipe( + v.optional( + v.variant('type', [ + v.object({ + type: v.literal('runtime'), + value: v.object({ + runtime: runtimeConfigSchema, + }), + }), + v.object({ + type: v.literal('static'), + value: v.object({ + static: staticConfigSchema, + }), + }), + ]), + ), + // アプリ作成時には最初undefinedになっているが、submit時にはundefinedで無い必要がある + v.check((input) => !!input, 'Select Deploy Type'), + ), + buildConfig: v.pipe( + v.optional( + v.variant('type', [ + v.object({ + type: v.literal('buildpack'), + value: v.object({ + buildpack: buildpackConfigSchema, + }), + }), + v.object({ + type: v.literal('cmd'), + value: v.object({ + cmd: cmdConfigSchema, + }), + }), + v.object({ + type: v.literal('dockerfile'), + value: v.object({ + dockerfile: dockerfileConfigSchema, + }), + }), + ]), + ), + // アプリ作成時には最初undefinedになっているが、submit時にはundefinedで無い必要がある + v.check((input) => !!input, 'Select Build Type'), + ), +}) + +export type ApplicationConfigInput = v.InferInput + +/** protobuf message -> valobot schema */ +export const configMessageToSchema = (config: ApplicationConfig): ApplicationConfigInput => { + let deployConfig: ApplicationConfigInput['deployConfig'] + const _case = config.buildConfig.case + switch (_case) { + case 'runtimeBuildpack': + case 'runtimeDockerfile': + case 'runtimeCmd': { + deployConfig = { + type: 'runtime', + value: { + runtime: config.buildConfig.value.runtimeConfig ?? { + command: '', + entrypoint: '', + useMariadb: false, + useMongodb: false, + }, + }, + } + break + } + case 'staticBuildpack': + case 'staticDockerfile': + case 'staticCmd': { + deployConfig = { + type: 'static', + value: { + static: config.buildConfig.value.staticConfig ?? { + spa: false, + artifactPath: '', + }, + }, + } + break + } + case undefined: { + break + } + default: { + const _unreachable: never = _case + throw new Error('unknown application build config case') + } + } + + let buildConfig: ApplicationConfigInput['buildConfig'] + switch (_case) { + case 'runtimeBuildpack': + case 'staticBuildpack': { + buildConfig = { + type: 'buildpack', + value: { + buildpack: config.buildConfig.value, + }, + } + break + } + case 'runtimeCmd': + case 'staticCmd': { + buildConfig = { + type: 'cmd', + value: { + cmd: config.buildConfig.value, + }, + } + break + } + case 'runtimeDockerfile': + case 'staticDockerfile': { + buildConfig = { + type: 'dockerfile', + value: { + dockerfile: config.buildConfig.value, + }, + } + break + } + case undefined: { + break + } + default: { + const _unreachable: never = _case + throw new Error('unknown application build config case') + } + } + + return { + deployConfig, + buildConfig, + } +} + +/** valobot schema -> protobuf message */ +export const configSchemaToMessage = ( + input: v.InferInput, +): PartialMessage => { + return match([input.deployConfig, input.buildConfig]) + .returnType>() + .with( + [ + { + type: 'runtime', + }, + { + type: 'buildpack', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'runtimeBuildpack', + value: { + ...buildConfig.value.buildpack, + runtimeConfig: deployConfig.value.runtime, + }, + }, + } + }, + ) + .with( + [ + { + type: 'runtime', + }, + { + type: 'cmd', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'runtimeCmd', + value: { + ...buildConfig.value.cmd, + runtimeConfig: deployConfig.value.runtime, + }, + }, + } + }, + ) + .with( + [ + { + type: 'runtime', + }, + { + type: 'dockerfile', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'runtimeDockerfile', + value: { + ...buildConfig.value.dockerfile, + runtimeConfig: deployConfig.value.runtime, + }, + }, + } + }, + ) + .with( + [ + { + type: 'static', + }, + { + type: 'buildpack', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'staticBuildpack', + value: { + ...buildConfig.value.buildpack, + staticConfig: deployConfig.value.static, + }, + }, + } + }, + ) + .with( + [ + { + type: 'static', + }, + { + type: 'cmd', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'staticCmd', + value: { + ...buildConfig.value.cmd, + staticConfig: deployConfig.value.static, + }, + }, + } + }, + ) + .with( + [ + { + type: 'static', + }, + { + type: 'dockerfile', + }, + ], + ([deployConfig, buildConfig]) => { + return { + buildConfig: { + case: 'staticDockerfile', + value: { + ...buildConfig.value.dockerfile, + staticConfig: deployConfig.value.static, + }, + }, + } + }, + ) + .otherwise(() => ({ + buildConfig: { case: undefined }, + })) +} diff --git a/dashboard/src/features/application/schema/applicationSchema.test.ts b/dashboard/src/features/application/schema/applicationSchema.test.ts index c32b703b..4d217e95 100644 --- a/dashboard/src/features/application/schema/applicationSchema.test.ts +++ b/dashboard/src/features/application/schema/applicationSchema.test.ts @@ -202,163 +202,53 @@ describe('Create Application Schema', () => { ).toEqual(expect.objectContaining({ success: true })) }) - // test("ng: empty name", () => { - // expect( - // validator({ - // ...base, - // name: "", - // }).issues, - // ).toEqual([ - // expect.objectContaining({ - // message: "Enter Repository Name", - // path: [ - // expect.objectContaining({ - // key: "name", - // }), - // ], - // }), - // ]); - // }); - - // test("ng: empty url", () => { - // expect( - // validator({ - // ...base, - // url: "", - // }).issues, - // ).toEqual([ - // expect.objectContaining({ - // message: "Enter Repository URL", - // path: [ - // expect.objectContaining({ - // key: "url", - // }), - // ], - // }), - // ]); - // }); + test('ng: empty name', () => { + expect( + validator({ + ...base, + name: '', + }).issues, + ).toEqual([ + expect.objectContaining({ + message: 'Enter Application Name', + path: [ + expect.objectContaining({ + key: 'name', + }), + ], + }), + ]) + }) - // test("ng: auth method is basic, but the URL starts with 'http://'", () => { - // expect( - // validator({ - // ...base, - // url: "http://example.com/test/test.git", - // auth: { - // method: "basic", - // value: { - // basic: { - // username: "test name", - // password: "test password", - // }, - // }, - // }, - // }).issues, - // ).toEqual([ - // expect.objectContaining({ - // message: - // "Basic認証を使用する場合、URLはhttps://から始まる必要があります", - // path: [ - // expect.objectContaining({ - // key: "url", - // }), - // ], - // }), - // ]); - // }); + test('ng: empty refname', () => { + expect( + validator({ + ...base, + refName: '', + }).issues, + ).toEqual([ + expect.objectContaining({ + message: 'Enter Branch Name', + path: [ + expect.objectContaining({ + key: 'refName', + }), + ], + }), + ]) + }) }) -// describe("Update Repository Schema", () => { -// const base = { -// id: "testRepositoryId", -// type: "update", -// name: "test repository", -// url: "https://example.com/test/test.git", -// auth: { -// method: "none", -// value: { -// none: {}, -// }, -// }, -// ownerIds: ["owner1"], -// }; - -// test("ok: valid input", () => { -// expect(validator(base)).toEqual(expect.objectContaining({ success: true })); -// }); - -// test("ok: update name", () => { -// expect( -// validator({ -// id: base.id, -// type: base.type, -// name: base.name, -// }), -// ).toEqual(expect.objectContaining({ success: true })); -// }); - -// test("ok: update auth config", () => { -// expect( -// validator({ -// id: base.id, -// type: base.type, -// url: base.url, -// auth: base.auth, -// }), -// ).toEqual(expect.objectContaining({ success: true })); -// }); - -// test("ok: update ownerIds", () => { -// expect( -// validator({ -// id: base.id, -// type: base.type, -// ownerIds: base.ownerIds, -// }), -// ).toEqual(expect.objectContaining({ success: true })); -// }); - -// test("ng: empty id", () => { -// expect( -// validator({ -// ...base, -// id: undefined, -// }).issues, -// ).toEqual([ -// expect.objectContaining({ -// path: [ -// expect.objectContaining({ -// key: "id", -// }), -// ], -// }), -// ]); -// }); - -// test("ng: auth method is basic, but the URL starts with 'http://'", () => { -// expect( -// validator({ -// ...base, -// url: "http://example.com/test/test.git", -// auth: { -// method: "basic", -// value: { -// basic: { -// username: "test name", -// password: "test password", -// }, -// }, -// }, -// }).issues, -// ).toEqual([ -// expect.objectContaining({ -// message: -// "Basic認証を使用する場合、URLはhttps://から始まる必要があります", -// path: [ -// expect.objectContaining({ -// key: "url", -// }), -// ], -// }), -// ]); -// }); -// }); +describe('Update Application Schema', () => { + test('ok: valid input (update general config)', () => { + expect( + validator({ + type: 'update', + id: 'testAppId', + name: 'test application', + repositoryId: 'testRepoId', + refName: 'main', + }), + ).toEqual(expect.objectContaining({ success: true })) + }) +}) diff --git a/dashboard/src/features/application/schema/applicationSchema.ts b/dashboard/src/features/application/schema/applicationSchema.ts index ded26aa9..8a971e22 100644 --- a/dashboard/src/features/application/schema/applicationSchema.ts +++ b/dashboard/src/features/application/schema/applicationSchema.ts @@ -1,9 +1,7 @@ import type { PartialMessage } from '@bufbuild/protobuf' -import { match } from 'ts-pattern' import * as v from 'valibot' import { type Application, - type ApplicationConfig, AuthenticationType, type AvailableDomain, type CreateApplicationRequest, @@ -13,80 +11,7 @@ import { type Website, } from '/@/api/neoshowcase/protobuf/gateway_pb' import { systemInfo } from '/@/libs/api' - -const runtimeConfigSchema = v.object({ - useMariadb: v.boolean(), - useMongodb: v.boolean(), - entrypoint: v.string(), - command: v.string(), -}) -const staticConfigSchema = v.object({ - artifactPath: v.pipe(v.string(), v.nonEmpty('Enter Artifact Path')), - spa: v.boolean(), -}) - -const buildpackConfigSchema = v.object({ - context: v.string(), -}) -const cmdConfigSchema = v.object({ - baseImage: v.string(), - buildCmd: v.string(), -}) -const dockerfileConfigSchema = v.object({ - dockerfileName: v.pipe(v.string(), v.nonEmpty('Enter Dockerfile Name')), - context: v.string(), -}) - -const applicationConfigSchema = v.object({ - deployConfig: v.pipe( - v.optional( - v.variant('type', [ - v.object({ - type: v.literal('runtime'), - value: v.object({ - runtime: runtimeConfigSchema, - }), - }), - v.object({ - type: v.literal('static'), - value: v.object({ - static: staticConfigSchema, - }), - }), - ]), - ), - // アプリ作成時には最初undefinedになっているが、submit時にはundefinedで無い必要がある - v.check((input) => !!input, 'Select Deploy Type'), - ), - buildConfig: v.pipe( - v.optional( - v.variant('type', [ - v.object({ - type: v.literal('buildpack'), - value: v.object({ - buildpack: buildpackConfigSchema, - }), - }), - v.object({ - type: v.literal('cmd'), - value: v.object({ - cmd: cmdConfigSchema, - }), - }), - v.object({ - type: v.literal('dockerfile'), - value: v.object({ - dockerfile: dockerfileConfigSchema, - }), - }), - ]), - ), - // アプリ作成時には最初undefinedになっているが、submit時にはundefinedで無い必要がある - v.check((input) => !!input, 'Select Build Type'), - ), -}) - -type ApplicationConfigInput = v.InferInput +import { applicationConfigSchema, configMessageToSchema, configSchemaToMessage } from './applicationConfigSchema' const createWebsiteSchema = v.object({ state: v.union([v.literal('noChange'), v.literal('readyToChange'), v.literal('readyToDelete'), v.literal('added')]), @@ -133,143 +58,6 @@ export const createApplicationFormInitialValues = (): CreateOrUpdateApplicationS startOnCreate: false, }) satisfies CreateApplicationSchema -/** valobot schema -> protobuf message */ -const configSchemaToMessage = ( - input: v.InferInput, -): PartialMessage => { - return match([input.deployConfig, input.buildConfig]) - .returnType>() - .with( - [ - { - type: 'runtime', - }, - { - type: 'buildpack', - }, - ], - ([deployConfig, buildConfig]) => { - return { - buildConfig: { - case: 'runtimeBuildpack', - value: { - ...buildConfig.value.buildpack, - runtimeConfig: deployConfig.value.runtime, - }, - }, - } - }, - ) - .with( - [ - { - type: 'runtime', - }, - { - type: 'cmd', - }, - ], - ([deployConfig, buildConfig]) => { - return { - buildConfig: { - case: 'runtimeCmd', - value: { - ...buildConfig.value.cmd, - runtimeConfig: deployConfig.value.runtime, - }, - }, - } - }, - ) - .with( - [ - { - type: 'runtime', - }, - { - type: 'dockerfile', - }, - ], - ([deployConfig, buildConfig]) => { - return { - buildConfig: { - case: 'runtimeDockerfile', - value: { - ...buildConfig.value.dockerfile, - runtimeConfig: deployConfig.value.runtime, - }, - }, - } - }, - ) - .with( - [ - { - type: 'static', - }, - { - type: 'buildpack', - }, - ], - ([deployConfig, buildConfig]) => { - return { - buildConfig: { - case: 'staticBuildpack', - value: { - ...buildConfig.value.buildpack, - staticConfig: deployConfig.value.static, - }, - }, - } - }, - ) - .with( - [ - { - type: 'static', - }, - { - type: 'cmd', - }, - ], - ([deployConfig, buildConfig]) => { - return { - buildConfig: { - case: 'staticCmd', - value: { - ...buildConfig.value.cmd, - staticConfig: deployConfig.value.static, - }, - }, - } - }, - ) - .with( - [ - { - type: 'static', - }, - { - type: 'dockerfile', - }, - ], - ([deployConfig, buildConfig]) => { - return { - buildConfig: { - case: 'staticDockerfile', - value: { - ...buildConfig.value.dockerfile, - staticConfig: deployConfig.value.static, - }, - }, - } - }, - ) - .otherwise(() => ({ - buildConfig: { case: undefined }, - })) -} - const createWebsiteSchemaToMessage = ( input: v.InferInput, ): PartialMessage => { @@ -314,104 +102,13 @@ export const updateApplicationSchema = v.object({ repositoryId: v.optional(v.pipe(v.string(), v.nonEmpty('Enter Repository ID'))), refName: v.optional(v.pipe(v.string(), v.nonEmpty('Enter Branch Name'))), config: v.optional(applicationConfigSchema), - websites: v.array(createWebsiteSchema), - portPublications: v.array(portPublicationSchema), + websites: v.optional(v.array(createWebsiteSchema)), + portPublications: v.optional(v.array(portPublicationSchema)), ownerIds: v.optional(ownersSchema), }) type UpdateApplicationSchema = v.InferInput -/** protobuf message -> valobot schema */ -const configMessageToSchema = (config: ApplicationConfig): ApplicationConfigInput => { - let deployConfig: ApplicationConfigInput['deployConfig'] - const _case = config.buildConfig.case - switch (_case) { - case 'runtimeBuildpack': - case 'runtimeDockerfile': - case 'runtimeCmd': { - deployConfig = { - type: 'runtime', - value: { - runtime: config.buildConfig.value.runtimeConfig ?? { - command: '', - entrypoint: '', - useMariadb: false, - useMongodb: false, - }, - }, - } - break - } - case 'staticBuildpack': - case 'staticDockerfile': - case 'staticCmd': { - deployConfig = { - type: 'static', - value: { - static: config.buildConfig.value.staticConfig ?? { - spa: false, - artifactPath: '', - }, - }, - } - break - } - case undefined: { - break - } - default: { - const _unreachable: never = _case - throw new Error('unknown application build config case') - } - } - - let buildConfig: ApplicationConfigInput['buildConfig'] - switch (_case) { - case 'runtimeBuildpack': - case 'staticBuildpack': { - buildConfig = { - type: 'buildpack', - value: { - buildpack: config.buildConfig.value, - }, - } - break - } - case 'runtimeCmd': - case 'staticCmd': { - buildConfig = { - type: 'cmd', - value: { - cmd: config.buildConfig.value, - }, - } - break - } - case 'runtimeDockerfile': - case 'staticDockerfile': { - buildConfig = { - type: 'dockerfile', - value: { - dockerfile: config.buildConfig.value, - }, - } - break - } - case undefined: { - break - } - default: { - const _unreachable: never = _case - throw new Error('unknown application build config case') - } - } - - return { - deployConfig, - buildConfig, - } -} - const extractSubdomain = ( fqdn: string, availableDomains: AvailableDomain[], @@ -490,13 +187,17 @@ export const convertUpdateApplicationInput = ( repositoryId: input.repositoryId, refName: input.refName, config: input.config ? configSchemaToMessage(input.config) : undefined, - websites: { - websites: input.websites.map((w) => createWebsiteSchemaToMessage(w)), - }, - portPublications: { portPublications: input.portPublications }, - ownerIds: { - ownerIds: input.ownerIds, - }, + websites: input.websites + ? { + websites: input.websites?.map((w) => createWebsiteSchemaToMessage(w)), + } + : undefined, + portPublications: input.portPublications ? { portPublications: input.portPublications } : undefined, + ownerIds: input.ownerIds + ? { + ownerIds: input.ownerIds, + } + : undefined, } } From 728bac6c007e8157eab08f1c3386d2f9e68db22e Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 26 Jul 2024 09:46:51 +0900 Subject: [PATCH 08/51] =?UTF-8?q?wip:=20app=E3=81=AE=E3=83=93=E3=83=AB?= =?UTF-8?q?=E3=83=89=E8=A8=AD=E5=AE=9A=E9=83=A8=E5=88=86=E3=81=AE=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/templates/RadioGroups.tsx | 57 +++++-- .../components/BuildConfigField.tsx | 18 ++ .../components/BuildConfigForm.tsx | 92 +++++++++++ .../application/components/BuildTypeField.tsx | 155 ++++++++++++++++++ .../components/GeneralConfigForm.tsx | 4 +- .../schema/applicationConfigSchema.ts | 5 + .../schema/applicationSchema.test.ts | 42 +++++ .../application/schema/applicationSchema.ts | 18 +- .../src/pages/apps/[id]/settings/build.tsx | 84 +--------- .../src/pages/apps/[id]/settings/general.tsx | 32 +--- 10 files changed, 374 insertions(+), 133 deletions(-) create mode 100644 dashboard/src/features/application/components/BuildConfigField.tsx create mode 100644 dashboard/src/features/application/components/BuildConfigForm.tsx create mode 100644 dashboard/src/features/application/components/BuildTypeField.tsx diff --git a/dashboard/src/components/templates/RadioGroups.tsx b/dashboard/src/components/templates/RadioGroups.tsx index 21239039..51aa610d 100644 --- a/dashboard/src/components/templates/RadioGroups.tsx +++ b/dashboard/src/components/templates/RadioGroups.tsx @@ -13,21 +13,29 @@ const OptionsContainer = styled('div', { base: { width: '100%', display: 'flex', - flexWrap: 'wrap', gap: '16px', }, + variant: { + wrap: { + true: { + flexWrap: 'wrap', + }, + }, + }, + defaultVariants: { + wrap: 'true', + }, }) const itemStyle = style({ - width: 'fit-content', + width: '100%', minWidth: 'min(200px, 100%)', }) const labelStyle = style({ width: '100%', + height: '100%', padding: '16px', - display: 'grid', - gridTemplateColumns: '1fr 20px', - alignItems: 'center', - justifyItems: 'start', + display: 'flex', + flexDirection: 'column', gap: '8px', background: colorVars.semantic.ui.primary, @@ -57,10 +65,28 @@ const labelStyle = style({ }, }, }) +const ItemTitle = styled('div', { + base: { + display: 'grid', + gridTemplateColumns: '1fr 20px', + alignItems: 'center', + justifyItems: 'start', + gap: '8px', + color: colorVars.semantic.text.black, + ...textVars.text.regular, + }, +}) +const Description = styled('div', { + base: { + color: colorVars.semantic.text.black, + ...textVars.caption.regular, + }, +}) export interface RadioOption { value: T label: string + description?: string } export interface Props { @@ -85,7 +111,7 @@ export const RadioGroup = (props: Props): JSX.Element => { const [rootProps, _addedProps, inputProps] = splitProps( props, ['name', 'value', 'options', 'required', 'disabled', 'readOnly'], - ['info', 'tooltip', 'setValue'], + ['info', 'tooltip', 'setValue', 'error', 'label'], ) return ( @@ -114,12 +140,17 @@ export const RadioGroup = (props: Props): JSX.Element => { - {option.label} - - - - - + + {option.label} + + + + + + + + {option.description} + )} diff --git a/dashboard/src/features/application/components/BuildConfigField.tsx b/dashboard/src/features/application/components/BuildConfigField.tsx new file mode 100644 index 00000000..66bf61b2 --- /dev/null +++ b/dashboard/src/features/application/components/BuildConfigField.tsx @@ -0,0 +1,18 @@ +import type { FormStore } from '@modular-forms/solid' +import type { Component } from 'solid-js' +import type { CreateOrUpdateRepositoryInput } from '../../repository/schema/repositorySchema' + +type Props = { + formStore: FormStore + readonly?: boolean +} + +const BuildConfigField: Component = (props) => { + return ( +
+

BuildConfigField

+
+ ) +} + +export default BuildConfigField diff --git a/dashboard/src/features/application/components/BuildConfigForm.tsx b/dashboard/src/features/application/components/BuildConfigForm.tsx new file mode 100644 index 00000000..6cb4971b --- /dev/null +++ b/dashboard/src/features/application/components/BuildConfigForm.tsx @@ -0,0 +1,92 @@ +import { Field, Form, type SubmitHandler, getValues, reset } from '@modular-forms/solid' +import { type Component, Show, createEffect, untrack } from 'solid-js' +import toast from 'solid-toast' +import type { Application, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import FormBox from '/@/components/layouts/FormBox' +import { client, handleAPIError } from '/@/libs/api' +import { useApplicationForm } from '../provider/applicationFormProvider' +import { + type CreateOrUpdateApplicationInput, + convertUpdateApplicationInput, + updateApplicationFormInitialValues, +} from '../schema/applicationSchema' +import BuildTypeField from './BuildTypeField' + +type Props = { + app: Application + refetchApp: () => Promise + hasPermission: boolean +} + +const BuildConfigForm: Component = (props) => { + const { formStore } = useApplicationForm() + + // reset forms when props.app changed + createEffect(() => { + reset( + untrack(() => formStore), + { + initialValues: updateApplicationFormInitialValues(props.app), + }, + ) + }) + + const handleSubmit: SubmitHandler = async (values) => { + try { + await client.updateApplication(convertUpdateApplicationInput(values)) + toast.success('アプリケーション設定を更新しました') + props.refetchApp() + // 非同期でビルドが開始されるので1秒程度待ってから再度リロード + setTimeout(props.refetchApp, 1000) + } catch (e) { + handleAPIError(e, 'アプリケーション設定の更新に失敗しました') + } + } + + const discardChanges = () => { + reset(formStore) + } + + return ( +
+ {JSON.stringify(getValues(formStore))} + + {() => null} + + + {() => null} + + + + + + + + + + + + +
+ ) +} + +export default BuildConfigForm diff --git a/dashboard/src/features/application/components/BuildTypeField.tsx b/dashboard/src/features/application/components/BuildTypeField.tsx new file mode 100644 index 00000000..28bd9ba7 --- /dev/null +++ b/dashboard/src/features/application/components/BuildTypeField.tsx @@ -0,0 +1,155 @@ +import { style } from '@macaron-css/core' +import { styled } from '@macaron-css/solid' +import { Field, type FormStore, getError, getValues, setValues } from '@modular-forms/solid' +import { type Component, Show, createEffect } from 'solid-js' +import { RadioIcon } from '/@/components/UI/RadioIcon' +import { FormItem } from '/@/components/templates/FormItem' +import { RadioGroup } from '/@/components/templates/RadioGroups' +import { colorOverlay } from '/@/libs/colorOverlay' +import { colorVars, media, textVars } from '/@/theme' +import type { CreateOrUpdateApplicationInput } from '../schema/applicationSchema' + +const ItemsContainer = styled('div', { + base: { + width: '100%', + display: 'flex', + alignItems: 'stretch', + gap: '16px', + + '@media': { + [media.mobile]: { + flexDirection: 'column', + }, + }, + }, +}) +const itemStyle = style({ + width: '100%', +}) +const labelStyle = style({ + width: '100%', + height: '100%', + padding: '16px', + display: 'flex', + flexDirection: 'column', + gap: '8px', + + background: colorVars.semantic.ui.primary, + borderRadius: '8px', + border: `1px solid ${colorVars.semantic.ui.border}`, + cursor: 'pointer', + + selectors: { + '&:hover:not([data-disabled]):not([data-readonly])': { + background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover), + }, + '&[data-readonly]': { + cursor: 'not-allowed', + }, + '&[data-checked]': { + outline: `2px solid ${colorVars.semantic.primary.main}`, + }, + '&[data-disabled]': { + cursor: 'not-allowed', + color: colorVars.semantic.text.disabled, + background: colorVars.semantic.ui.tertiary, + }, + '&[data-invalid]': { + outline: `2px solid ${colorVars.semantic.accent.error}`, + }, + }, +}) +const ItemTitle = styled('div', { + base: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px', + color: colorVars.semantic.text.black, + ...textVars.text.bold, + }, +}) +const Description = styled('div', { + base: { + color: colorVars.semantic.text.black, + ...textVars.caption.regular, + }, +}) +export const errorTextStyle = style({ + marginTop: '8px', + width: '100%', + color: colorVars.semantic.accent.error, + ...textVars.text.regular, +}) + +type Props = { + formStore: FormStore + readonly?: boolean +} + +const BuildTypeField: Component = (props) => { + const runType = () => getValues(props.formStore).config?.deployConfig?.type + + return ( + <> + + {(field, fieldProps) => ( + + )} + + + + {(field, fieldProps) => ( + + )} + + + + ) +} + +export default BuildTypeField diff --git a/dashboard/src/features/application/components/GeneralConfigForm.tsx b/dashboard/src/features/application/components/GeneralConfigForm.tsx index f220baf9..6b46f077 100644 --- a/dashboard/src/features/application/components/GeneralConfigForm.tsx +++ b/dashboard/src/features/application/components/GeneralConfigForm.tsx @@ -8,7 +8,7 @@ import FormBox from '/@/components/layouts/FormBox' import { client, handleAPIError } from '/@/libs/api' import { useApplicationForm } from '../provider/applicationFormProvider' import { - type CreateOrUpdateApplicationSchema, + type CreateOrUpdateApplicationInput, convertUpdateApplicationInput, updateApplicationFormInitialValues, } from '../schema/applicationSchema' @@ -34,7 +34,7 @@ const GeneralConfigForm: Component = (props) => { ) }) - const handleSubmit: SubmitHandler = async (values) => { + const handleSubmit: SubmitHandler = async (values) => { try { await client.updateApplication(convertUpdateApplicationInput(values)) toast.success('アプリケーション設定を更新しました') diff --git a/dashboard/src/features/application/schema/applicationConfigSchema.ts b/dashboard/src/features/application/schema/applicationConfigSchema.ts index bae4be41..9434cffe 100644 --- a/dashboard/src/features/application/schema/applicationConfigSchema.ts +++ b/dashboard/src/features/application/schema/applicationConfigSchema.ts @@ -162,6 +162,11 @@ export const configMessageToSchema = (config: ApplicationConfig): ApplicationCon } } + console.log({ + deployConfig, + buildConfig, + }) + return { deployConfig, buildConfig, diff --git a/dashboard/src/features/application/schema/applicationSchema.test.ts b/dashboard/src/features/application/schema/applicationSchema.test.ts index 4d217e95..704db378 100644 --- a/dashboard/src/features/application/schema/applicationSchema.test.ts +++ b/dashboard/src/features/application/schema/applicationSchema.test.ts @@ -251,4 +251,46 @@ describe('Update Application Schema', () => { }), ).toEqual(expect.objectContaining({ success: true })) }) + + test('ng: empty name', () => { + expect( + validator({ + type: 'update', + id: 'testAppId', + name: '', + repositoryId: 'testRepoId', + refName: 'main', + }).issues, + ).toEqual([ + expect.objectContaining({ + message: 'Enter Application Name', + path: [ + expect.objectContaining({ + key: 'name', + }), + ], + }), + ]) + }) + + test('ng: empty refname', () => { + expect( + validator({ + type: 'update', + id: 'testAppId', + name: 'test application', + repositoryId: 'testRepoId', + refName: '', + }).issues, + ).toEqual([ + expect.objectContaining({ + message: 'Enter Branch Name', + path: [ + expect.objectContaining({ + key: 'refName', + }), + ], + }), + ]) + }) }) diff --git a/dashboard/src/features/application/schema/applicationSchema.ts b/dashboard/src/features/application/schema/applicationSchema.ts index 8a971e22..cbf9776a 100644 --- a/dashboard/src/features/application/schema/applicationSchema.ts +++ b/dashboard/src/features/application/schema/applicationSchema.ts @@ -44,9 +44,9 @@ const createApplicationSchema = v.object({ startOnCreate: v.boolean(), }) -type CreateApplicationSchema = v.InferInput +type CreateApplicationInput = v.InferInput -export const createApplicationFormInitialValues = (): CreateOrUpdateApplicationSchema => +export const createApplicationFormInitialValues = (): CreateOrUpdateApplicationInput => ({ type: 'create', name: '', @@ -56,7 +56,7 @@ export const createApplicationFormInitialValues = (): CreateOrUpdateApplicationS websites: [], portPublications: [], startOnCreate: false, - }) satisfies CreateApplicationSchema + }) satisfies CreateApplicationInput const createWebsiteSchemaToMessage = ( input: v.InferInput, @@ -77,7 +77,7 @@ const createWebsiteSchemaToMessage = ( /** valobot schema -> protobuf message */ export const convertCreateApplicationInput = ( - input: CreateOrUpdateApplicationSchema, + input: CreateOrUpdateApplicationInput, ): PartialMessage => { if (input.type !== 'create') throw new Error("The type of input passed to convertCreateApplicationInput must be 'create'") @@ -107,7 +107,7 @@ export const updateApplicationSchema = v.object({ ownerIds: v.optional(ownersSchema), }) -type UpdateApplicationSchema = v.InferInput +type UpdateApplicationInput = v.InferInput const extractSubdomain = ( fqdn: string, @@ -160,7 +160,7 @@ const websiteMessageToSchema = (website: Website): v.InferInput { +export const updateApplicationFormInitialValues = (input: Application): CreateOrUpdateApplicationInput => { return { type: 'update', id: input.id, @@ -171,12 +171,12 @@ export const updateApplicationFormInitialValues = (input: Application): CreateOr websites: input.websites.map((w) => websiteMessageToSchema(w)), portPublications: input.portPublications, ownerIds: input.ownerIds, - } satisfies UpdateApplicationSchema + } satisfies UpdateApplicationInput } /** valobot schema -> protobuf message */ export const convertUpdateApplicationInput = ( - input: CreateOrUpdateApplicationSchema, + input: CreateOrUpdateApplicationInput, ): PartialMessage => { if (input.type !== 'update') throw new Error("The type of input passed to convertUpdateApplicationInput must be 'create'") @@ -203,4 +203,4 @@ export const convertUpdateApplicationInput = ( export const createOrUpdateApplicationSchema = v.variant('type', [createApplicationSchema, updateApplicationSchema]) -export type CreateOrUpdateApplicationSchema = v.InferInput +export type CreateOrUpdateApplicationInput = v.InferInput diff --git a/dashboard/src/pages/apps/[id]/settings/build.tsx b/dashboard/src/pages/apps/[id]/settings/build.tsx index 1a78550c..18cd2d99 100644 --- a/dashboard/src/pages/apps/[id]/settings/build.tsx +++ b/dashboard/src/pages/apps/[id]/settings/build.tsx @@ -1,92 +1,20 @@ -import { type SubmitHandler, createForm, reset, setValues } from '@modular-forms/solid' -import { Show, createEffect, onMount } from 'solid-js' -import toast from 'solid-toast' -import { Button } from '/@/components/UI/Button' +import { Show } from 'solid-js' import { DataTable } from '/@/components/layouts/DataTable' -import FormBox from '/@/components/layouts/FormBox' -import { - type BuildConfigForm, - BuildConfigs, - configToForm, - formToConfig, -} from '/@/components/templates/app/BuildConfigs' -import { client, handleAPIError } from '/@/libs/api' +import BuildConfigForm from '/@/features/application/components/BuildConfigForm' +import { ApplicationFormProvider } from '/@/features/application/provider/applicationFormProvider' import { useApplicationData } from '/@/routes' export default () => { const { app, refetch, hasPermission } = useApplicationData() const loaded = () => !!app() - const [buildConfig, BuildConfig] = createForm({ - initialValues: configToForm(structuredClone(app()?.config)), - }) - - // `reset` doesn't work on first render - // see: https://github.com/fabian-hiller/modular-forms/issues/157#issuecomment-1848567069 - onMount(() => { - setValues(buildConfig, configToForm(structuredClone(app()?.config))) - }) - - const discardChanges = () => { - reset(buildConfig, { - initialValues: configToForm(structuredClone(app()?.config)), - }) - } - - // reset form when app updated - createEffect(discardChanges) - - const handleSubmit: SubmitHandler = async (values) => { - try { - await client.updateApplication({ - id: app()?.id, - config: { - buildConfig: formToConfig(values), - }, - }) - toast.success('ビルド設定を更新しました') - void refetch() - // 非同期でビルドが開始されるので1秒程度待ってから再度リロード - setTimeout(refetch, 1000) - } catch (e) { - handleAPIError(e, 'ビルド設定の更新に失敗しました') - } - } - return ( Build - - - - - - - - - - - - - + + + ) diff --git a/dashboard/src/pages/apps/[id]/settings/general.tsx b/dashboard/src/pages/apps/[id]/settings/general.tsx index 8446a796..a4e76788 100644 --- a/dashboard/src/pages/apps/[id]/settings/general.tsx +++ b/dashboard/src/pages/apps/[id]/settings/general.tsx @@ -1,51 +1,21 @@ import { styled } from '@macaron-css/solid' -import { type SubmitHandler, createForm, reset } from '@modular-forms/solid' import { useNavigate } from '@solidjs/router' -import { type Component, Show, createEffect, on } from 'solid-js' +import { type Component, Show } from 'solid-js' import toast from 'solid-toast' import type { Application, Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' import { Button } from '/@/components/UI/Button' import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' import ModalDeleteConfirm from '/@/components/UI/ModalDeleteConfirm' -import { ToolTip } from '/@/components/UI/ToolTip' import { DataTable } from '/@/components/layouts/DataTable' import FormBox from '/@/components/layouts/FormBox' import { FormItem } from '/@/components/templates/FormItem' -import { List } from '/@/components/templates/List' -import { AppGeneralConfig, type AppGeneralForm } from '/@/components/templates/app/AppGeneralConfig' import GeneralConfigForm from '/@/features/application/components/GeneralConfigForm' import { ApplicationFormProvider } from '/@/features/application/provider/applicationFormProvider' import { client, handleAPIError } from '/@/libs/api' -import { diffHuman } from '/@/libs/format' import useModal from '/@/libs/useModal' import { useApplicationData } from '/@/routes' import { colorVars, textVars } from '/@/theme' -const GeneralInfo: Component<{ - app: Application -}> = (props) => { - return ( - - - {(nonNullCreatedAt) => { - const diff = diffHuman(nonNullCreatedAt().toDate()) - const localeString = nonNullCreatedAt().toDate().toLocaleString() - return ( - - - 作成日 - - {diff()} - - - - ) - }} - - - ) -} - const DeleteAppNotice = styled('div', { base: { color: colorVars.semantic.text.grey, From 32f9124d1bd2bb57c8e1819c166f076df4a89a80 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 26 Jul 2024 22:57:33 +0900 Subject: [PATCH 09/51] =?UTF-8?q?refactor:=20valibot=E3=81=AB=E3=82=88?= =?UTF-8?q?=E3=82=8Btransform=E3=81=A7Message=E3=81=AB=E5=A4=89=E6=8F=9B?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/components/AuthConfigForm.tsx | 27 ++- .../repository/components/AuthMethodField.tsx | 29 ++- .../repository/components/CreateForm.tsx | 39 +-- .../components/GeneralConfigForm.tsx | 25 +- .../schema/repositorySchema.test.ts | 182 ++++++++------ .../repository/schema/repositorySchema.ts | 223 +++++++++--------- 6 files changed, 289 insertions(+), 236 deletions(-) diff --git a/dashboard/src/features/repository/components/AuthConfigForm.tsx b/dashboard/src/features/repository/components/AuthConfigForm.tsx index 0fd6eada..90c392ff 100644 --- a/dashboard/src/features/repository/components/AuthConfigForm.tsx +++ b/dashboard/src/features/repository/components/AuthConfigForm.tsx @@ -8,7 +8,7 @@ import FormBox from '/@/components/layouts/FormBox' import { useRepositoryForm } from '/@/features/repository/provider/repositoryFormProvider' import { type CreateOrUpdateRepositoryInput, - convertUpdateRepositoryInput, + handleSubmitRepositoryForm, updateRepositoryFormInitialValues, } from '/@/features/repository/schema/repositorySchema' import { client, handleAPIError } from '/@/libs/api' @@ -33,15 +33,16 @@ const AuthConfigForm: Component = (props) => { ) }) - const handleSubmit: SubmitHandler = async (values) => { - try { - await client.updateRepository(convertUpdateRepositoryInput(values)) - toast.success('リポジトリの設定を更新しました') - await props.refetchRepo() - } catch (e) { - handleAPIError(e, 'リポジトリの設定の更新に失敗しました') - } - } + const handleSubmit: SubmitHandler = (values) => + handleSubmitRepositoryForm(values, async (output) => { + try { + await client.updateRepository(output) + toast.success('リポジトリの設定を更新しました') + await props.refetchRepo() + } catch (e) { + handleAPIError(e, 'リポジトリの設定の更新に失敗しました') + } + }) const discardChanges = () => { reset(formStore) @@ -52,12 +53,12 @@ const AuthConfigForm: Component = (props) => { {() => null} - + {() => null} - + {(field, fieldProps) => ( = (props) => { /> )} - + diff --git a/dashboard/src/features/repository/components/AuthMethodField.tsx b/dashboard/src/features/repository/components/AuthMethodField.tsx index 09afd8eb..0d11ca55 100644 --- a/dashboard/src/features/repository/components/AuthMethodField.tsx +++ b/dashboard/src/features/repository/components/AuthMethodField.tsx @@ -1,5 +1,5 @@ import { styled } from '@macaron-css/solid' -import { Field, type FormStore, getValue, setValues } from '@modular-forms/solid' +import { Field, getValue, setValues } from '@modular-forms/solid' import { type Component, Match, Show, Suspense, Switch, createEffect, createResource, createSignal } from 'solid-js' import { Button } from '/@/components/UI/Button' import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' @@ -9,6 +9,7 @@ import { RadioGroup, type RadioOption } from '/@/components/templates/RadioGroup import { client, systemInfo } from '/@/libs/api' import { colorVars, textVars } from '/@/theme' import { TextField } from '../../../components/UI/TextField' +import { useRepositoryForm } from '../provider/repositoryFormProvider' import type { CreateOrUpdateRepositoryInput } from '../schema/repositorySchema' const SshKeyContainer = styled('div', { @@ -58,18 +59,18 @@ const VisibilityButton = styled('button', { }) type Props = { - formStore: FormStore readonly?: boolean } -const authMethods: RadioOption['method']>[] = [ +const authMethods: RadioOption['method']>[] = [ { label: '認証を使用しない', value: 'none' }, { label: 'BASIC認証', value: 'basic' }, { label: 'SSH公開鍵認証', value: 'ssh' }, ] const AuthMethodField: Component = (props) => { - const authMethod = () => getValue(props.formStore, 'auth.method') + const { formStore } = useRepositoryForm() + const authMethod = () => getValue(formStore, 'form.auth.method') const [showPassword, setShowPassword] = createSignal(false) const [useTmpKey, setUseTmpKey] = createSignal(false) @@ -79,11 +80,13 @@ const AuthMethodField: Component = (props) => { ) createEffect(() => { if (tmpKey.latest !== undefined) { - setValues(props.formStore, { - auth: { - value: { - ssh: { - keyId: tmpKey().keyId, + setValues(formStore, { + form: { + auth: { + value: { + ssh: { + keyId: tmpKey().keyId, + }, }, }, }, @@ -94,7 +97,7 @@ const AuthMethodField: Component = (props) => { return ( <> - + {(field, fieldProps) => ( = (props) => { - + {(field, fieldProps) => ( = (props) => { /> )} - + {(field, fieldProps) => ( = (props) => { - + {() => ( diff --git a/dashboard/src/features/repository/components/CreateForm.tsx b/dashboard/src/features/repository/components/CreateForm.tsx index 52b8b1e2..da6910d1 100644 --- a/dashboard/src/features/repository/components/CreateForm.tsx +++ b/dashboard/src/features/repository/components/CreateForm.tsx @@ -9,8 +9,8 @@ import { TextField } from '/@/components/UI/TextField' import { useRepositoryForm } from '/@/features/repository/provider/repositoryFormProvider' import { type CreateOrUpdateRepositoryInput, - convertCreateRepositoryInput, createRepositoryFormInitialValues, + handleSubmitRepositoryForm, } from '/@/features/repository/schema/repositorySchema' import { client, handleAPIError } from '/@/libs/api' import { extractRepositoryNameFromURL } from '/@/libs/application' @@ -53,35 +53,38 @@ const CreateForm: Component = () => { // URLからリポジトリ名, 認証方法を自動入力 createEffect(() => { - const url = getValue(formStore, 'url') + const url = getValue(formStore, 'form.url') if (url === undefined || url === '') return // リポジトリ名を自動入力 const repositoryName = extractRepositoryNameFromURL(url) - setValue(formStore, 'name', repositoryName) + setValue(formStore, 'form.name', repositoryName) // 認証方法を自動入力 const isHTTPFormat = url.startsWith('http://') || url.startsWith('https://') if (!isHTTPFormat) { // Assume SSH or Git Protocol format setValues(formStore, { - auth: { - method: 'ssh', + form: { + auth: { + method: 'ssh', + }, }, }) } }) - const handleSubmit: SubmitHandler = async (values) => { - try { - const res = await client.createRepository(convertCreateRepositoryInput(values)) - toast.success('リポジトリを登録しました') - // 新規アプリ作成ページに遷移 - navigate(`/apps/new?repositoryID=${res.id}`) - } catch (e) { - return handleAPIError(e, 'リポジトリの登録に失敗しました') - } - } + const handleSubmit: SubmitHandler = (values) => + handleSubmitRepositoryForm(values, async (output) => { + try { + const res = await client.createRepository(output) + toast.success('リポジトリを登録しました') + // 新規アプリ作成ページに遷移 + navigate(`/apps/new?repositoryID=${res.id}`) + } catch (e) { + return handleAPIError(e, 'リポジトリの登録に失敗しました') + } + }) return (
@@ -90,7 +93,7 @@ const CreateForm: Component = () => { - + {(field, fieldProps) => ( { /> )} - + {(field, fieldProps) => ( { /> )} - + + + + ) +} + +export default WebsiteConfigForm diff --git a/dashboard/src/features/application/components/form/website/UrlField.tsx b/dashboard/src/features/application/components/form/website/UrlField.tsx new file mode 100644 index 00000000..ceadc17e --- /dev/null +++ b/dashboard/src/features/application/components/form/website/UrlField.tsx @@ -0,0 +1,128 @@ +import { Field, type FormStore, getValue, getValues } from '@modular-forms/solid' +import { type Component, Show } from 'solid-js' +import { TextField } from '/@/components/UI/TextField' +import { type SelectOption, SingleSelect } from '/@/components/templates/Select' +import { systemInfo } from '/@/libs/api' +import type { CreateWebsiteInput } from '../../../schema/websiteSchema' + +const schemeOptions: SelectOption<`${boolean}`>[] = [ + { value: 'false', label: 'http' }, + { value: 'true', label: 'https' }, +] + +type Props = { + formStore: FormStore + showHttpPort: boolean + readonly?: boolean +} + +const UrlField: Component = (props) => { + const selectedDomain = () => getValue(props.formStore, 'domain') + + return ( + <> + + {(field, fieldProps) => ( + +
スキーム
+
通常はhttpsが推奨です
+ + ), + }, + }} + {...fieldProps} + options={schemeOptions} + value={field.value ? 'true' : 'false'} + readOnly={props.readonly} + /> + )} +
+ + {(field, fieldProps) => ( + + + + )} + + + {(field, fieldProps) => ( + !domain.alreadyBound || selectedDomain() === domain.domain) + .map((domain) => { + const domainName = domain.domain.replace(/\*/g, '') + return { + value: domain.domain, + label: domainName, + } + }) ?? [] + } + value={field.value} + readOnly={props.readonly} + /> + )} + + + {(field, fieldProps) => ( + + )} + + + + {(field, fieldProps) => ( + + )} + + + + ) +} + +export default UrlField diff --git a/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx b/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx new file mode 100644 index 00000000..d8559903 --- /dev/null +++ b/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx @@ -0,0 +1,224 @@ +import { + Field, + Form, + type FormStore, + type SubmitHandler, + getErrors, + getValue, + reset, + setValue, + submit, + validate, +} from '@modular-forms/solid' +import { type Component, Show, createMemo } from 'solid-js' +import { AuthenticationType } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' +import ModalDeleteConfirm from '/@/components/UI/ModalDeleteConfirm' +import FormBox from '/@/components/layouts/FormBox' +import { CheckBox } from '/@/components/templates/CheckBox' +import { FormItem } from '/@/components/templates/FormItem' +import { RadioGroup, type RadioOption } from '/@/components/templates/RadioGroups' +import { systemInfo } from '/@/libs/api' +import useModal from '/@/libs/useModal' +import type { CreateWebsiteInput } from '../../../schema/websiteSchema' +import UrlField from './UrlField' + +const authenticationTypeOptions: RadioOption<`${AuthenticationType}`>[] = [ + { value: `${AuthenticationType.OFF}`, label: 'OFF' }, + { value: `${AuthenticationType.SOFT}`, label: 'SOFT' }, + { value: `${AuthenticationType.HARD}`, label: 'HARD' }, +] + +type Props = { + formStore: FormStore + isRuntimeApp: boolean + applyChanges: () => Promise | void + readonly?: boolean +} + +const WebsiteFieldGroup: Component = (props) => { + const { Modal, open, close } = useModal() + + const availableDomains = systemInfo()?.domains ?? [] + const selectedDomain = createMemo(() => { + const domainString = getValue(props.formStore, 'domain') + return availableDomains.find((d) => d.domain === domainString) + }) + const authAvailable = (): boolean => selectedDomain()?.authAvailable ?? false + + const discardChanges = () => { + reset(props.formStore) + } + + const websiteUrl = () => { + const scheme = getValue(props.formStore, 'https') ? 'https' : 'http' + const subDomain = getValue(props.formStore, 'subdomain') ?? '' + const domain = getValue(props.formStore, 'domain') ?? '' + const fqdn = domain.startsWith('*') ? `${subDomain}${domain.slice(1)}` : domain + + const pathPrefix = getValue(props.formStore, 'pathPrefix') + return `${scheme}://${fqdn}/${pathPrefix}` + } + + const handleSave = async () => { + // submit前のstateを保存しておく + const originalState = getValue(props.formStore, 'state') + if (!originalState) throw new Error("The field of state does not exist.") + + try { + setValue(props.formStore, 'state', 'readyToChange') + submit(props.formStore) + + const errors = getErrors(props.formStore) + console.log(errors); + } catch (e) { + console.log(e) + setValue(props.formStore, 'state', originalState) + } + } + + const handleDelete = () => { + setValue(props.formStore, 'state', 'readyToDelete') + submit(props.formStore) + close() + } + + const handleSubmit: SubmitHandler = () => { + props.applyChanges() + } + + const state = () => getValue(props.formStore, 'state') + + const disableSaveButton = () => + props.formStore.invalid || + props.formStore.submitting || + (state() !== 'added' && !props.formStore.dirty) || + props.readonly + + return ( + + + {() => null} + + + + + + {(field, fieldProps) => ( + + label="部員認証" + info={{ + style: 'left', + props: { + content: ( + <> +
OFF: 誰でもアクセス可能
+
SOFT: 部員の場合X-Forwarded-Userをセット
+
HARD: 部員のみアクセス可能
+ + ), + }, + }} + {...fieldProps} + tooltip={{ + props: { + content: `${selectedDomain()?.domain}では部員認証が使用できません`, + }, + disabled: authAvailable(), + }} + options={authenticationTypeOptions} + value={`${field.value ?? AuthenticationType.OFF}`} + disabled={!authAvailable()} + readOnly={props.readonly} + /> + )} +
+ + + {(field, fieldProps) => ( + + )} + + + + {(field, fieldProps) => ( + + )} + + + +
+ + + + + + + +
+ + Delete Website + + + language + {websiteUrl()} + + + + + + + + + ) +} + +export default WebsiteFieldGroup diff --git a/dashboard/src/features/application/schema/applicationConfigSchema.ts b/dashboard/src/features/application/schema/applicationConfigSchema.ts index 25d57079..c2847b93 100644 --- a/dashboard/src/features/application/schema/applicationConfigSchema.ts +++ b/dashboard/src/features/application/schema/applicationConfigSchema.ts @@ -2,13 +2,7 @@ import type { PartialMessage } from '@bufbuild/protobuf' import { match } from 'ts-pattern' import * as v from 'valibot' import type { ApplicationConfig } from '/@/api/neoshowcase/protobuf/gateway_pb' - -// KobalteのRadioGroupでは値としてbooleanが使えずstringしか使えないため、 -// RadioGroupでboolean入力を受け取りたい場合はこれを使用する -const stringBooleanSchema = v.pipe( - v.union([v.literal('true'), v.literal('false')]), - v.transform((i) => i === 'true'), -) +import { stringBooleanSchema } from '/@/libs/schemaUtil' const optionalBooleanSchema = (defaultValue = false) => v.pipe( diff --git a/dashboard/src/features/application/schema/applicationSchema.ts b/dashboard/src/features/application/schema/applicationSchema.ts index fa8edb68..27af20f3 100644 --- a/dashboard/src/features/application/schema/applicationSchema.ts +++ b/dashboard/src/features/application/schema/applicationSchema.ts @@ -2,49 +2,14 @@ import type { PartialMessage } from '@bufbuild/protobuf' import * as v from 'valibot' import { type Application, - AuthenticationType, - type AvailableDomain, type CreateApplicationRequest, - type CreateWebsiteRequest, type PortPublication, PortPublicationProtocol, type UpdateApplicationRequest, type UpdateApplicationRequest_UpdateOwners, - type Website, } from '/@/api/neoshowcase/protobuf/gateway_pb' -import { systemInfo } from '/@/libs/api' import { applicationConfigSchema, configMessageToSchema } from './applicationConfigSchema' - -const createWebsiteSchema = v.pipe( - v.object({ - state: v.union([v.literal('noChange'), v.literal('readyToChange'), v.literal('readyToDelete'), v.literal('added')]), - subdomain: v.string(), - domain: v.string(), - pathPrefix: v.string(), - stripPrefix: v.boolean(), - https: v.boolean(), - h2c: v.boolean(), - httpPort: v.pipe(v.number(), v.integer()), - authentication: v.enum(AuthenticationType), - }), - v.transform((input): PartialMessage => { - // wildcard domainならsubdomainとdomainを結合 - const fqdn = input.domain.startsWith('*') - ? `${input.subdomain}${input.domain.replace(/\*/g, '')}` - : // non-wildcard domainならdomainをそのまま使う - input.domain - - return { - fqdn, - authentication: input.authentication, - h2c: input.h2c, - httpPort: input.httpPort, - https: input.https, - pathPrefix: input.pathPrefix, - stripPrefix: input.stripPrefix, - } - }), -) +import { createWebsiteSchema, websiteMessageToSchema } from './websiteSchema' const portPublicationSchema = v.pipe( v.object({ @@ -137,57 +102,6 @@ export const updateApplicationSchema = v.pipe( type UpdateApplicationOutput = v.InferOutput -const extractSubdomain = ( - fqdn: string, - availableDomains: AvailableDomain[], -): { - subdomain: string - domain: string -} => { - const nonWildcardDomains = availableDomains.filter((d) => !d.domain.startsWith('*')) - const wildcardDomains = availableDomains.filter((d) => d.domain.startsWith('*')) - - const matchNonWildcardDomain = nonWildcardDomains.find((d) => fqdn === d.domain) - if (matchNonWildcardDomain !== undefined) { - return { - subdomain: '', - domain: matchNonWildcardDomain.domain, - } - } - - const matchDomain = wildcardDomains.find((d) => fqdn.endsWith(d.domain.replace(/\*/g, ''))) - if (matchDomain === undefined) { - const fallbackDomain = availableDomains.at(0) - if (fallbackDomain === undefined) throw new Error('No domain available') - return { - subdomain: '', - domain: fallbackDomain.domain, - } - } - return { - subdomain: fqdn.slice(0, -matchDomain.domain.length + 1), - domain: matchDomain.domain, - } -} - -const websiteMessageToSchema = (website: Website): v.InferInput => { - const availableDomains = systemInfo()?.domains ?? [] - - const { domain, subdomain } = extractSubdomain(website.fqdn, availableDomains) - - return { - state: 'noChange', - domain, - subdomain, - pathPrefix: website.pathPrefix, - stripPrefix: website.stripPrefix, - https: website.https, - h2c: website.h2c, - httpPort: website.httpPort, - authentication: website.authentication, - } -} - export const updateApplicationFormInitialValues = (input: Application): CreateOrUpdateApplicationInput => ({ type: 'update', form: { diff --git a/dashboard/src/features/application/schema/websiteSchema.ts b/dashboard/src/features/application/schema/websiteSchema.ts new file mode 100644 index 00000000..b0af6ea6 --- /dev/null +++ b/dashboard/src/features/application/schema/websiteSchema.ts @@ -0,0 +1,162 @@ +import type { PartialMessage } from '@bufbuild/protobuf' +import * as v from 'valibot' +import { + AuthenticationType, + type AvailableDomain, + type CreateWebsiteRequest, + type Website, +} from '/@/api/neoshowcase/protobuf/gateway_pb' +import { systemInfo } from '/@/libs/api' +import { stringBooleanSchema } from '/@/libs/schemaUtil' + +// KobalteのRadioGroupではstringしか扱えないためform内では文字列として持つ +const authenticationSchema = v.pipe( + v.union([ + v.literal(`${AuthenticationType.OFF}`), + v.literal(`${AuthenticationType.SOFT}`), + v.literal(`${AuthenticationType.HARD}`), + ]), + v.transform((input): AuthenticationType => { + switch (input) { + case `${AuthenticationType.OFF}`: { + return AuthenticationType.OFF + } + case `${AuthenticationType.SOFT}`: { + return AuthenticationType.SOFT + } + case `${AuthenticationType.HARD}`: { + return AuthenticationType.HARD + } + default: { + const _unreachable: never = input + throw new Error('unknown website AuthenticationType') + } + } + }), +) + +export const createWebsiteSchema = v.pipe( + v.variant('state', [ + v.pipe( + v.object({ + state: v.union([v.literal('noChange'), v.literal('readyToChange'), v.literal('added')]), + subdomain: v.optional(v.string()), + domain: v.string(), + pathPrefix: v.string(), + stripPrefix: v.boolean(), + https: stringBooleanSchema, + h2c: v.boolean(), + httpPort: v.pipe(v.number(), v.integer()), + authentication: authenticationSchema, + }), + // wildcard domainが選択されている場合サブドメインは空であってはならない + v.forward( + v.partialCheck( + [['subdomain'], ['domain']], + (input) => { + if (input.domain?.startsWith('*')) return input.subdomain !== '' + return true + }, + 'Please Enter Subdomain Name', + ), + ['subdomain'], + ), + ), + v.object({ + // 削除するwebsite設定の中身はチェックしない + state: v.literal('readyToDelete'), + }), + ]), + v.transform((input): PartialMessage => { + // 削除するwebsite設定の中身はチェックしない + if (input.state === 'readyToDelete') return {} + + // wildcard domainならsubdomainとdomainを結合 + const fqdn = input.domain.startsWith('*') + ? `${input.subdomain}${input.domain.replace(/\*/g, '')}` + : // non-wildcard domainならdomainをそのまま使う + input.domain + + return { + fqdn, + authentication: input.authentication, + h2c: input.h2c, + httpPort: input.httpPort, + https: input.https, + // バックエンド側では `/${prefix}` で持っている, フォーム内部では'/'を除いて持つ + pathPrefix: `/${input.pathPrefix}`, + stripPrefix: input.stripPrefix, + } + }), +) + +export type CreateWebsiteInput = v.InferInput + +export const parseCreateWebsiteInput = (input: unknown) => { + const result = v.parse(createWebsiteSchema, input) + return result +} + +const extractSubdomain = ( + fqdn: string, + availableDomains: AvailableDomain[], +): { + subdomain: string + domain: string +} => { + const nonWildcardDomains = availableDomains.filter((d) => !d.domain.startsWith('*')) + const wildcardDomains = availableDomains.filter((d) => d.domain.startsWith('*')) + + const matchNonWildcardDomain = nonWildcardDomains.find((d) => fqdn === d.domain) + if (matchNonWildcardDomain !== undefined) { + return { + subdomain: '', + domain: matchNonWildcardDomain.domain, + } + } + + const matchDomain = wildcardDomains.find((d) => fqdn.endsWith(d.domain.replace(/\*/g, ''))) + if (matchDomain === undefined) { + const fallbackDomain = availableDomains.at(0) + if (fallbackDomain === undefined) throw new Error('No domain available') + return { + subdomain: '', + domain: fallbackDomain.domain, + } + } + return { + subdomain: fqdn.slice(0, -matchDomain.domain.length + 1), + domain: matchDomain.domain, + } +} + +export const createWebsiteInitialValues = (domain: AvailableDomain): CreateWebsiteInput => ({ + state: 'added', + domain: domain.domain, + subdomain: '', + pathPrefix: '', + stripPrefix: false, + https: 'true', + h2c: false, + httpPort: 80, + authentication: `${AuthenticationType.OFF}`, +}) + +export const websiteMessageToSchema = (website: Website): CreateWebsiteInput => { + const availableDomains = systemInfo()?.domains ?? [] + + const { domain, subdomain } = extractSubdomain(website.fqdn, availableDomains) + + return { + state: 'noChange', + domain, + subdomain, + // バックエンド側では `/${prefix}` で持っている, フォーム内部では'/'を除いて持つ + pathPrefix: website.pathPrefix.slice(1), + stripPrefix: website.stripPrefix, + https: website.https ? 'true' : 'false', + h2c: website.h2c, + httpPort: website.httpPort, + authentication: `${website.authentication}`, + } +} diff --git a/dashboard/src/libs/schemaUtil.ts b/dashboard/src/libs/schemaUtil.ts new file mode 100644 index 00000000..05c2356a --- /dev/null +++ b/dashboard/src/libs/schemaUtil.ts @@ -0,0 +1,8 @@ +import * as v from 'valibot' + +// KobalteのRadioGroupでは値としてbooleanが使えずstringしか使えないため、 +// RadioGroupでboolean入力を受け取りたい場合はこれを使用する +export const stringBooleanSchema = v.pipe( + v.union([v.literal('true'), v.literal('false')]), + v.transform((i) => i === 'true'), +) diff --git a/dashboard/src/pages/apps/[id]/settings/urls.tsx b/dashboard/src/pages/apps/[id]/settings/urls.tsx index 89114a2d..5749af5a 100644 --- a/dashboard/src/pages/apps/[id]/settings/urls.tsx +++ b/dashboard/src/pages/apps/[id]/settings/urls.tsx @@ -4,8 +4,10 @@ import toast from 'solid-toast' import { DeployType } from '/@/api/neoshowcase/protobuf/gateway_pb' import { DataTable } from '/@/components/layouts/DataTable' import { type WebsiteFormStatus, WebsiteSettings, newWebsite } from '/@/components/templates/app/WebsiteSettings' +import { ApplicationFormProvider } from '/@/features/application/provider/applicationFormProvider' import { client, handleAPIError } from '/@/libs/api' import { useApplicationData } from '/@/routes' +import WebsiteConfigForm from '../../../../features/application/components/form/WebsitesConfigForm' export default () => { const { app, refetch, hasPermission } = useApplicationData() @@ -109,6 +111,11 @@ export default () => { /> )}
+ + + + + ) } From 8e9a9ab82ba467bb9e81d66d34c95c2c69d4efc7 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 16 Aug 2024 04:34:13 +0900 Subject: [PATCH 18/51] fmt --- dashboard/src/features/application/schema/websiteSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/features/application/schema/websiteSchema.ts b/dashboard/src/features/application/schema/websiteSchema.ts index b0af6ea6..5f6d67f0 100644 --- a/dashboard/src/features/application/schema/websiteSchema.ts +++ b/dashboard/src/features/application/schema/websiteSchema.ts @@ -75,7 +75,7 @@ export const createWebsiteSchema = v.pipe( const fqdn = input.domain.startsWith('*') ? `${input.subdomain}${input.domain.replace(/\*/g, '')}` : // non-wildcard domainならdomainをそのまま使う - input.domain + input.domain return { fqdn, From 146b0e709fc2d676eb2f184114d5f56f018546c7 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 16 Aug 2024 06:21:46 +0900 Subject: [PATCH 19/51] =?UTF-8?q?wip:=20websites=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/form/WebsitesConfigForm.tsx | 125 +++++++++++++----- .../src/pages/apps/[id]/settings/urls.tsx | 103 +-------------- 2 files changed, 95 insertions(+), 133 deletions(-) diff --git a/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx b/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx index b18c5f04..4ffd38b3 100644 --- a/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx +++ b/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx @@ -1,12 +1,18 @@ import { styled } from '@macaron-css/solid' -import { createFormStore, getValue, getValues, valiForm } from '@modular-forms/solid' -import { type Component, For } from 'solid-js' -import { createMutable, createStore } from 'solid-js/store' +import { createFormStore, getValues, setValue, valiForm } from '@modular-forms/solid' +import { type Component, For, createResource, createSignal } from 'solid-js' +import toast from 'solid-toast' +import { parse } from 'valibot' import type { Application } from '/@/api/neoshowcase/protobuf/gateway_pb' import { Button } from '/@/components/UI/Button' import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' -import { systemInfo } from '/@/libs/api' -import { createWebsiteInitialValues, createWebsiteSchema, websiteMessageToSchema } from '../../schema/websiteSchema' +import { client, handleAPIError, systemInfo } from '/@/libs/api' +import { + type CreateWebsiteInput, + createWebsiteInitialValues, + createWebsiteSchema, + websiteMessageToSchema, +} from '../../schema/websiteSchema' import WebsiteFieldGroup from './website/WebsiteFieldGroup' const AddMoreButtonContainer = styled('div', { @@ -23,17 +29,19 @@ type Props = { } const WebsiteConfigForm: Component = (props) => { - const formStores = createMutable( - props.app.websites.map((w) => - createFormStore({ - initialValues: websiteMessageToSchema(w), - validate: async (input) => { - console.log(input) - console.log(await valiForm(createWebsiteSchema)(input)) - return valiForm(createWebsiteSchema)(input) - }, - }), - ), + const [formStores, { mutate }] = createResource( + () => props.app.websites, + (websites) => + websites.map((w) => + createFormStore({ + initialValues: websiteMessageToSchema(w), + validate: async (input) => { + console.log(input) + console.log(await valiForm(createWebsiteSchema)(input)) + return valiForm(createWebsiteSchema)(input) + }, + }), + ), ) const availableDomains = systemInfo()?.domains ?? [] @@ -41,17 +49,17 @@ const WebsiteConfigForm: Component = (props) => { const addFormStore = () => { const defaultDomain = availableDomains.at(0) if (defaultDomain) { - formStores.push( - createFormStore({ - initialValues: createWebsiteInitialValues(defaultDomain), - validateOn: 'blur', - validate: async (input) => { - console.log(input) - console.log(await valiForm(createWebsiteSchema)(input)) - return valiForm(createWebsiteSchema)(input) - }, - }), - ) + const newForm = createFormStore({ + initialValues: createWebsiteInitialValues(defaultDomain), + // validateOn: 'blur', + validate: async (input) => { + console.log(input) + console.log(await valiForm(createWebsiteSchema)(input)) + return valiForm(createWebsiteSchema)(input) + }, + }) + + mutate((websites) => websites?.concat([newForm])) } } @@ -60,15 +68,70 @@ const WebsiteConfigForm: Component = (props) => { return configCase === 'runtimeBuildpack' || configCase === 'runtimeDockerfile' || configCase === 'runtimeCmd' } - const applyChanges = () => { - console.log(formStores.map((f) => getValues(f))) + const [isSubmitting, setIsSubmitting] = createSignal(false) + const applyChanges = async () => { + setIsSubmitting(true) + + try { + /** + * 送信するWebsite設定 + * - 変更を保存しないものの、initial value + * - 変更して保存するもの ( = `readyToSave`) + * - 追加するもの ( = `added`) + * - 削除しないもの ( = not `readyToDelete`) + */ + const websitesToSave = + formStores() + ?.map((form) => { + const values = getValues(form) + switch (values.state) { + case 'noChange': + return form.internal.initialValues + case 'readyToChange': + case 'added': + return values + case 'readyToDelete': + return undefined + } + }) + .filter((w): w is Exclude => w !== undefined) ?? [] + + const parsedWebsites = websitesToSave.map((w) => parse(createWebsiteSchema, w)) + + await client.updateApplication({ + id: props.app.id, + websites: { + websites: parsedWebsites, + }, + }) + toast.success('ウェブサイト設定を保存しました') + void props.refetchApp() + } catch (e) { + // `readyToChange` を `noChange` に戻す + for (const form of formStores() ?? []) { + const values = getValues(form) + if (values.state === 'readyToChange') { + setValue(form, 'state', 'noChange') + } + } + handleAPIError(e, 'Failed to save website settings') + } finally { + setIsSubmitting(false) + } } return ( <> - + {(formStore) =>
{JSON.stringify(getValues(formStore), null, 2)}
}
+ {(formStore) => ( - + )} diff --git a/dashboard/src/pages/apps/[id]/settings/urls.tsx b/dashboard/src/pages/apps/[id]/settings/urls.tsx index 5749af5a..28fa28c4 100644 --- a/dashboard/src/pages/apps/[id]/settings/urls.tsx +++ b/dashboard/src/pages/apps/[id]/settings/urls.tsx @@ -1,116 +1,15 @@ -import { createFormStore, getValue, getValues, setValue } from '@modular-forms/solid' -import { Show, createResource } from 'solid-js' -import toast from 'solid-toast' -import { DeployType } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Show } from 'solid-js' import { DataTable } from '/@/components/layouts/DataTable' -import { type WebsiteFormStatus, WebsiteSettings, newWebsite } from '/@/components/templates/app/WebsiteSettings' import { ApplicationFormProvider } from '/@/features/application/provider/applicationFormProvider' -import { client, handleAPIError } from '/@/libs/api' import { useApplicationData } from '/@/routes' import WebsiteConfigForm from '../../../../features/application/components/form/WebsitesConfigForm' export default () => { const { app, refetch, hasPermission } = useApplicationData() - const [websiteForms, { mutate }] = createResource( - () => app()?.websites, - (websites) => { - return websites.map((website) => { - const form = createFormStore({ - initialValues: { - state: 'noChange', - website: structuredClone(website), - }, - }) - return form - }) - }, - ) - const addWebsiteForm = () => { - const form = createFormStore({ - initialValues: { - state: 'added', - website: newWebsite(), - }, - }) - mutate((forms) => { - return forms?.concat([form]) - }) - } - const deleteWebsiteForm = (index: number) => { - if (!websiteForms.latest) return - - const state = getValue(websiteForms()[index], 'state') - if (state === 'added') { - mutate((forms) => { - return forms?.filter((_, i) => i !== index) - }) - } else { - setValue(websiteForms()[index], 'state', 'readyToDelete') - handleApplyChanges() - } - } - - const handleApplyChanges = async () => { - try { - /** - * 送信するWebsite設定 - * - 変更を保存しないものの、initial value - * - 変更して保存するもの ( = `readyToSave`) - * - 追加するもの ( = `added`) - * - 削除しないもの ( = not `readyToDelete`) - */ - const websitesToSave = websiteForms() - ?.map((form) => { - const values = getValues(form) - switch (values.state) { - case 'noChange': - return form.internal.initialValues.website - case 'readyToChange': - return values.website - case 'added': - return values.website - case 'readyToDelete': - return undefined - } - }) - .filter((w): w is Exclude => w !== undefined) - - await client.updateApplication({ - id: app()?.id, - websites: { - websites: websitesToSave, - }, - }) - toast.success('ウェブサイト設定を保存しました') - void refetch() - } catch (e) { - // `readyToChange` を `noChange` に戻す - for (const form of websiteForms() ?? []) { - const values = getValues(form) - if (values.state === 'readyToChange') { - setValue(form, 'state', 'noChange') - } - } - handleAPIError(e, 'Failed to save website settings') - } - } - return ( URLs - - {(nonNullForms) => ( - - )} - From e889a9a887a751536ed202912c05755ffa22f886 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 16 Aug 2024 06:22:12 +0900 Subject: [PATCH 20/51] chore: Update WebsiteFieldGroup component to disable save button when submitting --- .../components/form/website/WebsiteFieldGroup.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx b/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx index d8559903..11db164c 100644 --- a/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx +++ b/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx @@ -34,6 +34,7 @@ type Props = { formStore: FormStore isRuntimeApp: boolean applyChanges: () => Promise | void + isSubmitting: boolean readonly?: boolean } @@ -62,16 +63,13 @@ const WebsiteFieldGroup: Component = (props) => { } const handleSave = async () => { - // submit前のstateを保存しておく + // submitに失敗したときにstateを元に戻すため、submit前のstateを保存しておく const originalState = getValue(props.formStore, 'state') - if (!originalState) throw new Error("The field of state does not exist.") + if (!originalState) throw new Error('The field of state does not exist.') try { setValue(props.formStore, 'state', 'readyToChange') submit(props.formStore) - - const errors = getErrors(props.formStore) - console.log(errors); } catch (e) { console.log(e) setValue(props.formStore, 'state', originalState) @@ -90,9 +88,10 @@ const WebsiteFieldGroup: Component = (props) => { const state = () => getValue(props.formStore, 'state') - const disableSaveButton = () => + const isSaveDisabled = () => props.formStore.invalid || props.formStore.submitting || + props.isSubmitting || (state() !== 'added' && !props.formStore.dirty) || props.readonly @@ -186,7 +185,7 @@ const WebsiteFieldGroup: Component = (props) => { size="small" type="button" onClick={handleSave} - disabled={disableSaveButton()} + disabled={isSaveDisabled()} loading={props.formStore.submitting} tooltip={{ props: { From 3e07d213e07a5589a263f161a4ac62f02ed474dd Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 16 Aug 2024 06:35:14 +0900 Subject: [PATCH 21/51] =?UTF-8?q?fix:=20singleSelect=E3=81=AEhiddenSelect?= =?UTF-8?q?=E3=81=AEonChange=E3=81=8C=E5=8B=95=E3=81=84=E3=81=A6=E3=81=84?= =?UTF-8?q?=E3=81=AA=E3=81=8B=E3=81=A3=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/src/components/templates/Select.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/dashboard/src/components/templates/Select.tsx b/dashboard/src/components/templates/Select.tsx index 8616148f..12bee884 100644 --- a/dashboard/src/components/templates/Select.tsx +++ b/dashboard/src/components/templates/Select.tsx @@ -174,7 +174,21 @@ export const SingleSelect = (props: SingleSelectProps ['placeholder', 'ref', 'onInput', 'onChange', 'onBlur'], ) - const selectedOption = () => props.options.find((o) => o.value === props.value) + // const selectedOption = () => props.options.find((o) => o.value === props.value) + const [selectedOption, setSelectedOption] = createSignal>() + + createEffect(() => { + console.log(props.options) + + const found = props.options.find((o) => o.value === props.value) + // KobalteのSelect/Comboboxではundefinedを使用できないため、空文字列を指定している + setSelectedOption( + found ?? { + label: '', + value: '' as T, + }, + ) + }) return ( > @@ -183,7 +197,10 @@ export const SingleSelect = (props: SingleSelectProps multiple={false} disallowEmptySelection value={selectedOption()} - onChange={(v) => props.setValue?.(v.value)} + onChange={(v) => { + props.setValue?.(v.value) + setSelectedOption(v) + }} optionValue="value" optionTextValue="label" validationState={props.error ? 'invalid' : 'valid'} From a18da264f8d5292de0a65b348d8da1683cdc5995 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 16 Aug 2024 06:35:42 +0900 Subject: [PATCH 22/51] chore: refactor URL form --- .../components/form/website/UrlField.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/dashboard/src/features/application/components/form/website/UrlField.tsx b/dashboard/src/features/application/components/form/website/UrlField.tsx index ceadc17e..f6713867 100644 --- a/dashboard/src/features/application/components/form/website/UrlField.tsx +++ b/dashboard/src/features/application/components/form/website/UrlField.tsx @@ -18,6 +18,18 @@ type Props = { const UrlField: Component = (props) => { const selectedDomain = () => getValue(props.formStore, 'domain') + // 占有されているドメインはoptionに表示しない + // すでに設定されているドメインはoptionに表示する + const domainOptions = () => + systemInfo() + ?.domains.filter((domain) => !domain.alreadyBound || selectedDomain() === domain.domain) + .map((domain) => { + const domainName = domain.domain.replace(/\*/g, '') + return { + value: domain.domain, + label: domainName, + } + }) ?? [] return ( <> @@ -36,7 +48,7 @@ const UrlField: Component = (props) => { }} {...fieldProps} options={schemeOptions} - value={field.value ? 'true' : 'false'} + value={field.value} readOnly={props.readonly} /> )} @@ -68,19 +80,7 @@ const UrlField: Component = (props) => { }, }} {...fieldProps} - options={ - // 占有されているドメインはoptionに表示しない - // すでに設定されているドメインはoptionに表示する - systemInfo() - ?.domains.filter((domain) => !domain.alreadyBound || selectedDomain() === domain.domain) - .map((domain) => { - const domainName = domain.domain.replace(/\*/g, '') - return { - value: domain.domain, - label: domainName, - } - }) ?? [] - } + options={domainOptions()} value={field.value} readOnly={props.readonly} /> From af4fdaf53a751df6eb0d9b37782ad278a49cd611 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 16 Aug 2024 07:47:50 +0900 Subject: [PATCH 23/51] =?UTF-8?q?fix:=20authAvailable=E3=81=8Cfalse?= =?UTF-8?q?=E3=81=AE=E6=99=82=E8=87=AA=E5=8B=95=E3=81=A7=E8=AA=8D=E8=A8=BC?= =?UTF-8?q?off=E3=82=92=E9=81=B8=E6=8A=9E=E3=81=99=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/form/website/WebsiteFieldGroup.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx b/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx index 11db164c..7db8b6aa 100644 --- a/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx +++ b/dashboard/src/features/application/components/form/website/WebsiteFieldGroup.tsx @@ -3,14 +3,13 @@ import { Form, type FormStore, type SubmitHandler, - getErrors, getValue, reset, setValue, + setValues, submit, - validate, } from '@modular-forms/solid' -import { type Component, Show, createMemo } from 'solid-js' +import { type Component, Show, createEffect, createMemo } from 'solid-js' import { AuthenticationType } from '/@/api/neoshowcase/protobuf/gateway_pb' import { Button } from '/@/components/UI/Button' import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' @@ -48,6 +47,12 @@ const WebsiteFieldGroup: Component = (props) => { }) const authAvailable = (): boolean => selectedDomain()?.authAvailable ?? false + createEffect(() => { + if (!authAvailable()) { + setValues(props.formStore, { authentication: `${AuthenticationType.OFF}` }) + } + }) + const discardChanges = () => { reset(props.formStore) } @@ -127,7 +132,7 @@ const WebsiteFieldGroup: Component = (props) => { disabled: authAvailable(), }} options={authenticationTypeOptions} - value={`${field.value ?? AuthenticationType.OFF}`} + value={field.value ?? `${AuthenticationType.OFF}`} disabled={!authAvailable()} readOnly={props.readonly} /> From 47fb627d49fb5c3edc03d19737da2886653622dd Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 16 Aug 2024 07:48:01 +0900 Subject: [PATCH 24/51] fix type issue --- .../application/schema/websiteSchema.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/dashboard/src/features/application/schema/websiteSchema.ts b/dashboard/src/features/application/schema/websiteSchema.ts index 5f6d67f0..683f9254 100644 --- a/dashboard/src/features/application/schema/websiteSchema.ts +++ b/dashboard/src/features/application/schema/websiteSchema.ts @@ -35,19 +35,23 @@ const authenticationSchema = v.pipe( }), ) +const websiteValues = v.object({ + subdomain: v.optional(v.string()), + domain: v.string(), + pathPrefix: v.string(), + stripPrefix: v.boolean(), + https: stringBooleanSchema, + h2c: v.boolean(), + httpPort: v.pipe(v.number(), v.integer()), + authentication: authenticationSchema, +}) + export const createWebsiteSchema = v.pipe( v.variant('state', [ v.pipe( v.object({ state: v.union([v.literal('noChange'), v.literal('readyToChange'), v.literal('added')]), - subdomain: v.optional(v.string()), - domain: v.string(), - pathPrefix: v.string(), - stripPrefix: v.boolean(), - https: stringBooleanSchema, - h2c: v.boolean(), - httpPort: v.pipe(v.number(), v.integer()), - authentication: authenticationSchema, + ...websiteValues.entries, }), // wildcard domainが選択されている場合サブドメインは空であってはならない v.forward( @@ -65,6 +69,7 @@ export const createWebsiteSchema = v.pipe( v.object({ // 削除するwebsite設定の中身はチェックしない state: v.literal('readyToDelete'), + ...v.partial(websiteValues).entries, }), ]), v.transform((input): PartialMessage => { From 53795061775798da0ffc89c634a704c52dee9710 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 16 Aug 2024 07:57:31 +0900 Subject: [PATCH 25/51] remove debug logs --- .../components/form/WebsitesConfigForm.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx b/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx index 4ffd38b3..d6b30d85 100644 --- a/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx +++ b/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx @@ -35,11 +35,7 @@ const WebsiteConfigForm: Component = (props) => { websites.map((w) => createFormStore({ initialValues: websiteMessageToSchema(w), - validate: async (input) => { - console.log(input) - console.log(await valiForm(createWebsiteSchema)(input)) - return valiForm(createWebsiteSchema)(input) - }, + validate: valiForm(createWebsiteSchema), }), ), ) @@ -51,12 +47,7 @@ const WebsiteConfigForm: Component = (props) => { if (defaultDomain) { const newForm = createFormStore({ initialValues: createWebsiteInitialValues(defaultDomain), - // validateOn: 'blur', - validate: async (input) => { - console.log(input) - console.log(await valiForm(createWebsiteSchema)(input)) - return valiForm(createWebsiteSchema)(input) - }, + validate: valiForm(createWebsiteSchema), }) mutate((websites) => websites?.concat([newForm])) @@ -122,7 +113,6 @@ const WebsiteConfigForm: Component = (props) => { return ( <> - {(formStore) =>
{JSON.stringify(getValues(formStore), null, 2)}
}
{(formStore) => ( Date: Sat, 17 Aug 2024 03:56:20 +0900 Subject: [PATCH 26/51] chore: style URL form and add website warnings --- .../components/form/website/UrlField.tsx | 237 +++++++++++------- 1 file changed, 149 insertions(+), 88 deletions(-) diff --git a/dashboard/src/features/application/components/form/website/UrlField.tsx b/dashboard/src/features/application/components/form/website/UrlField.tsx index f6713867..0d9b0f7b 100644 --- a/dashboard/src/features/application/components/form/website/UrlField.tsx +++ b/dashboard/src/features/application/components/form/website/UrlField.tsx @@ -1,10 +1,49 @@ +import { styled } from '@macaron-css/solid' import { Field, type FormStore, getValue, getValues } from '@modular-forms/solid' -import { type Component, Show } from 'solid-js' +import { type Component, For, Show } from 'solid-js' import { TextField } from '/@/components/UI/TextField' +import { FormItem } from '/@/components/templates/FormItem' import { type SelectOption, SingleSelect } from '/@/components/templates/Select' import { systemInfo } from '/@/libs/api' +import { websiteWarnings } from '/@/libs/application' +import { colorVars } from '/@/theme' import type { CreateWebsiteInput } from '../../../schema/websiteSchema' +const URLContainer = styled('div', { + base: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-top', + gap: '8px', + }, +}) +const URLItem = styled('div', { + base: { + display: 'flex', + alignItems: 'center', + }, + variants: { + fixedWidth: { + true: { + flexShrink: 0, + width: 'calc(6ch + 60px)', + }, + }, + }, +}) +const WarningsContainer = styled('div', { + base: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, +}) +const WarningItem = styled('div', { + base: { + color: colorVars.semantic.accent.error, + }, +}) + const schemeOptions: SelectOption<`${boolean}`>[] = [ { value: 'false', label: 'http' }, { value: 'true', label: 'https' }, @@ -31,96 +70,118 @@ const UrlField: Component = (props) => { } }) ?? [] + const warnings = () => + websiteWarnings(getValue(props.formStore, 'subdomain'), getValue(props.formStore, 'https') === 'true') + return ( <> - - {(field, fieldProps) => ( - -
スキーム
-
通常はhttpsが推奨です
- - ), - }, - }} - {...fieldProps} - options={schemeOptions} - value={field.value} - readOnly={props.readonly} - /> - )} -
- - {(field, fieldProps) => ( - - + + + + + {(field, fieldProps) => ( + +
スキーム
+
通常はhttpsが推奨です
+ + ), + }, + }} + {...fieldProps} + options={schemeOptions} + value={field.value} + readOnly={props.readonly} + /> + )} +
+
+ :// + + {(field, fieldProps) => ( + + + + )} + + + {(field, fieldProps) => ( + + )} + +
+ + / + + {(field, fieldProps) => ( + + )} + + + + + + {(field, fieldProps) => ( + + )} + + + /TCP - )} -
- - {(field, fieldProps) => ( - - )} - - - {(field, fieldProps) => ( - - )} - - - - {(field, fieldProps) => ( - - )} - - + + 0}> + + {(item) => {item}} + + + ) } From 5c0da125965681cf268178f9758e0052d2968410 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Sat, 17 Aug 2024 04:48:06 +0900 Subject: [PATCH 27/51] chore: remove debug logs --- dashboard/src/components/templates/Select.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/dashboard/src/components/templates/Select.tsx b/dashboard/src/components/templates/Select.tsx index 12bee884..a36f4f55 100644 --- a/dashboard/src/components/templates/Select.tsx +++ b/dashboard/src/components/templates/Select.tsx @@ -178,8 +178,6 @@ export const SingleSelect = (props: SingleSelectProps const [selectedOption, setSelectedOption] = createSignal>() createEffect(() => { - console.log(props.options) - const found = props.options.find((o) => o.value === props.value) // KobalteのSelect/Comboboxではundefinedを使用できないため、空文字列を指定している setSelectedOption( From 0429719c11ce9576b733609caad2fb20c2abb7a9 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Sat, 17 Aug 2024 04:57:22 +0900 Subject: [PATCH 28/51] feat: use valibot form in PortForwarding --- .../components/form/PortForwardingForm.tsx | 152 ++++++++++++++++++ .../form/portForwarding/PortField.tsx | 143 ++++++++++++++++ .../application/schema/applicationSchema.ts | 24 +-- .../schema/portPublicationSchema.ts | 70 ++++++++ .../apps/[id]/settings/portForwarding.tsx | 84 ++-------- 5 files changed, 381 insertions(+), 92 deletions(-) create mode 100644 dashboard/src/features/application/components/form/PortForwardingForm.tsx create mode 100644 dashboard/src/features/application/components/form/portForwarding/PortField.tsx create mode 100644 dashboard/src/features/application/schema/portPublicationSchema.ts diff --git a/dashboard/src/features/application/components/form/PortForwardingForm.tsx b/dashboard/src/features/application/components/form/PortForwardingForm.tsx new file mode 100644 index 00000000..73bb0d30 --- /dev/null +++ b/dashboard/src/features/application/components/form/PortForwardingForm.tsx @@ -0,0 +1,152 @@ +import { styled } from '@macaron-css/solid' +import { Field, FieldArray, Form, type SubmitHandler, insert, reset, setValues } from '@modular-forms/solid' +import { type Component, For, Show, createEffect, onMount, untrack } from 'solid-js' +import toast from 'solid-toast' +import { type Application, PortPublicationProtocol } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' +import FormBox from '/@/components/layouts/FormBox' +import { client, handleAPIError, systemInfo } from '/@/libs/api' +import { pickRandom, randIntN } from '/@/libs/random' +import { colorVars } from '/@/theme' +import { useApplicationForm } from '../../provider/applicationFormProvider' +import { + type CreateOrUpdateApplicationInput, + handleSubmitUpdateApplicationForm, + updateApplicationFormInitialValues, +} from '../../schema/applicationSchema' +import type { PortPublicationInput } from '../../schema/portPublicationSchema' +import PortField from './portForwarding/PortField' + +const PortsContainer = styled('div', { + base: { + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '16px', + }, +}) +const FallbackText = styled('div', { + color: colorVars.semantic.text.black, +}) + +const suggestPort = (protocol: PortPublicationProtocol): number => { + const available = systemInfo()?.ports.filter((a) => a.protocol === protocol) || [] + if (available.length === 0) return 0 + const range = pickRandom(available) + return randIntN(range.endPort + 1 - range.startPort) + range.startPort +} + +const newPort = (): PortPublicationInput => { + return { + internetPort: suggestPort(PortPublicationProtocol.TCP), + applicationPort: 0, + protocol: `${PortPublicationProtocol.TCP}`, + } +} + +type Props = { + app: Application + refetchApp: () => Promise + hasPermission: boolean +} + +const PortForwardingForm: Component = (props) => { + const { formStore } = useApplicationForm() + + const discardChanges = () => { + reset( + untrack(() => formStore), + { + initialValues: updateApplicationFormInitialValues(props.app), + }, + ) + } + + // `reset` doesn't work on first render when the Field not rendered + // see: https://github.com/fabian-hiller/modular-forms/issues/157#issuecomment-1848567069 + onMount(() => { + setValues(formStore, updateApplicationFormInitialValues(props.app)) + }) + + // reset forms when props.app changed + createEffect(() => { + discardChanges() + }) + + const handleAdd = () => { + insert(formStore, 'form.portPublications', { + value: newPort(), + }) + } + + const handleSubmit: SubmitHandler = (values) => + handleSubmitUpdateApplicationForm(values, async (output) => { + try { + await client.updateApplication(output) + toast.success('ポート公開設定を更新しました') + props.refetchApp() + } catch (e) { + handleAPIError(e, 'ポート公開設定の更新に失敗しました') + } + }) + + return ( +
+ + {() => null} + + + {() => null} + + + + + + {(fieldArray) => ( + ポート公開が設定されていません}> + {(_, index) => } + + )} + + + + + + + + + + + +
+ ) +} + +export default PortForwardingForm diff --git a/dashboard/src/features/application/components/form/portForwarding/PortField.tsx b/dashboard/src/features/application/components/form/portForwarding/PortField.tsx new file mode 100644 index 00000000..1b685ff9 --- /dev/null +++ b/dashboard/src/features/application/components/form/portForwarding/PortField.tsx @@ -0,0 +1,143 @@ +import { styled } from '@macaron-css/solid' +import { Field, getValue, remove } from '@modular-forms/solid' +import { type Component, Show } from 'solid-js' +import { PortPublicationProtocol } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import { TextField } from '/@/components/UI/TextField' +import { type SelectOption, SingleSelect } from '/@/components/templates/Select' +import { useApplicationForm } from '../../../provider/applicationFormProvider' + +const PortRow = styled('div', { + base: { + width: '100%', + display: 'flex', + alignItems: 'center', + gap: '24px', + }, +}) +const PortVisualContainer = styled('div', { + base: { + alignItems: 'flex-start', + gap: '8px', + }, + variants: { + variant: { + from: { + width: '100%', + flexBasis: 'calc(60% - 4px)', + display: 'grid', + flexGrow: '1', + gridTemplateColumns: 'minmax(calc(8ch + 32px), 1fr) auto minmax(calc(4ch + 60px), 1fr)', + }, + to: { + width: '100%', + flexBasis: 'calc(40% - 4px)', + display: 'grid', + flexGrow: '1', + gridTemplateColumns: 'auto minmax(calc(8ch + 32px), 1fr) auto', + }, + wrapper: { + width: '100%', + display: 'flex', + flexWrap: 'wrap', + }, + }, + }, +}) +const PortItem = styled('div', { + base: { + height: '48px', + display: 'flex', + alignItems: 'center', + }, +}) + +const protocolItems: SelectOption<`${PortPublicationProtocol}`>[] = [ + { value: `${PortPublicationProtocol.TCP}`, label: 'TCP' }, + { value: `${PortPublicationProtocol.UDP}`, label: 'UDP' }, +] + +const protoToName: Record<`${PortPublicationProtocol}`, string> = { + [PortPublicationProtocol.TCP]: 'TCP', + [PortPublicationProtocol.UDP]: 'UDP', +} + +type Props = { + index: number + hasPermission: boolean +} + +const PortField: Component = (props) => { + const { formStore } = useApplicationForm() + + const handleDelete = (index: number) => { + remove(formStore, 'form.portPublications', { at: index }) + } + + return ( + + + + + {(field, fieldProps) => ( + + )} + + / + + {(field, fieldProps) => ( + + )} + + + + + + {(field, fieldProps) => ( + + )} + + + {(protocol) => /{protoToName[protocol()]}} + + + + + + + + ) +} + +export default PortField diff --git a/dashboard/src/features/application/schema/applicationSchema.ts b/dashboard/src/features/application/schema/applicationSchema.ts index 27af20f3..5df54e42 100644 --- a/dashboard/src/features/application/schema/applicationSchema.ts +++ b/dashboard/src/features/application/schema/applicationSchema.ts @@ -1,25 +1,15 @@ import type { PartialMessage } from '@bufbuild/protobuf' import * as v from 'valibot' -import { - type Application, - type CreateApplicationRequest, - type PortPublication, - PortPublicationProtocol, - type UpdateApplicationRequest, - type UpdateApplicationRequest_UpdateOwners, +import type { + Application, + CreateApplicationRequest, + UpdateApplicationRequest, + UpdateApplicationRequest_UpdateOwners, } from '/@/api/neoshowcase/protobuf/gateway_pb' import { applicationConfigSchema, configMessageToSchema } from './applicationConfigSchema' +import { portPublicationMessageToSchema, portPublicationSchema } from './portPublicationSchema' import { createWebsiteSchema, websiteMessageToSchema } from './websiteSchema' -const portPublicationSchema = v.pipe( - v.object({ - internetPort: v.pipe(v.number(), v.integer()), - applicationPort: v.pipe(v.number(), v.integer()), - protocol: v.enum(PortPublicationProtocol), - }), - v.transform((input): PartialMessage => input), -) - // --- create application const createApplicationSchema = v.pipe( @@ -111,7 +101,7 @@ export const updateApplicationFormInitialValues = (input: Application): CreateOr refName: input.refName, config: input.config ? configMessageToSchema(input.config) : undefined, websites: input.websites.map((w) => websiteMessageToSchema(w)), - portPublications: input.portPublications, + portPublications: input.portPublications.map((p) => portPublicationMessageToSchema(p)), ownerIds: input.ownerIds, }, }) diff --git a/dashboard/src/features/application/schema/portPublicationSchema.ts b/dashboard/src/features/application/schema/portPublicationSchema.ts new file mode 100644 index 00000000..9454f32f --- /dev/null +++ b/dashboard/src/features/application/schema/portPublicationSchema.ts @@ -0,0 +1,70 @@ +import type { PartialMessage } from '@bufbuild/protobuf' +import * as v from 'valibot' +import { + type Application, + type PortPublication, + PortPublicationProtocol, + Website, +} from '/@/api/neoshowcase/protobuf/gateway_pb' +import { systemInfo } from '/@/libs/api' + +const isValidPort = (port?: number, protocol?: PortPublicationProtocol): boolean => { + if (port === undefined) return false + const available = systemInfo()?.ports.filter((a) => a.protocol === protocol) || [] + if (available.length === 0) return false + return available.some((range) => port >= range.startPort && port <= range.endPort) +} + +// KobalteのSelectではstringしか扱えないためform内では文字列として持つ +const protocolSchema = v.pipe( + v.union([v.literal(`${PortPublicationProtocol.TCP}`), v.literal(`${PortPublicationProtocol.UDP}`)]), + v.transform((input): PortPublicationProtocol => { + switch (input) { + case `${PortPublicationProtocol.TCP}`: { + return PortPublicationProtocol.TCP + } + case `${PortPublicationProtocol.UDP}`: { + return PortPublicationProtocol.UDP + } + default: { + const _unreachable: never = input + throw new Error('unknown PortPublicationProtocol') + } + } + }), +) + +export const portPublicationSchema = v.pipe( + v.object({ + internetPort: v.pipe(v.number(), v.integer()), + applicationPort: v.pipe(v.number(), v.integer()), + protocol: protocolSchema, + }), + v.transform((input): PartialMessage => input), + v.forward( + v.partialCheck( + [['internetPort'], ['protocol']], + (input) => isValidPort(input.internetPort, input.protocol), + 'Please enter the available port', + ), + ['internetPort'], + ), + v.forward( + v.partialCheck( + [['applicationPort'], ['protocol']], + (input) => isValidPort(input.applicationPort, input.protocol), + 'Please enter the available port', + ), + ['applicationPort'], + ), +) + +export type PortPublicationInput = v.InferInput + +export const portPublicationMessageToSchema = (portPublication: PortPublication): PortPublicationInput => { + return { + applicationPort: portPublication.applicationPort, + internetPort: portPublication.internetPort, + protocol: `${portPublication.protocol}`, + } +} diff --git a/dashboard/src/pages/apps/[id]/settings/portForwarding.tsx b/dashboard/src/pages/apps/[id]/settings/portForwarding.tsx index a40ea91e..f87ea129 100644 --- a/dashboard/src/pages/apps/[id]/settings/portForwarding.tsx +++ b/dashboard/src/pages/apps/[id]/settings/portForwarding.tsx @@ -1,13 +1,9 @@ import { styled } from '@macaron-css/solid' -import { Form, type SubmitHandler, createFormStore, reset, setValues } from '@modular-forms/solid' import { For, Show, createEffect } from 'solid-js' -import { onMount } from 'solid-js' -import toast from 'solid-toast' -import { Button } from '/@/components/UI/Button' import { DataTable } from '/@/components/layouts/DataTable' -import FormBox from '/@/components/layouts/FormBox' -import { PortPublicationSettings, type PortSettingsStore } from '/@/components/templates/app/PortPublications' -import { client, handleAPIError, systemInfo } from '/@/libs/api' +import PortForwardingForm from '/@/features/application/components/form/PortForwardingForm' +import { ApplicationFormProvider } from '/@/features/application/provider/applicationFormProvider' +import { systemInfo } from '/@/libs/api' import { portPublicationProtocolMap } from '/@/libs/application' import { useApplicationData } from '/@/routes' @@ -19,47 +15,10 @@ const Li = styled('li', { export default () => { const { app, refetch, hasPermission } = useApplicationData() - const loaded = () => !!(app() && systemInfo()) - const form = createFormStore({ - initialValues: { - ports: structuredClone(app()?.portPublications), - }, - }) - - onMount(() => { - setValues(form, { - ports: structuredClone(app()?.portPublications), - }) - }) - - const discardChanges = () => { - reset(form, { - initialValues: { - ports: structuredClone(app()?.portPublications), - }, - }) - } - // reset form when app updated - createEffect(discardChanges) - - const handleSubmit: SubmitHandler = async (value) => { - try { - await client.updateApplication({ - id: app()?.id, - portPublications: { - portPublications: value.ports, - }, - }) - toast.success('ポート公開設定を更新しました') - void refetch() - } catch (e) { - handleAPIError(e, 'ポート公開設定の更新に失敗しました') - } - } return ( - + Port Forwarding TCP/UDPポート公開設定 (複数設定可能)
@@ -73,36 +32,11 @@ export default () => { )}
-
- - - - - - - - - - - -
+
+ + + +
) From e1180f41c38a8ef6b93442e1cf0321e602cd743a Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Sat, 17 Aug 2024 12:21:20 +0900 Subject: [PATCH 29/51] chore: Update useFormContext error message --- dashboard/src/libs/useFormContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/src/libs/useFormContext.tsx b/dashboard/src/libs/useFormContext.tsx index 1a83f15c..62bd5a4b 100644 --- a/dashboard/src/libs/useFormContext.tsx +++ b/dashboard/src/libs/useFormContext.tsx @@ -34,7 +34,7 @@ export const useFormContext = { const c = useContext(FormContext) - if (!c) throw new Error('useRepositoryCreateForm must be used within a RepositoryCreateFormProvider') + if (!c) throw new Error('useForm must be used within a FormProvider') return c } From 424508af29c9e2cac80d1c60bc6a1fae9d63ff31 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Sat, 17 Aug 2024 15:07:37 +0900 Subject: [PATCH 30/51] =?UTF-8?q?feat:=20=E7=92=B0=E5=A2=83=E5=A4=89?= =?UTF-8?q?=E6=95=B0=E8=A8=AD=E5=AE=9Aform=E3=81=A7=E3=81=AEvalibot?= =?UTF-8?q?=E3=81=AE=E4=BD=BF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/form/EnvVarConfigForm.tsx | 218 +++++++++++++++++ .../application/schema/envVarSchema.ts | 98 ++++++++ .../src/pages/apps/[id]/settings/envVars.tsx | 219 +----------------- 3 files changed, 320 insertions(+), 215 deletions(-) create mode 100644 dashboard/src/features/application/components/form/EnvVarConfigForm.tsx create mode 100644 dashboard/src/features/application/schema/envVarSchema.ts diff --git a/dashboard/src/features/application/components/form/EnvVarConfigForm.tsx b/dashboard/src/features/application/components/form/EnvVarConfigForm.tsx new file mode 100644 index 00000000..ae10b842 --- /dev/null +++ b/dashboard/src/features/application/components/form/EnvVarConfigForm.tsx @@ -0,0 +1,218 @@ +import { styled } from '@macaron-css/solid' +import { + Field, + FieldArray, + Form, + type SubmitHandler, + createFormStore, + getValue, + getValues, + insert, + remove, + reset, + setValues, + valiForm, +} from '@modular-forms/solid' +import { type Component, For, Show, createEffect, createReaction, onMount, untrack } from 'solid-js' +import toast from 'solid-toast' +import type { ApplicationEnvVars } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import { TextField } from '/@/components/UI/TextField' +import FormBox from '/@/components/layouts/FormBox' +import { client, handleAPIError } from '/@/libs/api' +import { colorVars, textVars } from '/@/theme' +import { + type EnvVarInput, + envVarSchema, + envVarsMessageToSchema, + handleSubmitEnvVarForm, +} from '../../schema/envVarSchema' + +const EnvVarsContainer = styled('div', { + base: { + width: '100%', + display: 'grid', + gridTemplateColumns: '1fr 1fr', + rowGap: '8px', + columnGap: '24px', + + color: colorVars.semantic.text.black, + ...textVars.text.bold, + }, +}) + +type Props = { + appId: string + refetch: () => void + envVars: ApplicationEnvVars +} + +const EnvVarConfigForm: Component = (props) => { + const formStore = createFormStore({ + validate: valiForm(envVarSchema), + }) + + const discardChanges = () => { + reset( + untrack(() => formStore), + { + initialValues: envVarsMessageToSchema(props.envVars), + }, + ) + } + + // `reset` doesn't work on first render when the Field not rendered + // see: https://github.com/fabian-hiller/modular-forms/issues/157#issuecomment-1848567069 + onMount(() => { + setValues(formStore, envVarsMessageToSchema(props.envVars)) + stripEnvVars() + }) + + // reset forms when props.envVars changed + createEffect(() => { + discardChanges() + }) + + // keyとvalueが空となるenv varを削除し、最後に空のenv varを追加する + const stripEnvVars = () => { + const envVars = getValues(formStore).variables ?? [] + + for (let i = envVars.length - 1; i >= 0; i--) { + if (envVars[i]?.key === '' && envVars[i]?.value === '') { + remove(formStore, 'variables', { at: i }) + } + } + + // add empty env var + insert(formStore, 'variables', { + value: { key: '', value: '', system: false }, + }) + + // 次にvariablesが変更された時に1度だけ再度stripする + track(() => getValues(formStore, 'variables')) + } + const track = createReaction(() => { + stripEnvVars() + }) + + const handleSubmit: SubmitHandler = (values) => + handleSubmitEnvVarForm(values, async (input) => { + const oldVars = new Map( + props.envVars.variables.filter((envVar) => !envVar.system).map((envVar) => [envVar.key, envVar.value]), + ) + const newVars = new Map( + input.variables + .filter((envVar) => !envVar.system && envVar.key !== '') + .map((envVar) => [envVar.key, envVar.value]), + ) + const addedKeys = Array.from(newVars.keys()).filter((key) => !oldVars.has(key)) + const deletedKeys = Array.from(oldVars.keys()).filter((key) => !newVars.has(key)) + const updatedKeys = Array.from(oldVars.keys()).filter( + (key) => newVars.has(key) && newVars.get(key) !== oldVars.get(key), + ) + + const addEnvVarRequests = [...addedKeys, ...updatedKeys].map((key) => { + return client.setEnvVar({ + applicationId: props.appId, + key, + value: newVars.get(key), + }) + }) + const deleteEnvVarRequests = deletedKeys.map((key) => { + return client.deleteEnvVar({ + applicationId: props.appId, + key, + }) + }) + try { + await Promise.all([...addEnvVarRequests, ...deleteEnvVarRequests]) + toast.success('環境変数を更新しました') + props.refetch() + // 非同期でビルドが開始されるので1秒程度待ってから再度リロード + setTimeout(props.refetch, 1000) + } catch (e) { + handleAPIError(e, '環境変数の更新に失敗しました') + } + }) + + return ( +
+ + + +
Key
+
Value
+ + {(fieldArray) => ( + + {(_, index) => { + const isSystem = () => + getValue(formStore, `variables.${index()}.system`, { + shouldActive: false, + }) + + return ( + <> + + {(field, fieldProps) => ( + + )} + + + {(field, fieldProps) => ( + + )} + + + ) + }} + + )} + +
+
+ + + + + + +
+
+ ) +} + +export default EnvVarConfigForm diff --git a/dashboard/src/features/application/schema/envVarSchema.ts b/dashboard/src/features/application/schema/envVarSchema.ts new file mode 100644 index 00000000..cf4ce12b --- /dev/null +++ b/dashboard/src/features/application/schema/envVarSchema.ts @@ -0,0 +1,98 @@ +import * as v from 'valibot' +import type { ApplicationEnvVars } from '/@/api/neoshowcase/protobuf/gateway_pb' + +export const envVarSchema = v.object({ + variables: v.pipe( + v.array( + v.object({ + key: v.pipe(v.string(), v.toUpperCase()), + value: v.string(), + system: v.optional(v.boolean()), + }), + ), + // 最後のkey以外は必須 + // see: https://valibot.dev/api/rawCheck/ + v.rawCheck(({ dataset, addIssue }) => { + if (dataset.typed) { + dataset.value.forEach((kv, i) => { + if (i < dataset.value.length - 1 && kv.key.length === 0) { + addIssue({ + message: 'Please enter a key', + path: [ + { + type: 'array', + origin: 'value', + input: dataset.value, + key: i, + value: kv, + }, + { + type: 'object', + origin: 'value', + input: dataset.value[i], + key: 'key', + value: kv.key, + }, + ], + }) + } + }) + } + }), + // keyの重複チェック + v.rawCheck(({ dataset, addIssue }) => { + if (dataset.typed) { + dataset.value.forEach((kv, i) => { + const isDuplicate = dataset.value.some((other, j) => i !== j && other.key === kv.key) + + if (isDuplicate) { + addIssue({ + message: '同じキーの環境変数が存在します', + path: [ + { + type: 'array', + origin: 'value', + input: dataset.value, + key: i, + value: kv, + }, + { + type: 'object', + origin: 'value', + input: dataset.value[i], + key: 'key', + value: kv.key, + }, + ], + }) + } + }) + } + }), + ), +}) + +export type EnvVarInput = v.InferInput + +export const parseCreateWebsiteInput = (input: unknown) => { + const result = v.parse(envVarSchema, input) + return result +} + +export const envVarsMessageToSchema = (envVars: ApplicationEnvVars): EnvVarInput => { + return { + variables: envVars.variables.map((variable) => ({ + key: variable.key, + value: variable.value, + system: variable.system, + })), + } +} + +export const handleSubmitEnvVarForm = ( + input: EnvVarInput, + handler: (output: v.InferOutput) => Promise, +) => { + const result = v.parse(envVarSchema, input) + return handler(result) +} diff --git a/dashboard/src/pages/apps/[id]/settings/envVars.tsx b/dashboard/src/pages/apps/[id]/settings/envVars.tsx index c9bd10c2..664d0ed9 100644 --- a/dashboard/src/pages/apps/[id]/settings/envVars.tsx +++ b/dashboard/src/pages/apps/[id]/settings/envVars.tsx @@ -1,219 +1,8 @@ -import type { PlainMessage } from '@bufbuild/protobuf' -import { styled } from '@macaron-css/solid' -import { - type SubmitHandler, - createForm, - custom, - getValue, - getValues, - insert, - remove, - reset, -} from '@modular-forms/solid' -import { type Component, For, Show, createEffect, createReaction, createResource, on } from 'solid-js' -import toast from 'solid-toast' -import type { ApplicationEnvVars } from '/@/api/neoshowcase/protobuf/gateway_pb' -import { Button } from '/@/components/UI/Button' -import { TextField } from '/@/components/UI/TextField' +import { Show, createResource } from 'solid-js' import { DataTable } from '/@/components/layouts/DataTable' -import FormBox from '/@/components/layouts/FormBox' -import { client, handleAPIError } from '/@/libs/api' +import EnvVarConfigForm from '/@/features/application/components/form/EnvVarConfigForm' +import { client } from '/@/libs/api' import { useApplicationData } from '/@/routes' -import { colorVars, textVars } from '/@/theme' - -const EnvVarsContainer = styled('div', { - base: { - width: '100%', - display: 'grid', - gridTemplateColumns: '1fr 1fr', - rowGap: '8px', - columnGap: '24px', - - color: colorVars.semantic.text.black, - ...textVars.text.bold, - }, -}) - -const EnvVarConfig: Component<{ - appId: string - envVars: PlainMessage - refetch: () => void -}> = (props) => { - const [envVarForm, EnvVar] = createForm>({ - initialValues: { - variables: props.envVars.variables, - }, - }) - - const discardChanges = () => { - reset(envVarForm, { - initialValues: { - variables: props.envVars.variables, - }, - }) - stripEnvVars() - } - - // reset form when envVars updated - createEffect( - on( - () => props.envVars, - () => { - discardChanges() - }, - ), - ) - - const stripEnvVars = () => { - const forms = getValues(envVarForm, 'variables') as PlainMessage['variables'] - // remove all empty env vars - // 後ろから見ていって、空のものを削除する - for (let i = forms.length - 1; i >= 0; i--) { - if (forms[i].key === '' && forms[i].value === '') { - remove(envVarForm, 'variables', { at: i }) - } - } - - // add empty env var - insert(envVarForm, 'variables', { - value: { applicationId: props.appId, key: '', value: '', system: false }, - }) - // 次にvariablesが変更された時に1度だけ再度stripする - track(() => getValues(envVarForm, 'variables')) - } - const track = createReaction(() => { - stripEnvVars() - }) - - const isUniqueKey = (key?: string) => { - const sameKey = (getValues(envVarForm, 'variables') as PlainMessage['variables']) - .map((envVar) => envVar.key) - .filter((k) => k === key) - return sameKey.length === 1 - } - - const handleSubmit: SubmitHandler> = async (values) => { - const oldVars = new Map( - props.envVars.variables.filter((envVar) => !envVar.system).map((envVar) => [envVar.key, envVar.value]), - ) - const newVars = new Map( - values.variables - .filter((envVar) => !envVar.system && envVar.key !== '') - .map((envVar) => [envVar.key, envVar.value]), - ) - - const addedKeys = [...newVars.keys()].filter((key) => !oldVars.has(key)) - const deletedKeys = [...oldVars.keys()].filter((key) => !newVars.has(key)) - const updatedKeys = [...oldVars.keys()].filter((key) => - newVars.has(key) ? oldVars.get(key) !== newVars.get(key) : false, - ) - - const addEnvVarRequests = Array.from([...addedKeys, ...updatedKeys]).map((key) => { - return client.setEnvVar({ - applicationId: props.appId, - key, - value: newVars.get(key), - }) - }) - const deleteEnvVarRequests = Array.from(deletedKeys).map((key) => { - return client.deleteEnvVar({ - applicationId: props.appId, - key, - }) - }) - try { - await Promise.all([...addEnvVarRequests, ...deleteEnvVarRequests]) - toast.success('環境変数を更新しました') - props.refetch() - // 非同期でビルドが開始されるので1秒程度待ってから再度リロード - setTimeout(props.refetch, 1000) - } catch (e) { - handleAPIError(e, '環境変数の更新に失敗しました') - } - } - - return ( - - - - -
Key
-
Value
- - {(fieldArray) => ( - - {(_, index) => { - const isSystem = () => getValue(envVarForm, `variables.${index()}.system`, { shouldActive: false }) - - return ( - <> - - val === '' && index() !== fieldArray.items.length - 1 ? 'Please enter a key' : '', - ]} - > - {(field, fieldProps) => ( - - )} - - - {(field, fieldProps) => ( - - )} - - - ) - }} - - )} - -
-
- - - - - - -
-
- ) -} export default () => { const { app, hasPermission, refetch: refetchApp } = useApplicationData() @@ -238,7 +27,7 @@ export default () => { } > - + From ec5b88d8ec14cdb761e55ffe3ab7039fc020e30f Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Sat, 17 Aug 2024 15:09:00 +0900 Subject: [PATCH 31/51] remove debug logs --- dashboard/src/libs/useFormContext.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/dashboard/src/libs/useFormContext.tsx b/dashboard/src/libs/useFormContext.tsx index 62bd5a4b..b28abd6b 100644 --- a/dashboard/src/libs/useFormContext.tsx +++ b/dashboard/src/libs/useFormContext.tsx @@ -13,12 +13,7 @@ export const useFormContext = { const formStore = createFormStore>({ - validate: async (input) => { - console.log(input) - console.log(await valiForm(schema)(input)) - return valiForm(schema)(input) - }, - // validate: valiForm(schema), + validate: valiForm(schema), }) return ( From f507fc9fbf0d45aa3fc1ee1afa7a430ca4be987e Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Sun, 18 Aug 2024 06:05:28 +0900 Subject: [PATCH 32/51] wip: replace create application form --- .../components/form/CreateAppForm.tsx | 109 ++++++++ .../components/form/GeneralConfigForm.tsx | 33 +-- .../components/form/GeneralStep.tsx | 120 +++++++++ .../components/form/RepositoryStep.tsx | 251 ++++++++++++++++++ .../components/form/WebsiteStep.tsx | 142 ++++++++++ .../components/form/general/BranchField.tsx | 4 +- .../components/form/general/NameField.tsx | 29 ++ .../form/general/RepositoryIdField.tsx | 34 +++ .../application/schema/applicationSchema.ts | 1 + 9 files changed, 692 insertions(+), 31 deletions(-) create mode 100644 dashboard/src/features/application/components/form/CreateAppForm.tsx create mode 100644 dashboard/src/features/application/components/form/GeneralStep.tsx create mode 100644 dashboard/src/features/application/components/form/RepositoryStep.tsx create mode 100644 dashboard/src/features/application/components/form/WebsiteStep.tsx create mode 100644 dashboard/src/features/application/components/form/general/NameField.tsx create mode 100644 dashboard/src/features/application/components/form/general/RepositoryIdField.tsx diff --git a/dashboard/src/features/application/components/form/CreateAppForm.tsx b/dashboard/src/features/application/components/form/CreateAppForm.tsx new file mode 100644 index 00000000..2ee9671f --- /dev/null +++ b/dashboard/src/features/application/components/form/CreateAppForm.tsx @@ -0,0 +1,109 @@ +import { Form, type SubmitHandler, getValue, setValue, setValues } from '@modular-forms/solid' +import { useSearchParams } from '@solidjs/router' +import { + type Component, + Match, + Show, + Switch, + createEffect, + createResource, + createSignal, + onMount, + untrack, +} from 'solid-js' +import toast from 'solid-toast' +import { client, handleAPIError } from '/@/libs/api' +import { useApplicationForm } from '../../provider/applicationFormProvider' +import { + type CreateOrUpdateApplicationInput, + createApplicationFormInitialValues, + handleSubmitCreateApplicationForm, +} from '../../schema/applicationSchema' +import GeneralStep from './GeneralStep' +import RepositoryStep from './RepositoryStep' + +enum formStep { + repository = 0, + general = 1, + website = 2, +} + +const CreateAppForm: Component = () => { + const { formStore } = useApplicationForm() + + // `reset` doesn't work on first render when the Field not rendered + // see: https://github.com/fabian-hiller/modular-forms/issues/157#issuecomment-1848567069 + onMount(() => { + setValues(formStore, createApplicationFormInitialValues()) + }) + + const [searchParams, setParam] = useSearchParams() + const [currentStep, setCurrentStep] = createSignal(formStep.repository) + + const backToRepoStep = () => { + setCurrentStep(formStep.repository) + // 選択していたリポジトリをリセットする + setParam({ repositoryID: undefined }) + } + const goToGeneralStep = () => { + setCurrentStep(formStep.general) + } + const goToWebsiteStep = () => { + setCurrentStep(formStep.website) + } + + const [repoBySearchParam, { mutate: mutateRepo }] = createResource( + () => searchParams.repositoryID, + (id) => client.getRepository({ repositoryId: id }), + ) + // repoBySearchParam更新時にformのrepositoryIdを更新する + createEffect(() => { + setValue( + untrack(() => formStore), + 'form.repositoryId', + repoBySearchParam()?.id, + ) + }) + + const handleSubmit: SubmitHandler = (values) => + handleSubmitCreateApplicationForm(values, async (output) => { + try { + await client.createApplication(output) + toast.success('アプリケーション設定を更新しました') + } catch (e) { + handleAPIError(e, 'アプリケーション設定の更新に失敗しました') + } + }) + + return ( +
+ + + mutateRepo(repo)} /> + + + + {(nonNullRepo) => ( + + )} + + + + + + +
+ ) +} + +export default CreateAppForm diff --git a/dashboard/src/features/application/components/form/GeneralConfigForm.tsx b/dashboard/src/features/application/components/form/GeneralConfigForm.tsx index 1a268916..c134c8bd 100644 --- a/dashboard/src/features/application/components/form/GeneralConfigForm.tsx +++ b/dashboard/src/features/application/components/form/GeneralConfigForm.tsx @@ -13,6 +13,8 @@ import { updateApplicationFormInitialValues, } from '../../schema/applicationSchema' import BranchField from './general/BranchField' +import NameField from './general/NameField' +import RepositoryIdField from './general/RepositoryIdField' type Props = { app: Application @@ -68,35 +70,8 @@ const GeneralConfigForm: Component = (props) => {
- - {(field, fieldProps) => ( - - )} - - - {(field, fieldProps) => ( - - )} - + + diff --git a/dashboard/src/features/application/components/form/GeneralStep.tsx b/dashboard/src/features/application/components/form/GeneralStep.tsx new file mode 100644 index 00000000..85b1abae --- /dev/null +++ b/dashboard/src/features/application/components/form/GeneralStep.tsx @@ -0,0 +1,120 @@ +import { styled } from '@macaron-css/solid' +import { Field, Form } from '@modular-forms/solid' +import type { Component } from 'solid-js' +import type { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' +import { Button } from '/@/components/UI/Button' +import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' +import { CheckBox } from '/@/components/templates/CheckBox' +import { FormItem } from '/@/components/templates/FormItem' +import { originToIcon, repositoryURLToOrigin } from '/@/libs/application' +import { colorVars, textVars } from '/@/theme' +import { useApplicationForm } from '../../provider/applicationFormProvider' +import BranchField from './general/BranchField' +import NameField from './general/NameField' + +const FormsContainer = styled('div', { + base: { + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '40px', + }, +}) +const FormContainer = styled('div', { + base: { + width: '100%', + padding: '24px', + display: 'flex', + flexDirection: 'column', + gap: '20px', + + background: colorVars.semantic.ui.primary, + borderRadius: '8px', + }, +}) +const FormTitle = styled('h2', { + base: { + display: 'flex', + alignItems: 'center', + gap: '4px', + overflowWrap: 'anywhere', + color: colorVars.semantic.text.black, + ...textVars.h2.medium, + }, +}) +const ButtonsContainer = styled('div', { + base: { + display: 'flex', + gap: '20px', + }, +}) + +const GeneralStep: Component<{ + repo: Repository + backToRepoStep: () => void + proceedToWebsiteStep: () => void +}> = (props) => { + const { formStore } = useApplicationForm() + + return ( +
+ + + + Create Application from + {originToIcon(repositoryURLToOrigin(props.repo.url), 24)} + {props.repo.name} + + + + + {(field, fieldProps) => ( + +
この設定で今すぐ起動するかどうか
+
(環境変数はアプリ作成後設定可能になります)
+ + ), + }, + }} + > + +
+ )} +
+
+ + + + +
+
+ ) +} +export default GeneralStep diff --git a/dashboard/src/features/application/components/form/RepositoryStep.tsx b/dashboard/src/features/application/components/form/RepositoryStep.tsx new file mode 100644 index 00000000..d799529c --- /dev/null +++ b/dashboard/src/features/application/components/form/RepositoryStep.tsx @@ -0,0 +1,251 @@ +import { styled } from '@macaron-css/solid' +import { A } from '@solidjs/router' +import Fuse from 'fuse.js' +import { type Component, For, createMemo, createResource, createSignal } from 'solid-js' +import { + GetApplicationsRequest_Scope, + GetRepositoriesRequest_Scope, + type Repository, +} from '/@/api/neoshowcase/protobuf/gateway_pb' +import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' +import { TextField } from '/@/components/UI/TextField' +import { List } from '/@/components/templates/List' +import ReposFilter from '/@/components/templates/repo/ReposFilter' +import { client } from '/@/libs/api' +import { type RepositoryOrigin, originToIcon, repositoryURLToOrigin } from '/@/libs/application' +import { colorOverlay } from '/@/libs/colorOverlay' +import { colorVars, textVars } from '/@/theme' + +const RepositoryStepContainer = styled('div', { + base: { + width: '100%', + height: '100%', + minHeight: '800px', + overflowY: 'hidden', + padding: '24px', + display: 'flex', + flexDirection: 'column', + gap: '24px', + + background: colorVars.semantic.ui.primary, + borderRadius: '8px', + }, +}) +const RepositoryListContainer = styled('div', { + base: { + width: '100%', + height: '100%', + overflowY: 'auto', + display: 'flex', + flexDirection: 'column', + }, +}) +const RepositoryButton = styled('button', { + base: { + width: '100%', + background: colorVars.semantic.ui.primary, + border: 'none', + cursor: 'pointer', + + selectors: { + '&:hover': { + background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover), + }, + '&:not(:last-child)': { + borderBottom: `1px solid ${colorVars.semantic.ui.border}`, + }, + }, + }, +}) +const RepositoryRow = styled('div', { + base: { + width: '100%', + padding: '16px', + display: 'grid', + gridTemplateColumns: '24px auto 1fr auto', + gridTemplateRows: 'auto auto', + gridTemplateAreas: ` + "icon name count button" + ". url url button"`, + rowGap: '2px', + columnGap: '8px', + textAlign: 'left', + }, +}) +const RepositoryIcon = styled('div', { + base: { + gridArea: 'icon', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, +}) +const RepositoryName = styled('div', { + base: { + width: '100%', + gridArea: 'name', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + color: colorVars.semantic.text.black, + ...textVars.h4.bold, + }, +}) +const AppCount = styled('div', { + base: { + display: 'flex', + alignItems: 'center', + whiteSpace: 'nowrap', + color: colorVars.semantic.text.grey, + ...textVars.caption.regular, + }, +}) +const RepositoryUrl = styled('div', { + base: { + gridArea: 'url', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + color: colorVars.semantic.text.grey, + ...textVars.caption.regular, + }, +}) +const CreateAppText = styled('div', { + base: { + gridArea: 'button', + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + gap: '4px', + color: colorVars.semantic.text.black, + ...textVars.text.bold, + }, +}) +const RegisterRepositoryButton = styled('div', { + base: { + width: '100%', + height: 'auto', + padding: '20px', + display: 'flex', + alignItems: 'center', + gap: '8px', + cursor: 'pointer', + background: colorVars.semantic.ui.primary, + color: colorVars.semantic.text.black, + ...textVars.text.bold, + + selectors: { + '&:hover': { + background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover), + }, + }, + }, +}) + +const RepositoryStep: Component<{ + setRepo: (repo: Repository) => void +}> = (props) => { + const [repos] = createResource(() => + client.getRepositories({ + scope: GetRepositoriesRequest_Scope.CREATABLE, + }), + ) + const [apps] = createResource(() => client.getApplications({ scope: GetApplicationsRequest_Scope.ALL })) + + const [query, setQuery] = createSignal('') + const [origin, setOrigin] = createSignal(['GitHub', 'Gitea', 'Others']) + + const filteredReposByOrigin = createMemo(() => { + const p = origin() + return repos()?.repositories.filter((r) => p.includes(repositoryURLToOrigin(r.url))) + }) + const repoWithApps = createMemo(() => { + const appsMap = apps()?.applications.reduce( + (acc, app) => { + if (!acc[app.repositoryId]) acc[app.repositoryId] = 0 + acc[app.repositoryId]++ + return acc + }, + {} as { [id: Repository['id']]: number }, + ) + + return ( + filteredReposByOrigin()?.map( + ( + repo, + ): { + repo: Repository + appCount: number + } => ({ repo, appCount: appsMap?.[repo.id] ?? 0 }), + ) ?? [] + ) + }) + + const fuse = createMemo( + () => + new Fuse(repoWithApps(), { + keys: ['repo.name', 'repo.htmlUrl'], + }), + ) + const filteredRepos = createMemo(() => { + if (query() === '') return repoWithApps() + return fuse() + .search(query()) + .map((r) => r.item) + }) + + return ( + + setQuery(e.currentTarget.value)} + leftIcon={search} + rightIcon={} + /> + + + + add + Register Repository + + + + + + Repository Not Found + + + } + > + {(repo) => ( + { + props.setRepo(repo.repo) + }} + type="button" + > + + {originToIcon(repositoryURLToOrigin(repo.repo.url), 24)} + {repo.repo.name} + {repo.appCount > 0 && `${repo.appCount} apps`} + {repo.repo.htmlUrl} + + Create App + arrow_forward + + + + )} + + + + + ) +} + +export default RepositoryStep diff --git a/dashboard/src/features/application/components/form/WebsiteStep.tsx b/dashboard/src/features/application/components/form/WebsiteStep.tsx new file mode 100644 index 00000000..bcb5bcaf --- /dev/null +++ b/dashboard/src/features/application/components/form/WebsiteStep.tsx @@ -0,0 +1,142 @@ +import { styled } from '@macaron-css/solid' +import { type FormStore, createFormStore, validate } from '@modular-forms/solid' +import { type Accessor, type Component, For, type Setter, Show, createSignal } from 'solid-js' +import { Button } from '/@/components/UI/Button' +import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' +import { List } from '/@/components/templates/List' +import { type WebsiteFormStatus, WebsiteSetting, newWebsite } from '/@/components/templates/app/WebsiteSettings' +import { systemInfo } from '/@/libs/api' + +const FormsContainer = styled('div', { + base: { + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '40px', + }, +}) +const DomainsContainer = styled('div', { + base: { + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '24px', + }, +}) +const AddMoreButtonContainer = styled('div', { + base: { + display: 'flex', + justifyContent: 'center', + }, +}) +const ButtonsContainer = styled('div', { + base: { + display: 'flex', + gap: '20px', + }, +}) + +const WebsiteStep: Component<{ + isRuntimeApp: boolean + websiteForms: Accessor[]> + setWebsiteForms: Setter[]> + backToGeneralStep: () => void + submit: () => Promise +}> = (props) => { + const [isSubmitting, setIsSubmitting] = createSignal(false) + const addWebsiteForm = () => { + const form = createFormStore({ + initialValues: { + state: 'added', + website: newWebsite(), + }, + }) + props.setWebsiteForms((prev) => prev.concat([form])) + } + + const handleSubmit = async () => { + try { + const isValid = (await Promise.all(props.websiteForms().map((form) => validate(form)))).every((v) => v) + if (!isValid) return + setIsSubmitting(true) + await props.submit() + } catch (err) { + console.error(err) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + + link_off + URLが設定されていません + + + } + > + {(form, i) => ( + props.setWebsiteForms((prev) => [...prev.slice(0, i()), ...prev.slice(i() + 1)])} + hasPermission + /> + )} + + 0}> + + + + + + + + + + + + ) +} + +export default WebsiteStep diff --git a/dashboard/src/features/application/components/form/general/BranchField.tsx b/dashboard/src/features/application/components/form/general/BranchField.tsx index 54ac83ae..cfb3a07c 100644 --- a/dashboard/src/features/application/components/form/general/BranchField.tsx +++ b/dashboard/src/features/application/components/form/general/BranchField.tsx @@ -7,7 +7,7 @@ import { useApplicationForm } from '../../../provider/applicationFormProvider' type Props = { repo: Repository - hasPermission: boolean + hasPermission?: boolean } const BranchField: Component = (props) => { @@ -66,7 +66,7 @@ const BranchField: Component = (props) => { disabled={branches().length === 0} value={field.value} error={field.error} - readOnly={!props.hasPermission} + readOnly={!(props.hasPermission ?? true)} /> )} diff --git a/dashboard/src/features/application/components/form/general/NameField.tsx b/dashboard/src/features/application/components/form/general/NameField.tsx new file mode 100644 index 00000000..ba37f781 --- /dev/null +++ b/dashboard/src/features/application/components/form/general/NameField.tsx @@ -0,0 +1,29 @@ +import { Field } from '@modular-forms/solid' +import type { Component } from 'solid-js' +import { TextField } from '/@/components/UI/TextField' +import { useApplicationForm } from '../../../provider/applicationFormProvider' + +type Props = { + hasPermission?: boolean +} + +const NameField: Component = (props) => { + const { formStore } = useApplicationForm() + + return ( + + {(field, fieldProps) => ( + + )} + + ) +} + +export default NameField diff --git a/dashboard/src/features/application/components/form/general/RepositoryIdField.tsx b/dashboard/src/features/application/components/form/general/RepositoryIdField.tsx new file mode 100644 index 00000000..5c7ef0ee --- /dev/null +++ b/dashboard/src/features/application/components/form/general/RepositoryIdField.tsx @@ -0,0 +1,34 @@ +import { Field } from '@modular-forms/solid' +import type { Component } from 'solid-js' +import { TextField } from '/@/components/UI/TextField' +import { useApplicationForm } from '../../../provider/applicationFormProvider' + +type Props = { + hasPermission?: boolean +} + +const RepositoryIdField: Component = (props) => { + const { formStore } = useApplicationForm() + + return ( + + {(field, fieldProps) => ( + + )} + + ) +} + +export default RepositoryIdField diff --git a/dashboard/src/features/application/schema/applicationSchema.ts b/dashboard/src/features/application/schema/applicationSchema.ts index 5df54e42..2fd385cc 100644 --- a/dashboard/src/features/application/schema/applicationSchema.ts +++ b/dashboard/src/features/application/schema/applicationSchema.ts @@ -71,6 +71,7 @@ export const updateApplicationSchema = v.pipe( websites: v.optional(v.array(createWebsiteSchema)), portPublications: v.optional(v.array(portPublicationSchema)), ownerIds: v.optional(ownersSchema), + startOnCreate: v.optional(v.boolean()), }), v.transform( (input): PartialMessage => ({ From 9a6e21c01f168c00a9941e2d1f936ad32070a96e Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 23 Aug 2024 17:00:51 +0900 Subject: [PATCH 33/51] =?UTF-8?q?feat:=20URL=E8=A8=AD=E5=AE=9A=E3=81=A7Fie?= =?UTF-8?q?ldArray=E3=82=92=E4=BD=BF=E7=94=A8=E3=81=99=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/form/WebsitesConfigForm.tsx | 276 +++++++++------ .../components/form/website/UrlField.tsx | 28 +- .../form/website/WebsiteFieldGroup.tsx | 331 ++++++++---------- .../application/schema/websiteSchema.ts | 61 ++-- 4 files changed, 364 insertions(+), 332 deletions(-) diff --git a/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx b/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx index d6b30d85..ced5e68b 100644 --- a/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx +++ b/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx @@ -1,24 +1,63 @@ +import type { PartialMessage } from '@bufbuild/protobuf' import { styled } from '@macaron-css/solid' -import { createFormStore, getValues, setValue, valiForm } from '@modular-forms/solid' -import { type Component, For, createResource, createSignal } from 'solid-js' +import { + Field, + FieldArray, + Form, + type SubmitHandler, + getValues, + insert, + reset, + setValues, +} from '@modular-forms/solid' +import { type Component, For, Show, createEffect, onMount, untrack } from 'solid-js' import toast from 'solid-toast' -import { parse } from 'valibot' -import type { Application } from '/@/api/neoshowcase/protobuf/gateway_pb' +import type { Application, UpdateApplicationRequest_UpdateWebsites } from '/@/api/neoshowcase/protobuf/gateway_pb' import { Button } from '/@/components/UI/Button' import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' +import FormBox from '/@/components/layouts/FormBox' +import { List } from '/@/components/templates/List' import { client, handleAPIError, systemInfo } from '/@/libs/api' +import { colorVars } from '/@/theme' +import { useApplicationForm } from '../../provider/applicationFormProvider' import { - type CreateWebsiteInput, - createWebsiteInitialValues, - createWebsiteSchema, - websiteMessageToSchema, -} from '../../schema/websiteSchema' + type CreateOrUpdateApplicationInput, + handleSubmitUpdateApplicationForm, + updateApplicationFormInitialValues, +} from '../../schema/applicationSchema' +import { createWebsiteInitialValues } from '../../schema/websiteSchema' import WebsiteFieldGroup from './website/WebsiteFieldGroup' +const Container = styled('div', { + base: { + width: '100%', + overflow: 'hidden', + border: `1px solid ${colorVars.semantic.ui.border}`, + borderRadius: '8px', + display: 'flex', + flexDirection: 'column', + gap: '1px', + }, +}) +const FieldRow = styled('div', { + base: { + width: '100%', + padding: '20px 24px', + + selectors: { + '&:not(:first-child)': { + borderTop: `1px solid ${colorVars.semantic.ui.border}`, + }, + }, + }, +}) + const AddMoreButtonContainer = styled('div', { base: { + width: '100%', display: 'flex', justifyContent: 'center', + alignItems: 'center', }, }) @@ -29,29 +68,38 @@ type Props = { } const WebsiteConfigForm: Component = (props) => { - const [formStores, { mutate }] = createResource( - () => props.app.websites, - (websites) => - websites.map((w) => - createFormStore({ - initialValues: websiteMessageToSchema(w), - validate: valiForm(createWebsiteSchema), - }), - ), - ) + const { formStore } = useApplicationForm() + + const discardChanges = () => { + reset( + untrack(() => formStore), + { + initialValues: updateApplicationFormInitialValues(props.app), + }, + ) + } + + // `reset` doesn't work on first render when the Field not rendered + // see: https://github.com/fabian-hiller/modular-forms/issues/157#issuecomment-1848567069 + onMount(() => { + setValues(formStore, updateApplicationFormInitialValues(props.app)) + }) - const availableDomains = systemInfo()?.domains ?? [] + // reset forms when props.app changed + createEffect(() => { + discardChanges() + }) + + const defaultDomain = () => systemInfo()?.domains.at(0) const addFormStore = () => { - const defaultDomain = availableDomains.at(0) - if (defaultDomain) { - const newForm = createFormStore({ - initialValues: createWebsiteInitialValues(defaultDomain), - validate: valiForm(createWebsiteSchema), - }) - - mutate((websites) => websites?.concat([newForm])) + const _defaultDomain = defaultDomain() + if (!_defaultDomain) { + throw new Error('Default domain is not found') } + insert(formStore, 'form.websites', { + value: createWebsiteInitialValues(_defaultDomain), + }) } const isRuntimeApp = () => { @@ -59,85 +107,109 @@ const WebsiteConfigForm: Component = (props) => { return configCase === 'runtimeBuildpack' || configCase === 'runtimeDockerfile' || configCase === 'runtimeCmd' } - const [isSubmitting, setIsSubmitting] = createSignal(false) - const applyChanges = async () => { - setIsSubmitting(true) - - try { - /** - * 送信するWebsite設定 - * - 変更を保存しないものの、initial value - * - 変更して保存するもの ( = `readyToSave`) - * - 追加するもの ( = `added`) - * - 削除しないもの ( = not `readyToDelete`) - */ - const websitesToSave = - formStores() - ?.map((form) => { - const values = getValues(form) - switch (values.state) { - case 'noChange': - return form.internal.initialValues - case 'readyToChange': - case 'added': - return values - case 'readyToDelete': - return undefined - } - }) - .filter((w): w is Exclude => w !== undefined) ?? [] - - const parsedWebsites = websitesToSave.map((w) => parse(createWebsiteSchema, w)) - - await client.updateApplication({ - id: props.app.id, - websites: { - websites: parsedWebsites, - }, - }) - toast.success('ウェブサイト設定を保存しました') - void props.refetchApp() - } catch (e) { - // `readyToChange` を `noChange` に戻す - for (const form of formStores() ?? []) { - const values = getValues(form) - if (values.state === 'readyToChange') { - setValue(form, 'state', 'noChange') + const handleSubmit: SubmitHandler = (values) => + handleSubmitUpdateApplicationForm(values, async (output) => { + try { + // websiteがすべて削除されている場合、modularformsでは空配列ではなくundefinedになってしまう + // undefinedを渡した場合、APIとしては 無更新 として扱われるため、空配列を渡す + if (output.websites === undefined) { + output.websites = [] as PartialMessage } + + await client.updateApplication(output) + toast.success('ウェブサイト設定を更新しました') + props.refetchApp() + // 非同期でビルドが開始されるので1秒程度待ってから再度リロード + setTimeout(props.refetchApp, 1000) + } catch (e) { + handleAPIError(e, 'ウェブサイト設定の更新に失敗しました') } - handleAPIError(e, 'Failed to save website settings') - } finally { - setIsSubmitting(false) - } + }) + + const showAddMoreButton = () => { + const websites = getValues(formStore, 'form.websites') + return websites && websites.length > 0 } return ( - <> - - {(formStore) => ( - - )} - - - - - +
+ + {() => null} + + + {() => null} + + + + {(fieldArray) => ( + + link_off + URLが設定されていません + + + } + > + {(_, index) => ( + + + + )} + + )} + + + + + + + + + + + + + + + +
) } diff --git a/dashboard/src/features/application/components/form/website/UrlField.tsx b/dashboard/src/features/application/components/form/website/UrlField.tsx index 0d9b0f7b..38ec5a2b 100644 --- a/dashboard/src/features/application/components/form/website/UrlField.tsx +++ b/dashboard/src/features/application/components/form/website/UrlField.tsx @@ -1,5 +1,5 @@ import { styled } from '@macaron-css/solid' -import { Field, type FormStore, getValue, getValues } from '@modular-forms/solid' +import { Field, getValue } from '@modular-forms/solid' import { type Component, For, Show } from 'solid-js' import { TextField } from '/@/components/UI/TextField' import { FormItem } from '/@/components/templates/FormItem' @@ -7,13 +7,13 @@ import { type SelectOption, SingleSelect } from '/@/components/templates/Select' import { systemInfo } from '/@/libs/api' import { websiteWarnings } from '/@/libs/application' import { colorVars } from '/@/theme' -import type { CreateWebsiteInput } from '../../../schema/websiteSchema' +import { useApplicationForm } from '../../../provider/applicationFormProvider' const URLContainer = styled('div', { base: { display: 'flex', flexDirection: 'row', - alignItems: 'flex-top', + alignItems: 'flex-start', gap: '8px', }, }) @@ -21,6 +21,7 @@ const URLItem = styled('div', { base: { display: 'flex', alignItems: 'center', + height: '48px', }, variants: { fixedWidth: { @@ -50,13 +51,15 @@ const schemeOptions: SelectOption<`${boolean}`>[] = [ ] type Props = { - formStore: FormStore + index: number showHttpPort: boolean readonly?: boolean } const UrlField: Component = (props) => { - const selectedDomain = () => getValue(props.formStore, 'domain') + const { formStore } = useApplicationForm() + + const selectedDomain = () => getValue(formStore, `form.websites.${props.index}.domain`) // 占有されているドメインはoptionに表示しない // すでに設定されているドメインはoptionに表示する const domainOptions = () => @@ -71,14 +74,17 @@ const UrlField: Component = (props) => { }) ?? [] const warnings = () => - websiteWarnings(getValue(props.formStore, 'subdomain'), getValue(props.formStore, 'https') === 'true') + websiteWarnings( + getValue(formStore, `form.websites.${props.index}.subdomain`), + getValue(formStore, `form.websites.${props.index}.https`) === 'true', + ) return ( <> - + {(field, fieldProps) => ( = (props) => { :// - + {(field, fieldProps) => ( = (props) => { )} - + {(field, fieldProps) => ( = (props) => { / - + {(field, fieldProps) => ( = (props) => { - + {(field, fieldProps) => ( [] = [ { value: `${AuthenticationType.OFF}`, label: 'OFF' }, { value: `${AuthenticationType.SOFT}`, label: 'SOFT' }, @@ -30,198 +91,110 @@ const authenticationTypeOptions: RadioOption<`${AuthenticationType}`>[] = [ ] type Props = { - formStore: FormStore + index: number isRuntimeApp: boolean - applyChanges: () => Promise | void - isSubmitting: boolean readonly?: boolean } const WebsiteFieldGroup: Component = (props) => { - const { Modal, open, close } = useModal() + const { formStore } = useApplicationForm() const availableDomains = systemInfo()?.domains ?? [] const selectedDomain = createMemo(() => { - const domainString = getValue(props.formStore, 'domain') + const domainString = getValue(formStore, `form.websites.${props.index}.domain`) return availableDomains.find((d) => d.domain === domainString) }) const authAvailable = (): boolean => selectedDomain()?.authAvailable ?? false createEffect(() => { if (!authAvailable()) { - setValues(props.formStore, { authentication: `${AuthenticationType.OFF}` }) + setValue(formStore, `form.websites.${props.index}.authentication`, `${AuthenticationType.OFF}`) } }) - const discardChanges = () => { - reset(props.formStore) - } - - const websiteUrl = () => { - const scheme = getValue(props.formStore, 'https') ? 'https' : 'http' - const subDomain = getValue(props.formStore, 'subdomain') ?? '' - const domain = getValue(props.formStore, 'domain') ?? '' - const fqdn = domain.startsWith('*') ? `${subDomain}${domain.slice(1)}` : domain - - const pathPrefix = getValue(props.formStore, 'pathPrefix') - return `${scheme}://${fqdn}/${pathPrefix}` - } - - const handleSave = async () => { - // submitに失敗したときにstateを元に戻すため、submit前のstateを保存しておく - const originalState = getValue(props.formStore, 'state') - if (!originalState) throw new Error('The field of state does not exist.') - - try { - setValue(props.formStore, 'state', 'readyToChange') - submit(props.formStore) - } catch (e) { - console.log(e) - setValue(props.formStore, 'state', originalState) - } - } - const handleDelete = () => { - setValue(props.formStore, 'state', 'readyToDelete') - submit(props.formStore) + remove(formStore, 'form.websites', { at: props.index }) close() } - const handleSubmit: SubmitHandler = () => { - props.applyChanges() - } - - const state = () => getValue(props.formStore, 'state') - - const isSaveDisabled = () => - props.formStore.invalid || - props.formStore.submitting || - props.isSubmitting || - (state() !== 'added' && !props.formStore.dirty) || - props.readonly - return ( -
- - {() => null} - - - - - - {(field, fieldProps) => ( - - label="部員認証" - info={{ - style: 'left', - props: { - content: ( - <> -
OFF: 誰でもアクセス可能
-
SOFT: 部員の場合X-Forwarded-Userをセット
-
HARD: 部員のみアクセス可能
- - ), - }, - }} - {...fieldProps} - tooltip={{ - props: { - content: `${selectedDomain()?.domain}では部員認証が使用できません`, - }, - disabled: authAvailable(), - }} - options={authenticationTypeOptions} - value={field.value ?? `${AuthenticationType.OFF}`} - disabled={!authAvailable()} - readOnly={props.readonly} - /> - )} -
- - - {(field, fieldProps) => ( - - )} - - - + <> + + + + + + {/* Field componentがmountされないとそのfieldがformに登録されないためforceMountする */} + + + 詳細 + + expand_more + + + + + {(field, fieldProps) => ( - + label="部員認証" + info={{ + style: 'left', + props: { + content: ( + <> +
OFF: 誰でもアクセス可能
+
SOFT: 部員の場合X-Forwarded-Userをセット
+
HARD: 部員のみアクセス可能
+ + ), + }, + }} {...fieldProps} - label="Use h2c" - checked={field.value ?? false} + tooltip={{ + props: { + content: `${selectedDomain()?.domain}では部員認証が使用できません`, + }, + disabled: authAvailable(), + }} + options={authenticationTypeOptions} + value={field.value ?? `${AuthenticationType.OFF}`} + disabled={!authAvailable()} readOnly={props.readonly} /> )}
-
-
-
- - - - - - - -
- - Delete Website - - - language - {websiteUrl()} - - - - - - - -
+ + + {(field, fieldProps) => ( + + )} + + + + {(field, fieldProps) => ( + + )} + + + + + + + + ) } diff --git a/dashboard/src/features/application/schema/websiteSchema.ts b/dashboard/src/features/application/schema/websiteSchema.ts index 683f9254..5c0dcc80 100644 --- a/dashboard/src/features/application/schema/websiteSchema.ts +++ b/dashboard/src/features/application/schema/websiteSchema.ts @@ -35,47 +35,30 @@ const authenticationSchema = v.pipe( }), ) -const websiteValues = v.object({ - subdomain: v.optional(v.string()), - domain: v.string(), - pathPrefix: v.string(), - stripPrefix: v.boolean(), - https: stringBooleanSchema, - h2c: v.boolean(), - httpPort: v.pipe(v.number(), v.integer()), - authentication: authenticationSchema, -}) - export const createWebsiteSchema = v.pipe( - v.variant('state', [ - v.pipe( - v.object({ - state: v.union([v.literal('noChange'), v.literal('readyToChange'), v.literal('added')]), - ...websiteValues.entries, - }), - // wildcard domainが選択されている場合サブドメインは空であってはならない - v.forward( - v.partialCheck( - [['subdomain'], ['domain']], - (input) => { - if (input.domain?.startsWith('*')) return input.subdomain !== '' - return true - }, - 'Please Enter Subdomain Name', - ), - ['subdomain'], - ), + v.object({ + subdomain: v.optional(v.string()), + domain: v.string(), + pathPrefix: v.string(), + stripPrefix: v.boolean(), + https: stringBooleanSchema, + h2c: v.boolean(), + httpPort: v.pipe(v.number(), v.integer()), + authentication: authenticationSchema, + }), + // wildcard domainが選択されている場合サブドメインは空であってはならない + v.forward( + v.partialCheck( + [['subdomain'], ['domain']], + (input) => { + if (input.domain?.startsWith('*')) return input.subdomain !== '' + return true + }, + 'Please Enter Subdomain Name', ), - v.object({ - // 削除するwebsite設定の中身はチェックしない - state: v.literal('readyToDelete'), - ...v.partial(websiteValues).entries, - }), - ]), + ['subdomain'], + ), v.transform((input): PartialMessage => { - // 削除するwebsite設定の中身はチェックしない - if (input.state === 'readyToDelete') return {} - // wildcard domainならsubdomainとdomainを結合 const fqdn = input.domain.startsWith('*') ? `${input.subdomain}${input.domain.replace(/\*/g, '')}` @@ -136,7 +119,6 @@ const extractSubdomain = ( } export const createWebsiteInitialValues = (domain: AvailableDomain): CreateWebsiteInput => ({ - state: 'added', domain: domain.domain, subdomain: '', pathPrefix: '', @@ -153,7 +135,6 @@ export const websiteMessageToSchema = (website: Website): CreateWebsiteInput => const { domain, subdomain } = extractSubdomain(website.fqdn, availableDomains) return { - state: 'noChange', domain, subdomain, // バックエンド側では `/${prefix}` で持っている, フォーム内部では'/'を除いて持つ From 1c0a38970c3c292f7262c0ecc66656c6332a9f53 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 23 Aug 2024 21:18:57 +0900 Subject: [PATCH 34/51] =?UTF-8?q?fix:=20useDB=E3=81=AE=E5=80=A4=E3=81=8C?= =?UTF-8?q?=E6=AD=A3=E3=81=97=E3=81=8F=E8=A1=A8=E7=A4=BA=E3=81=95=E3=82=8C?= =?UTF-8?q?=E3=81=A6=E3=81=84=E3=81=AA=E3=81=8B=E3=81=A3=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../form/config/deploy/RuntimeConfigField.tsx | 98 ++++++++++++++----- 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/dashboard/src/features/application/components/form/config/deploy/RuntimeConfigField.tsx b/dashboard/src/features/application/components/form/config/deploy/RuntimeConfigField.tsx index 94a744ed..7d476041 100644 --- a/dashboard/src/features/application/components/form/config/deploy/RuntimeConfigField.tsx +++ b/dashboard/src/features/application/components/form/config/deploy/RuntimeConfigField.tsx @@ -1,5 +1,5 @@ -import { Field, getValues } from '@modular-forms/solid' -import { type Component, Show, createSignal } from 'solid-js' +import { Field, getValues, setValues } from '@modular-forms/solid' +import { type Component, Show, createEffect, createResource } from 'solid-js' import { TextField } from '/@/components/UI/TextField' import { ToolTip } from '/@/components/UI/ToolTip' import { CheckBox } from '/@/components/templates/CheckBox' @@ -14,10 +14,41 @@ type Props = { const RuntimeConfigField: Component = (props) => { const { formStore } = useApplicationForm() - const [useDB, setUseDB] = createSignal(false) + const [useDB, { mutate: setUseDB }] = createResource( + () => + getValues(formStore, { + shouldActive: false, + }).form?.config?.deployConfig, + (config) => { + if (!config?.type || config?.type === 'static') { + return false + } + // @ts-expect-error: getValuesの結果のpropertyはすべてMaybeになるためnarrowingが正しく行われない + return config?.value?.runtime?.useMariadb || config?.value?.runtime?.useMongodb + }, + ) const buildType = () => getValues(formStore).form?.config?.buildConfig?.type + createEffect(() => { + if (!useDB()) { + setValues(formStore, { + form: { + config: { + deployConfig: { + value: { + runtime: { + useMariadb: false, + useMongodb: false, + }, + }, + }, + }, + }, + }) + } + }) + const EntryPointField = () => ( {(field, fieldProps) => ( @@ -90,26 +121,47 @@ const RuntimeConfigField: Component = (props) => {
- - {(field, fieldProps) => ( - - )} - - - {(field, fieldProps) => ( - - )} - + + アプリ作成後は変更できません, + }} + disabled={!props.disableEditDB} + > + + + {(field, fieldProps) => ( + + )} + + + {(field, fieldProps) => ( + + )} + + + + From 2e89deab88b38ddcd2a35e3c8dc557ec0318d231 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 23 Aug 2024 21:19:49 +0900 Subject: [PATCH 35/51] =?UTF-8?q?chore:=20create=E6=99=82=E3=81=AEwebsite?= =?UTF-8?q?=E3=81=A8portPublications=E3=82=92optional=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/application/schema/applicationSchema.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/src/features/application/schema/applicationSchema.ts b/dashboard/src/features/application/schema/applicationSchema.ts index 2fd385cc..9ebdd2e8 100644 --- a/dashboard/src/features/application/schema/applicationSchema.ts +++ b/dashboard/src/features/application/schema/applicationSchema.ts @@ -18,8 +18,8 @@ const createApplicationSchema = v.pipe( repositoryId: v.string(), refName: v.pipe(v.string(), v.nonEmpty('Enter Branch Name')), config: applicationConfigSchema, - websites: v.array(createWebsiteSchema), - portPublications: v.array(portPublicationSchema), + websites: v.optional(v.array(createWebsiteSchema)), + portPublications: v.optional(v.array(portPublicationSchema)), startOnCreate: v.boolean(), }), v.transform( From 75f996ec8cf5ab1d89354c1090cb8bb48d921262 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 23 Aug 2024 21:22:54 +0900 Subject: [PATCH 36/51] =?UTF-8?q?chore:=20appForm=E3=81=AEvalidation?= =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=9F=E3=83=B3=E3=82=B0=E3=82=92=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/application/provider/applicationFormProvider.tsx | 1 + dashboard/src/libs/useFormContext.tsx | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/dashboard/src/features/application/provider/applicationFormProvider.tsx b/dashboard/src/features/application/provider/applicationFormProvider.tsx index 5a18181c..b3407526 100644 --- a/dashboard/src/features/application/provider/applicationFormProvider.tsx +++ b/dashboard/src/features/application/provider/applicationFormProvider.tsx @@ -3,4 +3,5 @@ import { createOrUpdateApplicationSchema } from '../schema/applicationSchema' export const { FormProvider: ApplicationFormProvider, useForm: useApplicationForm } = useFormContext( createOrUpdateApplicationSchema, + 'change', ) diff --git a/dashboard/src/libs/useFormContext.tsx b/dashboard/src/libs/useFormContext.tsx index b28abd6b..cdd9f544 100644 --- a/dashboard/src/libs/useFormContext.tsx +++ b/dashboard/src/libs/useFormContext.tsx @@ -1,4 +1,4 @@ -import { type FieldValues, type FormStore, createFormStore, valiForm } from '@modular-forms/solid' +import { type FieldValues, type FormStore, type ValidationMode, createFormStore, valiForm } from '@modular-forms/solid' import { type ParentComponent, createContext, useContext } from 'solid-js' import type { BaseIssue, BaseSchema, InferInput } from 'valibot' @@ -8,12 +8,14 @@ type FormContextValue = { export const useFormContext = >( schema: BaseSchema, + validationMode?: ValidationMode, ) => { const FormContext = createContext>>() const FormProvider: ParentComponent = (props) => { const formStore = createFormStore>({ validate: valiForm(schema), + revalidateOn: validationMode, }) return ( From e40c9861a851340542234c176b2314261c1f53ad Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 23 Aug 2024 21:30:12 +0900 Subject: [PATCH 37/51] feat: use new form system in create app form --- .../src/components/templates/RadioGroups.tsx | 7 +- .../components/form/CreateAppForm.tsx | 160 +++- .../components/form/GeneralStep.tsx | 127 ++-- .../components/form/RepositoryStep.tsx | 4 +- .../components/form/WebsiteStep.tsx | 166 +++-- .../components/form/WebsitesConfigForm.tsx | 26 +- .../schema/applicationConfigSchema.ts | 68 +- dashboard/src/pages/apps/new.tsx | 699 +----------------- 8 files changed, 348 insertions(+), 909 deletions(-) diff --git a/dashboard/src/components/templates/RadioGroups.tsx b/dashboard/src/components/templates/RadioGroups.tsx index 0559631f..cd7044fb 100644 --- a/dashboard/src/components/templates/RadioGroups.tsx +++ b/dashboard/src/components/templates/RadioGroups.tsx @@ -94,6 +94,11 @@ const Description = styled('div', { ...textVars.caption.regular, }, }) +export const errorTextStyle = style({ + width: '100%', + color: colorVars.semantic.accent.error, + ...textVars.text.regular, +}) export interface RadioOption { value: T @@ -177,7 +182,7 @@ export const RadioGroup = (props: Props): JSX.Element => { - {props.error} + {props.error} ) } diff --git a/dashboard/src/features/application/components/form/CreateAppForm.tsx b/dashboard/src/features/application/components/form/CreateAppForm.tsx index 2ee9671f..80426f20 100644 --- a/dashboard/src/features/application/components/form/CreateAppForm.tsx +++ b/dashboard/src/features/application/components/form/CreateAppForm.tsx @@ -1,7 +1,9 @@ -import { Form, type SubmitHandler, getValue, setValue, setValues } from '@modular-forms/solid' -import { useSearchParams } from '@solidjs/router' +import { styled } from '@macaron-css/solid' +import { Field, Form, type SubmitHandler, getValue, setValue, setValues, validate } from '@modular-forms/solid' +import { useNavigate, useSearchParams } from '@solidjs/router' import { type Component, + For, Match, Show, Switch, @@ -12,6 +14,7 @@ import { untrack, } from 'solid-js' import toast from 'solid-toast' +import { Progress } from '/@/components/UI/StepProgress' import { client, handleAPIError } from '/@/libs/api' import { useApplicationForm } from '../../provider/applicationFormProvider' import { @@ -21,6 +24,24 @@ import { } from '../../schema/applicationSchema' import GeneralStep from './GeneralStep' import RepositoryStep from './RepositoryStep' +import WebsiteStep from './WebsiteStep' + +const StepsContainer = styled('div', { + base: { + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '40px', + }, + variants: { + fit: { + true: { + maxHeight: '100%', + }, + }, + }, +}) enum formStep { repository = 0, @@ -40,7 +61,7 @@ const CreateAppForm: Component = () => { const [searchParams, setParam] = useSearchParams() const [currentStep, setCurrentStep] = createSignal(formStep.repository) - const backToRepoStep = () => { + const goToRepoStep = () => { setCurrentStep(formStep.repository) // 選択していたリポジトリをリセットする setParam({ repositoryID: undefined }) @@ -52,10 +73,20 @@ const CreateAppForm: Component = () => { setCurrentStep(formStep.website) } - const [repoBySearchParam, { mutate: mutateRepo }] = createResource( - () => searchParams.repositoryID, - (id) => client.getRepository({ repositoryId: id }), + const [repoBySearchParam] = createResource( + () => searchParams.repositoryID ?? '', + (id) => { + return id !== '' ? client.getRepository({ repositoryId: id }) : undefined + }, ) + + // repositoryIDがない場合はリポジトリ選択ステップに遷移 + createEffect(() => { + if (!searchParams.repositoryID) { + goToRepoStep() + } + }) + // repoBySearchParam更新時にformのrepositoryIdを更新する createEffect(() => { setValue( @@ -65,44 +96,101 @@ const CreateAppForm: Component = () => { ) }) + // リポジトリ選択ステップで中に、リポジトリが選択された場合は次のステップに遷移 + createEffect(() => { + if (currentStep() === formStep.repository && getValue(formStore, 'form.repositoryId')) { + goToGeneralStep() + } + }) + + const handleGeneralToWebsiteStep = async () => { + const isValid = await validate(formStore) + // modularformsではsubmitフラグが立っていないとrevalidateされないため、手動でsubmitフラグを立てる + // TODO: internalのAPIを使っているため、将来的には変更が必要 + formStore.internal.submitted.set(true) + if (isValid) { + goToWebsiteStep() + } + } + + const navigate = useNavigate() const handleSubmit: SubmitHandler = (values) => handleSubmitCreateApplicationForm(values, async (output) => { try { - await client.createApplication(output) - toast.success('アプリケーション設定を更新しました') + const createdApp = await client.createApplication(output) + toast.success('アプリケーションを登録しました') + navigate(`/apps/${createdApp.id}`) } catch (e) { - handleAPIError(e, 'アプリケーション設定の更新に失敗しました') + handleAPIError(e, 'アプリケーションの登録に失敗しました') } }) return ( -
- - - mutateRepo(repo)} /> - - - - {(nonNullRepo) => ( - - )} - - - - - - -
+ + + + {(step) => ( + step.step ? 'complete' : 'incomplete'} + /> + )} + + +
+ + {() => null} + + + {() => null} + + + + { + setParam({ + repositoryID: repo.id, + }) + goToGeneralStep() + }} + /> + + + + {(nonNullRepo) => ( + + )} + + + + + + +
+
) } diff --git a/dashboard/src/features/application/components/form/GeneralStep.tsx b/dashboard/src/features/application/components/form/GeneralStep.tsx index 85b1abae..db62e006 100644 --- a/dashboard/src/features/application/components/form/GeneralStep.tsx +++ b/dashboard/src/features/application/components/form/GeneralStep.tsx @@ -1,6 +1,6 @@ import { styled } from '@macaron-css/solid' -import { Field, Form } from '@modular-forms/solid' -import type { Component } from 'solid-js' +import { Field, getValue } from '@modular-forms/solid' +import { type Component, Show } from 'solid-js' import type { Repository } from '/@/api/neoshowcase/protobuf/gateway_pb' import { Button } from '/@/components/UI/Button' import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' @@ -9,6 +9,8 @@ import { FormItem } from '/@/components/templates/FormItem' import { originToIcon, repositoryURLToOrigin } from '/@/libs/application' import { colorVars, textVars } from '/@/theme' import { useApplicationForm } from '../../provider/applicationFormProvider' +import BuildTypeField from './config/BuildTypeField' +import ConfigField from './config/ConfigField' import BranchField from './general/BranchField' import NameField from './general/NameField' @@ -58,63 +60,70 @@ const GeneralStep: Component<{ const { formStore } = useApplicationForm() return ( -
- - - - Create Application from - {originToIcon(repositoryURLToOrigin(props.repo.url), 24)} - {props.repo.name} - - - - - {(field, fieldProps) => ( - -
この設定で今すぐ起動するかどうか
-
(環境変数はアプリ作成後設定可能になります)
- - ), - }, - }} - > - -
- )} -
-
- - - - -
-
+ + + + Create Application from + {originToIcon(repositoryURLToOrigin(props.repo.url), 24)} + {props.repo.name} + + + + + + + + + {(field, fieldProps) => ( + +
この設定で今すぐ起動するかどうか
+
(環境変数はアプリ作成後設定可能になります)
+ + ), + }, + }} + > + +
+ )} +
+
+ + + + +
) } export default GeneralStep diff --git a/dashboard/src/features/application/components/form/RepositoryStep.tsx b/dashboard/src/features/application/components/form/RepositoryStep.tsx index d799529c..2a47f8c2 100644 --- a/dashboard/src/features/application/components/form/RepositoryStep.tsx +++ b/dashboard/src/features/application/components/form/RepositoryStep.tsx @@ -144,7 +144,7 @@ const RegisterRepositoryButton = styled('div', { }) const RepositoryStep: Component<{ - setRepo: (repo: Repository) => void + onSelect: (repo: Repository) => void }> = (props) => { const [repos] = createResource(() => client.getRepositories({ @@ -225,7 +225,7 @@ const RepositoryStep: Component<{ {(repo) => ( { - props.setRepo(repo.repo) + props.onSelect(repo.repo) }} type="button" > diff --git a/dashboard/src/features/application/components/form/WebsiteStep.tsx b/dashboard/src/features/application/components/form/WebsiteStep.tsx index bcb5bcaf..fd2967c2 100644 --- a/dashboard/src/features/application/components/form/WebsiteStep.tsx +++ b/dashboard/src/features/application/components/form/WebsiteStep.tsx @@ -1,11 +1,14 @@ import { styled } from '@macaron-css/solid' -import { type FormStore, createFormStore, validate } from '@modular-forms/solid' -import { type Accessor, type Component, For, type Setter, Show, createSignal } from 'solid-js' +import { FieldArray, getValue, getValues, insert } from '@modular-forms/solid' +import { type Component, For, Show } from 'solid-js' import { Button } from '/@/components/UI/Button' import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' import { List } from '/@/components/templates/List' -import { type WebsiteFormStatus, WebsiteSetting, newWebsite } from '/@/components/templates/app/WebsiteSettings' import { systemInfo } from '/@/libs/api' +import { colorVars } from '/@/theme' +import { useApplicationForm } from '../../provider/applicationFormProvider' +import { createWebsiteInitialValues } from '../../schema/websiteSchema' +import WebsiteFieldGroup from './website/WebsiteFieldGroup' const FormsContainer = styled('div', { base: { @@ -25,6 +28,32 @@ const DomainsContainer = styled('div', { gap: '24px', }, }) + +const Container = styled('div', { + base: { + width: '100%', + overflow: 'hidden', + background: colorVars.semantic.ui.primary, + border: `1px solid ${colorVars.semantic.ui.border}`, + borderRadius: '8px', + display: 'flex', + flexDirection: 'column', + gap: '1px', + }, +}) +const FieldRow = styled('div', { + base: { + width: '100%', + padding: '20px 24px', + + selectors: { + '&:not(:first-child)': { + borderTop: `1px solid ${colorVars.semantic.ui.border}`, + }, + }, + }, +}) + const AddMoreButtonContainer = styled('div', { base: { display: 'flex', @@ -39,80 +68,80 @@ const ButtonsContainer = styled('div', { }) const WebsiteStep: Component<{ - isRuntimeApp: boolean - websiteForms: Accessor[]> - setWebsiteForms: Setter[]> backToGeneralStep: () => void - submit: () => Promise }> = (props) => { - const [isSubmitting, setIsSubmitting] = createSignal(false) - const addWebsiteForm = () => { - const form = createFormStore({ - initialValues: { - state: 'added', - website: newWebsite(), - }, + const { formStore } = useApplicationForm() + + const defaultDomain = () => systemInfo()?.domains.at(0) + + const addFormStore = () => { + const _defaultDomain = defaultDomain() + if (!_defaultDomain) { + throw new Error('Default domain is not found') + } + insert(formStore, 'form.websites', { + value: createWebsiteInitialValues(_defaultDomain), }) - props.setWebsiteForms((prev) => prev.concat([form])) } - const handleSubmit = async () => { - try { - const isValid = (await Promise.all(props.websiteForms().map((form) => validate(form)))).every((v) => v) - if (!isValid) return - setIsSubmitting(true) - await props.submit() - } catch (err) { - console.error(err) - } finally { - setIsSubmitting(false) - } + const isRuntimeApp = () => getValue(formStore, 'form.config.deployConfig.type') === 'runtime' + + const showAddMoreButton = () => { + const websites = getValues(formStore, 'form.websites') + return websites && websites.length > 0 } return ( - - link_off - URLが設定されていません - + + } > - Add URL - - - } - > - {(form, i) => ( - props.setWebsiteForms((prev) => [...prev.slice(0, i()), ...prev.slice(i() + 1)])} - hasPermission - /> - )} - - 0}> - - - - + {(_, index) => ( + + + + )} + + )} + + + + + + + + + diff --git a/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx b/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx index ced5e68b..5a70790f 100644 --- a/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx +++ b/dashboard/src/features/application/components/form/WebsitesConfigForm.tsx @@ -1,18 +1,8 @@ -import type { PartialMessage } from '@bufbuild/protobuf' import { styled } from '@macaron-css/solid' -import { - Field, - FieldArray, - Form, - type SubmitHandler, - getValues, - insert, - reset, - setValues, -} from '@modular-forms/solid' +import { Field, FieldArray, Form, type SubmitHandler, getValues, insert, reset, setValues } from '@modular-forms/solid' import { type Component, For, Show, createEffect, onMount, untrack } from 'solid-js' import toast from 'solid-toast' -import type { Application, UpdateApplicationRequest_UpdateWebsites } from '/@/api/neoshowcase/protobuf/gateway_pb' +import type { Application } from '/@/api/neoshowcase/protobuf/gateway_pb' import { Button } from '/@/components/UI/Button' import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' import FormBox from '/@/components/layouts/FormBox' @@ -110,11 +100,11 @@ const WebsiteConfigForm: Component = (props) => { const handleSubmit: SubmitHandler = (values) => handleSubmitUpdateApplicationForm(values, async (output) => { try { - // websiteがすべて削除されている場合、modularformsでは空配列ではなくundefinedになってしまう - // undefinedを渡した場合、APIとしては 無更新 として扱われるため、空配列を渡す - if (output.websites === undefined) { - output.websites = [] as PartialMessage - } + // // websiteがすべて削除されている場合、modularformsでは空配列ではなくundefinedになってしまう + // // undefinedを渡した場合、APIとしては 無更新 として扱われるため、空配列を渡す + // if (output.websites === undefined) { + // output.websites = [] as PartialMessage + // } await client.updateApplication(output) toast.success('ウェブサイト設定を更新しました') @@ -132,7 +122,7 @@ const WebsiteConfigForm: Component = (props) => { } return ( -
+ {() => null} diff --git a/dashboard/src/features/application/schema/applicationConfigSchema.ts b/dashboard/src/features/application/schema/applicationConfigSchema.ts index c2847b93..f365cd93 100644 --- a/dashboard/src/features/application/schema/applicationConfigSchema.ts +++ b/dashboard/src/features/application/schema/applicationConfigSchema.ts @@ -18,25 +18,29 @@ const runtimeConfigSchema = v.object({ }) const staticConfigSchema = v.object({ artifactPath: v.pipe(v.string(), v.nonEmpty('Enter Artifact Path')), - spa: stringBooleanSchema, + spa: v.optional(stringBooleanSchema, 'false'), }) const deployConfigSchema = v.pipe( v.optional( - v.variant('type', [ - v.object({ - type: v.literal('runtime'), - value: v.object({ - runtime: runtimeConfigSchema, + v.variant( + 'type', + [ + v.object({ + type: v.literal('runtime'), + value: v.object({ + runtime: runtimeConfigSchema, + }), }), - }), - v.object({ - type: v.literal('static'), - value: v.object({ - static: staticConfigSchema, + v.object({ + type: v.literal('static'), + value: v.object({ + static: staticConfigSchema, + }), }), - }), - ]), + ], + 'Select Deploy Type', + ), ), // アプリ作成時には最初undefinedになっているが、submit時にはundefinedで無い必要がある v.check((input) => !!input, 'Select Deploy Type'), @@ -56,26 +60,30 @@ const dockerfileConfigSchema = v.object({ const buildConfigSchema = v.pipe( v.optional( - v.variant('type', [ - v.object({ - type: v.literal('buildpack'), - value: v.object({ - buildpack: buildpackConfigSchema, + v.variant( + 'type', + [ + v.object({ + type: v.literal('buildpack'), + value: v.object({ + buildpack: buildpackConfigSchema, + }), }), - }), - v.object({ - type: v.literal('cmd'), - value: v.object({ - cmd: cmdConfigSchema, + v.object({ + type: v.literal('cmd'), + value: v.object({ + cmd: cmdConfigSchema, + }), }), - }), - v.object({ - type: v.literal('dockerfile'), - value: v.object({ - dockerfile: dockerfileConfigSchema, + v.object({ + type: v.literal('dockerfile'), + value: v.object({ + dockerfile: dockerfileConfigSchema, + }), }), - }), - ]), + ], + 'Select Build Type', + ), ), // アプリ作成時には最初undefinedになっているが、submit時にはundefinedで無い必要がある v.check((input) => !!input, 'Select Build Type'), diff --git a/dashboard/src/pages/apps/new.tsx b/dashboard/src/pages/apps/new.tsx index 08e98d66..5b68bab2 100644 --- a/dashboard/src/pages/apps/new.tsx +++ b/dashboard/src/pages/apps/new.tsx @@ -1,644 +1,11 @@ -import { styled } from '@macaron-css/solid' -import { - Field, - Form, - type FormStore, - createFormStore, - getValue, - getValues, - setValue, - validate, -} from '@modular-forms/solid' import { Title } from '@solidjs/meta' -import { A, useNavigate, useSearchParams } from '@solidjs/router' -import Fuse from 'fuse.js' -import { - type Accessor, - type Component, - For, - Match, - type Setter, - Show, - Switch, - createEffect, - createMemo, - createResource, - createSignal, - onMount, -} from 'solid-js' -import toast from 'solid-toast' -import { - type Application, - ApplicationConfig, - GetApplicationsRequest_Scope, - GetRepositoriesRequest_Scope, - type Repository, -} from '/@/api/neoshowcase/protobuf/gateway_pb' -import { Button } from '/@/components/UI/Button' -import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' -import { Progress } from '/@/components/UI/StepProgress' -import { TextField } from '/@/components/UI/TextField' import { MainViewContainer } from '/@/components/layouts/MainView' import { WithNav } from '/@/components/layouts/WithNav' -import { CheckBox } from '/@/components/templates/CheckBox' -import { FormItem } from '/@/components/templates/FormItem' -import { List } from '/@/components/templates/List' import { Nav } from '/@/components/templates/Nav' -import { AppGeneralConfig, type AppGeneralForm } from '/@/components/templates/app/AppGeneralConfig' -import { - type BuildConfigForm, - BuildConfigs, - configToForm, - formToConfig, -} from '/@/components/templates/app/BuildConfigs' -import { type WebsiteFormStatus, WebsiteSetting, newWebsite } from '/@/components/templates/app/WebsiteSettings' -import ReposFilter from '/@/components/templates/repo/ReposFilter' -import { client, handleAPIError, systemInfo } from '/@/libs/api' -import { type RepositoryOrigin, originToIcon, repositoryURLToOrigin } from '/@/libs/application' -import { colorOverlay } from '/@/libs/colorOverlay' -import { colorVars, textVars } from '/@/theme' - -const RepositoryStepContainer = styled('div', { - base: { - width: '100%', - height: '100%', - minHeight: '800px', - overflowY: 'hidden', - padding: '24px', - display: 'flex', - flexDirection: 'column', - gap: '24px', - - background: colorVars.semantic.ui.primary, - borderRadius: '8px', - }, -}) -const RepositoryListContainer = styled('div', { - base: { - width: '100%', - height: '100%', - overflowY: 'auto', - display: 'flex', - flexDirection: 'column', - }, -}) -const RepositoryButton = styled('button', { - base: { - width: '100%', - background: colorVars.semantic.ui.primary, - border: 'none', - cursor: 'pointer', - - selectors: { - '&:hover': { - background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover), - }, - '&:not(:last-child)': { - borderBottom: `1px solid ${colorVars.semantic.ui.border}`, - }, - }, - }, -}) -const RepositoryRow = styled('div', { - base: { - width: '100%', - padding: '16px', - display: 'grid', - gridTemplateColumns: '24px auto 1fr auto', - gridTemplateRows: 'auto auto', - gridTemplateAreas: ` - "icon name count button" - ". url url button"`, - rowGap: '2px', - columnGap: '8px', - textAlign: 'left', - }, -}) -const RepositoryIcon = styled('div', { - base: { - gridArea: 'icon', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - }, -}) -const RepositoryName = styled('div', { - base: { - width: '100%', - gridArea: 'name', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - color: colorVars.semantic.text.black, - ...textVars.h4.bold, - }, -}) -const AppCount = styled('div', { - base: { - display: 'flex', - alignItems: 'center', - whiteSpace: 'nowrap', - color: colorVars.semantic.text.grey, - ...textVars.caption.regular, - }, -}) -const RepositoryUrl = styled('div', { - base: { - gridArea: 'url', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - color: colorVars.semantic.text.grey, - ...textVars.caption.regular, - }, -}) -const CreateAppText = styled('div', { - base: { - gridArea: 'button', - display: 'flex', - justifyContent: 'flex-end', - alignItems: 'center', - gap: '4px', - color: colorVars.semantic.text.black, - ...textVars.text.bold, - }, -}) -const RegisterRepositoryButton = styled('div', { - base: { - width: '100%', - height: 'auto', - padding: '20px', - display: 'flex', - alignItems: 'center', - gap: '8px', - cursor: 'pointer', - background: colorVars.semantic.ui.primary, - color: colorVars.semantic.text.black, - ...textVars.text.bold, - - selectors: { - '&:hover': { - background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover), - }, - }, - }, -}) - -const RepositoryStep: Component<{ - setRepo: (repo: Repository) => void -}> = (props) => { - const [repos] = createResource(() => - client.getRepositories({ - scope: GetRepositoriesRequest_Scope.CREATABLE, - }), - ) - const [apps] = createResource(() => client.getApplications({ scope: GetApplicationsRequest_Scope.ALL })) - - const [query, setQuery] = createSignal('') - const [origin, setOrigin] = createSignal(['GitHub', 'Gitea', 'Others']) - - const filteredReposByOrigin = createMemo(() => { - const p = origin() - return repos()?.repositories.filter((r) => p.includes(repositoryURLToOrigin(r.url))) - }) - const repoWithApps = createMemo(() => { - const appsMap = apps()?.applications.reduce( - (acc, app) => { - if (!acc[app.repositoryId]) acc[app.repositoryId] = 0 - acc[app.repositoryId]++ - return acc - }, - {} as { [id: Repository['id']]: number }, - ) - - return ( - filteredReposByOrigin()?.map( - ( - repo, - ): { - repo: Repository - appCount: number - } => ({ repo, appCount: appsMap?.[repo.id] ?? 0 }), - ) ?? [] - ) - }) - - const fuse = createMemo( - () => - new Fuse(repoWithApps(), { - keys: ['repo.name', 'repo.htmlUrl'], - }), - ) - const filteredRepos = createMemo(() => { - if (query() === '') return repoWithApps() - return fuse() - .search(query()) - .map((r) => r.item) - }) - - return ( - - setQuery(e.currentTarget.value)} - leftIcon={search} - rightIcon={} - /> - - - - add - Register Repository - - - - - - Repository Not Found - - - } - > - {(repo) => ( - { - props.setRepo(repo.repo) - }} - type="button" - > - - {originToIcon(repositoryURLToOrigin(repo.repo.url), 24)} - {repo.repo.name} - {repo.appCount > 0 && `${repo.appCount} apps`} - {repo.repo.htmlUrl} - - Create App - arrow_forward - - - - )} - - - - - ) -} - -const FormsContainer = styled('div', { - base: { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '40px', - }, -}) -const FormContainer = styled('div', { - base: { - width: '100%', - padding: '24px', - display: 'flex', - flexDirection: 'column', - gap: '20px', - - background: colorVars.semantic.ui.primary, - borderRadius: '8px', - }, -}) -const FormTitle = styled('h2', { - base: { - display: 'flex', - alignItems: 'center', - gap: '4px', - overflowWrap: 'anywhere', - color: colorVars.semantic.text.black, - ...textVars.h2.medium, - }, -}) -const ButtonsContainer = styled('div', { - base: { - display: 'flex', - gap: '20px', - }, -}) - -type GeneralForm = AppGeneralForm & BuildConfigForm & { startOnCreate: boolean } - -const GeneralStep: Component<{ - repo: Repository - createAppForm: FormStore - backToRepoStep: () => void - proceedToWebsiteStep: () => void -}> = (props) => { - return ( - - - - - Create Application from - {originToIcon(repositoryURLToOrigin(props.repo.url), 24)} - {props.repo.name} - - {/* - modular formsでは `FormStore`のような - genericsが使用できないためignoreしている - */} - {/* @ts-ignore */} - - {/* @ts-ignore */} - - - {(field, fieldProps) => ( - -
この設定で今すぐ起動するかどうか
-
(環境変数はアプリ作成後設定可能になります)
- - ), - }, - }} - > - -
- )} -
-
- - - - -
- - ) -} - -const DomainsContainer = styled('div', { - base: { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '24px', - }, -}) -const AddMoreButtonContainer = styled('div', { - base: { - display: 'flex', - justifyContent: 'center', - }, -}) - -const WebsiteStep: Component<{ - isRuntimeApp: boolean - websiteForms: Accessor[]> - setWebsiteForms: Setter[]> - backToGeneralStep: () => void - submit: () => Promise -}> = (props) => { - const [isSubmitting, setIsSubmitting] = createSignal(false) - const addWebsiteForm = () => { - const form = createFormStore({ - initialValues: { - state: 'added', - website: newWebsite(), - }, - }) - props.setWebsiteForms((prev) => prev.concat([form])) - } - - const handleSubmit = async () => { - try { - const isValid = (await Promise.all(props.websiteForms().map((form) => validate(form)))).every((v) => v) - if (!isValid) return - setIsSubmitting(true) - await props.submit() - } catch (err) { - console.error(err) - } finally { - setIsSubmitting(false) - } - } - - return ( - - - - - link_off - URLが設定されていません - - - } - > - {(form, i) => ( - props.setWebsiteForms((prev) => [...prev.slice(0, i()), ...prev.slice(i() + 1)])} - hasPermission - /> - )} - - 0}> - - - - - - - - - - - - ) -} - -const StepsContainer = styled('div', { - base: { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '40px', - }, - variants: { - fit: { - true: { - maxHeight: '100%', - }, - }, - }, -}) - -const formStep = { - repository: 0, - general: 1, - website: 2, -} as const -type FormStep = (typeof formStep)[keyof typeof formStep] +import CreateAppForm from '/@/features/application/components/form/CreateAppForm' +import { ApplicationFormProvider } from '/@/features/application/provider/applicationFormProvider' export default () => { - const [searchParams, setParam] = useSearchParams() - const [currentStep, setCurrentStep] = createSignal(formStep.repository) - - const [repo, { mutate: mutateRepo }] = createResource( - () => searchParams.repositoryID, - (id) => client.getRepository({ repositoryId: id }), - ) - - // このページに遷移した時にURLパラメータにrepositoryIDがあれば - // generalStepに遷移する - onMount(() => { - if (searchParams.repositoryID !== undefined) { - setCurrentStep(formStep.general) - } - }) - - const createAppForm = createFormStore({ - initialValues: { - name: '', - refName: '', - repositoryId: repo()?.id, - startOnCreate: false, - ...configToForm(new ApplicationConfig()), - }, - }) - const isRuntimeApp = () => { - return ( - getValue(createAppForm, 'case') === 'runtimeBuildpack' || - getValue(createAppForm, 'case') === 'runtimeCmd' || - getValue(createAppForm, 'case') === 'runtimeDockerfile' - ) - } - // repo更新時にcreateAppFormのrepositoryIdを更新する - createEffect(() => { - setValue(createAppForm, 'repositoryId', repo()?.id) - }) - - const [websiteForms, setWebsiteForms] = createSignal[]>([]) - - // TODO: ブラウザバック時のrepositoryIDの設定 - - // repositoryが指定されたらビルド設定に進む - createEffect(() => { - if (repo() !== undefined) { - setParam({ repositoryID: repo()?.id }) - GoToGeneralStep() - } - }) - - const backToRepoStep = () => { - setCurrentStep(formStep.repository) - // 選択していたリポジトリをリセットする - setParam({ repositoryID: undefined }) - } - const GoToGeneralStep = () => { - setCurrentStep(formStep.general) - } - const GoToWebsiteStep = () => { - setCurrentStep(formStep.website) - } - - const createApp = async (): Promise => { - const values = getValues(createAppForm, { shouldActive: false }) - const websitesToSave = websiteForms() - .map((form) => getValues(form).website) - .filter((w): w is Exclude => w !== undefined) - - const createdApp = await client.createApplication({ - name: values.name, - refName: values.refName, - repositoryId: values.repositoryId, - config: { - buildConfig: formToConfig({ - case: values.case, - config: values.config as BuildConfigs, - }), - }, - websites: websitesToSave, - startOnCreate: values.startOnCreate, - }) - return createdApp - } - - const navigate = useNavigate() - const submit = async () => { - try { - const createdApp = await createApp() - toast.success('アプリケーションを登録しました') - navigate(`/apps/${createdApp.id}`) - } catch (e) { - handleAPIError(e, 'アプリケーションの登録に失敗しました') - } - } - return ( Create Application - NeoShowcase @@ -647,65 +14,9 @@ export default () => { - - - - {(step) => ( - step.step ? 'complete' : 'incomplete' - } - /> - )} - - - - - mutateRepo(repo)} /> - - - - {(nonNullRepo) => ( - - )} - - - - - - - + + + From c950f87b1aa2cccd24f9f39eaa24bb6d486e6ab4 Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 23 Aug 2024 21:38:43 +0900 Subject: [PATCH 38/51] remove unused form components --- .../templates/app/AppGeneralConfig.tsx | 121 ---- .../components/templates/app/BuildConfigs.tsx | 585 ----------------- .../templates/app/PortPublications.tsx | 231 ------- .../templates/app/SelectBuildType.tsx | 275 -------- .../templates/app/WebsiteSettings.tsx | 605 ------------------ .../templates/repo/RepositoryAuthSettings.tsx | 261 -------- 6 files changed, 2078 deletions(-) delete mode 100644 dashboard/src/components/templates/app/AppGeneralConfig.tsx delete mode 100644 dashboard/src/components/templates/app/BuildConfigs.tsx delete mode 100644 dashboard/src/components/templates/app/PortPublications.tsx delete mode 100644 dashboard/src/components/templates/app/SelectBuildType.tsx delete mode 100644 dashboard/src/components/templates/app/WebsiteSettings.tsx delete mode 100644 dashboard/src/components/templates/repo/RepositoryAuthSettings.tsx diff --git a/dashboard/src/components/templates/app/AppGeneralConfig.tsx b/dashboard/src/components/templates/app/AppGeneralConfig.tsx deleted file mode 100644 index 07aeed9a..00000000 --- a/dashboard/src/components/templates/app/AppGeneralConfig.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import type { PlainMessage } from '@bufbuild/protobuf' -import { Field, type FormStore, required, setValue } from '@modular-forms/solid' -import { type Component, Show, Suspense } from 'solid-js' -import type { - CreateApplicationRequest, - Repository, - UpdateApplicationRequest, -} from '/@/api/neoshowcase/protobuf/gateway_pb' -import { TextField } from '/@/components/UI/TextField' -import { useBranches } from '/@/libs/branchesSuggestion' -import { ComboBox } from '../Select' - -export type AppGeneralForm = Pick< - PlainMessage | PlainMessage, - 'name' | 'repositoryId' | 'refName' -> - -interface GeneralConfigProps { - repo: Repository - formStore: FormStore - editBranchId?: boolean - hasPermission: boolean -} - -const BranchField: Component = (props) => { - const branches = useBranches(() => props.repo.id) - - return ( - - {(field, fieldProps) => ( - -
Gitブランチ名またはRef
-
入力欄をクリックして候補を表示
- - ), - }, - }} - {...fieldProps} - options={branches().map((branch) => ({ - label: branch, - value: branch, - }))} - value={field.value} - error={field.error} - setValue={(v) => { - setValue(props.formStore, 'refName', v) - }} - readOnly={!props.hasPermission} - /> - )} -
- ) -} - -export const AppGeneralConfig: Component = (props) => { - return ( - <> - - {(field, fieldProps) => ( - - )} - - - {(field, fieldProps) => ( - - - - )} - - -
Gitブランチ名またはRef
-
入力欄をクリックして候補を表示
- - ), - }, - }} - value={''} - options={[]} - disabled - readOnly={!props.hasPermission} - /> - } - > - -
- - ) -} diff --git a/dashboard/src/components/templates/app/BuildConfigs.tsx b/dashboard/src/components/templates/app/BuildConfigs.tsx deleted file mode 100644 index fd925a03..00000000 --- a/dashboard/src/components/templates/app/BuildConfigs.tsx +++ /dev/null @@ -1,585 +0,0 @@ -import type { PlainMessage } from '@bufbuild/protobuf' -import { Field, type FormStore, getValue, required, setValue } from '@modular-forms/solid' -import { type Component, Match, Show, Switch, createSignal } from 'solid-js' -import { type ApplicationConfig, RuntimeConfig, StaticConfig } from '/@/api/neoshowcase/protobuf/gateway_pb' -import { TextField } from '/@/components/UI/TextField' -import { ToolTip } from '/@/components/UI/ToolTip' -import { CheckBox } from '../CheckBox' -import { FormItem } from '../FormItem' -import { RadioGroup } from '../RadioGroups' - -import { createEffect } from 'solid-js' -import SelectBuildType from './SelectBuildType' - -export type BuildConfigMethod = Exclude - -interface RuntimeConfigProps { - formStore: FormStore - disableEditDB: boolean - hasPermission: boolean -} - -const RuntimeConfigs: Component = (props) => { - const [useDB, setUseDB] = createSignal(false) - const buildType = () => getValue(props.formStore, 'case') - - createEffect(() => { - if ( - getValue(props.formStore, 'config.runtimeConfig.useMariadb', { shouldActive: false }) || - getValue(props.formStore, 'config.runtimeConfig.useMongodb', { shouldActive: false }) - ) { - setUseDB(true) - } - }) - - const entrypointConfig = ( - - {(field, fieldProps) => ( - - )} - - ) - const commandOverrideConfig = ( - - {(field, fieldProps) => ( - - )} - - ) - - return ( - <> - - -
データーベースを使用する場合はチェック
- - ), - }, - }} - > - アプリ作成後は変更できません, - }, - disabled: !props.disableEditDB, - }} - options={[ - { value: 'true', label: 'Yes' }, - { value: 'false', label: 'No' }, - ]} - value={useDB() ? 'true' : 'false'} - setValue={(v) => setUseDB(v === 'true')} - disabled={props.disableEditDB} - /> -
-
- - - アプリ作成後は変更できません, - }} - > - - - {(field, fieldProps) => ( - - )} - - - {(field, fieldProps) => ( - - )} - - - - - - {entrypointConfig} - - {entrypointConfig} - {commandOverrideConfig} - - - ) -} - -interface StaticConfigProps { - formStore: FormStore - hasPermission: boolean -} - -const StaticConfigs = (props: StaticConfigProps) => { - const buildType = () => getValue(props.formStore, 'case') - - return ( - <> - - {(field, fieldProps) => ( - -
静的ファイルが生成されるディレクトリ
-
({buildType() === 'staticCmd' ? 'リポジトリルート' : 'Context'}からの相対パス)
- - ), - }, - }} - {...fieldProps} - value={field.value ?? ''} - error={field.error} - readOnly={!props.hasPermission} - /> - )} -
- - {(field, fieldProps) => ( - -
配信するファイルがSPAである
-
(いい感じのフォールバック設定が付きます)
- - ), - }, - }} - {...fieldProps} - options={[ - { value: 'true', label: 'Yes' }, - { value: 'false', label: 'No' }, - ]} - value={field.value ? 'true' : 'false'} - setValue={(v) => setValue(props.formStore, field.name, v === 'true')} - readOnly={!props.hasPermission} - /> - )} -
- - ) -} -interface BuildPackConfigProps { - formStore: FormStore - hasPermission: boolean -} - -const BuildPackConfigs = (props: BuildPackConfigProps) => { - return ( - - {(field, fieldProps) => ( - -
ビルド対象ディレクトリ
-
(リポジトリルートからの相対パス)
- - ), - }, - }} - {...fieldProps} - value={field.value ?? ''} - error={field.error} - readOnly={!props.hasPermission} - /> - )} -
- ) -} -interface CmdConfigProps { - formStore: FormStore - hasPermission: boolean -} - -const CmdConfigs = (props: CmdConfigProps) => { - return ( - <> - - {(field, fieldProps) => ( - -
ベースとなるDocker Image
-
「イメージ名:タグ名」の形式
-
ビルドが必要無い場合は空
- - ), - }, - }} - {...fieldProps} - value={field.value ?? ''} - error={field.error} - readOnly={!props.hasPermission} - /> - )} -
- - {(field, fieldProps) => ( - -
イメージ上でビルド時に実行するコマンド
-
リポジトリルートで実行されます
- - ), - }, - }} - {...fieldProps} - multiline - value={field.value ?? ''} - error={field.error} - readOnly={!props.hasPermission} - /> - )} -
- - ) -} -interface DockerConfigProps { - formStore: FormStore - hasPermission: boolean -} - -const DockerConfigs = (props: DockerConfigProps) => { - return ( - <> - - {(field, fieldProps) => ( - -
ビルドContext
-
(リポジトリルートからの相対パス)
- - ), - }, - }} - value={field.value ?? ''} - error={field.error} - readOnly={!props.hasPermission} - {...fieldProps} - /> - )} -
- - {(field, fieldProps) => ( - -
Dockerfileへのパス
-
(Contextからの相対パス)
- - ), - }, - }} - value={field.value ?? ''} - error={field.error} - readOnly={!props.hasPermission} - {...fieldProps} - /> - )} -
- - ) -} - -export type BuildConfigs = { - runtimeConfig: PlainMessage - staticConfig: PlainMessage - buildPackConfig: { - context: string - } - cmdConfig: { - baseImage: string - buildCmd: string - } - dockerfileConfig: { - dockerfileName: string - context: string - } -} - -export type BuildConfigForm = { - case: PlainMessage['buildConfig']['case'] - config: BuildConfigs -} - -export const formToConfig = (form: BuildConfigForm): PlainMessage['buildConfig'] => { - switch (form.case) { - case 'runtimeBuildpack': - return { - case: 'runtimeBuildpack', - value: { - runtimeConfig: form.config.runtimeConfig, - context: form.config.buildPackConfig.context, - }, - } - case 'runtimeCmd': - return { - case: 'runtimeCmd', - value: { - runtimeConfig: form.config.runtimeConfig, - baseImage: form.config.cmdConfig.baseImage, - buildCmd: form.config.cmdConfig.buildCmd, - }, - } - case 'runtimeDockerfile': - return { - case: 'runtimeDockerfile', - value: { - runtimeConfig: form.config.runtimeConfig, - dockerfileName: form.config.dockerfileConfig.dockerfileName, - context: form.config.dockerfileConfig.context, - }, - } - case 'staticBuildpack': - return { - case: 'staticBuildpack', - value: { - staticConfig: form.config.staticConfig, - context: form.config.buildPackConfig.context, - }, - } - case 'staticCmd': - return { - case: 'staticCmd', - value: { - staticConfig: form.config.staticConfig, - baseImage: form.config.cmdConfig.baseImage, - buildCmd: form.config.cmdConfig.buildCmd, - }, - } - case 'staticDockerfile': - return { - case: 'staticDockerfile', - value: { - staticConfig: form.config.staticConfig, - dockerfileName: form.config.dockerfileConfig.dockerfileName, - context: form.config.dockerfileConfig.context, - }, - } - } - throw new Error('Invalid BuildConfigForm') -} - -const defaultConfigs: BuildConfigs = { - buildPackConfig: { context: '' }, - cmdConfig: { baseImage: '', buildCmd: '' }, - dockerfileConfig: { context: '', dockerfileName: '' }, - runtimeConfig: structuredClone(new RuntimeConfig()), - staticConfig: structuredClone(new StaticConfig()), -} -export const configToForm = (config: PlainMessage | undefined): BuildConfigForm => { - const buildConfig = config?.buildConfig - switch (buildConfig?.case) { - case 'runtimeBuildpack': - return { - case: 'runtimeBuildpack', - config: { - ...defaultConfigs, - runtimeConfig: buildConfig.value.runtimeConfig ?? defaultConfigs.runtimeConfig, - buildPackConfig: { - context: buildConfig.value.context, - }, - }, - } - case 'runtimeCmd': - return { - case: 'runtimeCmd', - config: { - ...defaultConfigs, - runtimeConfig: buildConfig.value.runtimeConfig ?? defaultConfigs.runtimeConfig, - cmdConfig: { - baseImage: buildConfig.value.baseImage, - buildCmd: buildConfig.value.buildCmd, - }, - }, - } - case 'runtimeDockerfile': - return { - case: 'runtimeDockerfile', - config: { - ...defaultConfigs, - runtimeConfig: buildConfig.value.runtimeConfig ?? defaultConfigs.runtimeConfig, - dockerfileConfig: { - context: buildConfig.value.context, - dockerfileName: buildConfig.value.dockerfileName, - }, - }, - } - case 'staticBuildpack': - return { - case: 'staticBuildpack', - config: { - ...defaultConfigs, - staticConfig: buildConfig.value.staticConfig ?? defaultConfigs.staticConfig, - buildPackConfig: { - context: buildConfig.value.context, - }, - }, - } - case 'staticCmd': - return { - case: 'staticCmd', - config: { - ...defaultConfigs, - staticConfig: buildConfig.value.staticConfig ?? defaultConfigs.staticConfig, - cmdConfig: { - baseImage: buildConfig.value.baseImage, - buildCmd: buildConfig.value.buildCmd, - }, - }, - } - case 'staticDockerfile': - return { - case: 'staticDockerfile', - config: { - ...defaultConfigs, - staticConfig: buildConfig.value.staticConfig ?? defaultConfigs.staticConfig, - dockerfileConfig: { - context: buildConfig.value.context, - dockerfileName: buildConfig.value.dockerfileName, - }, - }, - } - default: - return { - case: undefined, - config: defaultConfigs, - } - } -} - -export interface BuildConfigsProps { - formStore: FormStore - disableEditDB: boolean - hasPermission: boolean -} - -export const BuildConfigs: Component = (props) => { - const buildType = () => getValue(props.formStore, 'case') - - return ( - <> - - {(field, fieldProps) => ( - setValue(props.formStore, 'case', v)} - readOnly={!props.hasPermission} - /> - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} diff --git a/dashboard/src/components/templates/app/PortPublications.tsx b/dashboard/src/components/templates/app/PortPublications.tsx deleted file mode 100644 index ef8fe044..00000000 --- a/dashboard/src/components/templates/app/PortPublications.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import type { PlainMessage } from '@bufbuild/protobuf' -import { style } from '@macaron-css/core' -import { styled } from '@macaron-css/solid' -import { Field, FieldArray, type FormStore, custom, getValue, insert, remove, setValue } from '@modular-forms/solid' -import { For, Show } from 'solid-js' -import { type PortPublication, PortPublicationProtocol } from '/@/api/neoshowcase/protobuf/gateway_pb' -import { Button } from '/@/components/UI/Button' -import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' -import { TextField } from '/@/components/UI/TextField' -import { systemInfo } from '/@/libs/api' -import { pickRandom, randIntN } from '/@/libs/random' -import { colorVars } from '/@/theme' -import { type SelectOption, SingleSelect } from '../Select' - -const PortsContainer = styled('div', { - base: { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '16px', - }, -}) -const PortRow = styled('div', { - base: { - width: '100%', - display: 'flex', - alignItems: 'center', - gap: '24px', - }, -}) -const PortVisualContainer = styled('div', { - base: { - alignItems: 'flex-start', - gap: '8px', - }, - variants: { - variant: { - from: { - width: '100%', - flexBasis: 'calc(60% - 4px)', - display: 'grid', - flexGrow: '1', - gridTemplateColumns: 'minmax(calc(8ch + 32px), 1fr) auto minmax(calc(4ch + 60px), 1fr)', - }, - to: { - width: '100%', - flexBasis: 'calc(40% - 4px)', - display: 'grid', - flexGrow: '1', - gridTemplateColumns: 'auto minmax(calc(8ch + 32px), 1fr) auto', - }, - wrapper: { - width: '100%', - display: 'flex', - flexWrap: 'wrap', - }, - }, - }, -}) -const PortItem = styled('div', { - base: { - height: '48px', - display: 'flex', - alignItems: 'center', - }, -}) -const textStyle = style({ - color: colorVars.semantic.text.black, -}) - -const protocolItems: SelectOption[] = [ - { value: PortPublicationProtocol.TCP, label: 'TCP' }, - { value: PortPublicationProtocol.UDP, label: 'UDP' }, -] - -const protoToName: Record = { - [PortPublicationProtocol.TCP]: 'TCP', - [PortPublicationProtocol.UDP]: 'UDP', -} - -interface PortPublicationProps { - formStore: FormStore - name: `ports.${number}` - deletePort: () => void - hasPermission: boolean -} - -const isValidPort = (port?: number, proto?: PortPublicationProtocol): boolean => { - if (port === undefined) return false - const available = systemInfo()?.ports.filter((a) => a.protocol === proto) || [] - if (available.length === 0) return false - return available.some((range) => port >= range.startPort && port <= range.endPort) -} - -const PortSetting = (props: PortPublicationProps) => { - return ( - - - - isValidPort(port, getValue(props.formStore, `${props.name}.protocol`)), - 'Please enter the available port', - )} - > - {(field, fieldProps) => ( - - )} - - / - - {(field, fieldProps) => ( - { - setValue(props.formStore, `${props.name}.protocol`, value) - }} - readOnly={!props.hasPermission} - /> - )} - - - - - - {(field, fieldProps) => ( - - )} - - - {(protocol) => /{protoToName[protocol()]}} - - - - - - - - ) -} - -const suggestPort = (proto: PortPublicationProtocol): number => { - const available = systemInfo()?.ports.filter((a) => a.protocol === proto) || [] - if (available.length === 0) return 0 - const range = pickRandom(available) - return randIntN(range.endPort + 1 - range.startPort) + range.startPort -} - -const newPort = (): PlainMessage => { - return { - internetPort: suggestPort(PortPublicationProtocol.TCP), - applicationPort: 0, - protocol: PortPublicationProtocol.TCP, - } -} - -export type PortSettingsStore = { - ports: PlainMessage[] -} -interface PortPublicationSettingsProps { - formStore: FormStore - hasPermission: boolean -} - -export const PortPublicationSettings = (props: PortPublicationSettingsProps) => { - return ( - - - {(fieldArray) => ( - ポート公開が設定されていません}> - {(_, index) => ( - remove(props.formStore, 'ports', { at: index() })} - hasPermission={props.hasPermission} - /> - )} - - )} - - - - - - ) -} diff --git a/dashboard/src/components/templates/app/SelectBuildType.tsx b/dashboard/src/components/templates/app/SelectBuildType.tsx deleted file mode 100644 index 045b063d..00000000 --- a/dashboard/src/components/templates/app/SelectBuildType.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { RadioGroup } from '@kobalte/core' -import { style } from '@macaron-css/core' -import { styled } from '@macaron-css/solid' -import { type Component, type JSX, Show, createEffect, createSignal, splitProps } from 'solid-js' -import { RadioIcon } from '/@/components/UI/RadioIcon' -import { colorOverlay } from '/@/libs/colorOverlay' -import { colorVars, media, textVars } from '/@/theme' -import { FormItem } from '../FormItem' -import type { BuildConfigMethod } from './BuildConfigs' - -const ItemsContainer = styled('div', { - base: { - width: '100%', - display: 'flex', - alignItems: 'stretch', - gap: '16px', - - '@media': { - [media.mobile]: { - flexDirection: 'column', - }, - }, - }, -}) -const itemStyle = style({ - width: '100%', -}) -const labelStyle = style({ - width: '100%', - height: '100%', - padding: '16px', - display: 'flex', - flexDirection: 'column', - gap: '8px', - - background: colorVars.semantic.ui.primary, - borderRadius: '8px', - border: `1px solid ${colorVars.semantic.ui.border}`, - cursor: 'pointer', - - selectors: { - '&:hover:not([data-disabled]):not([data-readonly])': { - background: colorOverlay(colorVars.semantic.ui.primary, colorVars.semantic.transparent.primaryHover), - }, - '&[data-readonly]': { - cursor: 'not-allowed', - }, - '&[data-checked]': { - outline: `2px solid ${colorVars.semantic.primary.main}`, - }, - '&[data-disabled]': { - cursor: 'not-allowed', - color: colorVars.semantic.text.disabled, - background: colorVars.semantic.ui.tertiary, - }, - '&[data-invalid]': { - outline: `2px solid ${colorVars.semantic.accent.error}`, - }, - }, -}) -const ItemTitle = styled('div', { - base: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: '8px', - color: colorVars.semantic.text.black, - ...textVars.text.bold, - }, -}) -const Description = styled('div', { - base: { - color: colorVars.semantic.text.black, - ...textVars.caption.regular, - }, -}) -export const errorTextStyle = style({ - marginTop: '8px', - width: '100%', - color: colorVars.semantic.accent.error, - ...textVars.text.regular, -}) - -const SelectBuildType: Component<{ - value: BuildConfigMethod | undefined - error?: string - setValue: (v: BuildConfigMethod | undefined) => void - readOnly: boolean - ref: (element: HTMLInputElement | HTMLTextAreaElement) => void - onInput: JSX.EventHandler - onChange: JSX.EventHandler - onBlur: JSX.EventHandler -}> = (props) => { - const [inputProps] = splitProps(props, ['ref', 'onInput', 'onChange', 'onBlur']) - const [runType, setRunType] = createSignal<'runtime' | 'static' | undefined>() - const [buildType, setBuildType] = createSignal<'buildpack' | 'cmd' | 'dockerfile' | undefined>() - - createEffect(() => { - switch (props.value) { - case 'runtimeBuildpack': - case 'runtimeCmd': - case 'runtimeDockerfile': - setRunType('runtime') - break - case 'staticBuildpack': - case 'staticCmd': - case 'staticDockerfile': - setRunType('static') - break - } - - switch (props.value) { - case 'runtimeBuildpack': - case 'staticBuildpack': - setBuildType('buildpack') - break - case 'runtimeCmd': - case 'staticCmd': - setBuildType('cmd') - break - case 'runtimeDockerfile': - case 'staticDockerfile': - setBuildType('dockerfile') - break - case undefined: - setBuildType(undefined) - break - } - }) - - createEffect(() => { - const _runType = runType() - const _buildType = buildType() - if (_runType === undefined || _buildType === undefined) { - props.setValue(undefined) - return - } - - switch (_runType) { - case 'runtime': - switch (_buildType) { - case 'buildpack': - props.setValue('runtimeBuildpack') - break - case 'cmd': - props.setValue('runtimeCmd') - break - case 'dockerfile': - props.setValue('runtimeDockerfile') - break - } - break - case 'static': - switch (_buildType) { - case 'buildpack': - props.setValue('staticBuildpack') - break - case 'cmd': - props.setValue('staticCmd') - break - case 'dockerfile': - props.setValue('staticDockerfile') - break - } - break - } - }) - - return ( - <> - - - - - - - - Runtime - - - - - - - - コマンドを実行してアプリを起動します。サーバープロセスやバックグラウンド処理がある場合、こちらを選びます。 - - - - - - - - Static - - - - - - - 静的ファイルを配信します。ビルド(任意)を実行できます。 - - - - - {props.error && buildType() === undefined ? 'Select Application Type' : ''} - - - - - - - - - - - - Buildpack - - - - - - - ビルド設定を、リポジトリ内ファイルから自動検出します。(オススメ) - - - - - - - Command - - - - - - - ベースイメージとビルドコマンド(任意)を設定します。 - - - - - - - Dockerfile - - - - - - - リポジトリ内Dockerfileからビルドを行います。 - - - - {props.error} - - - - - ) -} - -export default SelectBuildType diff --git a/dashboard/src/components/templates/app/WebsiteSettings.tsx b/dashboard/src/components/templates/app/WebsiteSettings.tsx deleted file mode 100644 index 96e128f4..00000000 --- a/dashboard/src/components/templates/app/WebsiteSettings.tsx +++ /dev/null @@ -1,605 +0,0 @@ -import type { PlainMessage } from '@bufbuild/protobuf' -import { styled } from '@macaron-css/solid' -import { Field, Form, type FormStore, getValue, reset, setValue, toCustom } from '@modular-forms/solid' -import { For, Show, createEffect, createMemo, createReaction, on, onMount } from 'solid-js' -import { - AuthenticationType, - type AvailableDomain, - type CreateWebsiteRequest, - type Website, -} from '/@/api/neoshowcase/protobuf/gateway_pb' -import { Button } from '/@/components/UI/Button' -import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' -import ModalDeleteConfirm from '/@/components/UI/ModalDeleteConfirm' -import { TextField } from '/@/components/UI/TextField' -import FormBox from '/@/components/layouts/FormBox' -import { systemInfo } from '/@/libs/api' -import { websiteWarnings } from '/@/libs/application' -import useModal from '/@/libs/useModal' -import { colorVars } from '/@/theme' -import { CheckBox } from '../CheckBox' -import { FormItem } from '../FormItem' -import { List } from '../List' -import { RadioGroup, type RadioOption } from '../RadioGroups' -import { type SelectOption, SingleSelect } from '../Select' - -const URLContainer = styled('div', { - base: { - display: 'flex', - flexDirection: 'row', - alignItems: 'flex-top', - gap: '8px', - }, -}) -const URLItem = styled('div', { - base: { - height: '48px', - display: 'flex', - alignItems: 'center', - }, -}) -const HttpSelectContainer = styled('div', { - base: { - flexShrink: 0, - width: 'calc(6ch + 60px)', - }, -}) -const WarningsContainer = styled('div', { - base: { - display: 'flex', - flexDirection: 'column', - gap: '4px', - }, -}) -const WarningItem = styled('div', { - base: { - color: colorVars.semantic.accent.error, - }, -}) -const DeleteButtonContainer = styled('div', { - base: { - width: 'fit-content', - marginRight: 'auto', - }, -}) -const AddMoreButtonContainer = styled('div', { - base: { - display: 'flex', - justifyContent: 'center', - }, -}) - -interface WebsiteSettingProps { - isRuntimeApp: boolean - formStore: FormStore - saveWebsite?: () => void - deleteWebsite: () => void - hasPermission: boolean -} - -const schemeOptions: SelectOption<`${boolean}`>[] = [ - { value: 'false', label: 'http' }, - { value: 'true', label: 'https' }, -] - -const authenticationTypeOptionsMap = { - [`${AuthenticationType.OFF}`]: AuthenticationType.OFF, - [`${AuthenticationType.SOFT}`]: AuthenticationType.SOFT, - [`${AuthenticationType.HARD}`]: AuthenticationType.HARD, -} - -const authenticationTypeOptions: RadioOption<`${AuthenticationType}`>[] = [ - { value: `${AuthenticationType.OFF}`, label: 'OFF' }, - { value: `${AuthenticationType.SOFT}`, label: 'SOFT' }, - { value: `${AuthenticationType.HARD}`, label: 'HARD' }, -] - -export const WebsiteSetting = (props: WebsiteSettingProps) => { - const state = () => getValue(props.formStore, 'state') - const discardChanges = () => reset(props.formStore) - - const { Modal, open, close } = useModal() - - const nonWildcardDomains = createMemo(() => systemInfo()?.domains.filter((d) => !d.domain.startsWith('*')) ?? []) - const wildCardDomains = createMemo(() => systemInfo()?.domains.filter((d) => d.domain.startsWith('*')) ?? []) - const websiteUrl = () => { - const scheme = getValue(props.formStore, 'website.https') ? 'https' : 'http' - const fqdn = getValue(props.formStore, 'website.fqdn') - const pathPrefix = getValue(props.formStore, 'website.pathPrefix') - return `${scheme}://${fqdn}${pathPrefix}` - } - - const extractSubdomain = ( - fqdn: string, - ): { - subdomain: string - domain: PlainMessage - } => { - const matchNonWildcardDomain = nonWildcardDomains().find((d) => fqdn === d.domain) - if (matchNonWildcardDomain !== undefined) { - return { - subdomain: '', - domain: matchNonWildcardDomain, - } - } - - const matchDomain = wildCardDomains().find((d) => fqdn?.endsWith(d.domain.replace(/\*/g, ''))) - if (matchDomain === undefined) { - const fallbackDomain = systemInfo()?.domains[0] - if (fallbackDomain === undefined) throw new Error('No domain available') - return { - subdomain: '', - domain: fallbackDomain, - } - } - return { - subdomain: fqdn.slice(0, -matchDomain.domain.length + 1), - domain: matchDomain, - } - } - - // set subdomain and domain from fqdn on fqdn change - createEffect( - on( - () => getValue(props.formStore, 'website.fqdn'), - (fqdn) => { - if (fqdn === undefined) return - const { subdomain, domain } = extractSubdomain(fqdn) - setValue(props.formStore, 'website.subdomain', subdomain) - setValue(props.formStore, 'website.domain', domain.domain) - setValue(props.formStore, 'website.authAvailable', domain.authAvailable) - if (domain.authAvailable === false) { - setValue(props.formStore, 'website.authentication', AuthenticationType.OFF) - } - }, - ), - ) - - const resetSubdomainAndDomain = createReaction(() => { - const fqdn = getValue(props.formStore, 'website.fqdn') - if (fqdn === undefined) return - const { subdomain, domain } = extractSubdomain(fqdn) - reset(props.formStore, 'website.subdomain', { - initialValue: subdomain, - }) - reset(props.formStore, 'website.domain', { - initialValue: domain.domain, - }) - reset(props.formStore, 'website.authAvailable', { - initialValue: domain.authAvailable, - }) - }) - - onMount(() => { - // Reset subdomain and domain on first fqdn change - resetSubdomainAndDomain(() => getValue(props.formStore, 'website.fqdn')) - }) - - // set fqdn from subdomain and domain on subdomain or domain change - createEffect( - on( - [() => getValue(props.formStore, 'website.subdomain'), () => getValue(props.formStore, 'website.domain')], - ([subdomain, domain]) => { - if (subdomain === undefined || domain === undefined) return - if (domain.startsWith('*')) { - // wildcard domainならsubdomainとdomainを結合 - const fqdn = `${subdomain}${domain?.replace(/\*/g, '')}` - setValue(props.formStore, 'website.fqdn', fqdn) - } else { - // non-wildcard domainならdomainをそのまま使う - setValue(props.formStore, 'website.fqdn', domain) - } - }, - ), - ) - - const warnings = () => - websiteWarnings(getValue(props.formStore, 'website.subdomain'), getValue(props.formStore, 'website.https')) - - return ( -
{ - if (props.saveWebsite) props.saveWebsite() - }} - style={{ width: '100%' }} - > - {/* - To make a field active, it must be included in the DOM - see: https://modularforms.dev/solid/guides/add-fields-to-form#active-state - */} - - {() => <>} - - - {() => <>} - - - {() => <>} - - - {() => <>} - - - - - - - - {(field, fieldProps) => ( - -
スキーム
-
通常はhttpsが推奨です
- - ), - }, - }} - {...fieldProps} - options={schemeOptions} - value={field.value ? 'true' : 'false'} - setValue={(selected) => { - setValue(props.formStore, 'website.https', selected === 'true') - }} - readOnly={props.hasPermission} - /> - )} -
-
- :// - { - if (getValue(props.formStore, 'website.domain')?.startsWith('*') && subdomain === '') { - return 'Please Enter Subdomain Name' - } - return '' - }} - > - {(field, fieldProps) => ( - - - - )} - - - {(field, fieldProps) => ( - - !domain.alreadyBound || getValue(props.formStore, 'website.domain') === domain.domain, - ) - .map((domain) => { - const domainName = domain.domain.replace(/\*/g, '') - return { - value: domain.domain, - label: domainName, - } - }) ?? [] - } - value={field.value} - setValue={(domain) => { - setValue(props.formStore, 'website.domain', domain) - }} - readOnly={!props.hasPermission} - /> - )} - -
- - / - `/${value}` as string, { - on: 'input', - })} - > - {(field, fieldProps) => ( - - )} - - - - - - {(field, fieldProps) => ( - - )} - - - /TCP - - - 0}> - - {(item) => {item}} - - -
- {/* website.authenticationがnumberであるため型としてはtype="number"の指定が正しいが、numberを指定すると入力時のonInput内でinput.valueAsNumberが使用される。すると、RadioGroup内で使用されているinput要素はtype="number"等が指定されていない(kobalteのRadioGroupではもともと文字列のみが扱える)ため、valueAsNumberでの取得結果がNaNになってしまい正しくsetValueできない。そのためtype="string"を指定してinput.valueが使用されるようにしています */} - {/* see: https://github.com/traPtitech/NeoShowcase/pull/878#issuecomment-1953994009 */} - { - if (v === undefined) return AuthenticationType.OFF - return authenticationTypeOptionsMap[v] - }} - > - {(field, fieldProps) => ( - - label="部員認証" - info={{ - style: 'left', - props: { - content: ( - <> -
OFF: 誰でもアクセス可能
-
SOFT: 部員の場合X-Forwarded-Userをセット
-
HARD: 部員のみアクセス可能
- - ), - }, - }} - {...fieldProps} - tooltip={{ - props: { - content: `${getValue(props.formStore, 'website.domain')}では部員認証が使用できません`, - }, - disabled: getValue(props.formStore, 'website.authAvailable') && props.hasPermission, - }} - options={authenticationTypeOptions} - value={`${field.value ?? AuthenticationType.OFF}`} - disabled={!getValue(props.formStore, 'website.authAvailable')} - readOnly={!props.hasPermission} - /> - )} -
- - - {(field, fieldProps) => ( - - )} - - - - {(field, fieldProps) => ( - - )} - - - -
- - - - - - - - - - - -
- - Delete Website - - - language - {websiteUrl()} - - - - - - - -
- ) -} - -type FQDN = { - subdomain: string - domain: PlainMessage['domain'] - authAvailable: PlainMessage['authAvailable'] -} - -export type WebsiteFormStatus = - | { - /** - * - `noChange`: 既存の設定を変更していない - * - `readyToChange`: 次の保存時に変更を反映する - * - `readyToDelete`: 次の保存時に削除する - */ - state: 'noChange' | 'readyToChange' | 'readyToDelete' - website: PlainMessage & FQDN - } - | { - /** - * - `added`: 新規に設定を追加した - */ - state: 'added' - website: PlainMessage & FQDN - } - -export type WebsiteSettingForm = { - websites: WebsiteFormStatus[] -} - -export const newWebsite = (): PlainMessage => ({ - fqdn: '', - pathPrefix: '/', - stripPrefix: false, - https: true, - h2c: false, - httpPort: 0, - authentication: AuthenticationType.OFF, -}) - -interface WebsiteSettingsProps { - isRuntimeApp: boolean - formStores: FormStore[] - addWebsite: () => void - deleteWebsiteForm: (index: number) => void - applyChanges: () => void - hasPermission: boolean -} - -export const WebsiteSettings = (props: WebsiteSettingsProps) => { - return ( - - - - link_off - URLが設定されていません - - - - - - } - > - {(form, index) => ( - { - if (getValue(props.formStores[index()], 'state') === 'noChange') { - setValue(props.formStores[index()], 'state', 'readyToChange') - } - props.applyChanges() - }} - deleteWebsite={() => { - props.deleteWebsiteForm(index()) - }} - hasPermission={props.hasPermission} - /> - )} - - 0 && props.hasPermission}> - - - - - - ) -} diff --git a/dashboard/src/components/templates/repo/RepositoryAuthSettings.tsx b/dashboard/src/components/templates/repo/RepositoryAuthSettings.tsx deleted file mode 100644 index 875f38e1..00000000 --- a/dashboard/src/components/templates/repo/RepositoryAuthSettings.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import type { PlainMessage } from '@bufbuild/protobuf' -import { styled } from '@macaron-css/solid' -import { Field, type FormStore, type ValidateField, getValue, required, setValue } from '@modular-forms/solid' -import { Match, Show, Switch, createEffect, createResource, createSignal } from 'solid-js' -import { Suspense } from 'solid-js' -import type { - CreateRepositoryAuth, - CreateRepositoryRequest, - UpdateRepositoryRequest, -} from '/@/api/neoshowcase/protobuf/gateway_pb' -import { Button } from '/@/components/UI/Button' -import { MaterialSymbols } from '/@/components/UI/MaterialSymbols' -import { TextField } from '/@/components/UI/TextField' -import { client, systemInfo } from '/@/libs/api' -import { colorVars, textVars } from '/@/theme' -import { TooltipInfoIcon } from '../../UI/TooltipInfoIcon' -import { FormItem } from '../FormItem' -import { RadioGroup, type RadioOption } from '../RadioGroups' - -const SshKeyContainer = styled('div', { - base: { - width: '100%', - display: 'flex', - flexDirection: 'column', - gap: '16px', - - color: colorVars.semantic.text.grey, - ...textVars.caption.regular, - }, -}) -const RefreshButtonContainer = styled('div', { - base: { - width: '100%', - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: '8px', - - color: colorVars.semantic.accent.error, - ...textVars.caption.regular, - }, -}) -const VisibilityButton = styled('button', { - base: { - width: '40px', - height: '40px', - padding: '8px', - background: 'none', - border: 'none', - borderRadius: '4px', - cursor: 'pointer', - - color: colorVars.semantic.text.black, - selectors: { - '&:hover': { - background: colorVars.semantic.transparent.primaryHover, - }, - '&:active': { - color: colorVars.semantic.primary.main, - background: colorVars.semantic.transparent.primarySelected, - }, - }, - }, -}) - -const AuthMethods: RadioOption>[] = [ - { label: '認証を使用しない', value: 'none' }, - { label: 'BASIC認証', value: 'basic' }, - { label: 'SSH公開鍵認証', value: 'ssh' }, -] - -type AuthMethods = { - [K in Exclude['auth']['case'], undefined>]: Extract< - PlainMessage['auth'], - { case: K } - >['value'] -} - -export type AuthForm = { - url: PlainMessage['url'] - case: PlainMessage['auth']['case'] - auth: AuthMethods -} - -export const formToAuth = (form: T): PlainMessage['auth'] => { - const authMethod = form.case - switch (authMethod) { - case 'none': - return { - case: 'none', - value: '', - } - case 'basic': - return { - case: 'basic', - value: { - username: form.auth.basic.username, - password: form.auth.basic.password, - }, - } - case 'ssh': - return { - case: 'ssh', - value: { - keyId: form.auth.ssh.keyId, - }, - } - } - throw new Error('unreachable') -} - -interface Props { - formStore: FormStore - hasPermission: boolean -} - -export const RepositoryAuthSettings = (props: Props) => { - const [showPassword, setShowPassword] = createSignal(false) - const [useTmpKey, setUseTmpKey] = createSignal(false) - const [tmpKey] = createResource( - () => (useTmpKey() ? true : undefined), - () => client.generateKeyPair({}), - ) - createEffect(() => { - if (tmpKey.latest !== undefined) { - setValue(props.formStore, 'auth.ssh.keyId', tmpKey().keyId) - } - }) - const publicKey = () => (useTmpKey() ? tmpKey()?.publicKey : systemInfo()?.publicKey ?? '') - - const AuthMethod = () => ( - - {(field, fieldProps) => ( - - )} - - ) - - const validateUrl: ValidateField = (url) => { - if (getValue(props.formStore, 'case') === 'basic' && !url?.startsWith('https')) { - return 'Basic認証を使用する場合、URLはhttps://から始まる必要があります' - } - return '' - } - const Url = () => { - return ( - - {(field, fieldProps) => ( - - )} - - ) - } - - const AuthConfig = () => { - const authMethod = () => getValue(props.formStore, 'case') - return ( - - - - {(field, fieldProps) => ( - - )} - - - {(field, fieldProps) => ( - setShowPassword((s) => !s)} type="button"> - visibility_off}> - visibility - - - } - /> - )} - - - - - {() => ( - - - - 以下のSSH公開鍵{useTmpKey() ? '(このリポジトリ専用)' : '(NeoShowcase全体共通)'} - を、リポジトリのデプロイキーとして登録してください。 -
- 公開リポジトリの場合は、この操作は不要です。 - - - - - -
このリポジトリ専用のSSH用鍵ペアを生成します。
-
- NeoShowcase全体で共通の公開鍵が、リポジトリに登録できない場合に生成してください。 -
-
GitHubプライベートリポジトリの場合は必ず生成が必要です。
- - ), - }} - style="left" - /> -
-
-
-
-
- )} -
-
-
- ) - } - - return { - AuthMethod, - Url, - AuthConfig, - } -} From 22a9d9ff084bcc7954dc392163d26d324612257b Mon Sep 17 00:00:00 2001 From: eyemono-moe Date: Fri, 23 Aug 2024 21:42:31 +0900 Subject: [PATCH 39/51] remove DiscardChanges buttons --- .../components/form/BuildConfigForm.tsx | 21 ++++++------------- .../components/form/EnvVarConfigForm.tsx | 2 +- .../components/form/GeneralConfigForm.tsx | 21 ++++++------------- .../components/form/PortForwardingForm.tsx | 21 ++++++------------- .../components/form/WebsitesConfigForm.tsx | 21 ++++++------------- .../repository/components/AuthConfigForm.tsx | 21 ++++++------------- .../components/GeneralConfigForm.tsx | 21 ++++++------------- 7 files changed, 37 insertions(+), 91 deletions(-) diff --git a/dashboard/src/features/application/components/form/BuildConfigForm.tsx b/dashboard/src/features/application/components/form/BuildConfigForm.tsx index 33c25da6..4426ffa2 100644 --- a/dashboard/src/features/application/components/form/BuildConfigForm.tsx +++ b/dashboard/src/features/application/components/form/BuildConfigForm.tsx @@ -24,15 +24,6 @@ type Props = { const BuildConfigForm: Component = (props) => { const { formStore } = useApplicationForm() - const discardChanges = () => { - reset( - untrack(() => formStore), - { - initialValues: updateApplicationFormInitialValues(props.app), - }, - ) - } - // `reset` doesn't work on first render when the Field not rendered // see: https://github.com/fabian-hiller/modular-forms/issues/157#issuecomment-1848567069 onMount(() => { @@ -41,7 +32,12 @@ const BuildConfigForm: Component = (props) => { // reset forms when props.app changed createEffect(() => { - discardChanges() + reset( + untrack(() => formStore), + { + initialValues: updateApplicationFormInitialValues(props.app), + }, + ) }) // TODO: propsでSubmitHandlerを受け取る @@ -72,11 +68,6 @@ const BuildConfigForm: Component = (props) => { - - -
diff --git a/dashboard/src/features/application/components/form/GeneralConfigForm.tsx b/dashboard/src/features/application/components/form/GeneralConfigForm.tsx index c134c8bd..42104a68 100644 --- a/dashboard/src/features/application/components/form/GeneralConfigForm.tsx +++ b/dashboard/src/features/application/components/form/GeneralConfigForm.tsx @@ -26,15 +26,6 @@ type Props = { const GeneralConfigForm: Component = (props) => { const { formStore } = useApplicationForm() - const discardChanges = () => { - reset( - untrack(() => formStore), - { - initialValues: updateApplicationFormInitialValues(props.app), - }, - ) - } - // `reset` doesn't work on first render when the Field not rendered // see: https://github.com/fabian-hiller/modular-forms/issues/157#issuecomment-1848567069 onMount(() => { @@ -43,7 +34,12 @@ const GeneralConfigForm: Component = (props) => { // reset forms when props.app changed createEffect(() => { - discardChanges() + reset( + untrack(() => formStore), + { + initialValues: updateApplicationFormInitialValues(props.app), + }, + ) }) // TODO: propsでSubmitHandlerを受け取る @@ -75,11 +71,6 @@ const GeneralConfigForm: Component = (props) => { - - - -
- - -