-
Notifications
You must be signed in to change notification settings - Fork 55
feat: поддержка множественных тегов для хостов #341
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
1da243a
4d9cbb2
381850c
dffa984
4ef75bb
fed660a
3a4cb85
5fd3bb8
d71a3ee
5da7d1b
511aa08
3ccc51f
add6e24
f5e5c2b
b2f9d68
bd06ce0
e569e81
611c1cd
3e8651b
5b9560c
d4e71d4
9463e71
8adeb59
95082f0
fb63d23
ad8a27e
500f103
39434c1
a65d4c9
7f18f5f
f4ec933
5f58671
180d246
8524329
54b953d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,6 +37,7 @@ export function HostCardWidget(props: IProps) { | |
| const openModalWithData = useModalsStoreOpenWithData() | ||
|
|
||
| const [isHovered, setIsHovered] = useState(false) | ||
| const [tagsExpanded, setTagsExpanded] = useState(false) | ||
| const isMobile = useMediaQuery('(max-width: 48em)') | ||
|
|
||
| const configProfile = configProfiles?.find( | ||
|
|
@@ -46,7 +47,7 @@ export function HostCardWidget(props: IProps) { | |
| const isFiltered = | ||
| (!!filters.configProfileUuid && configProfile?.uuid !== filters.configProfileUuid) || | ||
| (!!filters.inboundUuid && item.inbound.configProfileInboundUuid !== filters.inboundUuid) || | ||
| (!!filters.hostTag && item.tag !== filters.hostTag) | ||
| (!!filters.hostTag && !item.tags?.includes(filters.hostTag)) | ||
|
|
||
| const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ | ||
| id: item.uuid, | ||
|
|
@@ -123,16 +124,33 @@ export function HostCardWidget(props: IProps) { | |
| </Box> | ||
| </Group> | ||
|
|
||
| <Group gap="xs"> | ||
| {item.tag && ( | ||
| <Group gap="xs" wrap="wrap"> | ||
| {(tagsExpanded ? item.tags : item.tags?.slice(0, 2))?.map( | ||
| (tag) => ( | ||
| <Badge | ||
| autoContrast | ||
| color={ch.hex(tag)} | ||
| key={tag} | ||
| leftSection={<TbTagStarred size={12} />} | ||
| size="md" | ||
| variant="outline" | ||
| > | ||
| {tag} | ||
| </Badge> | ||
| ) | ||
| )} | ||
| {(item.tags?.length ?? 0) > 2 && ( | ||
| <Badge | ||
| autoContrast | ||
| color={ch.hex(item.tag)} | ||
| leftSection={<TbTagStarred size={12} />} | ||
| color="gray" | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| setTagsExpanded((v) => !v) | ||
| }} | ||
| size="md" | ||
| variant="outline" | ||
| style={{ cursor: 'pointer' }} | ||
| variant="default" | ||
| > | ||
| {item.tag} | ||
| {tagsExpanded ? '−' : `+${item.tags!.length - 2}`} | ||
| </Badge> | ||
| )} | ||
| </Group> | ||
|
|
@@ -348,15 +366,31 @@ export function HostCardWidget(props: IProps) { | |
| </Badge> | ||
| )} | ||
|
|
||
| {item.tag && ( | ||
| {!tagsExpanded && | ||
| item.tags?.slice(0, 2).map((tag) => ( | ||
| <Badge | ||
| autoContrast | ||
| color={ch.hex(tag)} | ||
| key={tag} | ||
| leftSection={<TbTagStarred size={12} />} | ||
| size="md" | ||
| variant="outline" | ||
| > | ||
| {tag} | ||
| </Badge> | ||
| ))} | ||
|
Comment on lines
+369
to
+381
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| {(item.tags?.length ?? 0) > 2 && ( | ||
| <Badge | ||
| autoContrast | ||
| color={ch.hex(item.tag)} | ||
| leftSection={<TbTagStarred size={12} />} | ||
| color="gray" | ||
| onClick={(e) => { | ||
| e.stopPropagation() | ||
| setTagsExpanded((v) => !v) | ||
| }} | ||
| size="md" | ||
| variant="outline" | ||
| style={{ cursor: 'pointer' }} | ||
| variant="default" | ||
| > | ||
| {item.tag} | ||
| {tagsExpanded ? '−' : `+${item.tags!.length - 2}`} | ||
| </Badge> | ||
| )} | ||
|
|
||
|
|
@@ -392,6 +426,23 @@ export function HostCardWidget(props: IProps) { | |
| </Badge> | ||
| </Group> | ||
| </Group> | ||
|
|
||
| {tagsExpanded && (item.tags?.length ?? 0) > 0 && ( | ||
| <Group gap="xs" mt="xs" wrap="wrap"> | ||
| {item.tags!.map((tag) => ( | ||
| <Badge | ||
| autoContrast | ||
| color={ch.hex(tag)} | ||
| key={tag} | ||
| leftSection={<TbTagStarred size={12} />} | ||
| size="md" | ||
| variant="outline" | ||
| > | ||
| {tag} | ||
| </Badge> | ||
| ))} | ||
| </Group> | ||
| )} | ||
| </Box> | ||
| </Group> | ||
| </Box> | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,155 +1,31 @@ | ||||||||||||||||||
| /* eslint-disable no-nested-ternary */ | ||||||||||||||||||
| import { CloseButton, Combobox, InputBase, Loader, useCombobox } from '@mantine/core' | ||||||||||||||||||
| import { TagsInput, TagsInputProps } from '@mantine/core' | ||||||||||||||||||
| import { useTranslation } from 'react-i18next' | ||||||||||||||||||
| import { PiTagDuotone } from 'react-icons/pi' | ||||||||||||||||||
| import { useEffect, useState } from 'react' | ||||||||||||||||||
|
|
||||||||||||||||||
| import { useGetHostTags } from '@shared/api/hooks' | ||||||||||||||||||
|
|
||||||||||||||||||
| import type { IProps } from './interfaces/props.interface' | ||||||||||||||||||
|
|
||||||||||||||||||
| export function HostTagsInputWidget(props: IProps) { | ||||||||||||||||||
| const { value, onChange, defaultValue, ...restProps } = props | ||||||||||||||||||
| const [data, setData] = useState<string[]>([]) | ||||||||||||||||||
| const [search, setSearch] = useState(value?.toString() || '') | ||||||||||||||||||
| const [error, setError] = useState('') | ||||||||||||||||||
|
|
||||||||||||||||||
| export function HostTagsInputWidget(props: TagsInputProps) { | ||||||||||||||||||
| const { t } = useTranslation() | ||||||||||||||||||
|
|
||||||||||||||||||
| const { | ||||||||||||||||||
| data: hostTags, | ||||||||||||||||||
| isRefetching: isHostTagsRefetching, | ||||||||||||||||||
| isLoading: isHostTagsLoading | ||||||||||||||||||
| } = useGetHostTags({ | ||||||||||||||||||
| const { data: hostTags } = useGetHostTags({ | ||||||||||||||||||
| rQueryParams: { | ||||||||||||||||||
| enabled: false | ||||||||||||||||||
| } | ||||||||||||||||||
| }) | ||||||||||||||||||
|
|
||||||||||||||||||
| const combobox = useCombobox({ | ||||||||||||||||||
| onDropdownOpen: async () => { | ||||||||||||||||||
| if (!isHostTagsLoading && !isHostTagsRefetching) { | ||||||||||||||||||
| setData(hostTags?.tags || []) | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
| }) | ||||||||||||||||||
|
|
||||||||||||||||||
| const validateTag = (tag: string) => { | ||||||||||||||||||
| if (!/^[A-Z0-9_:]+$/.test(tag)) { | ||||||||||||||||||
| return t( | ||||||||||||||||||
| 'host-tags-input.tag-can-only-contain-uppercase-letters-numbers-underscores-and-colons' | ||||||||||||||||||
| ) | ||||||||||||||||||
| } | ||||||||||||||||||
| if (tag.length > 32) { | ||||||||||||||||||
| return t('host-tags-input.tag-must-be-less-than-32-characters') | ||||||||||||||||||
| } | ||||||||||||||||||
| return null | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||
| if (hostTags && !isHostTagsLoading && !isHostTagsRefetching) { | ||||||||||||||||||
| setData(hostTags.tags) | ||||||||||||||||||
| } | ||||||||||||||||||
| }, [hostTags, isHostTagsLoading, isHostTagsRefetching]) | ||||||||||||||||||
|
|
||||||||||||||||||
| const exactOptionMatch = data.some((item) => item === search) | ||||||||||||||||||
| const filteredOptions = exactOptionMatch | ||||||||||||||||||
| ? data | ||||||||||||||||||
| : data.filter((item) => item.toLowerCase().includes(search.toLowerCase().trim())) | ||||||||||||||||||
|
|
||||||||||||||||||
| const options = filteredOptions.map((item) => ( | ||||||||||||||||||
| <Combobox.Option key={item} value={item}> | ||||||||||||||||||
| {item} | ||||||||||||||||||
| </Combobox.Option> | ||||||||||||||||||
| )) | ||||||||||||||||||
|
|
||||||||||||||||||
| return ( | ||||||||||||||||||
| <Combobox | ||||||||||||||||||
| onExitTransitionEnd={() => { | ||||||||||||||||||
| combobox.resetSelectedOption() | ||||||||||||||||||
| }} | ||||||||||||||||||
| onOptionSubmit={(val) => { | ||||||||||||||||||
| if (val === '$create') { | ||||||||||||||||||
| const validationError = validateTag(search) | ||||||||||||||||||
| if (validationError) { | ||||||||||||||||||
| setError(validationError) | ||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
| setData((current) => [...current, search]) | ||||||||||||||||||
| onChange?.(search) | ||||||||||||||||||
| setError('') | ||||||||||||||||||
|
|
||||||||||||||||||
| return | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| onChange?.(val) | ||||||||||||||||||
| setSearch(val) | ||||||||||||||||||
| setError('') | ||||||||||||||||||
|
|
||||||||||||||||||
| combobox.closeDropdown() | ||||||||||||||||||
| }} | ||||||||||||||||||
| position="top" | ||||||||||||||||||
| store={combobox} | ||||||||||||||||||
| withinPortal={false} | ||||||||||||||||||
| > | ||||||||||||||||||
| <Combobox.Target> | ||||||||||||||||||
| <InputBase | ||||||||||||||||||
| {...restProps} | ||||||||||||||||||
| description={t( | ||||||||||||||||||
| 'host-tags-input.tags-are-not-visible-to-end-users-tag-will-be-sent-with-raw-subscription-only' | ||||||||||||||||||
| )} | ||||||||||||||||||
| error={error || restProps.error} | ||||||||||||||||||
| label="Tag" | ||||||||||||||||||
| leftSection={<PiTagDuotone size="16px" />} | ||||||||||||||||||
| onBlur={() => { | ||||||||||||||||||
| combobox.closeDropdown() | ||||||||||||||||||
| onChange?.(search.trim() === '' ? null : search) | ||||||||||||||||||
| }} | ||||||||||||||||||
| onChange={(event) => { | ||||||||||||||||||
| combobox.openDropdown() | ||||||||||||||||||
| combobox.updateSelectedOptionIndex() | ||||||||||||||||||
| setSearch(event.currentTarget.value) | ||||||||||||||||||
| }} | ||||||||||||||||||
| onClick={() => combobox.openDropdown()} | ||||||||||||||||||
| onFocus={() => combobox.openDropdown()} | ||||||||||||||||||
| placeholder="ROUTING_HOST" | ||||||||||||||||||
| rightSection={ | ||||||||||||||||||
| isHostTagsRefetching ? ( | ||||||||||||||||||
| <Loader size="xs" /> | ||||||||||||||||||
| ) : (value !== null && value !== undefined) || search ? ( | ||||||||||||||||||
| <CloseButton | ||||||||||||||||||
| aria-label={t('host-tags-input.clear-value')} | ||||||||||||||||||
| onClick={() => { | ||||||||||||||||||
| onChange?.(null) | ||||||||||||||||||
| setError('') | ||||||||||||||||||
| setSearch('') | ||||||||||||||||||
| }} | ||||||||||||||||||
| onMouseDown={(event) => event.preventDefault()} | ||||||||||||||||||
| size="sm" | ||||||||||||||||||
| /> | ||||||||||||||||||
| ) : ( | ||||||||||||||||||
| <Combobox.Chevron /> | ||||||||||||||||||
| ) | ||||||||||||||||||
| } | ||||||||||||||||||
| rightSectionPointerEvents={ | ||||||||||||||||||
| (value === null || value === undefined) && !search ? 'none' : 'all' | ||||||||||||||||||
| } | ||||||||||||||||||
| value={search} | ||||||||||||||||||
| /> | ||||||||||||||||||
| </Combobox.Target> | ||||||||||||||||||
|
|
||||||||||||||||||
| <Combobox.Dropdown> | ||||||||||||||||||
| <Combobox.Options mah={200} style={{ overflowY: 'auto' }}> | ||||||||||||||||||
| {isHostTagsLoading || isHostTagsRefetching ? ( | ||||||||||||||||||
| <Combobox.Empty>Loading....</Combobox.Empty> | ||||||||||||||||||
| ) : ( | ||||||||||||||||||
| options | ||||||||||||||||||
| )} | ||||||||||||||||||
| {!exactOptionMatch && search.trim().length > 0 && ( | ||||||||||||||||||
| <Combobox.Option value="$create">+ {search}</Combobox.Option> | ||||||||||||||||||
| )} | ||||||||||||||||||
| </Combobox.Options> | ||||||||||||||||||
| </Combobox.Dropdown> | ||||||||||||||||||
| </Combobox> | ||||||||||||||||||
| <TagsInput | ||||||||||||||||||
| clearable | ||||||||||||||||||
| data={hostTags?.tags || []} | ||||||||||||||||||
| description={t( | ||||||||||||||||||
| 'host-tags-input.tags-are-not-visible-to-end-users-tag-will-be-sent-with-raw-subscription-only' | ||||||||||||||||||
| )} | ||||||||||||||||||
| label="Tags" | ||||||||||||||||||
| leftSection={<PiTagDuotone size="16px" />} | ||||||||||||||||||
| maxTags={10} | ||||||||||||||||||
| placeholder="Enter tags (comma, space, semicolon)" | ||||||||||||||||||
| splitChars={[',', ' ', ';']} | ||||||||||||||||||
|
Comment on lines
+24
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| {...props} | ||||||||||||||||||
| /> | ||||||||||||||||||
| ) | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,7 +1,3 @@ | ||
| import type { InputBaseProps } from '@mantine/core' | ||
| import type { TagsInputProps } from '@mantine/core' | ||
|
|
||
| export interface IProps extends InputBaseProps { | ||
| defaultValue?: null | string | ||
| onChange?: (value: null | string) => void | ||
| value?: null | string | ||
| } | ||
| export interface IProps extends TagsInputProps {} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[object Object]for non-stringReactNodeerrorsform.errors[key]is typed asReactNode, so.map(...).join(', ')will stringify any React element to[object Object]. In practice Zod validation only produces string messages, but the cast is fragile. Casting explicitly keeps this safe: