Project create form (#6923)

* Re-add project create form

* Add dropdown component and use in projct create form

* Fix "project create" button behavior
This commit is contained in:
somebody1234 2023-06-05 23:14:59 +10:00 committed by GitHub
parent cfb2f2916e
commit f09d922a41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 163 additions and 82 deletions

View File

@ -264,6 +264,13 @@ export const DEFAULT_USER_ICON = (
</svg> </svg>
) )
/** An icon representing a menu that can be expanded downwards. */
export const DOWN_CARET_ICON = (
<svg height={16} width={16} viewBox="-1-1 12 12" xmlns="http://www.w3.org/2000/svg">
<path d="M1 3l4 4 4-4" fill="transparent" stroke="currentColor" strokeLinecap="round" />
</svg>
)
/** Props for a {@link Spinner}. */ /** Props for a {@link Spinner}. */
export interface SpinnerProps { export interface SpinnerProps {
size: number size: number

View File

@ -39,7 +39,7 @@ function CreateForm(props: CreateFormProps) {
<Modal className="absolute overflow-hidden bg-opacity-25 w-full h-full top-0 left-0"> <Modal className="absolute overflow-hidden bg-opacity-25 w-full h-full top-0 left-0">
<form <form
style={{ left, top }} style={{ left, top }}
className="sticky bg-white shadow-soft rounded-lg w-60" className="sticky bg-white shadow-soft rounded-lg w-64"
onSubmit={onSubmit} onSubmit={onSubmit}
onClick={event => { onClick={event => {
event.stopPropagation() event.stopPropagation()

View File

@ -1,6 +1,7 @@
/** @file Main dashboard component, responsible for listing user's projects as well as other /** @file Main dashboard component, responsible for listing user's projects as well as other
* interactive components. */ * interactive components. */
import * as react from 'react' import * as react from 'react'
import toast from 'react-hot-toast'
import * as common from 'enso-common' import * as common from 'enso-common'
@ -80,6 +81,7 @@ export interface CreateFormProps {
left: number left: number
top: number top: number
directoryId: backendModule.DirectoryId directoryId: backendModule.DirectoryId
getNewProjectName: (templateId: string | null) => string
onSuccess: () => void onSuccess: () => void
} }
@ -585,19 +587,26 @@ function Dashboard(props: DashboardProps) {
/** Heading element for every column. */ /** Heading element for every column. */
const ColumnHeading = (column: Column, assetType: backendModule.AssetType) => const ColumnHeading = (column: Column, assetType: backendModule.AssetType) =>
column === Column.name ? ( column === Column.name ? (
assetType === backendModule.AssetType.project ? ( <div className="inline-flex">
<>{ASSET_TYPE_NAME[assetType]}</> {ASSET_TYPE_NAME[assetType]}
) : ( <button
<div className="inline-flex"> className="mx-1"
{ASSET_TYPE_NAME[assetType]} onClick={event => {
<button event.stopPropagation()
className="mx-1" const buttonPosition =
onClick={event => { // This type assertion is safe as this event handler is on a button.
event.stopPropagation() // eslint-disable-next-line no-restricted-syntax
const buttonPosition = (event.target as HTMLButtonElement).getBoundingClientRect()
// This type assertion is safe as this event handler is on a button. if (assetType === backendModule.AssetType.project) {
// eslint-disable-next-line no-restricted-syntax void toast.promise(handleCreateProject(null), {
(event.target as HTMLButtonElement).getBoundingClientRect() loading: 'Creating new empty project...',
success: 'Created new empty project.',
// This is UNSAFE, as the original function's parameter is of type
// `any`.
error: (promiseError: Error) =>
`Error creating new empty project: ${promiseError.message}`,
})
} else {
// This is a React component even though it doesn't contain JSX. // This is a React component even though it doesn't contain JSX.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const CreateForm = ASSET_TYPE_CREATE_FORM[assetType] const CreateForm = ASSET_TYPE_CREATE_FORM[assetType]
@ -605,6 +614,7 @@ function Dashboard(props: DashboardProps) {
<CreateForm <CreateForm
left={buttonPosition.left + window.scrollX} left={buttonPosition.left + window.scrollX}
top={buttonPosition.top + window.scrollY} top={buttonPosition.top + window.scrollY}
getNewProjectName={getNewProjectName}
// This is safe; headings are not rendered when there is no // This is safe; headings are not rendered when there is no
// internet connection. // internet connection.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -612,12 +622,12 @@ function Dashboard(props: DashboardProps) {
onSuccess={doRefresh} onSuccess={doRefresh}
/> />
)) ))
}} }
> }}
{svg.ADD_ICON} >
</button> {svg.ADD_ICON}
</div> </button>
) </div>
) : ( ) : (
<>{COLUMN_NAME[column]}</> <>{COLUMN_NAME[column]}</>
) )

View File

@ -0,0 +1,59 @@
/** @file A select menu with a dropdown. */
import * as react from 'react'
import * as svg from '../../components/svg'
/** Props for a {@link Dropdown}. */
export interface DropdownProps {
items: [string, ...string[]]
onChange: (value: string) => void
className?: string
optionsClassName?: string
}
/** A select menu with a dropdown. */
function Dropdown(props: DropdownProps) {
const { items, onChange, className, optionsClassName } = props
const [value, setValue] = react.useState(items[0])
// TODO:
const [isDropdownVisible, setIsDropdownVisible] = react.useState(false)
return (
<div
className={`whitespace-nowrap bg-gray-200 rounded-full cursor-pointer ${
className ?? ''
}`}
>
<div
className="flex flex-1"
onClick={() => {
setIsDropdownVisible(!isDropdownVisible)
}}
>
<span className="grow">{value}</span> {svg.DOWN_CARET_ICON}
</div>
<div className={`relative h-0 ${optionsClassName ?? ''}`}>
<div
className={`rounded-lg h-0 ${
isDropdownVisible ? 'overflow-visible' : 'overflow-hidden'
}`}
>
{items.map(item => (
<div
onClick={() => {
setIsDropdownVisible(false)
setValue(item)
onChange(item)
}}
className="cursor-pointer bg-white first:rounded-t-lg last:rounded-b-lg hover:bg-gray-100 p-1"
>
{item}
</div>
))}
</div>
</div>
</div>
)
}
export default Dropdown

View File

@ -4,9 +4,11 @@ import toast from 'react-hot-toast'
import * as backendModule from '../backend' import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend' import * as backendProvider from '../../providers/backend'
import * as error from '../../error'
import * as modalProvider from '../../providers/modal' import * as modalProvider from '../../providers/modal'
import * as templates from './templates'
import CreateForm, * as createForm from './createForm' import CreateForm, * as createForm from './createForm'
import Dropdown from './dropdown'
// ========================= // =========================
// === ProjectCreateForm === // === ProjectCreateForm ===
@ -15,78 +17,81 @@ import CreateForm, * as createForm from './createForm'
/** Props for a {@link ProjectCreateForm}. */ /** Props for a {@link ProjectCreateForm}. */
export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps { export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps {
directoryId: backendModule.DirectoryId directoryId: backendModule.DirectoryId
getNewProjectName: (templateId: string | null) => string
onSuccess: () => void onSuccess: () => void
} }
/** A form to create a project. */ /** A form to create a project. */
function ProjectCreateForm(props: ProjectCreateFormProps) { function ProjectCreateForm(props: ProjectCreateFormProps) {
const { directoryId, onSuccess, ...passThrough } = props const { directoryId, getNewProjectName, onSuccess, ...passThrough } = props
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
const [defaultName, setDefaultName] = react.useState(() => getNewProjectName(null))
const [name, setName] = react.useState<string | null>(null) const [name, setName] = react.useState<string | null>(null)
const [template, setTemplate] = react.useState<string | null>(null) const [templateId, setTemplateId] = react.useState<string | null>(null)
if (backend.type === backendModule.BackendType.local) { const onSubmit = async (event: react.FormEvent) => {
return <></> event.preventDefault()
} else { unsetModal()
const onSubmit = async (event: react.FormEvent) => { const finalName = name ?? defaultName
event.preventDefault() const templateText = templateId == null ? '' : `from template '${templateId}'`
if (name == null) { await toast.promise(
toast.error('Please provide a project name.') backend.createProject({
} else { parentDirectoryId: directoryId,
unsetModal() projectName: name ?? defaultName,
await toast projectTemplateName: templateId,
.promise( }),
backend.createProject({ {
parentDirectoryId: directoryId, loading: `Creating project '${finalName}'${templateText}...`,
projectName: name, success: `Sucessfully created project '${finalName}'${templateText}.`,
projectTemplateName: template, // This is UNSAFE, as the original function's parameter is of type `any`.
}), error: (promiseError: Error) =>
{ `Error creating project '${finalName}'${templateText}: ${promiseError.message}`,
loading: 'Creating project...',
success: 'Sucessfully created project.',
error: error.unsafeIntoErrorMessage,
}
)
.then(onSuccess)
} }
}
return (
<CreateForm title="New Project" onSubmit={onSubmit} {...passThrough}>
<div className="flex flex-row flex-nowrap m-1">
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
Name
</label>
<input
id="project_name"
type="text"
size={1}
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
onChange={event => {
setName(event.target.value)
}}
/>
</div>
<div className="flex flex-row flex-nowrap m-1">
{/* FIXME[sb]: Use the array of templates in a dropdown when it becomes available. */}
<label className="inline-block flex-1 grow m-1" htmlFor="project_template_name">
Template
</label>
<input
id="project_template_name"
type="text"
size={1}
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
onChange={event => {
setTemplate(event.target.value)
}}
/>
</div>
</CreateForm>
) )
onSuccess()
} }
return (
<CreateForm title="New Project" onSubmit={onSubmit} {...passThrough}>
<div className="flex flex-row flex-nowrap m-1">
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
Name
</label>
<input
id="project_name"
type="text"
size={1}
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
value={name ?? defaultName}
onChange={event => {
setName(event.target.value)
}}
/>
</div>
<div className="flex flex-row flex-nowrap m-1">
<label className="inline-block flex-1 grow m-1" htmlFor="project_template_name">
Template
</label>
<Dropdown
className="flex-1 grow-2 px-2 m-1"
optionsClassName="-mx-2"
items={['None', ...templates.TEMPLATES.map(item => item.title)]}
onChange={newTemplateTitle => {
const newTemplateId =
templates.TEMPLATES.find(
template => template.title === newTemplateTitle
)?.id ?? null
setTemplateId(newTemplateId)
if (name == null) {
setDefaultName(getNewProjectName(newTemplateId))
}
}}
/>
</div>
</CreateForm>
)
} }
export default ProjectCreateForm export default ProjectCreateForm

View File

@ -6,7 +6,7 @@ import * as svg from '../../components/svg'
// ================= // =================
/** Template metadata. */ /** Template metadata. */
interface Template { export interface Template {
title: string title: string
description: string description: string
id: string id: string
@ -14,7 +14,7 @@ interface Template {
} }
/** The full list of templates. */ /** The full list of templates. */
const TEMPLATES: Template[] = [ export const TEMPLATES: [Template, ...Template[]] = [
{ {
title: 'Colorado COVID', title: 'Colorado COVID',
id: 'Colorado_COVID', id: 'Colorado_COVID',