mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 11:41:56 +03:00
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:
parent
cfb2f2916e
commit
f09d922a41
@ -264,6 +264,13 @@ export const DEFAULT_USER_ICON = (
|
||||
</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}. */
|
||||
export interface SpinnerProps {
|
||||
size: number
|
||||
|
@ -39,7 +39,7 @@ function CreateForm(props: CreateFormProps) {
|
||||
<Modal className="absolute overflow-hidden bg-opacity-25 w-full h-full top-0 left-0">
|
||||
<form
|
||||
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}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
|
@ -1,6 +1,7 @@
|
||||
/** @file Main dashboard component, responsible for listing user's projects as well as other
|
||||
* interactive components. */
|
||||
import * as react from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as common from 'enso-common'
|
||||
|
||||
@ -80,6 +81,7 @@ export interface CreateFormProps {
|
||||
left: number
|
||||
top: number
|
||||
directoryId: backendModule.DirectoryId
|
||||
getNewProjectName: (templateId: string | null) => string
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
@ -585,19 +587,26 @@ function Dashboard(props: DashboardProps) {
|
||||
/** Heading element for every column. */
|
||||
const ColumnHeading = (column: Column, assetType: backendModule.AssetType) =>
|
||||
column === Column.name ? (
|
||||
assetType === backendModule.AssetType.project ? (
|
||||
<>{ASSET_TYPE_NAME[assetType]}</>
|
||||
) : (
|
||||
<div className="inline-flex">
|
||||
{ASSET_TYPE_NAME[assetType]}
|
||||
<button
|
||||
className="mx-1"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
const buttonPosition =
|
||||
// This type assertion is safe as this event handler is on a button.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(event.target as HTMLButtonElement).getBoundingClientRect()
|
||||
<div className="inline-flex">
|
||||
{ASSET_TYPE_NAME[assetType]}
|
||||
<button
|
||||
className="mx-1"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
const buttonPosition =
|
||||
// This type assertion is safe as this event handler is on a button.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
(event.target as HTMLButtonElement).getBoundingClientRect()
|
||||
if (assetType === backendModule.AssetType.project) {
|
||||
void toast.promise(handleCreateProject(null), {
|
||||
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.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const CreateForm = ASSET_TYPE_CREATE_FORM[assetType]
|
||||
@ -605,6 +614,7 @@ function Dashboard(props: DashboardProps) {
|
||||
<CreateForm
|
||||
left={buttonPosition.left + window.scrollX}
|
||||
top={buttonPosition.top + window.scrollY}
|
||||
getNewProjectName={getNewProjectName}
|
||||
// This is safe; headings are not rendered when there is no
|
||||
// internet connection.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@ -612,12 +622,12 @@ function Dashboard(props: DashboardProps) {
|
||||
onSuccess={doRefresh}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
>
|
||||
{svg.ADD_ICON}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{svg.ADD_ICON}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>{COLUMN_NAME[column]}</>
|
||||
)
|
||||
|
@ -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
|
@ -4,9 +4,11 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as error from '../../error'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as templates from './templates'
|
||||
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
import Dropdown from './dropdown'
|
||||
|
||||
// =========================
|
||||
// === ProjectCreateForm ===
|
||||
@ -15,78 +17,81 @@ import CreateForm, * as createForm from './createForm'
|
||||
/** Props for a {@link ProjectCreateForm}. */
|
||||
export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
directoryId: backendModule.DirectoryId
|
||||
getNewProjectName: (templateId: string | null) => string
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A form to create a project. */
|
||||
function ProjectCreateForm(props: ProjectCreateFormProps) {
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { directoryId, getNewProjectName, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const [defaultName, setDefaultName] = react.useState(() => getNewProjectName(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) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = async (event: react.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (name == null) {
|
||||
toast.error('Please provide a project name.')
|
||||
} else {
|
||||
unsetModal()
|
||||
await toast
|
||||
.promise(
|
||||
backend.createProject({
|
||||
parentDirectoryId: directoryId,
|
||||
projectName: name,
|
||||
projectTemplateName: template,
|
||||
}),
|
||||
{
|
||||
loading: 'Creating project...',
|
||||
success: 'Sucessfully created project.',
|
||||
error: error.unsafeIntoErrorMessage,
|
||||
}
|
||||
)
|
||||
.then(onSuccess)
|
||||
const onSubmit = async (event: react.FormEvent) => {
|
||||
event.preventDefault()
|
||||
unsetModal()
|
||||
const finalName = name ?? defaultName
|
||||
const templateText = templateId == null ? '' : `from template '${templateId}'`
|
||||
await toast.promise(
|
||||
backend.createProject({
|
||||
parentDirectoryId: directoryId,
|
||||
projectName: name ?? defaultName,
|
||||
projectTemplateName: templateId,
|
||||
}),
|
||||
{
|
||||
loading: `Creating project '${finalName}'${templateText}...`,
|
||||
success: `Sucessfully created project '${finalName}'${templateText}.`,
|
||||
// This is UNSAFE, as the original function's parameter is of type `any`.
|
||||
error: (promiseError: Error) =>
|
||||
`Error creating project '${finalName}'${templateText}: ${promiseError.message}`,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
@ -6,7 +6,7 @@ import * as svg from '../../components/svg'
|
||||
// =================
|
||||
|
||||
/** Template metadata. */
|
||||
interface Template {
|
||||
export interface Template {
|
||||
title: string
|
||||
description: string
|
||||
id: string
|
||||
@ -14,7 +14,7 @@ interface Template {
|
||||
}
|
||||
|
||||
/** The full list of templates. */
|
||||
const TEMPLATES: Template[] = [
|
||||
export const TEMPLATES: [Template, ...Template[]] = [
|
||||
{
|
||||
title: 'Colorado COVID',
|
||||
id: 'Colorado_COVID',
|
||||
|
Loading…
Reference in New Issue
Block a user