Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Package name validation #1998

Merged
merged 25 commits into from
Jan 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions catalog/app/containers/Bucket/PackageCopyDialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ function DialogTitle({ bucket }) {
)
}

const defaultNameWarning = ' ' // Reserve space for warning

const useStyles = M.makeStyles((t) => ({
meta: {
marginTop: t.spacing(3),
Expand All @@ -92,6 +94,8 @@ function DialogForm({
workflowsConfig,
}) {
const nameValidator = PD.useNameValidator()
const nameExistence = PD.useNameExistence(successor.slug)
const [nameWarning, setNameWarning] = React.useState('')
const classes = useStyles()

const initialMeta = React.useMemo(
Expand Down Expand Up @@ -128,6 +132,27 @@ function DialogForm({
}
}

const onFormChange = React.useCallback(
async ({ values }) => {
const { name } = values
const fullName = `${successor.slug}/${name}`

const nameExists = await nameExistence.validate(name)
if (nameExists) {
setNameWarning(`Package "${fullName}" exists. Submitting will revise it`)
return
}

if (name) {
setNameWarning(`Package "${fullName}" will be created`)
return
}

setNameWarning(defaultNameWarning)
},
[successor, nameExistence],
)

return (
<RF.Form onSubmit={onSubmit}>
{({
Expand All @@ -144,11 +169,11 @@ function DialogForm({
<DialogTitle bucket={successor.slug} />
<M.DialogContent style={{ paddingTop: 0 }}>
<form onSubmit={handleSubmit}>
<RF.FormSpy subscription={{ values: true }} onChange={onFormChange} />

<RF.Field
component={PD.Field}
component={PD.PackageNameInput}
name="name"
label="Name"
placeholder="e.g. user/package"
validate={validators.composeAsync(
validators.required,
nameValidator.validate,
Expand All @@ -158,23 +183,18 @@ function DialogForm({
required: 'Enter a package name',
invalid: 'Invalid package name',
}}
margin="normal"
fullWidth
helperText={nameWarning}
initialValue={initialName}
/>

<RF.Field
component={PD.Field}
component={PD.CommitMessageInput}
name="commitMessage"
label="Commit message"
placeholder="Enter a commit message"
validate={validators.required}
validateFields={['commitMessage']}
errors={{
required: 'Enter a commit message',
}}
fullWidth
margin="normal"
/>

<PD.SchemaFetcher
Expand Down
41 changes: 31 additions & 10 deletions catalog/app/containers/Bucket/PackageCreateDialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ const getTotalProgress = R.pipe(
}),
)

const defaultNameWarning = ' ' // Reserve space for warning

function PackageCreateDialog({
bucket,
open,
Expand All @@ -356,13 +358,17 @@ function PackageCreateDialog({
const [uploads, setUploads] = React.useState({})
const [success, setSuccess] = React.useState(null)
const nameValidator = PD.useNameValidator()
const nameExistence = PD.useNameExistence(bucket)
const [nameWarning, setNameWarning] = React.useState(defaultNameWarning)
const classes = useStyles()

const reset = (form) => () => {
form.restart()
setSuccess(null)
setUploads({})
nameValidator.inc()
nameExistence.inc()
setNameWarning(defaultNameWarning)
}

const handleClose = ({ submitting = false } = {}) => () => {
Expand Down Expand Up @@ -470,6 +476,22 @@ function PackageCreateDialog({
}
}

const onFormChange = React.useCallback(
async ({ modified, values }) => {
if (!modified.name) return

const { name } = values

setNameWarning(defaultNameWarning)

const nameExists = await nameExistence.validate(name)
if (nameExists) {
setNameWarning(`Package "${name}" exists. Submitting will revise it`)
}
},
[nameExistence],
)

return (
<RF.Form onSubmit={uploadPackage}>
{({
Expand Down Expand Up @@ -541,11 +563,14 @@ function PackageCreateDialog({
<M.DialogTitle>Create package</M.DialogTitle>
<M.DialogContent style={{ paddingTop: 0 }}>
<form onSubmit={handleSubmit}>
<RF.FormSpy
subscription={{ modified: true, values: true }}
onChange={onFormChange}
/>

<RF.Field
component={PD.Field}
component={PD.PackageNameInput}
name="name"
label="Name"
placeholder="e.g. user/package"
validate={validators.composeAsync(
validators.required,
nameValidator.validate,
Expand All @@ -555,22 +580,18 @@ function PackageCreateDialog({
required: 'Enter a package name',
invalid: 'Invalid package name',
}}
margin="normal"
fullWidth
helperText={nameWarning}
validating={nameValidator.processing}
/>

<RF.Field
component={PD.Field}
component={PD.CommitMessageInput}
name="msg"
label="Commit message"
placeholder="Enter a commit message"
validate={validators.required}
validateFields={['msg']}
errors={{
required: 'Enter a commit message',
}}
fullWidth
margin="normal"
/>

<RF.Field
Expand Down
99 changes: 82 additions & 17 deletions catalog/app/containers/Bucket/PackageDialog/PackageDialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,25 +92,61 @@ const readFile = (file) =>
reader.readAsText(file)
})

const validateName = (req) =>
cacheDebounce(async (name) => {
if (name) {
const res = await req({
endpoint: '/package_name_valid',
method: 'POST',
body: { name },
})
if (!res.valid) return 'invalid'
}
return undefined
}, 200)

export function useNameValidator() {
const req = APIConnector.use()
const [counter, setCounter] = React.useState(0)
const [processing, setProcessing] = React.useState(false)
const inc = React.useCallback(() => setCounter(R.inc), [setCounter])

const validator = React.useMemo(() => validateName(req), [req])

const validate = React.useCallback(
async (name) => {
setProcessing(true)
const error = await validator(name)
setProcessing(false)
return error
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[counter, validator],
)

return React.useMemo(() => ({ validate, processing, inc }), [validate, processing, inc])
}

export function useNameExistence(bucket) {
const [counter, setCounter] = React.useState(0)
const inc = React.useCallback(() => setCounter(R.inc), [setCounter])

const s3 = AWS.S3.use()

// eslint-disable-next-line react-hooks/exhaustive-deps
const validate = React.useCallback(
cacheDebounce(async (name) => {
if (name) {
const res = await req({
endpoint: '/package_name_valid',
method: 'POST',
body: { name },
const packageExists = await requests.ensurePackageIsPresent({
s3,
bucket,
name,
})
if (!res.valid) return 'invalid'
if (packageExists) return 'exists'
}
return undefined
}, 200),
[req, counter],
[bucket, counter, s3],
)

return React.useMemo(() => ({ validate, inc }), [validate, inc])
Expand Down Expand Up @@ -153,23 +189,52 @@ export const getMetaValue = (value) =>
)
: undefined

export function Field({ input, meta, errors, label, ...rest }) {
const error = meta.submitFailed && meta.error
const validating = meta.submitFailed && meta.validating
export function Field({ error, helperText, validating, warning, ...rest }) {
const props = {
InputLabelProps: { shrink: true },
InputProps: {
endAdornment: validating && <M.CircularProgress size={20} />,
},
error: !!error,
label: (
<>
{error ? errors[error] || error : label}
{validating && <M.CircularProgress size={13} style={{ marginLeft: 8 }} />}
</>
),
helperText: error || helperText,
...rest,
}
return <M.TextField {...props} />
}

export function PackageNameInput({ errors, input, meta, validating, ...rest }) {
nl0 marked this conversation as resolved.
Show resolved Hide resolved
const errorCode = (input.value || meta.submitFailed) && meta.error
const error = errorCode ? errors[errorCode] || errorCode : ''
const props = {
disabled: meta.submitting || meta.submitSucceeded,
InputLabelProps: { shrink: true },
error,
fullWidth: true,
label: 'Name',
margin: 'normal',
placeholder: 'e.g. user/package',
// NOTE: react-form doesn't change `FormState.validating` on async validation when field loses focus
validating,
...input,
...rest,
}
return <M.TextField {...props} />
return <Field {...props} />
}

export function CommitMessageInput({ errors, input, meta, ...rest }) {
const errorCode = meta.submitFailed && meta.error
const error = errorCode ? errors[errorCode] || errorCode : ''
const props = {
disabled: meta.submitting || meta.submitSucceeded,
error,
fullWidth: true,
label: 'Commit message',
margin: 'normal',
placeholder: 'Enter a commit message',
validating: meta.submitFailed && meta.validating,
...input,
...rest,
}
return <Field {...props} />
}

const useWorkflowInputStyles = M.makeStyles((t) => ({
Expand Down
Loading