diff --git a/static/client/App.scss b/static/client/App.scss index b750ab0d..be7f5caa 100644 --- a/static/client/App.scss +++ b/static/client/App.scss @@ -13,6 +13,8 @@ @include vf-p-side-navigation; @include vf-p-buttons; @include vf-p-tooltips; +@include vf-p-chip; +@include vf-p-search-and-filter; // Icons @include vf-p-icons; @@ -47,3 +49,8 @@ @extend %icon; @include vf-icon-logout(#ffffff); } + +// allow SearchAndFilter component increase height when there are several options selected (in Reviewers) +.p-search-and-filter__search-container { + height: auto !important; +} diff --git a/static/client/components/OwnerAndReviewers/CustomSearchAndFilter.tsx b/static/client/components/OwnerAndReviewers/CustomSearchAndFilter.tsx new file mode 100644 index 00000000..f8cb91aa --- /dev/null +++ b/static/client/components/OwnerAndReviewers/CustomSearchAndFilter.tsx @@ -0,0 +1,100 @@ +// the existing SearchAndFilter component provided by react-components did not provide ability to have dynamic options +import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; + +import type { ICustomSearchAndFilterProps } from "./OwnerAndReviewers.types"; + +import type { IUser } from "@/services/api/types/users"; + +const CustomSearchAndFilter = ({ + label, + options, + selectedOptions, + placeholder, + onChange, + onRemove, + onSelect, +}: ICustomSearchAndFilterProps): JSX.Element => { + const [dropdownHidden, setDropdownHidden] = useState(true); + const [containerExpanded, setContainerExpanded] = useState(false); + + const inputRef = useRef(null); + + useEffect(() => { + setDropdownHidden(!options.length); + setContainerExpanded(!!options.length); + }, [options]); + + const handleSelect = useCallback( + (option: IUser) => () => { + onSelect(option); + if (inputRef.current) { + inputRef.current.value = ""; + } + }, + [onSelect], + ); + + // a callback that closes an options dropdown when focus is not on an input field + const handleInputBlur = useCallback(() => { + setDropdownHidden(true); + setContainerExpanded(false); + if (inputRef.current) { + inputRef.current.value = ""; + } + }, []); + + // this callback is needed for handleSelect to have higher priority than handleInputBlur when selecting an option + const handleOptionMouseDown = useCallback((event: MouseEvent) => { + event.preventDefault(); + }, []); + + return ( +
+

+ {label} +

