UI improvements (#1640)

This commit is contained in:
Anbraten 2023-03-18 21:49:12 +01:00 committed by GitHub
parent 277a839157
commit 25e2c8055c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 199 additions and 252 deletions

View file

@ -58,7 +58,7 @@ import { inject } from '~/compositions/useInjectProvide';
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
pipelineNumber: number; pipelineNumber: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View file

@ -1,15 +1,21 @@
<template> <template>
<header class="bg-white dark:bg-dark-gray-900 border-b dark:border-gray-700 text-color"> <header class="bg-white dark:bg-dark-gray-900 border-b dark:border-gray-700 text-color">
<FluidContainer class="!py-0"> <FluidContainer class="!py-0">
<div class="flex flex-wrap items-center justify-between py-4 <md:flex-row <md:gap-y-4"> <div class="flex w-full items-center justify-between py-4 <md:flex-row <md:gap-y-4">
<div <div
class="flex flex-wrap items-center justify-start <md:w-full <md:justify-center" class="flex items-center min-w-0 justify-start <md:justify-center"
:class="{ :class="{
'md:flex-1': searchBoxPresent, 'md:flex-1': searchBoxPresent,
}" }"
> >
<IconButton v-if="goBack" icon="back" :title="$t('back')" class="mr-2 <md:hidden" @click="goBack" /> <IconButton
<h1 class="flex flex-wrap text-xl text-color items-center gap-x-2"> v-if="goBack"
icon="back"
:title="$t('back')"
class="flex-shrink-0 mr-2 <md:hidden"
@click="goBack"
/>
<h1 class="flex text-xl min-w-0 text-color items-center gap-x-2">
<slot name="title" /> <slot name="title" />
</h1> </h1>
</div> </div>
@ -22,7 +28,7 @@
/> />
<div <div
v-if="$slots.titleActions" v-if="$slots.titleActions"
class="flex flex-wrap items-center justify-end gap-x-2 <md:w-full <md:justify-center" class="flex items-center justify-end gap-x-2 <md:w-full <md:justify-center"
:class="{ :class="{
'md:flex-1': searchBoxPresent, 'md:flex-1': searchBoxPresent,
}" }"
@ -31,7 +37,7 @@
</div> </div>
</div> </div>
<div v-if="enableTabs" class="flex flex-wrap justify-between"> <div v-if="enableTabs" class="flex justify-between">
<Tabs class="<md:order-2" /> <Tabs class="<md:order-2" />
<div <div
v-if="$slots.titleActions" v-if="$slots.titleActions"
@ -50,13 +56,11 @@ import FluidContainer from '~/components/layout/FluidContainer.vue';
import Tabs from './Tabs.vue'; import Tabs from './Tabs.vue';
export interface Props { const props = defineProps<{
goBack?: () => void; goBack?: () => void;
enableTabs?: boolean; enableTabs?: boolean;
search?: string; search?: string;
} }>();
const props = defineProps<Props>();
defineEmits(['update:search']); defineEmits(['update:search']);
const searchBoxPresent = props.search !== undefined; const searchBoxPresent = props.search !== undefined;

View file

@ -69,6 +69,11 @@
/> />
<PipelineStatusIcon :status="workflow.state" class="!h-4 !w-4" /> <PipelineStatusIcon :status="workflow.state" class="!h-4 !w-4" />
<span class="truncate">{{ workflow.name }}</span> <span class="truncate">{{ workflow.name }}</span>
<PipelineStepDuration
v-if="workflow.start_time !== workflow.end_time"
:step="workflow"
class="mr-1 pr-2px"
/>
</button> </button>
</div> </div>
<div <div

View file

@ -73,7 +73,7 @@ export default class WoodpeckerClient extends ApiClient {
// Deploy triggers a deployment for an existing pipeline using the // Deploy triggers a deployment for an existing pipeline using the
// specified target environment. // specified target environment.
deployPipeline(owner: string, repo: string, pipelineNumber: number, options: DeploymentOptions): Promise<Pipeline> { deployPipeline(owner: string, repo: string, pipelineNumber: string, options: DeploymentOptions): Promise<Pipeline> {
const vars = { const vars = {
...options.variables, ...options.variables,
event: 'deployment', event: 'deployment',

View file

@ -15,7 +15,10 @@ export const usePipelineStore = defineStore('pipelines', () => {
function setPipeline(owner: string, repo: string, pipeline: Pipeline) { function setPipeline(owner: string, repo: string, pipeline: Pipeline) {
const _repoSlug = repoSlug(owner, repo); const _repoSlug = repoSlug(owner, repo);
const repoPipelines = pipelines.get(_repoSlug) || new Map(); const repoPipelines = pipelines.get(_repoSlug) || new Map();
repoPipelines.set(pipeline.number, { ...(repoPipelines.get(pipeline.number) || {}), ...pipeline }); repoPipelines.set(pipeline.number, {
...(repoPipelines.get(pipeline.number) || {}),
...pipeline,
});
pipelines.set(_repoSlug, repoPipelines); pipelines.set(_repoSlug, repoPipelines);
} }

View file

@ -32,7 +32,9 @@ export default defineComponent({
} }
const allPipelines = inject<Ref<Pipeline[]>>('pipelines'); const allPipelines = inject<Ref<Pipeline[]>>('pipelines');
const pipelines = computed(() => allPipelines?.value.filter((b) => b.branch === branch.value)); const pipelines = computed(() =>
allPipelines?.value.filter((b) => b.branch === branch.value && b.event !== 'pull_request'),
);
return { pipelines, repo }; return { pipelines, repo };
}, },

View file

@ -15,8 +15,8 @@
</div> </div>
<div v-else-if="pipeline.status === 'blocked'" class="flex flex-col flex-grow justify-center items-center p-2"> <div v-else-if="pipeline.status === 'blocked'" class="flex flex-col flex-grow justify-center items-center p-2">
<Icon name="status-blocked" class="w-32 h-32 text-color" /> <Icon name="status-blocked" class="w-16 h-16 text-color mb-4" />
<p class="text-xl text-color">{{ $t('repo.pipeline.protected.awaits') }}</p> <p class="text-xl text-color mb-4">{{ $t('repo.pipeline.protected.awaits') }}</p>
<div v-if="repoPermissions.push" class="flex mt-2 space-x-4"> <div v-if="repoPermissions.push" class="flex mt-2 space-x-4">
<Button <Button
color="green" color="green"
@ -49,8 +49,8 @@
</FluidContainer> </FluidContainer>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { computed, defineComponent, inject, PropType, Ref, toRef } from 'vue'; import { computed, inject, Ref, toRef } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@ -65,112 +65,84 @@ import useNotifications from '~/compositions/useNotifications';
import { Pipeline, PipelineStep, Repo, RepoPermissions } from '~/lib/api/types'; import { Pipeline, PipelineStep, Repo, RepoPermissions } from '~/lib/api/types';
import { findStep } from '~/utils/helpers'; import { findStep } from '~/utils/helpers';
export default defineComponent({ const props = defineProps<{
name: 'Pipeline', stepId?: string | null;
}>();
components: { const apiClient = useApiClient();
Button, const router = useRouter();
PipelineStepList, const route = useRoute();
Icon, const notifications = useNotifications();
PipelineLog, const i18n = useI18n();
FluidContainer,
},
props: { const pipeline = inject<Ref<Pipeline>>('pipeline');
stepId: { const repo = inject<Ref<Repo>>('repo');
type: String as PropType<string | null>, const repoPermissions = inject<Ref<RepoPermissions>>('repo-permissions');
default: null, if (!repo || !repoPermissions || !pipeline) {
}, throw new Error('Unexpected: "repo", "repoPermissions" & "pipeline" should be provided at this place');
}, }
setup(props) { const stepId = toRef(props, 'stepId');
const apiClient = useApiClient();
const router = useRouter();
const route = useRoute();
const notifications = useNotifications();
const i18n = useI18n();
const pipeline = inject<Ref<Pipeline>>('pipeline'); const defaultStepId = computed(() => {
const repo = inject<Ref<Repo>>('repo'); if (!pipeline.value || !pipeline.value.steps || !pipeline.value.steps[0].children) {
const repoPermissions = inject<Ref<RepoPermissions>>('repo-permissions'); return null;
if (!repo || !repoPermissions || !pipeline) { }
throw new Error('Unexpected: "repo", "repoPermissions" & "pipeline" should be provided at this place');
return pipeline.value.steps[0].children[0].pid;
});
const selectedStepId = computed({
get() {
if (stepId.value !== '' && stepId.value !== null && stepId.value !== undefined) {
const id = parseInt(stepId.value, 10);
const step = pipeline.value?.steps?.reduce(
(prev, p) => prev || p.children?.find((c) => c.pid === id),
undefined as PipelineStep | undefined,
);
if (step) {
return step.pid;
}
// return fallback if step-id is provided, but step can not be found
return defaultStepId.value;
} }
const stepId = toRef(props, 'stepId'); // is opened on >= md-screen
if (window.innerWidth > 768) {
return defaultStepId.value;
}
const defaultStepId = computed(() => { return null;
if (!pipeline.value || !pipeline.value.steps || !pipeline.value.steps[0].children) { },
return null; set(_selectedStepId: number | null) {
} if (!_selectedStepId) {
router.replace({ params: { ...route.params, stepId: '' } });
return;
}
return pipeline.value.steps[0].children[0].pid; router.replace({ params: { ...route.params, stepId: `${_selectedStepId}` } });
});
const selectedStepId = computed({
get() {
if (stepId.value !== '' && stepId.value !== null) {
const id = parseInt(stepId.value, 10);
const step = pipeline.value?.steps?.reduce(
(prev, p) => prev || p.children?.find((c) => c.pid === id),
undefined as PipelineStep | undefined,
);
if (step) {
return step.pid;
}
// return fallback if step-id is provided, but step can not be found
return defaultStepId.value;
}
// is opened on >= md-screen
if (window.innerWidth > 768) {
return defaultStepId.value;
}
return null;
},
set(_selectedStepId: number | null) {
if (!_selectedStepId) {
router.replace({ params: { ...route.params, stepId: '' } });
return;
}
router.replace({ params: { ...route.params, stepId: `${_selectedStepId}` } });
},
});
const selectedStep = computed(() => findStep(pipeline.value.steps || [], selectedStepId.value || -1));
const error = computed(() => pipeline.value?.error || selectedStep.value?.error);
const { doSubmit: approvePipeline, isLoading: isApprovingPipeline } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo is undefined');
}
await apiClient.approvePipeline(repo.value.owner, repo.value.name, `${pipeline.value.number}`);
notifications.notify({ title: i18n.t('repo.pipeline.protected.approve_success'), type: 'success' });
});
const { doSubmit: declinePipeline, isLoading: isDecliningPipeline } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo is undefined');
}
await apiClient.declinePipeline(repo.value.owner, repo.value.name, `${pipeline.value.number}`);
notifications.notify({ title: i18n.t('repo.pipeline.protected.decline_success'), type: 'success' });
});
return {
repoPermissions,
selectedStepId,
pipeline,
error,
isApprovingPipeline,
isDecliningPipeline,
approvePipeline,
declinePipeline,
};
}, },
}); });
const selectedStep = computed(() => findStep(pipeline.value.steps || [], selectedStepId.value || -1));
const error = computed(() => pipeline.value?.error || selectedStep.value?.error);
const { doSubmit: approvePipeline, isLoading: isApprovingPipeline } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo is undefined');
}
await apiClient.approvePipeline(repo.value.owner, repo.value.name, `${pipeline.value.number}`);
notifications.notify({ title: i18n.t('repo.pipeline.protected.approve_success'), type: 'success' });
});
const { doSubmit: declinePipeline, isLoading: isDecliningPipeline } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo is undefined');
}
await apiClient.declinePipeline(repo.value.owner, repo.value.name, `${pipeline.value.number}`);
notifications.notify({ title: i18n.t('repo.pipeline.protected.decline_success'), type: 'success' });
});
</script> </script>

