Skip to content

Commit

Permalink
feat(web): pipeline event watchers (#905)
Browse files Browse the repository at this point in the history
  • Loading branch information
m8vago committed Jan 31, 2024
1 parent b16a13e commit dc7e91a
Show file tree
Hide file tree
Showing 48 changed files with 1,652 additions and 241 deletions.
1 change: 1 addition & 0 deletions web/crux-ui/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@
},

"pipelineStatuses": {
"unknown": "Unknown",
"queued": "Queued",
"running": "Running"
},
Expand Down
27 changes: 25 additions & 2 deletions web/crux-ui/locales/en/pipelines.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"new": "New pipeline",
"pipelinesName": "Pipelines - {{name}}",
"tips": "Pipelines are CI/CD jobs for your git repository.",
"noItems": "You haven't added a pipeline yet.",
"noPipelines": "You haven't added a pipeline yet.",
"repository": "Repository",
"repoBranchingIcon": "Code repository branching icon",
"trigger": "Trigger",
Expand All @@ -12,12 +12,35 @@
"pipelineName": "Pipeline name",
"areYouSureTriggerName": "Are you sure you want to trigger pipeline {{name}}?",
"triggerPipeline": "Trigger pipeline",
"noRuns": "You haven't triggered any runs yet.",
"noEventWatchers": "You haven't added an event watcher yet.",

"type": {
"gitlab": "Gitlab",
"github": "GitHub",
"azure": "Azure DevOps"
},

"finishedAt": "Finished at"
"triggeredBy": "Triggered by",
"finishedAt": "Finished at",
"runs": "Runs",

"newEventWatcher": "New event watcher",
"eventWatchers": "Event watchers",
"addEventWatcher": "Add event watcher",
"needV2Registry": "You will need a V2 registry to create an event watcher",

"triggerEvent": "Trigger event",

"triggerEvents": {
"image-push": "Image push",
"image-pull": "Image pull"
},

"templateTips": "Available templates: {{imageName}}, {{imageTag}}, {{label:<label-name>}}",
"areYouSureDeleteEventWatcher": "Are you sure you want to delete event watcher {{name}}?",

"filters": {
"imageNameStartsWith": "Image name starts with"
}
}
1 change: 1 addition & 0 deletions web/crux-ui/public/edit.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions web/crux-ui/quality-assurance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export const QA_DIALOG_LABEL_DELETE_USER_TOKEN = 'deleteUserToken'
export const QA_DIALOG_LABEL_DELETE_USER = 'deleteUser'
export const QA_DIALOG_LABEL_REVOKE_REGISTRY_TOKEN = 'revokeRegistryToken'
export const QA_DIALOG_LABEL_TRIGGER_PIPELINE = 'triggerPipeline'
export const QA_DIALOG_LABEL_DELETE_PIPELINE_EVENT_WATCHER = 'deletePipelineEventWatcher'

export const QA_MODAL_LABEL_NODE_AUDIT_DETAILS = 'nodeAuditDetails'
export const QA_MODAL_LABEL_DEPLOYMENT_NOTE = 'deploymentNote'
Expand Down
195 changes: 195 additions & 0 deletions web/crux-ui/src/components/pipelines/edit-event-watcher-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import DyoButton from '@app/elements/dyo-button'
import { DyoCard } from '@app/elements/dyo-card'
import DyoChips, { chipsQALabelFromValue } from '@app/elements/dyo-chips'
import DyoForm from '@app/elements/dyo-form'
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 { defaultApiErrorHandler } from '@app/errors'
import useDyoFormik from '@app/hooks/use-dyo-formik'
import useTeamRoutes from '@app/hooks/use-team-routes'
import {
CreatePipelineEventWatcher,
PIPELINE_TRIGGER_EVENT_VALUES,
PipelineDetails,
PipelineEventWatcher,
Registry,
UpdatePipelineEventWatcher,
} from '@app/models'
import { fetcher, sendForm } from '@app/utils'
import { upsertEventWatcherSchema } from '@app/validations'
import useTranslation from 'next-translate/useTranslation'
import { useEffect } from 'react'
import toast from 'react-hot-toast'
import useSWR from 'swr'
import { OFFLINE_EDITOR_STATE } from '../editor/use-item-editor-state'
import KeyValueInput from '../shared/key-value-input'

