Skip to content

Commit

Permalink
feat: show newer image tags as new
Browse files Browse the repository at this point in the history
  • Loading branch information
m8vago committed Jan 8, 2025
1 parent 4cad013 commit dd058f3
Show file tree
Hide file tree
Showing 21 changed files with 199 additions and 209 deletions.
Original file line number Diff line number Diff line change
@@ -1,72 +1,78 @@
import { DyoHeading } from '@app/elements/dyo-heading'
import { DyoInput } from '@app/elements/dyo-input'
import { DyoLabel } from '@app/elements/dyo-label'
import DyoMessage from '@app/elements/dyo-message'
import DyoRadioButton from '@app/elements/dyo-radio-button'
import LoadingIndicator from '@app/elements/loading-indicator'
import { TextFilter, textFilterFor, useFilters } from '@app/hooks/use-filters'
import { RegistryImageTag } from '@app/models'
import { utcDateToLocale } from '@app/utils'
import useTranslation from 'next-translate/useTranslation'
import { useEffect, useMemo, useState } from 'react'
import TagSortToggle, { SortState } from './tag-sort-toggle'
import LoadingIndicator from '@app/elements/loading-indicator'
import { DyoLabel } from '@app/elements/dyo-label'
import DyoIndicator from '@app/elements/dyo-indicator'
import TagSortToggle, { TagSortState } from './tag-sort-toggle'

