Skip to content

Commit

Permalink
Move Delete Project button to Danger zone tab (#1059)
Browse files Browse the repository at this point in the history
Motivation:

We got two feedbacks about UX:

- `Delete Project`'s location was inappropriate.

> After I click on Project Settings, the button is immediately replaced
  > with Delete Project, while I'm still hovering over it.

- Automatically focus to the project seach box on the welcome page.

  > would it be possible to add something like auto-focus to the
  > "Search project..." field when opening the web UI?
  > Perhaps also supporting the / key would help, for consistency with
  > the Projects page

Modifications:

- Create `Danger zone` tab and move `Delete Project` to it.
- Miscellaneous)
  - Remove `repos` segment from all `Breadcrumb`
- I felt it was unnecessary to include `repos` since it is assumed to be
a repo without additional explanation.
  - Fix to auto-focus to the search box on the welcome page.
  - Fix broken APIs in the mock server.

Result:

Improved usability of the UI.
  • Loading branch information
ikhoon authored Nov 13, 2024
1 parent 74d173e commit dee2f40
Show file tree
Hide file tree
Showing 17 changed files with 216 additions and 183 deletions.
85 changes: 4 additions & 81 deletions webapp/src/dogma/common/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,13 @@
* License for the specific language governing permissions and limitations
* under the License.
*/
import { ReactNode, useEffect, useRef, useState } from 'react';
import { ReactNode } from 'react';
import {
Box,
Button,
Flex,
HStack,
IconButton,
Kbd,
Link,
Menu,
MenuButton,
Expand All @@ -36,14 +35,13 @@ import { CloseIcon, HamburgerIcon, MoonIcon, SunIcon } from '@chakra-ui/icons';
import { default as RouteLink } from 'next/link';
import { logout } from 'dogma/features/auth/authSlice';
import Router from 'next/router';
import { useGetProjectsQuery, useGetTitleQuery } from 'dogma/features/api/apiSlice';
import { ProjectDto } from 'dogma/features/project/ProjectDto';
import { components, DropdownIndicatorProps, GroupBase, OptionBase, Select } from 'chakra-react-select';
import { useGetTitleQuery } from 'dogma/features/api/apiSlice';
import { NewProject } from 'dogma/features/project/NewProject';
import { usePathname } from 'next/navigation';
import { useAppDispatch, useAppSelector } from 'dogma/hooks';
import { LabelledIcon } from 'dogma/common/components/LabelledIcon';
import { FaUser } from 'react-icons/fa';
import ProjectSearchBox from 'dogma/common/components/ProjectSearchBox';

interface TopMenu {
name: string;
Expand All @@ -66,67 +64,11 @@ const NavLink = ({ link, children }: { link: string; children: ReactNode }) => (
</Link>
);

export interface ProjectOptionType extends OptionBase {
value: string;
label: string;
}

const initialState: ProjectOptionType = {
value: '',
label: '',
};

const DropdownIndicator = (
props: JSX.IntrinsicAttributes & DropdownIndicatorProps<unknown, boolean, GroupBase<unknown>>,
) => {
return (
<components.DropdownIndicator {...props}>
<Kbd>/</Kbd>
</components.DropdownIndicator>
);
};

export const Navbar = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { colorMode, toggleColorMode } = useColorMode();
const { user } = useAppSelector((state) => state.auth);
const dispatch = useAppDispatch();
const result = useGetProjectsQuery({ admin: false });
const projects = result.data || [];
const projectOptions: ProjectOptionType[] = projects.map((project: ProjectDto) => ({
value: project.name,
label: project.name,
}));
const [selectedOption, setSelectedOption] = useState(initialState);
const handleChange = (option: ProjectOptionType) => {
setSelectedOption(option);
};

useEffect(() => {
if (selectedOption?.value) {
Router.push(`/app/projects/${selectedOption.value}`);
}
}, [selectedOption?.value]);

const selectRef = useRef(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = (e.target as HTMLElement).tagName.toLowerCase();
if (target == 'textarea' || target == 'input') {
return;
}
if (e.key === '/') {
e.preventDefault();
selectRef.current.clearValue();
selectRef.current.focus();
} else if (e.key === 'Escape') {
selectRef.current.blur();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);

const pathname = usePathname();

const { data: titleDto } = useGetTitleQuery();
Expand Down Expand Up @@ -159,26 +101,7 @@ export const Navbar = () => {
<div />
) : (
<Box w="40%">
<Select
id="color-select"
name="project-search"
options={projectOptions}
value={selectedOption?.value}
onChange={(option: ProjectOptionType) => option && handleChange(option)}
placeholder="Jump to project ..."
closeMenuOnSelect={true}
openMenuOnFocus={true}
isClearable={true}
isSearchable={true}
ref={selectRef}
components={{ DropdownIndicator }}
chakraStyles={{
control: (baseStyles) => ({
...baseStyles,
backgroundColor: colorMode === 'light' ? 'white' : 'whiteAlpha.50',
}),
}}
/>
<ProjectSearchBox id="nav-search" placeholder="Jump to project ..." />
</Box>
)}
<Flex alignItems="center" gap={2}>
Expand Down
111 changes: 111 additions & 0 deletions webapp/src/dogma/common/components/ProjectSearchBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
components,
DropdownIndicatorProps,
GroupBase,
OptionBase,
Select,
SizeProp,
} from 'chakra-react-select';
import { useEffect, useRef, useState } from 'react';
import { Kbd, useColorMode } from '@chakra-ui/react';
import Router from 'next/router';
import { useGetProjectsQuery } from 'dogma/features/api/apiSlice';
import { ProjectDto } from 'dogma/features/project/ProjectDto';

export interface ProjectSearchBoxProps {
id: string;
size?: SizeProp;
placeholder: string;
autoFocus?: boolean;
}

export interface ProjectOptionType extends OptionBase {
value: string;
label: string;
}

const initialState: ProjectOptionType = {
value: '',
label: '',
};

const DropdownIndicator = (
props: JSX.IntrinsicAttributes & DropdownIndicatorProps<unknown, boolean, GroupBase<unknown>>,
) => {
return (
<components.DropdownIndicator {...props}>
<Kbd>/</Kbd>
</components.DropdownIndicator>
);
};

const ProjectSearchBox = ({ id, size, placeholder, autoFocus }: ProjectSearchBoxProps) => {
const { colorMode } = useColorMode();
const { data, isLoading } = useGetProjectsQuery({ admin: false });
const projects = data || [];
const projectOptions: ProjectOptionType[] = projects.map((project: ProjectDto) => ({
value: project.name,
label: project.name,
}));

const [selectedOption, setSelectedOption] = useState(initialState);
const handleChange = (option: ProjectOptionType) => {
setSelectedOption(option);
};

const selectRef = useRef(null);
useEffect(() => {
if (selectedOption?.value) {
selectRef.current.blur();
Router.push(`/app/projects/${selectedOption.value}`);
}
}, [selectedOption?.value]);

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = (e.target as HTMLElement).tagName.toLowerCase();
if (target == 'textarea' || target == 'input') {
return;
}
if (e.key === '/') {
e.preventDefault();
selectRef.current.clearValue();
selectRef.current.focus();
} else if (e.key === 'Escape') {
selectRef.current.blur();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [selectRef]);

return (
<Select
size={size}
id={id}
autoFocus={autoFocus}
name="project-search"
options={projectOptions}
value={selectedOption?.value}
onChange={(option: ProjectOptionType) => option && handleChange(option)}
placeholder={placeholder}
closeMenuOnSelect={true}
openMenuOnFocus={!autoFocus}
isClearable={true}
isSearchable={true}
ref={selectRef}
isLoading={isLoading}
components={{ DropdownIndicator }}
chakraStyles={{
control: (baseStyles) => ({
...baseStyles,
backgroundColor: colorMode === 'light' ? 'white' : 'whiteAlpha.50',
}),
}}
/>
);
};

export default ProjectSearchBox;
2 changes: 1 addition & 1 deletion webapp/src/dogma/features/project/DeleteProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const DeleteProject = ({ projectName }: { projectName: string }) => {
return (
<>
<Box>
<Button colorScheme="red" variant="outline" size="sm" onClick={onToggle}>
<Button colorScheme="red" variant="outline" size="lg" onClick={onToggle}>
Delete Project
</Button>
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,14 @@ interface ProjectSettingsViewProps {
children: (meta: ProjectMetadataDto) => ReactNode;
}

type TabName = 'repositories' | 'permissions' | 'members' | 'tokens' | 'mirrors' | 'credentials';
type TabName =
| 'repositories'
| 'permissions'
| 'members'
| 'tokens'
| 'mirrors'
| 'credentials'
| 'danger zone';
type UserRole = 'OWNER' | 'MEMBER' | 'GUEST';

export interface TapInfo {
Expand All @@ -52,6 +59,7 @@ const TABS: TapInfo[] = [
{ name: 'tokens', path: 'tokens', accessRole: 'OWNER', allowAnonymous: false },
{ name: 'mirrors', path: 'mirrors', accessRole: 'OWNER', allowAnonymous: true },
{ name: 'credentials', path: 'credentials', accessRole: 'OWNER', allowAnonymous: true },
{ name: 'danger zone', path: 'danger-zone', accessRole: 'OWNER', allowAnonymous: true },
];

function isAllowed(userRole: string, anonymous: boolean, tabInfo: TapInfo): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,17 @@ import { NextApiRequest, NextApiResponse } from 'next';
import { newRandomCredential } from 'pages/api/v1/projects/[projectName]/credentials/index';
import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto';

const credentials: Map<number, CredentialDto> = new Map();
const credentials: Map<string, CredentialDto> = new Map();

export default function handler(req: NextApiRequest, res: NextApiResponse) {
const index = parseInt(req.query.index as string, 10);
const id = req.query.id as string;
switch (req.method) {
case 'GET':
const credential = newRandomCredential(index);
credentials.set(index, credential);
const credential = newRandomCredential(id);
res.status(200).json(credential);
break;
case 'PUT':
credentials.set(index, req.body);
credentials.set(id, req.body);
res.status(201).json(`${credentials.size + 2}`);
break;
default:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,26 @@
import { NextApiRequest, NextApiResponse } from 'next';
import _ from 'lodash';
import { CredentialDto } from 'dogma/features/project/settings/credentials/CredentialDto';
import { faker } from '@faker-js/faker';

const credentials: CredentialDto[] = _.range(0, 20).map((i) => newRandomCredential(i));
const credentials: CredentialDto[] = _.range(0, 20).map(() =>
newRandomCredential('credential-' + faker.random.word()),
);

export function newRandomCredential(index: number): CredentialDto {
export function newRandomCredential(id: string): CredentialDto {
const index = id.length;
switch (index % 4) {
case 0:
return {
id: `password-id-${index}`,
id,
type: 'password',
username: `username-${index}`,
password: `password-${index}`,
enabled: true,
};
case 1:
return {
id: `public-key-id-${index}`,
id,
type: 'public_key',
username: `username-${index}`,
publicKey: `public-key-${index}`,
Expand All @@ -42,14 +46,14 @@ export function newRandomCredential(index: number): CredentialDto {
};
case 2:
return {
id: `access-token-id-${index}`,
id,
type: 'access_token',
accessToken: `access-token-${index}`,
enabled: true,
};
case 3:
return {
id: `none-id-${index}`,
id,
type: 'none',
enabled: true,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { FileDto, FileType } from 'dogma/features/file/FileDto';
// eslint-disable-next-line import/no-extraneous-dependencies
import { faker } from '@faker-js/faker';

const newFile = (id: number): FileDto => {
const type: FileType = faker.helpers.arrayElement(['TEXT', 'DIRECTORY', 'JSON', 'YML']);
const extension = type == 'DIRECTORY' ? '' : '.' + type.toLowerCase();
return {
revision: faker.datatype.number({
min: 1,
max: 10,
}),
path: `/${id}-${faker.animal.rabbit().replaceAll(' ', '-').toLowerCase()}${extension}`,
type: type,
url: faker.internet.url(),
};
};
const fileList: FileDto[] = [];
const makeData = (len: number) => {
for (let i = 0; i < len; i++) {
fileList.push(newFile(i));
}
};
makeData(20);

export default function handler(req: NextApiRequest, res: NextApiResponse) {
const { fileItem } = req.body;
const { query } = req;
const { revision } = query;
const revisionNumber = parseInt(revision as string);
const filtered = isNaN(revisionNumber)
? fileList
: fileList.filter((file: FileDto) => file.revision <= revisionNumber);
switch (req.method) {
case 'GET':
res.status(200).json(filtered);
break;
case 'POST':
fileList.push(fileItem);
res.status(200).json(fileList);
break;
default:
res.setHeader('Allow', ['GET', 'POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
break;
}
}
Loading

0 comments on commit dee2f40

Please sign in to comment.