Files
pleno-vue/src/components/release/ReleaseContextBar.vue
T

336 lines
9.1 KiB
Vue

<script setup>
import { computed } from "vue";
import ReleaseEntityLink from "@/components/release/ReleaseEntityLink.vue";
const props = defineProps({
channels: {
type: Array,
default: () => [],
},
selectedChannelSlug: {
type: String,
default: "",
},
selectedApp: {
type: String,
default: "all",
},
selectedBranch: {
type: String,
default: "master",
},
branchOptions: {
type: Array,
default: () => [],
},
branchStatus: {
type: Object,
default: () => ({}),
},
runtimeSource: {
type: String,
default: "",
},
runtimeApiBaseUrl: {
type: String,
default: "",
},
runtimeFrontendBaseUrl: {
type: String,
default: "",
},
busy: {
type: String,
default: "",
},
loading: {
type: Boolean,
default: false,
},
refreshing: {
type: Boolean,
default: false,
},
loadingText: {
type: String,
default: "Loading release context...",
},
canDeploy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:channel", "update:app", "update:branch", "test", "sync", "refresh", "open-entity"]);
const routeSlugForChannel = (channel) => {
const slug = String(channel?.slug || channel?.channel_slug || "").trim().toLowerCase();
return slug === "stable" ? "master" : slug;
};
const selectedChannel = computed(
() =>
props.channels.find((channel) => String(channel.slug || channel.channel_slug) === props.selectedChannelSlug) ||
props.channels.find((channel) => channel.default_channel) ||
props.channels[0] ||
null
);
const selectedRouteSlug = computed(() => routeSlugForChannel(selectedChannel.value) || props.selectedBranch || "master");
const selectedEndpointApps = computed(() => {
if (props.selectedApp === "frontend") return ["frontend"];
if (props.selectedApp === "api") return ["api"];
return ["frontend", "api"];
});
const isLocalRuntime = computed(() => String(props.runtimeSource || "").toLowerCase() === "local");
const releaseSourceLabel = computed(() => (isLocalRuntime.value ? "Local source" : "Deployment source"));
const runtimeFrontendBaseUrl = computed(() => {
const value = String(props.runtimeFrontendBaseUrl || "").replace(/\/+$/, "");
if (value) return value;
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
return "/";
});
const runtimeApiBaseUrl = computed(() => String(props.runtimeApiBaseUrl || "/api").replace(/\/+$/, "") || "/api");
const endpointForApp = (app) =>
isLocalRuntime.value
? app === "api"
? runtimeApiBaseUrl.value
: runtimeFrontendBaseUrl.value
: `https://api-v2.truckwash.io/${selectedRouteSlug.value}/${app}`;
const endpointLabelForApp = (app) =>
isLocalRuntime.value
? app === "api"
? runtimeApiBaseUrl.value
: "local frontend"
: `/${selectedRouteSlug.value}/${app}`;
const appBranchStatus = computed(() => {
if (props.selectedApp === "frontend" || props.selectedApp === "api") {
return props.branchStatus?.[props.selectedApp] || {};
}
const values = [props.branchStatus?.frontend, props.branchStatus?.api].filter(Boolean);
const missing = values.find((status) => ["missing", "not_configured", "unknown"].includes(String(status.state || "").toLowerCase()));
return missing || values[0] || {};
});
const branchState = computed(() => String(appBranchStatus.value?.state || "unknown").toLowerCase());
const branchWarning = computed(() =>
["missing", "not_configured", "unknown", "stale", "failed"].includes(branchState.value)
? `${props.selectedBranch || "unknown"} is ${branchState.value || "unknown"} for the selected context.`
: ""
);
</script>
<template>
<section
class="release-context-bar"
data-testid="release-context-bar"
aria-label="Release context"
:aria-busy="loading || refreshing"
>
<div v-if="loading" class="release-context-bar__loading" data-testid="release-context-loading" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span>{{ loadingText }}</span>
</div>
<div class="release-context-bar__controls">
<label>
<span>Channel</span>
<select
class="select is-small"
:value="selectedChannelSlug"
data-testid="release-context-channel"
:disabled="loading"
@change="emit('update:channel', $event.target.value)"
>
<option v-for="channel in channels" :key="channel.id || channel.slug" :value="channel.slug">
{{ channel.name || channel.slug }} / {{ routeSlugForChannel(channel) || "unknown" }}
</option>
</select>
</label>
<label>
<span>App</span>
<select
class="select is-small"
:value="selectedApp"
data-testid="release-context-app"
:disabled="loading"
@change="emit('update:app', $event.target.value)"
>
<option value="all">All</option>
<option value="frontend">Frontend</option>
<option value="api">API</option>
</select>
</label>
<label class="release-context-bar__branch">
<span>Branch</span>
<input
class="input is-small"
list="release-context-branches"
:value="selectedBranch"
data-testid="release-context-branch"
:disabled="loading"
@change="emit('update:branch', $event.target.value)"
@keydown.enter.prevent="emit('update:branch', $event.target.value)"
/>
<datalist id="release-context-branches">
<option v-for="branch in branchOptions" :key="branch" :value="branch" />
</datalist>
</label>
</div>
<div class="release-context-bar__status">
<b-tag
:class="isLocalRuntime ? 'is-info' : 'is-success'"
data-testid="release-context-source"
>
{{ releaseSourceLabel }}
</b-tag>
<b-tag :class="branchState === 'available' || branchState === 'ready' ? 'is-success' : 'is-warning'">
{{ branchState }}
</b-tag>
<span v-if="branchWarning" class="release-context-bar__warning" data-testid="release-context-branch-warning">
{{ branchWarning }}
</span>
</div>
<div class="release-context-bar__endpoints" data-testid="release-context-endpoints">
<ReleaseEntityLink
v-for="app in selectedEndpointApps"
:key="app"
type="endpoint"
:label="endpointLabelForApp(app)"
:entity="{ url: endpointForApp(app), route_prefix: endpointLabelForApp(app), app, channel: selectedChannelSlug }"
@open="(payload) => emit('open-entity', payload)"
/>
</div>
<div class="release-context-bar__actions">
<b-button
size="is-small"
icon-left="vial"
icon-pack="fas"
:disabled="loading || !canDeploy"
:loading="busy === 'release:test'"
data-testid="release-context-test"
@click="emit('test')"
>
Test
</b-button>
<b-button
size="is-small"
icon-left="rotate"
icon-pack="fas"
:disabled="loading || !canDeploy || !selectedChannel"
:loading="busy === `channel:${selectedChannel?.id}:sync`"
data-testid="release-context-sync"
@click="emit('sync', selectedChannel)"
>
Sync
</b-button>
<b-button
size="is-small"
icon-left="refresh"
icon-pack="fas"
data-testid="release-context-refresh"
:disabled="loading || refreshing"
:loading="refreshing"
@click="emit('refresh')"
>
Refresh
</b-button>
</div>
</section>
</template>
<style scoped>
.release-context-bar {
align-items: center;
background: #f8fafc;
border-bottom: 1px solid #d8dee8;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(0, 1.4fr) minmax(11rem, 0.6fr) minmax(0, 1fr) auto;
min-height: 4rem;
padding: 0.6rem 0.75rem;
}
.release-context-bar__loading {
align-items: center;
color: #52627a;
display: inline-flex;
font-size: 0.82rem;
font-weight: 700;
gap: 0.45rem;
grid-column: 1 / -1;
}
.release-context-bar__controls,
.release-context-bar__status,
.release-context-bar__endpoints,
.release-context-bar__actions {
align-items: center;
display: flex;
gap: 0.5rem;
min-width: 0;
}
.release-context-bar label {
display: grid;
gap: 0.2rem;
min-width: 8rem;
}
.release-context-bar label > span {
color: #667085;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
}
.release-context-bar__branch {
min-width: 12rem;
}
.release-context-bar__status {
align-items: flex-start;
flex-wrap: wrap;
}
.release-context-bar__status .tag {
flex: 0 0 auto;
}
.release-context-bar__warning {
color: #9f1f17;
flex: 1 1 12rem;
font-size: 0.82rem;
line-height: 1.25;
min-width: min(12rem, 100%);
overflow-wrap: break-word;
word-break: normal;
}
.release-context-bar__endpoints {
flex-wrap: wrap;
}
.release-context-bar__actions {
justify-content: flex-end;
}
@media (max-width: 1180px) {
.release-context-bar {
grid-template-columns: 1fr;
}
.release-context-bar__controls,
.release-context-bar__actions {
flex-wrap: wrap;
}
}
</style>