Skip to content

Commit

Permalink
[42] Add support for a palette
Browse files Browse the repository at this point in the history
Bug: #42
Signed-off-by: Stéphane Bégaudeau <stephane.begaudeau@gmail.com>
  • Loading branch information
sbegaudeau committed Jun 26, 2023
1 parent 2cd103d commit af4bd73
Show file tree
Hide file tree
Showing 8 changed files with 395 additions and 32 deletions.
65 changes: 34 additions & 31 deletions frontend/svalyn-studio-app/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { PaletteProvider } from '../palette/PaletteProvider';
import { ChangeProposalView } from '../views/changeproposal/ChangeProposalView';
import { DomainView } from '../views/domain/DomainView';
import { DomainsView } from '../views/domains/DomainsView';
Expand Down Expand Up @@ -49,37 +50,39 @@ export const App = () => {
<CssBaseline />
<BrowserRouter>
<AuthenticationRedirectionBoundary>
<Routes>
<Route path="/" element={<HomeView />} />
<Route path="/new/organization" element={<NewOrganizationView />} />
<Route path="/orgs/:organizationIdentifier" element={<OrganizationView />} />
<Route path="/orgs/:organizationIdentifier/tags" element={<OrganizationView />} />
<Route path="/orgs/:organizationIdentifier/members" element={<OrganizationView />} />
<Route path="/orgs/:organizationIdentifier/settings" element={<OrganizationView />} />
<Route path="/projects/:projectIdentifier" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/activity" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/changeproposals" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/tags" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/settings" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/new/changeproposal" element={<NewChangeProposalView />} />
<Route path="/projects/:projectIdentifier/changes/:changeId/resources/*" element={<ResourceView />} />
<Route path="/projects/:projectIdentifier/changes/:changeId" element={<WorkspaceView />} />
<Route path="/changeproposals/:changeProposalId" element={<ChangeProposalView />} />
<Route path="/changeproposals/:changeProposalId/files" element={<ChangeProposalView />} />
<Route path="/domains" element={<DomainsView />} />
<Route path="/domains/:domainIdentifier" element={<DomainView />} />
<Route path="/search" element={<SearchView />} />
<Route path="/settings" element={<SettingsView />} />
<Route path="/settings/authentication-tokens" element={<SettingsView />} />
<Route path="/login" element={<LoginView />} />
<Route path="/oauth2/redirect" element={<OAuth2RedirectView />} />
<Route path="/profile/:username" element={<ProfileView />} />
<Route path="/invitations" element={<InvitationsView />} />
<Route path="/notifications" element={<NotificationsView />} />
<Route path="/error" element={<ErrorView />} />
<Route path="help" element={<HelpView />} />
<Route path="*" element={<NotFoundView />} />
</Routes>
<PaletteProvider>
<Routes>
<Route path="/" element={<HomeView />} />
<Route path="/new/organization" element={<NewOrganizationView />} />
<Route path="/orgs/:organizationIdentifier" element={<OrganizationView />} />
<Route path="/orgs/:organizationIdentifier/tags" element={<OrganizationView />} />
<Route path="/orgs/:organizationIdentifier/members" element={<OrganizationView />} />
<Route path="/orgs/:organizationIdentifier/settings" element={<OrganizationView />} />
<Route path="/projects/:projectIdentifier" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/activity" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/changeproposals" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/tags" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/settings" element={<ProjectView />} />
<Route path="/projects/:projectIdentifier/new/changeproposal" element={<NewChangeProposalView />} />
<Route path="/projects/:projectIdentifier/changes/:changeId/resources/*" element={<ResourceView />} />
<Route path="/projects/:projectIdentifier/changes/:changeId" element={<WorkspaceView />} />
<Route path="/changeproposals/:changeProposalId" element={<ChangeProposalView />} />
<Route path="/changeproposals/:changeProposalId/files" element={<ChangeProposalView />} />
<Route path="/domains" element={<DomainsView />} />
<Route path="/domains/:domainIdentifier" element={<DomainView />} />
<Route path="/search" element={<SearchView />} />
<Route path="/settings" element={<SettingsView />} />
<Route path="/settings/authentication-tokens" element={<SettingsView />} />
<Route path="/login" element={<LoginView />} />
<Route path="/oauth2/redirect" element={<OAuth2RedirectView />} />
<Route path="/profile/:username" element={<ProfileView />} />
<Route path="/invitations" element={<InvitationsView />} />
<Route path="/notifications" element={<NotificationsView />} />
<Route path="/error" element={<ErrorView />} />
<Route path="help" element={<HelpView />} />
<Route path="*" element={<NotFoundView />} />
</Routes>
</PaletteProvider>
</AuthenticationRedirectionBoundary>
</BrowserRouter>
</ThemeProvider>
Expand Down
38 changes: 37 additions & 1 deletion frontend/svalyn-studio-app/src/navbars/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ import LogoutIcon from '@mui/icons-material/Logout';
import MailOutlineIcon from '@mui/icons-material/MailOutline';
import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone';
import PersonIcon from '@mui/icons-material/Person';
import SearchIcon from '@mui/icons-material/Search';
import SettingsIcon from '@mui/icons-material/Settings';
import AppBar from '@mui/material/AppBar';
import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import ListItem from '@mui/material/ListItem';
Expand All @@ -37,10 +39,12 @@ import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Toolbar from '@mui/material/Toolbar';
import { useEffect, useState } from 'react';
import { useContext, useEffect, useState } from 'react';
import { Navigate, Link as RouterLink } from 'react-router-dom';
import { getCookie } from '../cookies/getCookie';
import { Svalyn } from '../icons/Svalyn';
import { PaletteContext } from '../palette/PaletteContext';
import { PaletteContextValue } from '../palette/PaletteContext.types';
import { ErrorSnackbar } from '../snackbar/ErrorSnackbar';
import { GetViewerData, GetViewerVariables, NavbarProps, NavbarState } from './Navbar.types';
const { VITE_BACKEND_URL } = import.meta.env;
Expand Down Expand Up @@ -79,6 +83,10 @@ export const Navbar = ({ children }: NavbarProps) => {

const handleCloseSnackbar = () => setState((prevState) => ({ ...prevState, message: null }));

const { openPalette }: PaletteContextValue = useContext<PaletteContextValue>(PaletteContext);

const handleOnSearchClick: React.MouseEventHandler<HTMLButtonElement> = () => openPalette();

const handleOpenUserMenu: React.MouseEventHandler<HTMLButtonElement> = (event) => {
const { currentTarget } = event;
setState((prevState) => ({ ...prevState, anchorElement: currentTarget }));
Expand All @@ -104,6 +112,8 @@ export const Navbar = ({ children }: NavbarProps) => {
return <Navigate to="/login" />;
}

var isApple = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);

return (
<>
<AppBar position="static">
Expand All @@ -123,6 +133,32 @@ export const Navbar = ({ children }: NavbarProps) => {
marginLeft: 'auto',
}}
>
<Button
sx={{ color: 'inherit', border: (theme) => `1px solid ${theme.palette.background.paper}` }}
startIcon={<SearchIcon fontSize="small" color="inherit" />}
onClick={handleOnSearchClick}
size="small"
>
Search...
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
border: (theme) => `1px solid ${theme.palette.background.paper}`,
borderRadius: '3px',
marginLeft: (theme) => theme.spacing(4),
fontSize: '0.75rem',
fontWeight: '700',
lineHeight: '20px',
padding: '0px 4px',
fontFamily: 'sans-serif',
opacity: 0.7,
}}
>
{isApple ? '⌘ ' : 'Ctrl '}+ K
</Box>
</Button>
<IconButton component={RouterLink} to="/notifications" size="small" color="inherit">
<Badge badgeContent={state.viewer.unreadNotificationsCount} color="secondary">
<NotificationsNoneIcon />
Expand Down
161 changes: 161 additions & 0 deletions frontend/svalyn-studio-app/src/palette/Palette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import CorporateFareIcon from '@mui/icons-material/CorporateFare';
import HomeIcon from '@mui/icons-material/Home';
import HubIcon from '@mui/icons-material/Hub';
import SearchIcon from '@mui/icons-material/Search';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import Input from '@mui/material/Input';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PaletteAction, PaletteProps, PaletteState } from './Palette.types';