type EditEventWatcherCardProps = {
className?: string
pipeline: PipelineDetails
eventWatcher: PipelineEventWatcher
onEventWatcherEdited: (eventWatcher: PipelineEventWatcher) => void
onDiscard: VoidFunction
}

const EditEventWatcherCard = (props: EditEventWatcherCardProps) => {
const { className, pipeline, eventWatcher, onEventWatcherEdited, onDiscard } = props

const { t } = useTranslation('pipelines')

const routes = useTeamRoutes()

const handleApiError = defaultApiErrorHandler(t)

const editing = !!eventWatcher.id

const formik = useDyoFormik<CreatePipelineEventWatcher | UpdatePipelineEventWatcher>({
initialValues: {
...eventWatcher,
registryId: eventWatcher.registry?.id ?? null,
},
validationSchema: upsertEventWatcherSchema,
t,
onSubmit: async (values, { setFieldError }) => {
const body: CreatePipelineEventWatcher | UpdatePipelineEventWatcher = {
...values,
}

const res = !editing
? await sendForm('POST', routes.pipeline.api.eventWatchers(pipeline.id), body)
: await sendForm('PUT', routes.pipeline.api.eventWatcherDetails(pipeline.id, eventWatcher.id), body)

if (res.ok) {
const result =
res.status === 201
? ((await res.json()) as PipelineEventWatcher)
: {
...eventWatcher,
...values,
}

onEventWatcherEdited(result)
} else {
await handleApiError(res, setFieldError)
}
},
})

const { data: fetchedRegistries, error: fetchRegistriesError } = useSWR<Registry[]>(
routes.registry.api.list(),
fetcher,
)

const registries = fetchedRegistries?.filter(it => it.type === 'v2')

useEffect(() => {
if (registries && registries.length < 1) {
toast.error(t('needV2Registry'))
}
if (registries?.length === 1 && !formik.values.registryId) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
formik.setFieldValue('registryId', registries[0].id)
}
}, [registries, t, formik])

return (
<DyoCard className={className}>
<div className="flex flex-row">
<DyoHeading element="h4" className="text-lg text-bright">
{editing ? t('common:editName', { name: eventWatcher.name }) : t('newEventWatcher')}
</DyoHeading>

<DyoButton outlined secondary className="ml-auto mr-2 px-10" onClick={onDiscard}>
{t('common:discard')}
</DyoButton>

<DyoButton outlined className="ml-2 px-10" onClick={() => formik.submitForm()}>
{editing ? t('common:save') : t('common:add')}
</DyoButton>
</div>

<DyoForm className="flex flex-col" onSubmit={formik.handleSubmit} onReset={formik.handleReset}>
<DyoInput
className="max-w-lg"
grow
name="name"
type="name"
required
label={t('common:name')}
onChange={formik.handleChange}
value={formik.values.name}
message={formik.errors.name}
/>

<DyoLabel textColor="mt-8 mb-2.5 text-light-eased">{t('common:event')}</DyoLabel>

<DyoChips
className="text-bright"
name="event"
choices={PIPELINE_TRIGGER_EVENT_VALUES}
selection={formik.values.event}
converter={it => t(`triggerEvents.${it}`)}
onSelectionChange={async (it): Promise<void> => {
await formik.setFieldValue('event', it, false)
}}
qaLabel={chipsQALabelFromValue}
/>

{fetchRegistriesError ? (
<DyoLabel>
{t('errors:fetchFailed', {
type: t('common:registries'),
})}
</DyoLabel>
) : !registries ? (
<DyoLabel>{t('common:loading')}</DyoLabel>
) : (
<div className="flex flex-col">
<DyoLabel className="mt-8 mb-2.5">{t('common:registries')}</DyoLabel>

<DyoChips
name="registries"
choices={registries ?? []}
converter={(it: Registry) => it.name}
selection={registries.find(it => it.id === formik.values.registryId)}
onSelectionChange={it => formik.setFieldValue('registryId', it.id)}
/>
{formik.errors.registryId && <DyoMessage message={formik.errors.registryId} messageType="error" />}
</div>
)}

<DyoInput
className="max-w-lg"
grow
name="filters.imageNameStartsWith"
type="text"
required
label={t('filters.imageNameStartsWith')}
onChange={formik.handleChange}
value={formik.values.filters.imageNameStartsWith}
message={formik.errors.filters?.imageNameStartsWith}
/>

<div className="mt-6">
<DyoLabel>{t('defaultInputs')}</DyoLabel>

<DyoMessage className="text-xs mt-2" message={t('templateTips')} messageType="info" />

<KeyValueInput
items={formik.values.triggerInputs}
onChange={async it => {
await formik.setFieldValue('triggerInputs', it)
}}
editorOptions={OFFLINE_EDITOR_STATE}
/>
</div>

<DyoButton className="hidden" type="submit" />
</DyoForm>
</DyoCard>
)
}

