feat: add tags

This commit is contained in:
Khairul Hidayat 2024-11-18 01:28:31 +07:00
parent 95f9f76edd
commit b0932c39b1
17 changed files with 409 additions and 67 deletions

View File

@ -0,0 +1,18 @@
import React from "react";
import { GetProps, Text, View } from "tamagui";
type BadgeProps = GetProps<typeof View>;
const Badge = ({ children, ...props }: BadgeProps) => {
return (
<View px={5} py={1} flexShrink={0} bg="$blue6" borderRadius="$3" {...props}>
{typeof children === "string" ? (
<Text fontSize="$2">{children}</Text>
) : (
children
)}
</View>
);
};
export default Badge;

View File

@ -56,7 +56,7 @@ export const GridLayout = <T extends GridItem>({
}, [columns]);
return (
<View flexDirection="row" flexWrap="wrap" {...props}>
<View flexDirection="row" flexWrap="wrap" alignItems="stretch" {...props}>
{data?.map((item, idx) => (
<View key={item.key} p={gap} flexShrink={0} {...basisProps}>
{renderItem(item, idx)}

View File

@ -27,17 +27,10 @@ const Modal = ({
return (
<Dialog open={open} onOpenChange={onOpenChange} modal>
<Adapt when="sm" platform="touch">
<Sheet
animation="quick"
zIndex={999}
modal
dismissOnSnapToBottom
snapPoints={[40, 60, 0]}
// disableDrag
>
<Sheet animation="quick" zIndex={999} modal dismissOnSnapToBottom>
<Sheet.Overlay
opacity={0.1}
animation="quick"
animation="quickest"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
zIndex={0}
@ -52,7 +45,7 @@ const Modal = ({
<Dialog.Overlay
key="overlay"
animation="quickest"
opacity={0.5}
opacity={0.2}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>

View File

@ -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<typeof ListItem> & {
items?: SelectItem[] | null;
value?: string[];
defaultValue?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
isCreatable?: boolean;
onCreate?: (value: string) => void;
};
type SelectMultipleRef = React.ElementRef<typeof ListItem>;
const SelectMultiple = forwardRef<SelectMultipleRef, SelectMultipleProps>(
(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 (
<Dialog>
<Dialog.Trigger asChild {...restProps}>
<ListItem
ref={ref}
backgrounded
hoverTheme
pressTheme
borderRadius="$4"
tabIndex={0}
focusVisibleStyle={{
outlineStyle: "solid",
outlineWidth: 2,
outlineColor: "$outlineColor",
}}
borderWidth={1}
title={value?.join(", ") || placeholder}
/>
</Dialog.Trigger>
<Adapt when="sm" platform="touch">
<Sheet animation="quick" zIndex={999} modal dismissOnSnapToBottom>
<Sheet.Overlay
opacity={0.1}
animation="quickest"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
zIndex={0}
/>
<Sheet.Frame>
<Adapt.Contents />
</Sheet.Frame>
</Sheet>
</Adapt>
<Dialog.Portal zIndex={999}>
<Dialog.Overlay
key="overlay"
animation="quickest"
opacity={0.2}
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
/>
<Dialog.Content
bordered
key="content"
elevate
animateOnly={["transform", "opacity"]}
animation={["quickest", { opacity: { overshootClamping: true } }]}
enterStyle={{ x: 0, opacity: 0, scale: 0.95 }}
exitStyle={{ x: 0, opacity: 0, scale: 0.98 }}
p="$1"
w="90%"
maxWidth={400}
height="80%"
maxHeight={400}
>
<View p="$3">
<Dialog.Title fontWeight="normal" fontSize="$7">
{placeholder}
</Dialog.Title>
<Input
mt="$1"
placeholder="Search..."
autoFocus
value={search}
onChangeText={setSearch}
onSubmitEditing={onEnter}
/>
</View>
<Dialog.Close asChild>
<Button
position="absolute"
top="$2"
right="$2"
size="$3"
bg="$colorTransparent"
circular
icon={<Icons name="close" size={16} />}
/>
</Dialog.Close>
<ScrollView>
{!itemList?.length && !isCreatable ? (
<Text textAlign="center" my="$2">
No results
</Text>
) : null}
{!itemList?.length && isCreatable && search.trim().length > 0 ? (
<ListItem
hoverTheme
pressTheme
onPress={onCreateNew}
title={`Create "${search}"`}
/>
) : null}
{itemList?.map((item) => (
<ListItem
key={item.value}
bg="$colorTransparent"
hoverTheme
pressTheme
onPress={() => onItemPress(item.value)}
iconAfter={
value?.includes(item.value) ? (
<Icons name="check" size={16} />
) : undefined
}
title={item.label}
/>
))}
</ScrollView>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
);
}
);
type SelectMultipleFieldProps<T extends FieldValues> = FormFieldBaseProps<T> &
SelectMultipleProps;
export const SelectMultipleField = <T extends FieldValues>({
form,
name,
...props
}: SelectMultipleFieldProps<T>) => (
<Controller
control={form.control}
name={name}
render={({ field, fieldState }) => (
<>
<SelectMultiple w="auto" f={1} {...field} {...props} />
<ErrorMessage error={fieldState.error} />
</>
)}
/>
);
export const useSelectCreatableItems = (initialItems?: SelectItem[] | null) => {
const [items, setItems] = useState<SelectItem[]>([]);
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;

View File

@ -46,7 +46,7 @@ const Select = forwardRef<SelectRef, SelectProps>(
<Sheet native modal dismissOnSnapToBottom snapPoints={[40, 60, 80]}>
<Sheet.Overlay
opacity={0.1}
animation="quick"
animation="quickest"
enterStyle={{ opacity: 0 }}
exitStyle={{ opacity: 0 }}
zIndex={0}

View File

@ -8,12 +8,16 @@ import { ScrollView, XStack } from "tamagui";
import { FormSchema, formSchema, typeOptions } from "../schema/form";
import { InputField } from "@/components/ui/input";
import FormField from "@/components/ui/form";
import { useSaveHost } from "../hooks/query";
import { useSaveHost, useTags } from "../hooks/query";
import { ErrorAlert } from "@/components/ui/alert";
import Button from "@/components/ui/button";
import { PVEFormFields } from "./pve";
import { IncusFormFields } from "./incus";
import { SSHFormFields } from "./ssh";
import {
SelectMultipleField,
useSelectCreatableItems,
} from "@/components/ui/select-multiple";
export const hostFormModal = createDisclosure<FormSchema>();
@ -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" && (
<>
<FormField label="Tags">
<SelectMultipleField
form={form}
name="tags"
placeholder="Select Tags"
items={tags}
isCreatable
onCreate={addTag}
/>
</FormField>
<FormField label="Hostname">
<InputField
form={form}
@ -98,4 +115,4 @@ const HostForm = () => {
);
};
export default HostForm;
export default React.memo(HostForm);

View File

@ -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%"
>
<Card
bordered
p="$4"
borderColor={selected ? "$blue8" : "$borderColor"}
bg={selected ? "$blue3" : undefined}
h="100%"
>
<XStack>
{host.type === "group" ? (
@ -48,6 +51,15 @@ const HostItem = ({
<View flex={1}>
<Text>{host.label}</Text>
{host.tags?.length > 0 && (
<XStack mt="$1" gap="$1" flexWrap="wrap">
{host.tags.map((i: any) => (
<Badge key={i.name}>{i.name}</Badge>
))}
</XStack>
)}
<Text fontSize="$3" mt="$2">
{host.host}
</Text>

View File

@ -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 = ({
</View>
) : (
<ScrollView>
{groups.length > 0 && (
{groups.length > 0 && !hideGroups && (
<>
<Text mx="$4">Groups</Text>
<ItemList

View File

@ -66,3 +66,17 @@ export const useMoveHost = () => {
},
});
};
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,
}));
},
});
};

View File

@ -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<typeof formSchema>;
export const initialValues: FormSchema = {
type: "ssh",
host: "",
tags: [],
port: 22,
label: "",
keyId: "",

View File

@ -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<Params>();
const parentId = queryParams.params?.parentId;
const setParentId = (id: string | null) => {
queryParams.push({ parentId: id || "" });
};
const onGoBack = () => {
if (!queryParams.goBack()) {
queryParams.replace({ parentId: "" });
}
};
return (
<>
<XStack alignItems="center" h="$6">
{parentId ? <BackButton onPress={onGoBack} /> : null}
</XStack>
<HostList
allowEdit={false}
parentId={parentId}
onParentIdChange={setParentId}
/>
</>
);
};
export default NewSessionPage;

View File

@ -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<PagerViewRef>(null!);
@ -58,7 +58,11 @@ const TerminalPage = () => {
/>
{sessions.length > 0 && media.gtSm ? <SessionTabs /> : null}
{!sessions.length ? <NewSessionPage /> : pagerView}
{!sessions.length ? (
<HostList allowEdit={false} parentId="none" hideGroups />
) : (
pagerView
)}
</>
);
};

View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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"`

View File

@ -10,6 +10,7 @@ var Models = []interface{}{
&models.UserAccount{},
&models.Keychain{},
&models.Host{},
&models.HostTag{},
&models.Team{},
&models.TeamMembers{},
&models.TermSession{},

View File

@ -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{}