export const Palette = ({ open, onClose }: PaletteProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);

const navigate = useNavigate();

const goToHome: PaletteAction = {
id: 'go-to-home',
icon: <HomeIcon fontSize="small" />,
label: 'Home',
handle: () => navigate(`/`),
};
const goToDomains: PaletteAction = {
id: 'go-to-domains',
icon: <HubIcon fontSize="small" />,
label: 'Domains',
handle: () => navigate(`/domains`),
};
const goToNewOrganization: PaletteAction = {
id: 'go-to-new-organization',
icon: <CorporateFareIcon fontSize="small" />,
label: 'New organization',
handle: () => navigate(`/new/organization`),
};

const defaultPaletteActions: PaletteAction[] = [goToHome, goToDomains, goToNewOrganization];

const [state, setState] = useState<PaletteState>({
query: '',
actions: defaultPaletteActions,
selectedActionId: null,
});

const handleChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> = (event) => {
const {
target: { value },
} = event;
setState((prevState) => ({ ...prevState, query: value }));
};

const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement | HTMLTextAreaElement> = (event) => {
if (event.key === 'Enter') {
navigate(`/search?q=${encodeURIComponent(state.query)}`);
onClose();
} else if (event.key === 'ArrowDown' && listRef.current) {
const firstListItem = listRef.current.childNodes[0];
if (firstListItem instanceof HTMLElement) {
const firstListItemButton = firstListItem.childNodes[0];
if (firstListItemButton instanceof HTMLElement) {
firstListItemButton.focus();
}
}
}
};

const handleListItemKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
if (event.key === 'ArrowDown') {
const nextListItemButton = event.currentTarget.parentElement?.nextSibling?.childNodes[0];
if (nextListItemButton instanceof HTMLElement) {
nextListItemButton.focus();
}
} else if (event.key === 'ArrowUp') {
const previousListItemButton = event.currentTarget.parentElement?.previousSibling?.childNodes[0];
if (previousListItemButton instanceof HTMLElement) {
previousListItemButton.focus();
} else {
const input = inputRef.current?.querySelector('input');
if (input) {
input.focus();
}
}
}
};

const handleOnActionClick = (action: PaletteAction) => {
action.handle();
onClose();
};

return (
<Dialog open={open} onClose={onClose} fullWidth>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<FormControl
variant="standard"
fullWidth
sx={{ paddingX: (theme) => theme.spacing(1), paddingY: (theme) => theme.spacing(0.5) }}
>
<Input
value={state.query}
onChange={handleChange}
onKeyDown={handleKeyDown}
ref={inputRef}
placeholder="Search..."
disableUnderline
autoFocus
fullWidth
startAdornment={
<InputAdornment position="start">
<SearchIcon fontSize="medium" />
</InputAdornment>
}
inputProps={{
style: {
fontSize: '1.5rem',
},
}}
/>
</FormControl>
<Divider />
<List ref={listRef} disablePadding>
{state.actions.map((action) => (
<ListItem key={action.id} disablePadding>
<ListItemButton onClick={() => handleOnActionClick(action)} onKeyDown={handleListItemKeyDown}>
<ListItemIcon>{action.icon}</ListItemIcon>
<ListItemText>{action.label}</ListItemText>
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Dialog>
);
};
36 changes: 36 additions & 0 deletions frontend/svalyn-studio-app/src/palette/Palette.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

export interface PaletteProps {
open: boolean;
onClose: () => void;
}

export interface PaletteState {
query: string;
actions: PaletteAction[];
selectedActionId: string | null;
}

export interface PaletteAction {
id: string;
icon: JSX.Element;
label: string;
handle: () => void;
}
25 changes: 25 additions & 0 deletions frontend/svalyn-studio-app/src/palette/PaletteContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 Stéphane Bégaudeau.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
* associated documentation files (the "Software"), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial
* portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import React from 'react';
import { PaletteContextValue } from './PaletteContext.types';

export const PaletteContext = React.createContext<PaletteContextValue>({
openPalette: () => {},
});
Loading

0 comments on commit af4bd73

Please sign in to comment.