Skip to content

[server][dashboard] Improve 'New Workspace' modal #7715

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

Merged
merged 7 commits into from
Feb 8, 2022
11 changes: 11 additions & 0 deletions components/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { Experiment } from './experiments';
import { workspacesPathMain } from './workspaces/workspaces.routes';
import { settingsPathAccount, settingsPathIntegrations, settingsPathMain, settingsPathNotifications, settingsPathPlans, settingsPathPreferences, settingsPathTeams, settingsPathTeamsJoin, settingsPathTeamsNew, settingsPathVariables } from './settings/settings.routes';
import { projectsPathInstallGitHubApp, projectsPathMain, projectsPathMainWithParams, projectsPathNew } from './projects/projects.routes';
import { refreshSearchData } from './components/RepositoryFinder';
import { StartWorkspaceModal } from './workspaces/StartWorkspaceModal';

const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup'));
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces'));
Expand All @@ -35,6 +37,7 @@ const Teams = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Te
const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ './settings/EnvironmentVariables'));
const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Integrations'));
const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Preferences'));
const Open = React.lazy(() => import(/* webpackPrefetch: true */ './start/Open'));
const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/StartWorkspace'));
const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace'));
const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam'));
Expand Down Expand Up @@ -216,6 +219,12 @@ function App() {
return () => window.removeEventListener("click", handleButtonOrAnchorTracking, true);
}, []);

useEffect(() => {
if (user) {
refreshSearchData('', user);
}
}, [user]);