export default EditEventWatcherCard
6 changes: 3 additions & 3 deletions web/crux-ui/src/components/pipelines/edit-pipeline-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const DEFAULT_EDITABLE_PIPELINE: EditablePipeline = {
},
token: '',
audit: null,
runs: [],
eventWatchers: [],
}

type EditPipelineCardProps = Omit<DyoCardProps, 'children'> & {
Expand Down Expand Up @@ -239,8 +239,6 @@ const EditPipelineCard = (props: EditPipelineCardProps) => {
/>
</div>

<DyoButton className="hidden" type="submit" />

<div className="col-span-2">
<DyoLabel>{t('defaultInputs')}</DyoLabel>

Expand All @@ -252,6 +250,8 @@ const EditPipelineCard = (props: EditPipelineCardProps) => {
editorOptions={OFFLINE_EDITOR_STATE}
/>
</div>

<DyoButton className="hidden" type="submit" />
</DyoForm>
</DyoCard>
)
Expand Down
5 changes: 0 additions & 5 deletions web/crux-ui/src/components/pipelines/pipeline-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,12 @@ type PipelineCardProps = Omit<DyoCardProps, 'children'> & {
const statusOf = (
pipeline: (Pipeline | PipelineDetails) & {
lastRun?: PipelineRun
runs?: PipelineRun[]
},
): PipelineRunStatus => {
if (pipeline.lastRun) {
return pipeline.lastRun.status
}

if (pipeline.runs && pipeline.runs.length > 0) {
return pipeline.runs[0].status
}

return 'unknown'
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { DyoCard } from '@app/elements/dyo-card'
import DyoImgButton from '@app/elements/dyo-img-button'
import DyoTable, { DyoColumn } from '@app/elements/dyo-table'
import { PipelineEventWatcher } from '@app/models'
import { utcDateToLocale } from '@app/utils'
import useTranslation from 'next-translate/useTranslation'

type PipelineEventWatcherListProps = {
eventWatchers: PipelineEventWatcher[]
onEditEventWatcher: (eventWatcher: PipelineEventWatcher) => void
onDeleteEventWatcher: (eventWatcher: PipelineEventWatcher) => Promise<void>
}

const PipelineEventWatcherList = (props: PipelineEventWatcherListProps) => {
const { eventWatchers, onEditEventWatcher, onDeleteEventWatcher } = props

const { t } = useTranslation('pipelines')

return eventWatchers.length < 1 ? (
<span className="text-bright m-auto">{t('noEventWatchers')}</span>
) : (
<DyoCard className="mt-4">
<DyoTable data={eventWatchers} dataKey="id">
<DyoColumn header={t('common:name')} className="text-center" body={(it: PipelineEventWatcher) => it.name} />

<DyoColumn
header={t('common:event')}
className="w-2/12"
suppressHydrationWarning
body={(it: PipelineEventWatcher) => t(`triggerEvents.${it.event}`)}
/>

<DyoColumn
header={t('common:registry')}
className="w-2/12"
suppressHydrationWarning
body={(it: PipelineEventWatcher) => it.registry?.name}
/>

<DyoColumn
header={t('common:createdAt')}
className="w-2/12"
suppressHydrationWarning
body={(it: PipelineEventWatcher) => utcDateToLocale(it.createdAt)}
/>

<DyoColumn
header={t('common:actions')}
className="w-40 text-center"
bodyClassName="flex flex-row justify-center"
preventClickThrough
body={(it: PipelineEventWatcher) => (
<>
<DyoImgButton src="/edit.svg" alt={t('common:edit')} height={24} onClick={() => onEditEventWatcher(it)} />

<DyoImgButton
src="/trash-can.svg"
alt={t('common:delete')}
height={24}
onClick={async () => await onDeleteEventWatcher(it)}
/>
</>
)}
/>
</DyoTable>
</DyoCard>
)
}

export default PipelineEventWatcherList
Loading

0 comments on commit dc7e91a

Please sign in to comment.