From b0932c39b169808adc91d913f4cc3d75719a85e7 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Mon, 18 Nov 2024 01:28:31 +0700 Subject: [PATCH] feat: add tags --- frontend/components/ui/badge.tsx | 18 ++ frontend/components/ui/grid-view.tsx | 2 +- frontend/components/ui/modal.tsx | 13 +- frontend/components/ui/select-multiple.tsx | 257 ++++++++++++++++++ frontend/components/ui/select.tsx | 2 +- frontend/pages/hosts/components/form.tsx | 21 +- frontend/pages/hosts/components/host-item.tsx | 12 + frontend/pages/hosts/components/host-list.tsx | 14 +- frontend/pages/hosts/hooks/query.ts | 14 + frontend/pages/hosts/schema/form.ts | 2 + frontend/pages/terminal/new-session-page.tsx | 40 --- frontend/pages/terminal/page.tsx | 8 +- server/app/hosts/repository.go | 21 +- server/app/hosts/router.go | 43 ++- server/app/hosts/schema.go | 1 + server/db/models.go | 1 + server/models/host.go | 7 + 17 files changed, 409 insertions(+), 67 deletions(-) create mode 100644 frontend/components/ui/badge.tsx create mode 100644 frontend/components/ui/select-multiple.tsx delete mode 100644 frontend/pages/terminal/new-session-page.tsx diff --git a/frontend/components/ui/badge.tsx b/frontend/components/ui/badge.tsx new file mode 100644 index 0000000..3fff6aa --- /dev/null +++ b/frontend/components/ui/badge.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { GetProps, Text, View } from "tamagui"; + +type BadgeProps = GetProps; + +const Badge = ({ children, ...props }: BadgeProps) => { + return ( + + {typeof children === "string" ? ( + {children} + ) : ( + children + )} + + ); +}; + +export default Badge; diff --git a/frontend/components/ui/grid-view.tsx b/frontend/components/ui/grid-view.tsx index a6a5815..4685781 100644 --- a/frontend/components/ui/grid-view.tsx +++ b/frontend/components/ui/grid-view.tsx @@ -56,7 +56,7 @@ export const GridLayout = ({ }, [columns]); return ( - + {data?.map((item, idx) => ( {renderItem(item, idx)} diff --git a/frontend/components/ui/modal.tsx b/frontend/components/ui/modal.tsx index 2b72d56..32985f8 100644 --- a/frontend/components/ui/modal.tsx +++ b/frontend/components/ui/modal.tsx @@ -27,17 +27,10 @@ const Modal = ({ return ( - + diff --git a/frontend/components/ui/select-multiple.tsx b/frontend/components/ui/select-multiple.tsx new file mode 100644 index 0000000..f4569ea --- /dev/null +++ b/frontend/components/ui/select-multiple.tsx @@ -0,0 +1,257 @@ +import React, { forwardRef, useMemo, useState } from "react"; +import { Controller, FieldValues } from "react-hook-form"; +import { + Adapt, + Button, + Dialog, + Input, + ListItem, + ScrollView, + Sheet, + Text, + View, +} from "tamagui"; +import { FormFieldBaseProps } from "./utility"; +import { ErrorMessage } from "./form"; +import Icons from "./icons"; + +export type SelectItem = { + label: string; + value: string; +}; + +type SelectMultipleProps = React.ComponentPropsWithoutRef & { + items?: SelectItem[] | null; + value?: string[]; + defaultValue?: string[]; + onChange?: (value: string[]) => void; + placeholder?: string; + isCreatable?: boolean; + onCreate?: (value: string) => void; +}; + +type SelectMultipleRef = React.ElementRef; + +const SelectMultiple = forwardRef( + (props, ref) => { + const { + items, + value, + defaultValue, + onChange, + placeholder = "Select...", + isCreatable = false, + onCreate, + ...restProps + } = props; + + const [search, setSearch] = useState(""); + + const itemList = useMemo(() => { + if (!items?.length) { + return []; + } + + let list = [...items]; + + const searchText = search.toLowerCase().trim(); + if (searchText?.length > 0) { + list = list.filter((i) => i.label.toLowerCase().includes(searchText)); + } + + return list.sort((a) => (value?.includes(a.value) ? -1 : 1)); + }, [items, search, value]); + + const onCreateNew = () => { + const newItem = search.trim(); + onCreate?.(newItem); + onItemPress(newItem); + setSearch(""); + }; + + const onItemPress = (key: string) => { + const curValues = [...(value || [])]; + const idx = curValues.indexOf(key); + if (idx >= 0) { + curValues.splice(idx, 1); + onChange?.(curValues); + } else { + curValues.push(key); + onChange?.(curValues); + } + }; + + const onEnter = () => { + if (itemList.length > 0) { + onItemPress(itemList[0].value); + setSearch(""); + return; + } + + const newItem = search.trim(); + if (isCreatable && newItem.length > 0) { + return onCreateNew(); + } + }; + + return ( + + + + + + + + + + + + + + + + + + + + + {placeholder} + + + + + + + ); + } +); + +type SelectMultipleFieldProps = FormFieldBaseProps & + SelectMultipleProps; + +export const SelectMultipleField = ({ + form, + name, + ...props +}: SelectMultipleFieldProps) => ( + ( + <> + + + + )} + /> +); + +export const useSelectCreatableItems = (initialItems?: SelectItem[] | null) => { + const [items, setItems] = useState([]); + const itemsCombined = useMemo(() => { + return [...items, ...(initialItems || [])]; + }, [items, initialItems]); + + const addItem = (value: string) => { + const idx = itemsCombined.findIndex((i) => i.value === value); + if (idx >= 0) { + return; + } + setItems([...items, { label: value, value }]); + }; + + return [itemsCombined, addItem, setItems] as const; +}; + +export default SelectMultiple; diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index 4b58c22..754f65d 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -46,7 +46,7 @@ const Select = forwardRef( (); @@ -22,6 +26,8 @@ const HostForm = () => { const form = useZForm(formSchema, data); const isEditing = data?.id != null; const type = form.watch("type"); + const hostTags = useTags(); + const [tags, addTag] = useSelectCreatableItems(hostTags.data); const saveMutation = useSaveHost(); @@ -53,6 +59,17 @@ const HostForm = () => { {type !== "group" && ( <> + + + + { ); }; -export default HostForm; +export default React.memo(HostForm); diff --git a/frontend/pages/hosts/components/host-item.tsx b/frontend/pages/hosts/components/host-item.tsx index 800fb4b..f41936a 100644 --- a/frontend/pages/hosts/components/host-item.tsx +++ b/frontend/pages/hosts/components/host-item.tsx @@ -3,6 +3,7 @@ import React from "react"; import { MultiTapPressable } from "@/components/ui/pressable"; import Icons from "@/components/ui/icons"; import OSIcons from "@/components/ui/os-icons"; +import Badge from "@/components/ui/badge"; type HostItemProps = { host: any; @@ -26,12 +27,14 @@ const HostItem = ({ numberOfTaps={2} onMultiTap={onMultiTap} onTap={onTap} + h="100%" > {host.type === "group" ? ( @@ -48,6 +51,15 @@ const HostItem = ({ {host.label} + + {host.tags?.length > 0 && ( + + {host.tags.map((i: any) => ( + {i.name} + ))} + + )} + {host.host} diff --git a/frontend/pages/hosts/components/host-list.tsx b/frontend/pages/hosts/components/host-list.tsx index ddb61bd..dd09a85 100644 --- a/frontend/pages/hosts/components/host-list.tsx +++ b/frontend/pages/hosts/components/host-list.tsx @@ -14,6 +14,7 @@ type HostsListProps = { onParentIdChange?: (id: string | null) => void; selected?: string[]; onSelectedChange?: (ids: string[]) => void; + hideGroups?: boolean; }; const HostList = ({ @@ -22,12 +23,15 @@ const HostList = ({ onParentIdChange, selected = [], onSelectedChange, + hideGroups = false, }: HostsListProps) => { const openSession = useTermSession((i) => i.push); const navigation = useNavigation(); const [search, setSearch] = useState(""); - const { data, isLoading } = useHosts({ parentId }); + const { data, isLoading } = useHosts({ + parentId: !search.length ? parentId : "none", + }); const hostsList = useMemo(() => { let items = data || []; @@ -37,7 +41,8 @@ const HostList = ({ const q = search.toLowerCase(); return ( item.label.toLowerCase().includes(q) || - item.host.toLowerCase().includes(q) + item.host.toLowerCase().includes(q) || + item.tags.find((i: any) => i.name.toLowerCase().includes(q)) != null ); }); } @@ -65,7 +70,8 @@ const HostList = ({ const onEdit = (host: any) => { if (!allowEdit) return; - hostFormModal.onOpen(host); + const data = { ...host, tags: host.tags?.map((i: any) => i.name) }; + hostFormModal.onOpen(data); }; const onOpenTerminal = (host: any) => { @@ -103,7 +109,7 @@ const HostList = ({ ) : ( - {groups.length > 0 && ( + {groups.length > 0 && !hideGroups && ( <> Groups { }, }); }; + +export const useTags = () => { + const teamId = useTeamId(); + return useQuery({ + queryKey: ["hosts/tags", teamId], + queryFn: () => api("/hosts/tags", { params: { teamId } }), + select: (data) => { + return data?.rows?.map((row: any) => ({ + label: row.name, + value: row.name, + })); + }, + }); +}; diff --git a/frontend/pages/hosts/schema/form.ts b/frontend/pages/hosts/schema/form.ts index 121d9c8..3c5297d 100644 --- a/frontend/pages/hosts/schema/form.ts +++ b/frontend/pages/hosts/schema/form.ts @@ -14,6 +14,7 @@ const groupSchema = baseFormSchema.merge( const hostSchema = baseFormSchema.merge( z.object({ + tags: z.string().array(), host: hostnameShape(), port: z.coerce .number({ message: "Invalid port" }) @@ -65,6 +66,7 @@ export type FormSchema = z.infer; export const initialValues: FormSchema = { type: "ssh", host: "", + tags: [], port: 22, label: "", keyId: "", diff --git a/frontend/pages/terminal/new-session-page.tsx b/frontend/pages/terminal/new-session-page.tsx deleted file mode 100644 index a982587..0000000 --- a/frontend/pages/terminal/new-session-page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { View, Text } from "react-native"; -import React from "react"; -import HostList from "../hosts/components/host-list"; -import { useQueryParams } from "@/hooks/useQueryParams"; -import { BackButton } from "@/components/ui/button"; -import { XStack } from "tamagui"; - -type Params = { - parentId?: string | null; -}; - -const NewSessionPage = () => { - const queryParams = useQueryParams(); - const parentId = queryParams.params?.parentId; - - const setParentId = (id: string | null) => { - queryParams.push({ parentId: id || "" }); - }; - - const onGoBack = () => { - if (!queryParams.goBack()) { - queryParams.replace({ parentId: "" }); - } - }; - - return ( - <> - - {parentId ? : null} - - - - ); -}; - -export default NewSessionPage; diff --git a/frontend/pages/terminal/page.tsx b/frontend/pages/terminal/page.tsx index 6158071..4b38de3 100644 --- a/frontend/pages/terminal/page.tsx +++ b/frontend/pages/terminal/page.tsx @@ -8,7 +8,7 @@ import Drawer from "expo-router/drawer"; import { router } from "expo-router"; import Icons from "@/components/ui/icons"; import { useDebounceCallback } from "@/hooks/useDebounce"; -import NewSessionPage from "./new-session-page"; +import HostList from "../hosts/components/host-list"; const TerminalPage = () => { const pagerViewRef = useRef(null!); @@ -58,7 +58,11 @@ const TerminalPage = () => { /> {sessions.length > 0 && media.gtSm ? : null} - {!sessions.length ? : pagerView} + {!sessions.length ? ( + + ) : ( + pagerView + )} ); }; diff --git a/server/app/hosts/repository.go b/server/app/hosts/repository.go index 676d936..54a07d0 100644 --- a/server/app/hosts/repository.go +++ b/server/app/hosts/repository.go @@ -21,7 +21,9 @@ func NewRepository(r *Hosts) *Hosts { } func (r *Hosts) GetAll(opt GetAllOpt) ([]*models.Host, error) { - query := r.db.Order("id DESC") + query := r.db. + Preload("Tags"). + Order("id DESC") if len(opt.ID) > 0 { query = query.Where("hosts.id IN (?)", opt.ID) @@ -33,7 +35,7 @@ func (r *Hosts) GetAll(opt GetAllOpt) ([]*models.Host, error) { query = query.Where("hosts.owner_id = ? AND hosts.team_id IS NULL", r.User.ID) } - if opt.ParentID != nil { + if opt.ParentID != nil && *opt.ParentID != "none" { if *opt.ParentID != "" { query = query.Where("hosts.parent_id = ?", *opt.ParentID) } else { @@ -89,3 +91,18 @@ func (r *Hosts) Delete(id string) error { func (r *Hosts) SetParentId(id *string, hostIds []string) error { return r.db.Model(&models.Host{}).Where("id IN (?)", hostIds).Update("parent_id", id).Error } + +func (r *Hosts) GetAvailableTags(teamId string) (*[]models.HostTag, error) { + query := r.db.Model(&models.HostTag{}). + Joins("JOIN hosts ON hosts.id = host_tags.host_id"). + Distinct("host_tags.name") + + if teamId != "" { + query = query.Where("hosts.team_id = ?", teamId) + } else { + query = query.Where("hosts.owner_id = ? AND hosts.team_id IS NULL", r.User.ID) + } + + var result []models.HostTag + return &result, query.Find(&result).Error +} diff --git a/server/app/hosts/router.go b/server/app/hosts/router.go index cdefe61..e67d707 100644 --- a/server/app/hosts/router.go +++ b/server/app/hosts/router.go @@ -20,6 +20,7 @@ func Router(app fiber.Router) { router.Put("/:id", update) router.Delete("/:id", delete) router.Post("/move", move) + router.Get("/tags", getTags) } func getAll(c *fiber.Ctx) error { @@ -69,6 +70,11 @@ func create(c *fiber.Ctx) error { AltKeyID: body.AltKeyID, } + item.Tags = []*models.HostTag{} + for _, tag := range body.Tags { + item.Tags = append(item.Tags, &models.HostTag{Name: tag}) + } + osName, err := tryConnect(c, item) if err != nil { return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500) @@ -113,16 +119,28 @@ func update(c *fiber.Ctx) error { AltKeyID: body.AltKeyID, } - osName, err := tryConnect(c, item) - if err != nil { - return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500) - } - item.OS = osName + // osName, err := tryConnect(c, item) + // if err != nil { + // return utils.ResponseError(c, fmt.Errorf("cannot connect to the host: %s", err), 500) + // } + // item.OS = osName if err := repo.Update(id, item); err != nil { return utils.ResponseError(c, err, 500) } + tags := []models.HostTag{} + for _, tag := range body.Tags { + tags = append(tags, models.HostTag{ + Name: tag, + }) + } + + if err := repo.db.Unscoped().Model(&item). + Association("Tags").Unscoped().Replace(&tags); err != nil { + return utils.ResponseError(c, err, 500) + } + return c.JSON(item) } @@ -200,3 +218,18 @@ func move(c *fiber.Ctx) error { return c.JSON(true) } + +func getTags(c *fiber.Ctx) error { + teamId := c.Query("teamId") + user := lib.GetUser(c) + repo := NewRepository(&Hosts{User: user}) + + rows, err := repo.GetAvailableTags(teamId) + if err != nil { + return utils.ResponseError(c, err, 500) + } + + return c.JSON(fiber.Map{ + "rows": rows, + }) +} diff --git a/server/app/hosts/schema.go b/server/app/hosts/schema.go index b3d577c..e3fc107 100644 --- a/server/app/hosts/schema.go +++ b/server/app/hosts/schema.go @@ -8,6 +8,7 @@ type CreateHostSchema struct { Host string `json:"host"` Port int `json:"port"` Metadata datatypes.JSONMap `json:"metadata"` + Tags []string `json:"tags"` TeamID *string `json:"teamId"` ParentID *string `json:"parentId"` diff --git a/server/db/models.go b/server/db/models.go index 0c27f46..a320832 100644 --- a/server/db/models.go +++ b/server/db/models.go @@ -10,6 +10,7 @@ var Models = []interface{}{ &models.UserAccount{}, &models.Keychain{}, &models.Host{}, + &models.HostTag{}, &models.Team{}, &models.TeamMembers{}, &models.TermSession{}, diff --git a/server/models/host.go b/server/models/host.go index a5b5e9b..e7bd942 100644 --- a/server/models/host.go +++ b/server/models/host.go @@ -28,6 +28,7 @@ type Host struct { Port int `json:"port" gorm:"type:smallint"` OS string `json:"os" gorm:"type:varchar(32)"` Metadata datatypes.JSONMap `json:"metadata"` + Tags []*HostTag `json:"tags" gorm:"foreignKey:HostID"` ParentID *string `json:"parentId" gorm:"type:varchar(26)"` Parent *Host `json:"parent" gorm:"foreignKey:ParentID"` @@ -40,6 +41,12 @@ type Host struct { SoftDeletes } +type HostTag struct { + HostID string `gorm:"primarykey;type:varchar(26)" json:"-"` + Host *Host `gorm:"foreignKey:HostID;references:ID" json:"-"` + Name string `gorm:"primarykey;type:varchar(64)" json:"name"` +} + type HostDecrypted struct { Host Key map[string]interface{}