Separated templates and directory list (#6995)

Closes https://github.com/enso-org/cloud-v2/issues/490:
- Make templates collapsible
- Add scroll-based shadow to templates
- Move scrollbars from body to templates and directory listing

# Important Notes
None
This commit is contained in:
somebody1234 2023-06-30 23:59:34 +10:00 committed by GitHub
parent 66894bd79e
commit 8504295e5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 525 additions and 421 deletions

View File

@ -0,0 +1,4 @@
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path d="M5 4.93L5 11.07A.5.5 0 0 0 5.77 11.5L10.4 8.4A.5.5 0 0 0 10.4 7.6L5.77 4.5A.5.5 0 0 0 5 4.93"
fill="#3e515fe5" />
</svg>

After

Width:  |  Height:  |  Size: 225 B

View File

@ -1061,7 +1061,7 @@ function Dashboard(props: DashboardProps) {
return ( return (
<div <div
className={`flex flex-col relative select-none text-primary text-xs min-h-screen p-2 ${ className={`flex flex-col gap-2 relative select-none text-primary text-xs h-screen py-2 ${
tab === Tab.dashboard ? '' : 'hidden' tab === Tab.dashboard ? '' : 'hidden'
}`} }`}
onClick={event => { onClick={event => {
@ -1091,21 +1091,21 @@ function Dashboard(props: DashboardProps) {
setQuery={setQuery} setQuery={setQuery}
/> />
{isListingRemoteDirectoryWhileOffline ? ( {isListingRemoteDirectoryWhileOffline ? (
<div className="grow grid place-items-center"> <div className="grow grid place-items-center mx-2">
<div className="text-base text-center"> <div className="text-base text-center">
You are offline. Please connect to the internet and refresh to access the You are offline. Please connect to the internet and refresh to access the
cloud backend. cloud backend.
</div> </div>
</div> </div>
) : isListingLocalDirectoryAndWillFail ? ( ) : isListingLocalDirectoryAndWillFail ? (
<div className="grow grid place-items-center"> <div className="grow grid place-items-center mx-2">
<div className="text-base text-center"> <div className="text-base text-center">
Could not connect to the Project Manager. Please try restarting{' '} Could not connect to the Project Manager. Please try restarting{' '}
{common.PRODUCT_NAME}, or manually launching the Project Manager. {common.PRODUCT_NAME}, or manually launching the Project Manager.
</div> </div>
</div> </div>
) : isListingRemoteDirectoryAndWillFail ? ( ) : isListingRemoteDirectoryAndWillFail ? (
<div className="grow grid place-items-center"> <div className="grow grid place-items-center mx-2">
<div className="text-base text-center"> <div className="text-base text-center">
We will review your user details and enable the cloud experience for you We will review your user details and enable the cloud experience for you
shortly. shortly.
@ -1114,7 +1114,7 @@ function Dashboard(props: DashboardProps) {
) : ( ) : (
<> <>
<Templates onTemplateClick={handleCreateProject} /> <Templates onTemplateClick={handleCreateProject} />
<div className="flex flex-row flex-nowrap my-2"> <div className="flex flex-row flex-nowrap mx-2">
<h1 className="text-xl font-bold mx-4 self-center">Drive</h1> <h1 className="text-xl font-bold mx-4 self-center">Drive</h1>
<div className="flex flex-row flex-nowrap mx-4"> <div className="flex flex-row flex-nowrap mx-4">
{backend.type === backendModule.BackendType.remote && ( {backend.type === backendModule.BackendType.remote && (
@ -1224,9 +1224,12 @@ function Dashboard(props: DashboardProps) {
)} )}
</div> </div>
</div> </div>
<table className="table-fixed items-center border-collapse mt-2 w-0"> {/* Padding. */}
<div className="h-6 mx-2" />
<div className="flex-1 overflow-auto mx-2">
<table className="table-fixed items-center border-collapse mt-2">
<tbody> <tbody>
<tr className="h-10"> <tr className="h-0">
{columnsFor(columnDisplayMode, backend.type).map(column => ( {columnsFor(columnDisplayMode, backend.type).map(column => (
<td key={column} className={COLUMN_CSS_CLASS[column]} /> <td key={column} className={COLUMN_CSS_CLASS[column]} />
))} ))}
@ -1237,8 +1240,8 @@ function Dashboard(props: DashboardProps) {
isLoading={isLoadingAssets} isLoading={isLoadingAssets}
placeholder={ placeholder={
<span className="opacity-75"> <span className="opacity-75">
You have no project yet. Go ahead and create one using the You have no projects yet. Go ahead and create one using
form above. the form above.
</span> </span>
} }
columns={columnsFor(columnDisplayMode, backend.type).map( columns={columnsFor(columnDisplayMode, backend.type).map(
@ -1248,7 +1251,10 @@ function Dashboard(props: DashboardProps) {
column, column,
backendModule.AssetType.project backendModule.AssetType.project
), ),
render: renderer(column, backendModule.AssetType.project), render: renderer(
column,
backendModule.AssetType.project
),
}) })
)} )}
onClick={(projectAsset, event) => { onClick={(projectAsset, event) => {
@ -1271,8 +1277,8 @@ function Dashboard(props: DashboardProps) {
}) })
} }
const doOpenAsFolder = () => { const doOpenAsFolder = () => {
// FIXME[sb]: Uncomment once backend support // TODO[sb]: Implement once backend support is in place.
// is in place. // https://github.com/enso-org/cloud-v2/issues/506
// The following code does not typecheck // The following code does not typecheck
// since `ProjectId`s are not `DirectoryId`s. // since `ProjectId`s are not `DirectoryId`s.
// enterDirectory(projectAsset) // enterDirectory(projectAsset)
@ -1302,11 +1308,14 @@ function Dashboard(props: DashboardProps) {
newName newName
) )
) )
await backend.projectUpdate(projectAsset.id, { await backend.projectUpdate(
projectAsset.id,
{
ami: null, ami: null,
ideVersion: null, ideVersion: null,
projectName: newName, projectName: newName,
}) }
)
}} }}
onComplete={doRefresh} onComplete={doRefresh}
/> />
@ -1326,13 +1335,15 @@ function Dashboard(props: DashboardProps) {
projectAsset projectAsset
) )
) )
return backend.deleteProject(projectAsset.id) return backend.deleteProject(
projectAsset.id
)
}} }}
onComplete={doRefresh} onComplete={doRefresh}
/> />
)) ))
} }
const isDisabled = const isDeleteDisabled =
backend.type === backendModule.BackendType.local && backend.type === backendModule.BackendType.local &&
(projectDatas[projectAsset.id]?.isRunning ?? false) (projectDatas[projectAsset.id]?.isRunning ?? false)
setModal(() => ( setModal(() => (
@ -1340,8 +1351,12 @@ function Dashboard(props: DashboardProps) {
<ContextMenuEntry onClick={doOpenForEditing}> <ContextMenuEntry onClick={doOpenForEditing}>
Open for editing Open for editing
</ContextMenuEntry> </ContextMenuEntry>
{backend.type !== backendModule.BackendType.local && ( {backend.type !==
<ContextMenuEntry disabled onClick={doOpenAsFolder}> backendModule.BackendType.local && (
<ContextMenuEntry
disabled
onClick={doOpenAsFolder}
>
Open as folder Open as folder
</ContextMenuEntry> </ContextMenuEntry>
)} )}
@ -1349,8 +1364,8 @@ function Dashboard(props: DashboardProps) {
Rename Rename
</ContextMenuEntry> </ContextMenuEntry>
<ContextMenuEntry <ContextMenuEntry
disabled={isDisabled} disabled={isDeleteDisabled}
{...(isDisabled {...(isDeleteDisabled
? { ? {
title: 'A running local project cannot be removed.', title: 'A running local project cannot be removed.',
} }
@ -1415,7 +1430,9 @@ function Dashboard(props: DashboardProps) {
}} }}
/> />
<tr className="h-10" /> <tr className="h-10" />
<Rows<backendModule.Asset<backendModule.AssetType.secret>> <Rows<
backendModule.Asset<backendModule.AssetType.secret>
>
items={visibleSecretAssets} items={visibleSecretAssets}
getKey={secret => secret.id} getKey={secret => secret.id}
isLoading={isLoadingAssets} isLoading={isLoadingAssets}
@ -1554,10 +1571,16 @@ function Dashboard(props: DashboardProps) {
} }
setModal(() => ( setModal(() => (
<ContextMenu key={file.id} event={event}> <ContextMenu key={file.id} event={event}>
<ContextMenuEntry disabled onClick={doCopy}> <ContextMenuEntry
disabled
onClick={doCopy}
>
Copy Copy
</ContextMenuEntry> </ContextMenuEntry>
<ContextMenuEntry disabled onClick={doCut}> <ContextMenuEntry
disabled
onClick={doCut}
>
Cut Cut
</ContextMenuEntry> </ContextMenuEntry>
<ContextMenuEntry onClick={doDelete}> <ContextMenuEntry onClick={doDelete}>
@ -1579,6 +1602,7 @@ function Dashboard(props: DashboardProps) {
))(backend)} ))(backend)}
</tbody> </tbody>
</table> </table>
</div>
{isFileBeingDragged && {isFileBeingDragged &&
directoryId != null && directoryId != null &&
backend.type === backendModule.BackendType.remote ? ( backend.type === backendModule.BackendType.remote ? (

View File

@ -1,5 +1,35 @@
/** @file Renders the list of templates from which a project can be created. */ /** @file Renders the list of templates from which a project can be created. */
import * as react from 'react'
import PlusCircledIcon from 'enso-assets/plus_circled.svg' import PlusCircledIcon from 'enso-assets/plus_circled.svg'
import RotatingArrowIcon from 'enso-assets/rotating_arrow.svg'
import * as common from 'enso-common'
// =================
// === Constants ===
// =================
/** The `localStorage` key used to store whether the {@link Templates} element should be
* expanded by default. */
const IS_TEMPLATES_OPEN_KEY = `${common.PRODUCT_NAME.toLowerCase()}-is-templates-expanded`
/** The max width at which the bottom shadow should be visible. */
const MAX_WIDTH_NEEDING_SCROLL = 1031
/** The height of the bottom padding - 8px for the grid gap, and another 8px for the height
* of the padding div. */
const PADDING_HEIGHT = 16
// =============
// === Types ===
// =============
/** The CSS class to apply inset shadows on the specified side(s). */
enum ShadowClass {
none = '',
top = 'shadow-inset-t-lg',
bottom = 'shadow-inset-b-lg',
both = 'shadow-inset-v-lg',
}
// ================= // =================
// === Templates === // === Templates ===
@ -77,10 +107,8 @@ function TemplatesRender(props: TemplatesRenderProps) {
className="h-40 cursor-pointer" className="h-40 cursor-pointer"
> >
<div className="flex h-full w-full border-dashed-custom rounded-2xl text-primary"> <div className="flex h-full w-full border-dashed-custom rounded-2xl text-primary">
<div className="m-auto text-center"> <div className="flex flex-col text-center items-center m-auto">
<button>
<img src={PlusCircledIcon} /> <img src={PlusCircledIcon} />
</button>
<p className="font-semibold text-sm">New empty project</p> <p className="font-semibold text-sm">New empty project</p>
</div> </div>
</div> </div>
@ -130,10 +158,94 @@ export interface TemplatesProps {
function Templates(props: TemplatesProps) { function Templates(props: TemplatesProps) {
const { onTemplateClick } = props const { onTemplateClick } = props
const [shadowClass, setShadowClass] = react.useState(
window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL ? ShadowClass.bottom : ShadowClass.none
)
const [isOpen, setIsOpen] = react.useState(() => {
/** This must not be in a `useEffect` as it would flash open for one frame.
* It can be in a `useLayoutEffect` but as that needs to be checked every re-render,
* this is slightly more performant. */
const savedIsOpen = localStorage.getItem(IS_TEMPLATES_OPEN_KEY)
let result = true
if (savedIsOpen != null) {
try {
result = JSON.parse(savedIsOpen) !== false
} catch {
// Ignored. This should only happen when a user manually sets invalid JSON into
// the `localStorage` key used by this component.
}
}
return result
})
// This is incorrect, but SAFE, as its value will always be assigned before any hooks are run.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const containerRef = react.useRef<HTMLDivElement>(null!)
const toggleIsOpen = react.useCallback(() => {
setIsOpen(oldIsOpen => !oldIsOpen)
}, [])
const updateShadowClass = () => {
const element = containerRef.current
const boundingBox = element.getBoundingClientRect()
let newShadowClass: ShadowClass
const shouldShowTopShadow = element.scrollTop !== 0
// `window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL` is repeated. This is intentional,
// to avoid adding it as a dependency.
const paddingHeight = window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL ? 0 : PADDING_HEIGHT
// Chrome has decimal places in its bounding box, which can overshoot the target size
// slightly.
const shouldShowBottomShadow =
element.scrollTop + boundingBox.height + paddingHeight + 1 < element.scrollHeight
if (shouldShowTopShadow && shouldShowBottomShadow) {
newShadowClass = ShadowClass.both
} else if (shouldShowTopShadow) {
newShadowClass = ShadowClass.top
} else if (shouldShowBottomShadow) {
newShadowClass = ShadowClass.bottom
} else {
newShadowClass = ShadowClass.none
}
setShadowClass(newShadowClass)
}
react.useEffect(() => {
window.addEventListener('resize', updateShadowClass)
return () => {
window.removeEventListener('resize', updateShadowClass)
}
})
react.useEffect(() => {
localStorage.setItem(IS_TEMPLATES_OPEN_KEY, JSON.stringify(isOpen))
}, [isOpen])
return ( return (
<div className="my-2 p-2"> <div className="mx-2">
<div className="grid gap-2 grid-cols-fill-60-minmax-scrollbar-aware justify-center"> <div className="flex items-center my-2">
<div className="w-4">
<div
className={`cursor-pointer transition-all ease-in-out ${
isOpen ? 'rotate-90' : ''
}`}
onClick={toggleIsOpen}
>
<img src={RotatingArrowIcon} />
</div>
</div>
<h1 className="text-xl font-bold self-center">Templates</h1>
</div>
<div
ref={containerRef}
className={`grid gap-2 grid-cols-fill-60 justify-center overflow-y-scroll scroll-hidden transition-all duration-300 ease-in-out px-4 ${
isOpen ? `h-templates-custom ${shadowClass}` : 'h-0'
}`}
onScroll={updateShadowClass}
>
<TemplatesRender templates={TEMPLATES} onTemplateClick={onTemplateClick} /> <TemplatesRender templates={TEMPLATES} onTemplateClick={onTemplateClick} />
{/* Spacing. */}
<div className="col-span-full h-2" />
</div> </div>
</div> </div>
) )

View File

@ -57,7 +57,7 @@ function TopBar(props: TopBarProps) {
}, [isUserMenuVisible]) }, [isUserMenuVisible])
return ( return (
<div className="flex mb-2 h-8"> <div className="flex mx-2 h-8">
{supportsLocalBackend && ( {supportsLocalBackend && (
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap p-1.5"> <div className="bg-gray-100 rounded-full flex flex-row flex-nowrap p-1.5">
<button <button

View File

@ -107,73 +107,19 @@ body {
background-image: url("enso-assets/dashed_border.svg"); background-image: url("enso-assets/dashed_border.svg");
} }
.grid-cols-fill-60-minmax-scrollbar-aware { .scroll-hidden {
/* Graceful degradation for extremely large monitors. */ -ms-overflow-style: none; /* Internet Explorer 10+ */
@media screen and (min-width: 1275px) { scrollbar-width: none; /* Firefox */
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); }
.scroll-hidden::-webkit-scrollbar {
display: none; /* Safari and Chrome */
} }
/* Each column is 240px (tile) + 8px (padding). */ .h-templates-custom {
/* Hardcore scrollbar-aware values for monitors up to 3840px wide. */ height: 21.5rem;
@media screen and (min-width: 3755px) and (max-width: 4002px) { @media screen and (min-width: 1771px) {
grid-template-columns: repeat(14, minmax(15rem, 1fr)); height: 11rem;
}
@media screen and (min-width: 3507px) and (max-width: 3754px) {
grid-template-columns: repeat(14, minmax(15rem, 1fr));
}
@media screen and (min-width: 3259px) and (max-width: 3506px) {
grid-template-columns: repeat(13, minmax(15rem, 1fr));
}
@media screen and (min-width: 3011px) and (max-width: 3258px) {
grid-template-columns: repeat(12, minmax(15rem, 1fr));
}
@media screen and (min-width: 2763px) and (max-width: 3010px) {
grid-template-columns: repeat(11, minmax(15rem, 1fr));
}
@media screen and (min-width: 2515px) and (max-width: 2762px) {
grid-template-columns: repeat(10, minmax(15rem, 1fr));
}
@media screen and (min-width: 2267px) and (max-width: 2514px) {
grid-template-columns: repeat(9, minmax(15rem, 1fr));
}
@media screen and (min-width: 2019px) and (max-width: 2266px) {
grid-template-columns: repeat(8, minmax(15rem, 1fr));
}
@media screen and (min-width: 1771px) and (max-width: 2018px) {
grid-template-columns: repeat(7, minmax(15rem, 1fr));
}
@media screen and (min-width: 1523px) and (max-width: 1770px) {
grid-template-columns: repeat(6, minmax(15rem, 1fr));
}
@media screen and (min-width: 1275px) and (max-width: 1522px) {
grid-template-columns: repeat(5, minmax(15rem, 1fr));
}
@media screen and (min-width: 1027px) and (max-width: 1274px) {
grid-template-columns: repeat(4, minmax(15rem, 1fr));
}
@media screen and (min-width: 779px) and (max-width: 1026px) {
grid-template-columns: repeat(3, minmax(15rem, 1fr));
}
@media screen and (min-width: 531px) and (max-width: 778px) {
grid-template-columns: repeat(2, minmax(15rem, 1fr));
}
@media screen and (max-width: 530) {
grid-template-columns: repeat(1, 1fr);
} }
} }
} }

View File

@ -48,6 +48,21 @@ export const theme = {
soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \ soft: `0 0.5px 2.2px 0px #00000008, 0 1.2px 5.3px 0px #0000000b, \
0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \ 0 2.3px 10px 0 #0000000e, 0 4px 18px 0 #00000011, 0 7.5px 33.4px 0 #00000014, \
0 18px 80px 0 #0000001c`, 0 18px 80px 0 #0000001c`,
'inset-t-lg': `inset 0 1px 1.4px -1.4px #00000002, \
inset 0 2.4px 3.4px -3.4px #00000003, inset 0 4.5px 6.4px -6.4px #00000004, \
inset 0 8px 11.4px -11.4px #00000005, inset 0 15px 21.3px -21.3px #00000006, \
inset 0 36px 51px -51px #00000014`,
'inset-b-lg': `inset 0 -1px 1.4px -1.4px #00000002, \
inset 0 -2.4px 3.4px -3.4px #00000003, inset 0 -4.5px 6.4px -6.4px #00000004, \
inset 0 -8px 11.4px -11.4px #00000005, inset 0 -15px 21.3px -21.3px #00000006, \
inset 0 -36px 51px -51px #00000014`,
'inset-v-lg': `inset 0 1px 1.4px -1.4px #00000002, \
inset 0 2.4px 3.4px -3.4px #00000003, inset 0 4.5px 6.4px -6.4px #00000004, \
inset 0 8px 11.4px -11.4px #00000005, inset 0 15px 21.3px -21.3px #00000006, \
inset 0 36px 51px -51px #00000014, inset 0 -1px 1.4px -1.4px #00000002, \
inset 0 -2.4px 3.4px -3.4px #00000003, inset 0 -4.5px 6.4px -6.4px #00000004, \
inset 0 -8px 11.4px -11.4px #00000005, inset 0 -15px 21.3px -21.3px #00000006, \
inset 0 -36px 51px -51px #00000014`,
}, },
animation: { animation: {
'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite', 'spin-ease': 'spin cubic-bezier(0.67, 0.33, 0.33, 0.67) 1.5s infinite',
@ -59,5 +74,8 @@ export const theme = {
transitionDuration: { transitionDuration: {
'90000': '90000ms', '90000': '90000ms',
}, },
gridTemplateColumns: {
'fill-60': 'repeat(auto-fill, minmax(15rem, 1fr))',
},
}, },
} }