+
+ {selectedOptions.map((option) => ( + + {option.name} + + + ))} +
+ +
+
+
+
+ +
+
+
+ ); +}; + +export default CustomSearchAndFilter; diff --git a/static/client/components/OwnerAndReviewers/Owner.tsx b/static/client/components/OwnerAndReviewers/Owner.tsx new file mode 100644 index 00000000..7ea959ea --- /dev/null +++ b/static/client/components/OwnerAndReviewers/Owner.tsx @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from "react"; + +import CustomSearchAndFilter from "./CustomSearchAndFilter"; +import { useUsersRequest } from "./OwnerAndReviewers.hooks"; +import type { IOwnerAndReviewersProps } from "./OwnerAndReviewers.types"; + +import { PagesServices } from "@/services/api/services/pages"; +import { type IUser } from "@/services/api/types/users"; + +const Owner = ({ page }: IOwnerAndReviewersProps): JSX.Element => { + const [currentOwner, setCurrentOwner] = useState(null); + const { options, setOptions, handleChange } = useUsersRequest(); + + useEffect(() => { + setCurrentOwner(page.owner); + }, [page]); + + const handleRemoveOwner = useCallback( + () => () => { + setCurrentOwner(null); + PagesServices.setOwner({}, page.id); + }, + [page], + ); + + const selectOwner = useCallback( + (option: IUser) => { + PagesServices.setOwner(option, page.id); + setOptions([]); + setCurrentOwner(option); + // mutate the tree structure element to reflect the recent change + page.owner = option; + }, + [page, setOptions], + ); + + return ( + + ); +}; + +export default Owner; diff --git a/static/client/components/OwnerAndReviewers/OwnerAndReviewers.hooks.ts b/static/client/components/OwnerAndReviewers/OwnerAndReviewers.hooks.ts new file mode 100644 index 00000000..852054c3 --- /dev/null +++ b/static/client/components/OwnerAndReviewers/OwnerAndReviewers.hooks.ts @@ -0,0 +1,27 @@ +import { type ChangeEvent, useCallback, useState } from "react"; + +import type { IUseUsersRequest } from "./OwnerAndReviewers.types"; + +import { UsersServices } from "@/services/api/services/users"; +import { UtilServices } from "@/services/api/services/utils"; +import type { IUser } from "@/services/api/types/users"; + +const debouncedRequest = UtilServices.debounce(UsersServices.getUsers, 500); + +export const useUsersRequest = (): IUseUsersRequest => { + const [options, setOptions] = useState([]); + + const handleChange = useCallback(async (event: ChangeEvent) => { + const { value } = event.target; + if (value.length >= 2) { + const users = await debouncedRequest(value); + if (users?.data?.length) { + setOptions(users.data); + } + } else { + setOptions([]); + } + }, []); + + return { options, setOptions, handleChange }; +}; diff --git a/static/client/components/OwnerAndReviewers/OwnerAndReviewers.tsx b/static/client/components/OwnerAndReviewers/OwnerAndReviewers.tsx new file mode 100644 index 00000000..d8c81260 --- /dev/null +++ b/static/client/components/OwnerAndReviewers/OwnerAndReviewers.tsx @@ -0,0 +1,13 @@ +import Owner from "./Owner"; +import type { IOwnerAndReviewersProps } from "./OwnerAndReviewers.types"; +import Reviewers from "./Reviewers"; + +const OwnerAndReviewers = ({ page }: IOwnerAndReviewersProps): JSX.Element => ( + <> + +
+ + +); + +export default OwnerAndReviewers; diff --git a/static/client/components/OwnerAndReviewers/OwnerAndReviewers.types.ts b/static/client/components/OwnerAndReviewers/OwnerAndReviewers.types.ts new file mode 100644 index 00000000..d9b508c7 --- /dev/null +++ b/static/client/components/OwnerAndReviewers/OwnerAndReviewers.types.ts @@ -0,0 +1,24 @@ +import type { ChangeEvent, Dispatch, SetStateAction } from "react"; + +import type { IPage } from "@/services/api/types/pages"; +import type { IUser } from "@/services/api/types/users"; + +export interface IOwnerAndReviewersProps { + page: IPage; +} + +export interface ICustomSearchAndFilterProps { + label: string; + options: IUser[]; + selectedOptions: IUser[]; + placeholder: string; + onChange: (event: ChangeEvent) => void; + onRemove: (option?: IUser) => () => void; + onSelect: (option: IUser) => void; +} + +export interface IUseUsersRequest { + options: IUser[]; + setOptions: Dispatch>; + handleChange: (event: ChangeEvent) => void; +} diff --git a/static/client/components/OwnerAndReviewers/Reviewers.tsx b/static/client/components/OwnerAndReviewers/Reviewers.tsx new file mode 100644 index 00000000..559b20e6 --- /dev/null +++ b/static/client/components/OwnerAndReviewers/Reviewers.tsx @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from "react"; + +import CustomSearchAndFilter from "./CustomSearchAndFilter"; +import { useUsersRequest } from "./OwnerAndReviewers.hooks"; +import type { IOwnerAndReviewersProps } from "./OwnerAndReviewers.types"; + +import { PagesServices } from "@/services/api/services/pages"; +import { type IUser } from "@/services/api/types/users"; + +const Reviewers = ({ page }: IOwnerAndReviewersProps): JSX.Element => { + const [currentReviewers, setCurrentReviewers] = useState([]); + const { options, setOptions, handleChange } = useUsersRequest(); + + useEffect(() => { + setCurrentReviewers(page.reviewers); + }, [page]); + + const handleRemoveReviewer = useCallback( + (option?: IUser) => () => { + const newReviewers = currentReviewers.filter((r) => r.id !== option?.id); + setCurrentReviewers(newReviewers); + PagesServices.setReviewers(newReviewers, page.id); + }, + [currentReviewers, page], + ); + + const selectReviewer = useCallback( + (option: IUser) => { + // check if a person with the same email already exists + // proceed with setting the reviewer only if not + if (!currentReviewers.find((r) => r.email === option.email)) { + const newReviewers = [...currentReviewers, option]; + PagesServices.setReviewers(newReviewers, page.id); + setOptions([]); + setCurrentReviewers(newReviewers); + // mutate the tree structure element to reflect the recent change + page.reviewers = newReviewers; + } + }, + [page, currentReviewers, setOptions], + ); + + return ( + + ); +}; + +export default Reviewers; diff --git a/static/client/components/OwnerAndReviewers/index.ts b/static/client/components/OwnerAndReviewers/index.ts new file mode 100644 index 00000000..bb06f98f --- /dev/null +++ b/static/client/components/OwnerAndReviewers/index.ts @@ -0,0 +1 @@ +export { default } from "./OwnerAndReviewers"; diff --git a/static/client/components/Search/Search.tsx b/static/client/components/Search/Search.tsx index 94b08b63..3e05dfbe 100644 --- a/static/client/components/Search/Search.tsx +++ b/static/client/components/Search/Search.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { type MouseEvent, useCallback, useRef, useState } from "react"; import { SearchBox } from "@canonical/react-components"; import { useNavigate } from "react-router-dom"; @@ -16,7 +16,7 @@ const Search = (): JSX.Element => { const [matches, setMatches] = useState([]); - const searchRef = useRef(null); + const searchRef = useRef(null); const handleChange = useCallback( (inputValue: string) => { @@ -33,7 +33,7 @@ const Search = (): JSX.Element => { (selectedItem: IMatch) => () => { setMatches([]); if (searchRef?.current) { - (searchRef.current as any).value = ""; + searchRef.current.value = ""; } // if the selected webpage has a different project, propagate to the rest of the app via the store if (selectedItem.project !== selectedProject?.name) { @@ -45,27 +45,42 @@ const Search = (): JSX.Element => { [data, navigate, searchRef, selectedProject, setSelectedProject], ); + // a callback that closes an options dropdown when focus is not on an input field + const handleInputBlur = useCallback(() => { + setMatches([]); + if (searchRef?.current) { + searchRef.current.value = ""; + } + }, []); + + // this callback is needed for handleSelect to have higher priority than handleInputBlur when selecting an option + const handleOptionMouseDown = useCallback((event: MouseEvent) => { + event.preventDefault(); + }, []); + return ( -
- { - - } - {matches.length >= 0 && ( -
    - {matches.map((match) => ( -
  • - {match.name} - {match.title} -
  • - ))} -
- )} -
+ <> + +
+ {matches.length >= 0 && ( +
    + {matches.map((match) => ( +
  • + {match.name} - {match.title} +
  • + ))} +
+ )} +
+ ); }; diff --git a/static/client/components/Search/_Search.scss b/static/client/components/Search/_Search.scss index c530c0ad..1ecff2ab 100644 --- a/static/client/components/Search/_Search.scss +++ b/static/client/components/Search/_Search.scss @@ -9,9 +9,10 @@ list-style-type: none; margin: 0; max-height: calc(100vh - 200px); + overflow: auto; padding: 0; position: absolute; - top: 50px; + top: -10px; width: 100%; z-index: 10; diff --git a/static/client/pages/Webpage/Webpage.tsx b/static/client/pages/Webpage/Webpage.tsx index 9564397a..9b8297fa 100644 --- a/static/client/pages/Webpage/Webpage.tsx +++ b/static/client/pages/Webpage/Webpage.tsx @@ -4,6 +4,7 @@ import { Button } from "@canonical/react-components"; import { type IWebpageProps } from "./Webpage.types"; +import OwnerAndReviewers from "@/components/OwnerAndReviewers"; import config from "@/config"; const Webpage = ({ page, project }: IWebpageProps): JSX.Element => { @@ -49,11 +50,16 @@ const Webpage = ({ page, project }: IWebpageProps): JSX.Element => { )}
-
-

- Description -

-

{page.description || "-"}

+
+
+

+ Description +

+

{page.description || "-"}

+
+
+ +
); diff --git a/static/client/services/api/constants.ts b/static/client/services/api/constants.ts index 7138447f..e3bb68ff 100644 --- a/static/client/services/api/constants.ts +++ b/static/client/services/api/constants.ts @@ -1,6 +1,8 @@ export const ENDPOINTS = { getPagesTree: (domain: string) => `/get-tree/${domain}`, - login: (url: string) => `/login?next=${url}`, + getUsers: (inputStr: string) => `/get-users/${inputStr}`, + setOwner: "/set-owner", + setReviewers: "/set-reviewers", }; export const REST_TYPES = { diff --git a/static/client/services/api/index.ts b/static/client/services/api/index.ts index 17405d88..4dd9bc8e 100644 --- a/static/client/services/api/index.ts +++ b/static/client/services/api/index.ts @@ -1,13 +1,13 @@ -import { AuthApiClass } from "./partials/AuthApiClass"; import { PagesApiClass } from "./partials/PagesApiClass"; +import { UsersApiClass } from "./partials/UsersApiClass"; class ApiClass { public pages: PagesApiClass; - public auth: AuthApiClass; + public users: UsersApiClass; constructor() { this.pages = new PagesApiClass(); - this.auth = new AuthApiClass(); + this.users = new UsersApiClass(); } } diff --git a/static/client/services/api/partials/AuthApiClass.ts b/static/client/services/api/partials/AuthApiClass.ts deleted file mode 100644 index 5d3317b8..00000000 --- a/static/client/services/api/partials/AuthApiClass.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BasicApiClass } from "./BasicApiClass"; - -import { ENDPOINTS, REST_TYPES } from "@/services/api/constants"; - -export class AuthApiClass extends BasicApiClass { - public login(url: string) { - return this.callApi(ENDPOINTS.login(url), REST_TYPES.GET); - } -} diff --git a/static/client/services/api/partials/BasicApiClass.ts b/static/client/services/api/partials/BasicApiClass.ts index d7aff360..dd97d264 100644 --- a/static/client/services/api/partials/BasicApiClass.ts +++ b/static/client/services/api/partials/BasicApiClass.ts @@ -13,7 +13,7 @@ export class BasicApiClass { this.timeout = 5 * 60 * 1000; this.headers = { Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", + "Content-Type": "application/json", }; } diff --git a/static/client/services/api/partials/PagesApiClass.ts b/static/client/services/api/partials/PagesApiClass.ts index c00daa00..a097b9f5 100644 --- a/static/client/services/api/partials/PagesApiClass.ts +++ b/static/client/services/api/partials/PagesApiClass.ts @@ -2,9 +2,24 @@ import { BasicApiClass } from "./BasicApiClass"; import { ENDPOINTS, REST_TYPES } from "@/services/api/constants"; import type { IPagesResponse } from "@/services/api/types/pages"; +import { type IUser } from "@/services/api/types/users"; export class PagesApiClass extends BasicApiClass { public getPages(domain: string): Promise { return this.callApi(ENDPOINTS.getPagesTree(domain), REST_TYPES.GET); } + + public setOwner(user: IUser | {}, webpageId: number): Promise { + return this.callApi(ENDPOINTS.setOwner, REST_TYPES.POST, { + user_struct: user, + webpage_id: webpageId, + }); + } + + public setReviewers(users: IUser[], webpageId: number): Promise { + return this.callApi(ENDPOINTS.setReviewers, REST_TYPES.POST, { + user_structs: users, + webpage_id: webpageId, + }); + } } diff --git a/static/client/services/api/partials/UsersApiClass.ts b/static/client/services/api/partials/UsersApiClass.ts new file mode 100644 index 00000000..bcbfa8cf --- /dev/null +++ b/static/client/services/api/partials/UsersApiClass.ts @@ -0,0 +1,10 @@ +import { BasicApiClass } from "./BasicApiClass"; + +import { ENDPOINTS, REST_TYPES } from "@/services/api/constants"; +import { type IUsersResponse } from "@/services/api/types/users"; + +export class UsersApiClass extends BasicApiClass { + public getUsers(username: string): Promise { + return this.callApi(ENDPOINTS.getUsers(username), REST_TYPES.GET); + } +} diff --git a/static/client/services/api/services/auth.ts b/static/client/services/api/services/auth.ts deleted file mode 100644 index fef9134d..00000000 --- a/static/client/services/api/services/auth.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { api } from ".."; - -export const login = (url: string) => { - return api.auth.login(url); -}; - -export * as AuthServices from "./auth"; diff --git a/static/client/services/api/services/pages.ts b/static/client/services/api/services/pages.ts index 5548d78d..f8d5d120 100644 --- a/static/client/services/api/services/pages.ts +++ b/static/client/services/api/services/pages.ts @@ -1,8 +1,17 @@ import { api } from "@/services/api"; import type { IPagesResponse } from "@/services/api/types/pages"; +import type { IUser } from "@/services/api/types/users"; export const getPages = async (domain: string): Promise => { return api.pages.getPages(domain); }; +export const setOwner = async (user: IUser | {}, webpageId: number): Promise => { + return api.pages.setOwner(user, webpageId); +}; + +export const setReviewers = async (users: IUser[], webpageId: number): Promise => { + return api.pages.setReviewers(users, webpageId); +}; + export * as PagesServices from "./pages"; diff --git a/static/client/services/api/services/users.ts b/static/client/services/api/services/users.ts new file mode 100644 index 00000000..a12a76c0 --- /dev/null +++ b/static/client/services/api/services/users.ts @@ -0,0 +1,8 @@ +import { api } from "@/services/api"; +import type { IUsersResponse } from "@/services/api/types/users"; + +export const getUsers = async (username: string): Promise => { + return api.users.getUsers(username); +}; + +export * as UsersServices from "./users"; diff --git a/static/client/services/api/services/utils.ts b/static/client/services/api/services/utils.ts new file mode 100644 index 00000000..a5a9cba2 --- /dev/null +++ b/static/client/services/api/services/utils.ts @@ -0,0 +1,22 @@ +export function debounce(func: (arg: string) => Promise, wait: number): (arg: string) => Promise { + let timeout: NodeJS.Timeout | undefined; + + return function (arg: string): Promise { + if (timeout) { + clearTimeout(timeout); + } + + return new Promise((resolve, reject) => { + timeout = setTimeout(async () => { + try { + const result = await func(arg); + resolve(result); + } catch (error) { + reject(error); + } + }, wait); + }); + }; +} + +export * as UtilServices from "./utils"; diff --git a/static/client/services/api/types/pages.ts b/static/client/services/api/types/pages.ts index 9e85604c..ef54f37c 100644 --- a/static/client/services/api/types/pages.ts +++ b/static/client/services/api/types/pages.ts @@ -1,8 +1,13 @@ +import type { IUser } from "./users"; + export interface IPage { + id: number; name: string; title: string; description: string; link: string; + owner: IUser; + reviewers: IUser[]; children: IPage[]; } diff --git a/static/client/services/api/types/users.ts b/static/client/services/api/types/users.ts new file mode 100644 index 00000000..176beaea --- /dev/null +++ b/static/client/services/api/types/users.ts @@ -0,0 +1,12 @@ +export interface IUser { + id: number; + name: string; + email: string; + jobTitle: string; + department: string; + team: string; +} + +export interface IUsersResponse { + data: IUser[]; +}