interface ImageTagInputProps {
type ImageTagInputProps = {
disabled?: boolean
selected: string
onTagSelected: (tag: string) => void
}

type ImageTagSelectListProps = ImageTagInputProps & {
tags: Record<string, RegistryImageTag>
type SelectImageTagListProps = ImageTagInputProps & {
tags: RegistryImageTag[]
loadingTags: boolean
}

type Entry = [string, RegistryImageTag]

const ImageTagSelectList = (props: ImageTagSelectListProps) => {
const SelectImageTagList = (props: SelectImageTagListProps) => {
const { disabled, tags, selected: propsSelected, onTagSelected, loadingTags } = props

const { t } = useTranslation('images')

const [selected, setSelected] = useState(propsSelected)
const [sortState, setSortState] = useState<SortState>({
const [sortState, setSortState] = useState<TagSortState>({
mode: 'date',
dir: 1,
direction: 'desc',
})

const filters = useFilters<Entry, TextFilter>({
filters: [textFilterFor<Entry>(it => [it[0]])],
initialData: Object.entries(tags),
const filters = useFilters<RegistryImageTag, TextFilter>({
filters: [textFilterFor<RegistryImageTag>(it => [it.name, it.created])],
initialData: tags,
initialFilter: {
text: '',
},
})

useEffect(() => filters.setItems(Object.entries(tags)), [tags])
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => filters.setItems(tags), [tags])

const sortedItems = useMemo(() => {
const dir = sortState.direction === 'asc' ? -1 : 1

const items = filters.filtered

switch (sortState.mode) {
case 'alphabetic':
return items.sort((a, b) => b[0].localeCompare(a[0]) * sortState.dir)
case 'alphabetical':
return items.sort((one, other) => other.name.localeCompare(one.name) * dir)
case 'date':
return items.sort((a, b) => {
const aDate = Date.parse(a[1].created)
const bDate = Date.parse(b[1].created)
return items.sort((one, other) => {
const oneDate = Date.parse(one.created)
const otherDate = Date.parse(other.created)

return Math.sign(bDate - aDate) * sortState.dir
return Math.sign(otherDate - oneDate) * dir
})
default:
return items
}
}, [sortState, filters.filtered])

const isTagNewer = (tagIndex: number, currentTagIndex: number) =>
currentTagIndex >= 0 &&
((sortState.dir === -1 && currentTagIndex < tagIndex) || (sortState.dir === 1 && currentTagIndex > tagIndex))
const selectedTag = tags.find(it => it.name === selected) ?? null

const newerThanSelected = (tag: RegistryImageTag): boolean => {
if (!selectedTag?.created) {
return false
}

const selectedTagIndex = selected ? sortedItems.findIndex(it => it[0] === selected) : -1
return Date.parse(tag.created) > Date.parse(selectedTag.created)
}

return (
<div className="flex flex-col">
Expand All @@ -92,27 +98,34 @@ const ImageTagSelectList = (props: ImageTagSelectListProps) => {
) : (
<>
{selected ? null : <DyoMessage messageType="info" message={t('selectTag')} />}
<div className="flex flex-col max-h-96 overflow-y-auto">
<div className="flex flex-col max-h-96 overflow-y-auto mt-2">
{sortedItems.map((it, index) => (
<DyoRadioButton
key={`tag-${it}`}
disabled={disabled}
label={it[0]}
checked={it[0] === selected}
onSelect={() => {
setSelected(it[0])
onTagSelected(it[0])
}}
qaLabel={`imageTag-${index}`}
labelTemplate={label => (
<>
{isTagNewer(index, selectedTagIndex) && (
<DyoIndicator color="bg-dyo-violet" className="self-center" />
)}
<DyoLabel className="my-auto mx-2">{label}</DyoLabel>
</>
)}
/>
<div className="flex flex-row gap-2 justify-between">
<DyoRadioButton
key={`tag-${it}`}
disabled={disabled}
label={it.name}
checked={it.name === selected}
onSelect={() => {
setSelected(it.name)
onTagSelected(it.name)
}}
qaLabel={`image-tag-${index}`}
labelTemplate={label => (
<>
<DyoLabel className="my-auto mx-2">{label}</DyoLabel>

{newerThanSelected(it) && (
<span className="text-dyo-green bg-dyo-green bg-opacity-10 rounded-full bg-opacity-10 text-xs font-semibold h-fit px-2 py-0.5 my-auto">
{t('common:new')}
</span>
)}
</>
)}
/>

{it.created && <span className="text-bright-muted">{utcDateToLocale(it.created)}</span>}
</div>
))}
</div>
</>
Expand All @@ -126,7 +139,7 @@ const ImageTagInput = (props: ImageTagInputProps) => {

const { t } = useTranslation('images')

const [selected, setSelected] = useState(propsSelected)
const [selected, setSelected] = useState(propsSelected ?? '')

return (
<div className="flex flex-col mt-6 mb-8">
Expand All @@ -146,14 +159,15 @@ const ImageTagInput = (props: ImageTagInputProps) => {
messageType="info"
message={!selected.length && t('tagRequired')}
/>

<p className="text-light-eased ml-4 mt-2">{t('uncheckedRegistryExplanation')}</p>
</div>
)
}

const EditImageTags = (props: ImageTagSelectListProps) => {
const EditImageTags = (props: SelectImageTagListProps) => {
const { tags } = props
return tags ? <ImageTagSelectList {...props} /> : <ImageTagInput {...props} />
return tags ? <SelectImageTagList {...props} /> : <ImageTagInput {...props} />
}

export default EditImageTags
Original file line number Diff line number Diff line change
@@ -1,50 +1,41 @@
import clsx from 'clsx'
import useTranslation from 'next-translate/useTranslation'
import Image from 'next/image'

export const SORT_MODES = ['alphabetic', 'date'] as const
export type SortModesEnum = (typeof SORT_MODES)[number]
export const SORT_MODES = ['alphabetical', 'date'] as const
export type TagSortMode = (typeof SORT_MODES)[number]

export type SortState = {
mode: SortModesEnum
dir: -1 | 1
export type TagSortDirection = 'asc' | 'desc'

export type TagSortState = {
mode: TagSortMode
direction: TagSortDirection
}

type TagSortToggleProps = {
className?: string
state: SortState
onStateChange: (state: SortState) => void
state: TagSortState
onStateChange: (state: TagSortState) => void
}

const SORT_ICONS: Record<SortModesEnum, { '1': string; '-1': string }> = {
alphabetic: {
'1': '/sort-alphabetical-asc.svg',
'-1': '/sort-alphabetical-desc.svg',
},
date: {
'1': '/sort-date-asc.svg',
'-1': '/sort-date-desc.svg',
},
}
const iconOf = (mode: TagSortMode, direction: TagSortDirection) => `/sort-${mode}-${direction}.svg`

const TagSortToggle = (props: TagSortToggleProps) => {
const { className, state, onStateChange } = props
const { mode, dir } = state

const { t } = useTranslation('common')
const { mode, direction } = state

const onToggleMode = (newMode: SortModesEnum) => {
if (mode === newMode) {
onStateChange({
mode,
dir: dir == 1 ? -1 : 1,
})
} else {
const onSortIconClick = (newMode: TagSortMode) => {
if (mode !== newMode) {
onStateChange({
mode: newMode,
dir,
direction,
})
return
}

onStateChange({
mode,
direction: direction === 'asc' ? 'desc' : 'asc',
})
}

return (
Expand All @@ -58,9 +49,9 @@ const TagSortToggle = (props: TagSortToggleProps) => {
<div
key={it}
className={clsx('px-2 py-1.5 my-1 mr-0.5', mode === it && 'bg-dyo-turquoise rounded')}
onClick={() => onToggleMode(it)}
onClick={() => onSortIconClick(it)}
>
<Image src={SORT_ICONS[it][dir]} alt={mode} width={22} height={22} />
<Image src={iconOf(it, direction)} alt={mode} width={22} height={22} />
</div>
))}
</div>
Expand Down
50 changes: 25 additions & 25 deletions web/crux-ui/src/components/projects/versions/use-version-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
OrderImagesMessage,
PatchVersionImage,
RegistryImages,
RegistryImageTags,
RegistryImageTag,
RegistryImageTagsMessage,
VersionDetails,
VersionImage,
Expand All @@ -28,7 +28,6 @@ import {
WS_TYPE_GET_IMAGE,
WS_TYPE_IMAGE,
WS_TYPE_IMAGE_DELETED,
WS_TYPE_SET_IMAGE_TAG,
WS_TYPE_IMAGE_TAG_UPDATED,
WS_TYPE_IMAGES_ADDED,
WS_TYPE_IMAGES_WERE_REORDERED,
Expand All @@ -37,15 +36,15 @@ import {
WS_TYPE_PATCH_RECEIVED,
WS_TYPE_REGISTRY_FETCH_IMAGE_TAGS,
WS_TYPE_REGISTRY_IMAGE_TAGS,
RegistryImageTag,
WS_TYPE_SET_IMAGE_TAG,
} from '@app/models'
import WebSocketClientEndpoint from '@app/websockets/websocket-client-endpoint'
import useTranslation from 'next-translate/useTranslation'
import { useEffect, useState } from 'react'
import useCopyDeploymentState from '../../deployments/use-copy-deployment-state'

// state
export type ImageTagsMap = { [key: string]: RegistryImageTags } // image key to RegistryImageTags
export type ImageTagsMap = Record<string, Record<string, RegistryImageTag[]>> // registryId to imageName to RegistryImageTag list

export type VersionAddSection = 'image' | 'deployment' | 'copy-deployment' | 'none'

Expand Down Expand Up @@ -87,8 +86,6 @@ export type VersionActions = {
onDeploymentDeleted: (deploymentId: string) => void
}

export const imageTagKey = (registryId: string, imageName: string) => `${registryId}/${imageName}`

const mergeImagePatch = (oldImage: VersionImage, newImage: PatchVersionImage): VersionImage => ({
...oldImage,
...newImage,
Expand Down Expand Up @@ -127,15 +124,14 @@ const refreshImageTags = (registriesSock: WebSocketClientEndpoint, images: Versi
})
}

export const selectTagsOfImage = (state: VerionState, image: VersionImage): Record<string, RegistryImageTag> => {
const regImgTags = state.tags[imageTagKey(image.registry.id, image.name)]
return regImgTags
? regImgTags.tags
: image.tag
? {
[image.tag]: null,
}
: {}
export const selectTagsOfImage = (state: VerionState, image: VersionImage): RegistryImageTag[] | null => {
const images = state.tags[image.registry.id]
if (!images) {
return null
}

const tags = images[image.name]
return tags ?? null
}

export const useVersionState = (options: VersionStateOptions): [VerionState, VersionActions] => {
Expand Down Expand Up @@ -192,12 +188,18 @@ export const useVersionState = (options: VersionStateOptions): [VerionState, Ver
return
}

const newTags = { ...tags }
message.images.forEach(it => {
const key = imageTagKey(message.registryId, it.name)
newTags[key] = it
const newTagMap = { ...tags }
let images = newTagMap[message.registryId]
if (!images) {
images = {}
newTagMap[message.registryId] = images
}

message.images.forEach(img => {
images[img.name] = img.tags
})
setTags(newTags)

setTags(newTagMap)
})

versionSock.on(WS_TYPE_IMAGES_WERE_REORDERED, (message: ImagesWereReorderedMessage) => {
Expand Down Expand Up @@ -283,15 +285,13 @@ export const useVersionState = (options: VersionStateOptions): [VerionState, Ver
setSection('images')
}

const fetchImageTags = (image: VersionImage): RegistryImageTags => {
const fetchImageTags = (image: VersionImage) => {
if (image.registry.type === 'unchecked') {
return
}

const key = imageTagKey(image.registry.id, image.name)
const imgTags = tags[key]

if (imgTags) {
const images = tags[image.registry.id]
if (images && images[image.name]) {
return
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,9 @@ const VersionViewList = (props: VersionViewListProps) => {
qaLabel={QA_MODAL_LABEL_IMAGE_TAGS}
>
<EditImageTags
loadingTags={tagsModalTarget.registry.type === 'unchecked' ? false : imageTags == null}
selected={tagsModalTarget?.tag ?? ''}
tags={tagsModalTarget.registry.type === 'unchecked' ? null : selectTagsOfImage(state, tagsModalTarget)}
loadingTags={tagsModalTarget.registry.type === 'unchecked' ? false : !imageTags}
selected={tagsModalTarget.tag}
tags={tagsModalTarget.registry.type === 'unchecked' ? null : imageTags}
onTagSelected={it => actions.selectTagForImage(tagsModalTarget, it)}
/>
</DyoModal>
Expand Down
Loading

0 comments on commit dd058f3

Please sign in to comment.