View file

@ -8,9 +8,9 @@
:fluid-content="activeTab !== 'tasks'" :fluid-content="activeTab !== 'tasks'"
> >
<template #title> <template #title>
<span class="w-full md:w-auto text-center">{{ $t('repo.pipeline.pipeline', { pipelineId }) }}</span> <span class="flex-shrink-0 text-center">{{ $t('repo.pipeline.pipeline', { pipelineId }) }}</span>
<span class="<md:hidden">-</span> <span class="<md:hidden">-</span>
<span class="w-full md:w-auto text-center truncate">{{ message }}</span> <span class="text-center truncate">{{ message }}</span>
</template> </template>
<template #titleActions> <template #titleActions>
@ -75,13 +75,14 @@
</template> </template>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { Tooltip } from 'floating-vue'; import { Tooltip } from 'floating-vue';
import { computed, defineComponent, inject, onBeforeUnmount, onMounted, provide, Ref, ref, toRef, watch } from 'vue'; import { computed, inject, onBeforeUnmount, onMounted, provide, Ref, ref, toRef, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Button from '~/components/atomic/Button.vue'; import Button from '~/components/atomic/Button.vue';
import DeployPipelinePopup from '~/components/layout/popups/DeployPipelinePopup.vue';
import Scaffold from '~/components/layout/scaffold/Scaffold.vue'; import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
import Tab from '~/components/layout/scaffold/Tab.vue'; import Tab from '~/components/layout/scaffold/Tab.vue';
import PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue'; import PipelineStatusIcon from '~/components/repo/pipeline/PipelineStatusIcon.vue';
@ -94,150 +95,110 @@ import { useRouteBackOrDefault } from '~/compositions/useRouteBackOrDefault';
import { Repo, RepoPermissions } from '~/lib/api/types'; import { Repo, RepoPermissions } from '~/lib/api/types';
import { usePipelineStore } from '~/store/pipelines'; import { usePipelineStore } from '~/store/pipelines';
export default defineComponent({ const props = defineProps<{
name: 'PipelineWrapper', repoOwner: string;
repoName: string;
pipelineId: string;
}>();
components: { const apiClient = useApiClient();
Button, const route = useRoute();
PipelineStatusIcon, const router = useRouter();
Tab, const notifications = useNotifications();
Tooltip, const favicon = useFavicon();
Scaffold, const i18n = useI18n();
},
props: { const pipelineStore = usePipelineStore();
repoOwner: { const pipelineId = toRef(props, 'pipelineId');
type: String, const repoOwner = toRef(props, 'repoOwner');
required: true, const repoName = toRef(props, 'repoName');
}, const repo = inject<Ref<Repo>>('repo');
const repoPermissions = inject<Ref<RepoPermissions>>('repo-permissions');
if (!repo || !repoPermissions) {
throw new Error('Unexpected: "repo" & "repoPermissions" should be provided at this place');
}
repoName: { const pipeline = pipelineStore.getPipeline(repoOwner, repoName, pipelineId);
type: String, const { since, duration, created } = usePipeline(pipeline);
required: true, provide('pipeline', pipeline);
},
pipelineId: { const { message } = usePipeline(pipeline);
type: String,
required: true,
},
},
setup(props) { const showDeployPipelinePopup = ref(false);
const apiClient = useApiClient();
const route = useRoute();
const router = useRouter();
const notifications = useNotifications();
const favicon = useFavicon();
const i18n = useI18n();
const pipelineStore = usePipelineStore(); async function loadPipeline(): Promise<void> {
const pipelineId = toRef(props, 'pipelineId'); if (!repo) {
const repoOwner = toRef(props, 'repoOwner'); throw new Error('Unexpected: Repo is undefined');
const repoName = toRef(props, 'repoName'); }
const repo = inject<Ref<Repo>>('repo');
const repoPermissions = inject<Ref<RepoPermissions>>('repo-permissions'); await pipelineStore.loadPipeline(repo.value.owner, repo.value.name, parseInt(pipelineId.value, 10));
if (!repo || !repoPermissions) {
throw new Error('Unexpected: "repo" & "repoPermissions" should be provided at this place'); favicon.updateStatus(pipeline.value?.status);
}
const { doSubmit: cancelPipeline, isLoading: isCancelingPipeline } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo is undefined');
}
if (!pipeline.value?.steps) {
throw new Error('Unexpected: Pipeline steps not loaded');
}
// TODO: is selectedStepId right?
// const step = findStep(pipeline.value.steps, selectedStepId.value || 2);
// if (!step) {
// throw new Error('Unexpected: Step not found');
// }
await apiClient.cancelPipeline(repo.value.owner, repo.value.name, parseInt(pipelineId.value, 10));
notifications.notify({ title: i18n.t('repo.pipeline.actions.cancel_success'), type: 'success' });
});
const { doSubmit: restartPipeline, isLoading: isRestartingPipeline } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo is undefined');
}
await apiClient.restartPipeline(repo.value.owner, repo.value.name, pipelineId.value, { fork: true });
notifications.notify({ title: i18n.t('repo.pipeline.actions.restart_success'), type: 'success' });
// TODO: directly send to newest pipeline?
await router.push({ name: 'repo', params: { repoName: repo.value.name, repoOwner: repo.value.owner } });
});
onMounted(loadPipeline);
watch([repoName, repoOwner, pipelineId], loadPipeline);
onBeforeUnmount(() => {
favicon.updateStatus('default');
});
const activeTab = computed({
get() {
if (route.name === 'repo-pipeline-changed-files') {
return 'changed-files';
} }
const pipeline = pipelineStore.getPipeline(repoOwner, repoName, pipelineId); if (route.name === 'repo-pipeline-config') {
const { since, duration, created } = usePipeline(pipeline); return 'config';
provide('pipeline', pipeline);
const { message } = usePipeline(pipeline);
const showDeployPipelinePopup = ref(false);
async function loadPipeline(): Promise<void> {
if (!repo) {
throw new Error('Unexpected: Repo is undefined');
}
await pipelineStore.loadPipeline(repo.value.owner, repo.value.name, parseInt(pipelineId.value, 10));
favicon.updateStatus(pipeline.value?.status);
} }
const { doSubmit: cancelPipeline, isLoading: isCancelingPipeline } = useAsyncAction(async () => { return 'tasks';
if (!repo) { },
throw new Error('Unexpected: Repo is undefined'); set(tab: string) {
} if (tab === 'tasks') {
router.replace({ name: 'repo-pipeline' });
}
if (!pipeline.value?.steps) { if (tab === 'changed-files') {
throw new Error('Unexpected: Pipeline steps not loaded'); router.replace({ name: 'repo-pipeline-changed-files' });
} }
// TODO: is selectedStepId right? if (tab === 'config') {
// const step = findStep(pipeline.value.steps, selectedStepId.value || 2); router.replace({ name: 'repo-pipeline-config' });
}
// if (!step) {
// throw new Error('Unexpected: Step not found');
// }
await apiClient.cancelPipeline(repo.value.owner, repo.value.name, parseInt(pipelineId.value, 10));
notifications.notify({ title: i18n.t('repo.pipeline.actions.cancel_success'), type: 'success' });
});
const { doSubmit: restartPipeline, isLoading: isRestartingPipeline } = useAsyncAction(async () => {
if (!repo) {
throw new Error('Unexpected: Repo is undefined');
}
await apiClient.restartPipeline(repo.value.owner, repo.value.name, pipelineId.value, { fork: true });
notifications.notify({ title: i18n.t('repo.pipeline.actions.restart_success'), type: 'success' });
// TODO: directly send to newest pipeline?
await router.push({ name: 'repo', params: { repoName: repo.value.name, repoOwner: repo.value.owner } });
});
onMounted(loadPipeline);
watch([repo, pipelineId], loadPipeline);
onBeforeUnmount(() => {
favicon.updateStatus('default');
});
const activeTab = computed({
get() {
if (route.name === 'repo-pipeline-changed-files') {
return 'changed-files';
}
if (route.name === 'repo-pipeline-config') {
return 'config';
}
return 'tasks';
},
set(tab: string) {
if (tab === 'tasks') {
router.replace({ name: 'repo-pipeline' });
}
if (tab === 'changed-files') {
router.replace({ name: 'repo-pipeline-changed-files' });
}
if (tab === 'config') {
router.replace({ name: 'repo-pipeline-config' });
}
},
});
return {
repoPermissions,
pipeline,
repo,
message,
isCancelingPipeline,
isRestartingPipeline,
showDeployPipelinePopup,
activeTab,
since,
duration,
cancelPipeline,
restartPipeline,
goBack: useRouteBackOrDefault({ name: 'repo' }),
created,
};
}, },
}); });
const goBack = useRouteBackOrDefault({ name: 'repo' });
</script> </script>