// redirect to website for any website slugs
if (isGitpodIo() && isWebsiteSlug(window.location.pathname)) {
window.location.host = 'www.gitpod.io';
Expand Down Expand Up @@ -275,6 +284,7 @@ function App() {
<Menu />
<Switch>
<Route path={projectsPathNew} exact component={NewProject} />
<Route path="/open" exact component={Open} />
<Route path="/setup" exact component={Setup} />
<Route path={workspacesPathMain} exact component={Workspaces} />
<Route path={settingsPathAccount} exact component={Account} />
Expand Down Expand Up @@ -383,6 +393,7 @@ function App() {
}}>
</Route>
</Switch>
<StartWorkspaceModal />
</div>
</Route>;

Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export default function Menu() {
}

// Hide most of the top menu when in a full-page form.
const isMinimalUI = ['/new', '/teams/new'].includes(location.pathname);
const isMinimalUI = ['/new', '/teams/new', '/open'].includes(location.pathname);

const [ teamMembers, setTeamMembers ] = useState<Record<string, TeamMemberInfo[]>>({});
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function Modal(props: {
return (
<div className="fixed top-0 left-0 bg-black bg-opacity-70 z-50 w-screen h-screen">
<div className="w-screen h-screen align-middle" style={{display: 'table-cell'}}>
<div className={"relative bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 max-w-lg mx-auto text-left text-gray-600 " + (props.className || '')} onClick={e => e.stopPropagation()}>
<div className={"relative bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 max-w-lg mx-auto text-left " + (props.className || '')} onClick={e => e.stopPropagation()}>
Copy link
Contributor Author

@jankeromnes jankeromnes Jan 21, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was making all text in all modals text-gray-600 by default, which was impractical.

I've removed it and checked several Modals, but I hope I haven't missed any side effects of this fix.

{props.closeable !== false && (
<div className="absolute right-7 top-6 cursor-pointer text-gray-800 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md p-2" onClick={props.onClose}>
<svg version="1.1" width="14px" height="14px"
Expand Down
175 changes: 175 additions & 0 deletions components/dashboard/src/components/RepositoryFinder.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { User } from "@gitpod/gitpod-protocol";
import React, { useContext, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { getGitpodService } from "../service/service";
import { UserContext } from "../user-context";

type SearchResult = string;
type SearchData = SearchResult[];

const LOCAL_STORAGE_KEY = 'open-in-gitpod-search-data';
const MAX_DISPLAYED_ITEMS = 15;

export default function RepositoryFinder(props: { initialQuery?: string }) {
const { user } = useContext(UserContext);
const [searchQuery, setSearchQuery] = useState<string>(props.initialQuery || '');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [selectedSearchResult, setSelectedSearchResult] = useState<SearchResult | undefined>();

const onResults = (results: SearchResult[]) => {
if (JSON.stringify(results) !== JSON.stringify(searchResults)) {
setSearchResults(results);
setSelectedSearchResult(results[0]);
}
}

const search = async (query: string) => {
setSearchQuery(query);
await findResults(query, onResults);
if (await refreshSearchData(query, user)) {
// Re-run search if the underlying search data has changed
await findResults(query, onResults);
}
}

// Up/Down keyboard navigation between results
const onKeyDown = (event: React.KeyboardEvent) => {
if (!selectedSearchResult) {
return;
}
const selectedIndex = searchResults.indexOf(selectedSearchResult);
const select = (index: number) => {
// Implement a true modulus in order to "wrap around" (e.g. `select(-1)` should select the last result)
// Source: https://stackoverflow.com/a/4467559/3461173
const n = Math.min(searchResults.length, MAX_DISPLAYED_ITEMS);
setSelectedSearchResult(searchResults[((index % n) + n) % n]);
}
if (event.key === 'ArrowDown') {
event.preventDefault();
select(selectedIndex + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
select(selectedIndex - 1);
return;
}
}

useEffect(() => {
const element = document.querySelector(`a[href='/#${selectedSearchResult}']`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [selectedSearchResult]);

const onSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (selectedSearchResult) {
window.location.href = '/#' + selectedSearchResult;
}
}

return <form onSubmit={onSubmit}>
<div className="flex px-4 rounded-xl border border-gray-300 dark:border-gray-500">
<div className="py-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" width="16" height="16"><path fill="#A8A29E" d="M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z" /></svg>
</div>
<input type="search" className="flex-grow" placeholder="Search repositories and examples" autoFocus value={searchQuery} onChange={e => search(e.target.value)} onKeyDown={onKeyDown} />
</div>
<div className="mt-3 -mx-5 px-5 flex flex-col space-y-2 h-64 overflow-y-auto">
{searchQuery === '' && searchResults.length === 0 &&
<div className="mt-12 mx-auto w-96 text-gray-500">
Paste a <a className="gp-link" href="https://www.gitpod.io/docs/context-urls">repository context URL</a>, or start typing to see suggestions from:
<ul className="list-disc mt-4 pl-7 flex flex-col space-y-1">
<li>Your recent repositories</li>
<li>Your repositories from <Link className="gp-link" to="/integrations">connected integrations</Link></li>
Copy link
Contributor

@gtsiolis gtsiolis Feb 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: The page redirect when clicking this feels a bit abrupt, no? 😬

suggestion: What if, we skipped linking to the integrations on this empty state and only prompted users to connect more providers when they couldn't find the repository they were looking for? We could also close the modal when someone clicked on the link to connect more providers. What do you think? Cc @jldec

Modal (Empty) Modal (Missing Repository)
1 2

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: The page redirect when clicking this feels a bit abrupt, no? 😬

Hmm, good catch, but I think that's how we agreed to implement links (i.e. no more target="_blank" to open links in a new tab by default).

Thankfully, you can still right-click > Open in New Tab, or Cmd + click the link.

I think linking to integrations is still useful, as it provides a valuable clue on where results come from / how to get more results. 💭

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good catch, but I think that's how we agreed to implement links (i.e. no more target="_blank" to open links in a new tab by default).

Correct and agree! To clarify I was not suggesting to open this link in a new tab but either 🅰️ close the modal and redirect to the the integrations page or even better 🅱️ use help links only for external links like docs and skip the link to integrations on the empty state.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Went with 🅰️ for now. Will open a follow-up issue for the remaining design adjustments.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up issue can be found in #8088.

<li>Example repositories</li>
</ul>
</div>}
{searchResults.slice(0, MAX_DISPLAYED_ITEMS).map((result, index) =>
<a className={`px-4 py-3 rounded-xl` + (result === selectedSearchResult ? ' bg-gray-600 text-gray-50 dark:bg-gray-700' : '')} href={`/#${result}`} key={`search-result-${index}`} onMouseEnter={() => setSelectedSearchResult(result)}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: It would be probably good to add some right margin here to avoid squashing the highlighted reasult entry with the visible scrollbar. What do you think?

Suggested change
<a className={`px-4 py-3 rounded-xl` + (result === selectedSearchResult ? ' bg-gray-600 text-gray-50 dark:bg-gray-700' : '')} href={`/#${result}`} key={`search-result-${index}`} onMouseEnter={() => setSelectedSearchResult(result)}>
<a className={`px-4 py-3 rounded-xl mr-2` + (result === selectedSearchResult ? ' bg-gray-600 text-gray-50 dark:bg-gray-700' : '')} href={`/#${result}`} key={`search-result-${index}`} onMouseEnter={() => setSelectedSearchResult(result)}>
BEFORE AFTER
Screenshot 2022-01-21 at 6 17 21 PM (2) Screenshot 2022-01-21 at 6 17 35 PM (2)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uh oh 😬 I'm not a big fan of either, and I think I'd prefer having the results be the same width as the input. I'll see if I can improve this somehow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, no biggie. Regarding width, same width would be nice but that could require us to move the scrollbar outside the scrolling area. Posting below a slightly related design with scrollbars from #4948 to better visualize the problem. In any case, feel free to leave this as is, too. 🏓

127672486-fae3178f-e301-433d-9275-0e198e2a35ff

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gtsiolis Okay, I believe I've found an elegant fix for this 😁 I've simply made the scroll zone wider (negative X-margin) and compensated with some padding (positive X-padding). This doesn't require changing the size of other elements, and has the effect of "simply moving the scrollbar a bit to the right":

Screenshot 2022-02-01 at 15 40 55

Screenshot 2022-02-01 at 15 43 17

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for giving this a go, @jankeromnes!

suggestion: I think going with the simple inner right padding for now sounds better.

issue: Moving the scrollbar outside could make the user feel like they can scroll the through the whole modal inner elements, including title, text input, etc, not just the repository list. This also becomes more visible or an issue when always showing scrollbars, see screenshots below. This is also what I see! 👀

Light Dark
scrollbar-light scrollbar-dark

thought: Keeping the scrollbar near the scrollbar area could help us 🅰️ use better layouts like the one with the gray background mentioned in #7715 (comment), 🅱️ potentially introduce more fixed elements like a bottom toolbar for surfacing results found, etc.

Regarding the scrollbar blindness issue we discussed a few days ago, here's a relevant thread[1] but unfortunately the original article has been removed. I could only find this partial screenshot[2]. 🌟

{searchQuery.length < 2
? <span>{result}</span>
: result.split(searchQuery).map((segment, index) => <span>
{index === 0 ? <></> : <strong>{searchQuery}</strong>}
{segment}
</span>)}
</a>
)}
{searchResults.length > MAX_DISPLAYED_ITEMS &&
<span className="mt-3 px-4 py-2 text-sm text-gray-400 dark:text-gray-500">{searchResults.length - MAX_DISPLAYED_ITEMS} more result{(searchResults.length - MAX_DISPLAYED_ITEMS) === 1 ? '' : 's'} found</span>}
</div>
</form>;
}

function loadSearchData(): SearchData {
const string = localStorage.getItem(LOCAL_STORAGE_KEY);
if (!string) {
return [];
}
try {
const data = JSON.parse(string);
return data;
} catch (error) {
console.warn('Could not load search data from local storage', error);
return [];
}
}

function saveSearchData(searchData: SearchData): void {
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(searchData));
} catch (error) {
console.warn('Could not save search data into local storage', error);
}
}

let refreshSearchDataPromise: Promise<boolean> | undefined;
export async function refreshSearchData(query: string, user: User | undefined): Promise<boolean> {
if (refreshSearchDataPromise) {
// Another refresh is already in progress, no need to run another one in parallel.
return refreshSearchDataPromise;
}
refreshSearchDataPromise = actuallyRefreshSearchData(query, user);
const didChange = await refreshSearchDataPromise;
refreshSearchDataPromise = undefined;
return didChange;
}

// Fetch all possible search results and cache them into local storage
async function actuallyRefreshSearchData(query: string, user: User | undefined): Promise<boolean> {
console.log('refreshing search data');
const oldData = loadSearchData();
const newData = await getGitpodService().server.getSuggestedContextURLs();
if (JSON.stringify(oldData) !== JSON.stringify(newData)) {
console.log('new data:', newData);
saveSearchData(newData);
return true;
}
return false;
}

async function findResults(query: string, onResults: (results: string[]) => void) {
if (!query) {
onResults([]);
return;
}
const searchData = loadSearchData();
try {
// If the query is a URL, and it's not present in the proposed results, "artificially" add it here.
new URL(query);
if (!searchData.includes(query)) {
searchData.push(query);
}
} catch {
}
// console.log('searching', query, 'in', searchData);
onResults(searchData.filter(result => result.toLowerCase().includes(query.toLowerCase())));
}
9 changes: 6 additions & 3 deletions components/dashboard/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AdminContextProvider } from './admin-context';
import { TeamsContextProvider } from './teams/teams-context';
import { ProjectContextProvider } from './projects/project-context';
import { ThemeContextProvider } from './theme-context';
import { StartWorkspaceModalContextProvider } from './workspaces/start-workspace-modal-context';
import { BrowserRouter } from 'react-router-dom';

import "./index.css"
Expand All @@ -23,9 +24,11 @@ ReactDOM.render(
<TeamsContextProvider>
<ProjectContextProvider>
<ThemeContextProvider>
<BrowserRouter>
<App />
</BrowserRouter>
<StartWorkspaceModalContextProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</StartWorkspaceModalContextProvider>
</ThemeContextProvider>
</ProjectContextProvider>
</TeamsContextProvider>
Expand Down
34 changes: 34 additions & 0 deletions components/dashboard/src/start/Open.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { useEffect, useState } from "react";
import RepositoryFinder from "../components/RepositoryFinder";

export default function Open() {
const [ initialQuery, setInitialQuery ] = useState<string | undefined>();

// Support pre-filling the search bar via the URL hash
useEffect(() => {
const onHashChange = () => {
const hash = window.location.hash.slice(1);
if (hash) {
setInitialQuery(hash);
}
}
onHashChange();
window.addEventListener('hashchange', onHashChange, false);
return () => {
window.removeEventListener('hashchange', onHashChange, false);
}
}, []);

return <div className="mt-24 mx-auto w-96 flex flex-col items-stretch">
<h1 className="text-center">Open in Gitpod</h1>
<div className="mt-8">
<RepositoryFinder initialQuery={initialQuery}/>
</div>
</div>;
}
75 changes: 15 additions & 60 deletions components/dashboard/src/workspaces/StartWorkspaceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,70 +4,25 @@
* See License-AGPL.txt in the project root for license information.
*/

import { useEffect, useState } from "react";
import { useContext, useEffect } from "react";
import { useLocation } from "react-router";
import Modal from "../components/Modal";
import TabMenuItem from "../components/TabMenuItem";
import RepositoryFinder from "../components/RepositoryFinder";
import { StartWorkspaceModalContext } from "./start-workspace-modal-context";

export interface WsStartEntry {
title: string
description: string
startUrl: string
}

interface StartWorkspaceModalProps {
visible: boolean;
recent: WsStartEntry[];
examples: WsStartEntry[];
selected?: Mode;
onClose: () => void;
}
export function StartWorkspaceModal() {
const { isStartWorkspaceModalVisible, setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext);
const location = useLocation();

type Mode = 'Recent' | 'Examples';
// Close the modal on navigation events.
useEffect(() => {
setIsStartWorkspaceModalVisible(false);
}, [location]);

export function StartWorkspaceModal(p: StartWorkspaceModalProps) {
const computeSelection = () => p.selected || (p.recent.length > 0 ? 'Recent' : 'Examples');
const [selection, setSelection] = useState(computeSelection());
useEffect(() => { !p.visible && setSelection(computeSelection()) }, [p.visible, p.recent, p.selected]);

const list = (selection === 'Recent' ? p.recent : p.examples).map((e, i) =>
<a key={`item-${i}-${e.title}`} href={e.startUrl} className="rounded-xl group hover:bg-gray-100 dark:hover:bg-gray-800 flex p-4 my-1">
<div className="w-full">
<p className="text-base text-gray-800 dark:text-gray-200 font-semibold">{e.title}</p>
<p>{e.description}</p>
</div>
</a>);

return <Modal onClose={p.onClose} visible={p.visible}>
<h3 className="pb-2">New Workspace</h3>
{/* separator */}
<div className="border-t border-gray-200 dark:border-gray-800 mt-2 -mx-6 px-6 pt-2">
<div className="flex">
<TabMenuItem name='Recent' selected={selection === 'Recent'} onClick={() => setSelection('Recent')} />
{p.examples.length>0 && <TabMenuItem name='Examples' selected={selection === 'Examples'} onClick={() => setSelection('Examples')} />}
</div>
</div>
<div className="border-t border-gray-200 dark:border-gray-800 -mx-6 px-6 py-2">
{list.length > 0 ?
<p className="my-4 text-base">
{selection === 'Recent' ?
'Create a new workspace using the default branch.' :
'Create a new workspace using an example project.'}
</p> : <p className="h-6 my-4"></p>}
<div className="space-y-2 mt-4 overflow-y-scroll h-80 pr-2">
{list.length > 0 ? list :
(selection === 'Recent' ?
<div className="flex flex-col pt-10 items-center px-2">
<h3 className="mb-2 text-gray-500 dark:text-gray-400">No Recent Projects</h3>
<p className="text-center">Projects you use frequently will show up here.</p>
<p className="text-center">Prefix a Git repository URL with {window.location.host}/# or start with an example.</p>
<button onClick={() => setSelection('Examples')} className="font-medium mt-8">Select Example</button>
</div> :
<div className="flex flex-col pt-10 items-center px-2">
<h3 className="mb-2 text-gray-500 dark:text-gray-400">No Example Projects</h3>
<p className="text-center">Sorry there seem to be no example projects, that work with your current Git provider.</p>
</div>)
}
</div>
return <Modal onClose={() => setIsStartWorkspaceModalVisible(false)} onEnter={() => false} visible={!!isStartWorkspaceModalVisible}>
<h3 className="pb-2">Open in Gitpod</h3>
<div className="border-t border-gray-200 dark:border-gray-800 mt-2 -mx-6 px-6 pt-4">
<RepositoryFinder />
</div>
</Modal>;
}
Loading