<template>
<div class="w-full h-full flex flex-col">
<div
:class="
clsx(
'header flex-none flex flex-row items-center gap-xs px-sm py-xs border-b',
themeClasses(
'bg-white border-neutral-300',
'bg-neutral-800 border-neutral-600',
),
)
"
>
<NewButton
tooltip="Close (Esc)"
tooltipPlacement="top"
icon="x"
tone="empty"
:class="
clsx(
'active:bg-white active:text-black',
themeClasses('hover:bg-neutral-200', 'hover:bg-neutral-600'),
)
"
@click="exitReview"
/>
<div class="flex-1 text-sm font-medium">Review Changes</div>
<div v-if="!ctx.onHead.value" class="flex gap-xs items-center">
<div
v-if="filteredAndOrderedComponentList?.length > 0"
:class="
clsx(
'text-sm px-xs py-2xs rounded',
themeClasses(
'text-neutral-600 bg-neutral-100',
'text-neutral-400 bg-neutral-700',
),
)
"
>
{{
selectedComponentId
? `${currentComponentIndex + 1} / ${
filteredAndOrderedComponentList.length
}`
: filteredAndOrderedComponentList.length
}}
</div>
<NewButton
label="Previous"
:disabled="!canGoBack"
@click.stop.prevent="goToPreviousComponent"
>
<template #icon>
<div class="border border-neutral-400 rounded p-3xs mr-2xs">
<Icon name="arrow--left" size="xs" />
</div>
</template>
</NewButton>
<NewButton
label="Next"
tone="action"
:disabled="!canGoForward"
@click.stop.prevent="goToNextComponent"
>
<template #iconRight>
<div class="border border-action-200 rounded p-3xs ml-2xs">
<Icon name="arrow--right" size="xs" />
</div>
</template>
</NewButton>
</div>
</div>
<section
v-if="ctx.onHead.value"
class="p-lg flex flex-col items-center gap-sm"
>
<EmptyState
icon="x"
text="You are on HEAD"
secondaryText="There are no changes to review"
/>
<NewButton label="Exit" icon="chevron--left" @click="exitReview" />
</section>
<section v-else class="grid review w-full min-h-0 grow flex-1 p-xs">
<div
:class="
clsx(
'left',
'flex flex-col gap-xs m-xs p-xs border',
themeClasses(
'border-neutral-400 bg-white',
'border-neutral-600 bg-neutral-800',
),
)
"
>
<div class="text-sm flex-none">Components Changed</div>
<SiSearch
ref="searchRef"
v-model="searchString"
class="flex-none"
variant="new"
placeholder="Find a component"
:borderBottom="false"
@focus="() => (selectedComponentId = undefined)"
@keydown.tab="onSearchTab"
@keydown.up.prevent.stop="controlUp"
@keydown.down.prevent.stop="controlDown"
/>
<div ref="componentListRef" class="flex-1 min-h-0 scrollable">
<EmptyState
v-if="componentList.length === 0"
icon="diff"
text="No components changed"
class="p-sm"
/>
<EmptyState
v-else-if="filteredAndOrderedComponentList?.length === 0"
icon="diff"
text="No changed components match your search"
class="p-sm"
/>
<div
v-else
class="w-full relative"
:style="{
['overflow-anchor']: 'none',
height: `${virtualListHeight}px`,
}"
>
<ComponentListItem
v-for="item in virtualItems"
:key="item.index"
:component="filteredAndOrderedComponentList[item.index]!"
:status="filteredAndOrderedComponentList[item.index]!.diffStatus"
:selected="filteredAndOrderedComponentList[item.index]!.id === selectedComponentId"
:data-component-id="filteredAndOrderedComponentList[item.index]!.id"
:data-virtualizer-idx="item.index"
:style="{
transform: `translateY(${item.start}px)`,
}"
@click="
selectComponent(filteredAndOrderedComponentList[item.index]!.id)
"
/>
</div>
</div>
</div>
<div class="main flex flex-col gap-sm m-xs">
<CollapsingFlexItem
v-if="selectedComponentId && selectedComponent"
disableCollapse
headerTextSize="sm"
>
<template #header>
<div
class="group/title flex flex-row items-center gap-xs w-full cursor-pointer"
@click="goToComponentDetails"
>
<TruncateWithTooltip
:class="
clsx(
'py-2xs max-w-fit flex-1 group-hover/title:underline',
themeClasses(
'text-neutral-800 group-hover/title:text-action-500',
'text-neutral-100 group-hover/title:text-action-300',
),
)
"
>
{{ selectedComponent.schemaName }}
</TruncateWithTooltip>
<TruncateWithTooltip
:class="
clsx(
'py-2xs max-w-fit flex-1 group-hover/title:underline',
themeClasses(
'text-neutral-600 group-hover/title:text-action-500',
'text-neutral-400 group-hover/title:text-action-300',
),
)
"
>
({{ selectedComponent.name }})
</TruncateWithTooltip>
</div>
</template>
<div class="flex flex-col gap-sm px-sm py-sm min-h-full">
<div
v-if="selectedComponent.diffStatus === 'Removed'"
:class="
clsx(
'flex flex-row items-center gap-xs p-xs text-sm',
themeClasses(
'text-neutral-800 bg-neutral-300',
'text-neutral-100 bg-neutral-600',
),
)
"
>
<template v-if="selectedComponent.toDelete">
<div class="mr-auto">
This component will be removed from HEAD once the current
change set is applied.
</div>
<NewButton
v-if="
selectedComponent.toDelete &&
restoreComponentStatus !== 'succeeded'
"
label="Restore"
:loading="restoreComponentStatus === 'inProgress'"
loadingIcon="loader"
loadingText="Restoring..."
@click="restoreComponent"
/>
</template>
<div v-else>
This component will be removed from HEAD without queueing a
delete action once the current change set is applied. This
cannot be undone within this change set.
</div>
</div>
<!-- Show /si/name-->
<ReviewAttributeItem
v-if="
selectedComponent.attributeDiffTree?.children?.si?.children
?.name
"
:selectedComponentId="selectedComponentId"
name="name"
:item="
selectedComponent.attributeDiffTree.children.si.children.name
"
:disableRevert="disableRevert"
/>
<!-- Show children of /si/domain -->
<template
v-if="
selectedComponent.attributeDiffTree?.children?.domain?.children
"
>
<ReviewAttributeItem
v-for="(item, name) in selectedComponent.attributeDiffTree
.children.domain.children"
:key="name"
:selectedComponentId="selectedComponentId"
:name="name"
:item="item"
:disableRevert="disableRevert"
/>
</template>
<!-- Show children of /si/secrets -->
<template
v-if="
selectedComponent.attributeDiffTree?.children?.secrets?.children
"
>
<ReviewAttributeItem
v-for="(item, name) in selectedComponent.attributeDiffTree
.children.secrets.children"
:key="name"
:selectedComponentId="selectedComponentId"
:name="name"
:item="item"
:disableRevert="disableRevert"
/>
</template>
<div
v-if="noAVDiffs"
:class="
clsx(
'w-full grow flex flex-row items-center justify-center border',
themeClasses('border-neutral-400', 'border-neutral-600'),
)
"
>
<EmptyState
icon="diff"
text="No Attribute Values changed"
secondaryText="There are no attribute value changes to display for this component"
/>
</div>
</div>
</CollapsingFlexItem>
<div
v-else
:class="
clsx(
'border grow flex flex-col items-center justify-center',
themeClasses(
'border-neutral-400 bg-white',
'border-neutral-600 bg-neutral-800',
),
)
"
>
<EmptyState
icon="component"
text="No component selected"
secondaryText="Select a component to see information about it"
/>
</div>
<CollapsingFlexItem
ref="actionsRef"
headerTextSize="sm"
maxHeightContent
:expandable="false"
>
<template #header> Actions </template>
<!--
For anything related to actions, check if we have both the "selectedComponentId" and the
"details" to make sure the data comes in atomically and with reactivity.
-->
<template v-if="selectedComponent" #headerIcons>
<ActionPills
:actionCounts="actionCounts"
mode="row"
showNoPendingActions
/>
</template>
<template v-if="selectedComponent">
<ActionsPanel
ref="actionsPanelRef"
:component="selectedComponent"
/>
</template>
<EmptyState
v-else
class="p-lg"
icon="component"
text="No component selected"
secondaryText="Select a component to see and configure actions"
/>
</CollapsingFlexItem>
</div>
<div class="right flex flex-col p-xs">
<CollapsingFlexItem open headerTextSize="sm">
<template #header>Component History</template>
<template v-if="selectedComponentId && selectedComponent">
<ComponentHistory
:componentId="selectedComponentId"
:enabled="!!selectedComponent"
/>
</template>
<EmptyState
v-else
class="p-lg"
icon="component"
text="No component selected"
secondaryText="Select a component to see its history"
/>
</CollapsingFlexItem>
<CollapsingFlexItem open headerTextSize="sm">
<template #header>Diff</template>
<CodeViewer
v-if="selectedComponent"
:title="`${selectedComponent.name}: ${selectedComponent.schemaName}`"
:code="
(selectedComponent.componentDiff?.resourceDiff?.diff ??
undefined) ||
(selectedComponent.componentDiff?.resourceDiff?.current ??
undefined)
"
codeLanguage="diff"
copyTooltip="Copy diff to clipboard"
/>
<EmptyState
v-else
class="p-lg"
icon="component"
text="No component selected"
secondaryText="Select a component to see the diff for it"
/>
</CollapsingFlexItem>
</div>
</section>
</div>
</template>
<script setup lang="ts">
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { useQueries, useQuery, useQueryClient } from "@tanstack/vue-query";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
import clsx from "clsx";
import {
Icon,
SiSearch,
themeClasses,
TruncateWithTooltip,
NewButton,
} from "@si/vue-lib/design-system";
import { useRouter, useRoute } from "vue-router";
import * as _ from "lodash-es";
import { sleep } from "@si/ts-lib/src/async-sleep";
import { useVirtualizer } from "@tanstack/vue-virtual";
import {
bifrost,
bifrostList,
useMakeArgs,
useMakeKey,
} from "@/store/realtime/heimdall";
import {
ActionDiffList,
ActionDiffView,
AttributeDiff,
AttributeSourceAndValue,
ComponentDiff,
ComponentInList,
EntityKind,
ErasedComponents,
} from "@/workers/types/entity_kind_types";
import CodeViewer from "@/components/CodeViewer.vue";
import { AttributePath, ComponentId } from "@/api/sdf/dal/component";
import { ActionState } from "@/api/sdf/dal/action";
import ComponentListItem from "./ComponentListItem.vue";
import ActionsPanel from "./ActionsPanel.vue";
import ActionPills from "./ActionPills.vue";
import { useContext } from "./logic_composables/context";
import EmptyState from "./EmptyState.vue";
import ComponentHistory from "./ComponentHistory.vue";
import CollapsingFlexItem from "./layout_components/CollapsingFlexItem.vue";
import ReviewAttributeItem from "./ReviewAttributeItem.vue";
import { KeyDetails, keyEmitter } from "./logic_composables/emitters";
import { useComponentSearch } from "./logic_composables/search";
import { useComponentActions } from "./logic_composables/component_actions";
import { useComponentDeletion } from "./composables/useComponentDeletion";
const ctx = useContext();
const router = useRouter();
const route = useRoute();
const queryClient = useQueryClient();
// const changeSetName = computed(() => ctx.changeSet.value?.name);
const selectedComponentId = ref<ComponentId>();
// Initialize selected component from URL query parameter
const initializeFromUrl = () => {
const componentIdFromUrl = route.query.component as ComponentId;
if (componentIdFromUrl) {
selectedComponentId.value = componentIdFromUrl;
} else {
// TODO(Wendy) - remove this part to not have the first component selected by default
nextTick(() => {
if (filteredAndOrderedComponentList.value[0]?.id) {
selectedComponentId.value = filteredAndOrderedComponentList.value[0].id;
}
});
}
};
const key = useMakeKey();
const args = useMakeArgs();
// All components on this change set
const changeSetComponentListQuery = useQuery({
queryKey: key(EntityKind.ComponentList),
enabled: ctx.queriesEnabled,
queryFn: async () =>
await bifrostList<ComponentInList[]>(args(EntityKind.ComponentList)),
});
const changeSetComponentList = computed(
() => changeSetComponentListQuery.data.value ?? [],
);
/** Queries for complete attribute-by-attribute diff of every component */
const componentDiffQueries = useQueries({
queries: computed(() =>
changeSetComponentList.value.map((component) => ({
queryKey: key(EntityKind.ComponentDiff, component.id),
queryFn: async () =>
await bifrost<ComponentDiff>(
args(EntityKind.ComponentDiff, component.id),
),
})),
),
});
const erasedComponents = useQuery({
queryKey: key(EntityKind.ErasedComponents),
enabled: ctx.queriesEnabled,
queryFn: async () =>
await bifrost<ErasedComponents>(args(EntityKind.ErasedComponents)),
});
/** Query to get actions that have changed relative to HEAD. */
const actionDiffListQuery = useQuery({
queryKey: key(EntityKind.ActionDiffList),
enabled: ctx.queriesEnabled,
queryFn: async () =>
await bifrost<ActionDiffList>(args(EntityKind.ActionDiffList)),
});
/**
* The complete component list without diff information added yet
* We need this computed so that we can get info about the components
* inside of the computed "componentList"
*/
const rawComponentList = computed(() => {
const result = changeSetComponentList.value.map((component) => {
return {
...component,
};
});
return result;
});
/**
* Complete component list, including:
* - Added and modified components from the changeset
* - Removed components from HEAD
* - `diff` set to the ComponentDiff MV for the component (if found)
* - `diffStatus` reflecting the status from the ComponentDiff MV
*
* TODO we will want to make the diffStatus correct in the ComponentList MV in the first place,
* so we don't have to "fix" it here
*/
const componentList = computed(() => {
const componentActionDiffs: { [id in ComponentId]?: ActionDiffView[] } = {};
for (const actionDiff of Object.values(
actionDiffListQuery.data.value?.actionDiffs ?? [],
)) {
if (actionDiff.diffStatus !== "None") {
componentActionDiffs[actionDiff.componentId] ??= [];
componentActionDiffs[actionDiff.componentId]?.push(actionDiff);
}
}
// Add component and action diffs to each component in the change set, and include "removed"
const componentDiffs: { [id in ComponentId]?: ComponentDiff } =
Object.fromEntries(
componentDiffQueries.value.map(
(query) => [query.data?.id, query.data] as const,
),
);
const mapped = rawComponentList.value
.map((component) => {
const componentDiff = componentDiffs[component.id];
const actionDiffs = componentActionDiffs[component.id];
const attributeDiffTree = toAttributeDiffTree(componentDiff);
// Store the original diffStatus before we modify it
const originalDiffStatus = componentDiff?.diffStatus;
// Figure out diffStatus
let diffStatus = componentDiff?.diffStatus;
// If the diffStatus *was* Modified, but none of the diffs were worth showing, then we
// don't want to show it. (If it's Added or Removed, or has other things that are worth
// showing, we will bring the status back in the next few.)
if (
diffStatus === "Modified" &&
!attributeDiffTree.diff &&
!attributeDiffTree.children
) {
// There's an issue here where we skip restored components! They won't have an attributeDiffTree diff
// or children but we are intentionall skipping them!
// We may want to revist this behaviour - I am not 100% sure why it was added this way
diffStatus = "None";
}
// If we don't have a ComponentDiff diffStatus, fall back to the ComponentInList's diffStatus
diffStatus ??= component.diffStatus;
// If there are diffs, we are Modified
if (diffStatus === "None" && actionDiffs && actionDiffs?.length > 0) {
diffStatus = "Modified";
}
// If it's toDelete and has a componentDiff with original Modified status, it was deleted in this change set
// we want to skip anything that's not removed in this change set so that we avoid components
// that are marked as toDelete on HEAD (which would have originalDiffStatus === "None")
if (component.toDelete && originalDiffStatus === "Removed") {
diffStatus = "Removed";
}
return {
...component,
diffStatus,
componentDiff,
attributeDiffTree,
actionDiffs,
};
})
.filter((component) => component.diffStatus !== "None");
// Add erased components
for (const { diff, component } of Object.values(
erasedComponents.data.value?.erased ?? {},
)) {
const actionDiffs = componentActionDiffs[component.id];
const attributeDiffTree = toAttributeDiffTree(diff);
mapped.push({
...component,
diffStatus: "Removed",
componentDiff: diff,
attributeDiffTree,
actionDiffs,
});
}
return mapped;
});
/**
* A tree version of AttributeDiff:
*
* {
* children: {
* domain: {
* children: {
* SubnetIds: {
* children: {
* 0: { diff: ... }
* }
* // parents may or may not have a diff! Especially if they have a *source* difference
* diff: {...},
* },
* extra: {
* children: {
* Region: { diff: {} }
* }
* }
* }
* }
* }
* }
*/
export interface AttributeDiffTree {
path: AttributePath;
diff?: AttributeDiff;
children?: Record<string, AttributeDiffTree>;
}
// TEMPORARY: post-process the ComponentDiff MV:
// - add nesting when there are both child and parent values in the diff
// - correct for a bug where subscriptions weren't showing right for some reason
function toAttributeDiffTree(componentDiff?: ComponentDiff): AttributeDiffTree {
const tree: AttributeDiffTree = { path: "" as AttributePath };
const attributeDiffs = componentDiff?.attributeDiffs;
if (!attributeDiffs) return tree;
for (const [attributePath, attributeDiff] of Object.entries(attributeDiffs)) {
const attributePathSegments = attributePath.slice(1).split("/");
// Do any fixups we want to the diff!
// TODO fix this in the backend MV instead, and don't do this
const diff = {
new: fixAttributeSourceAndValue(attributeDiff.new),
old: fixAttributeSourceAndValue(attributeDiff.old),
} as AttributeDiff;
if (!shouldIncludeDiff(attributePathSegments, diff)) continue;
// Recursively create (or get) the element in the tree we want; then set the diff on it
let child = tree;
if (attributePath.length > 1) {
for (const segment of attributePathSegments) {
const path = `${child.path}/${segment}` as AttributePath;
child.children ??= {};
child.children[segment] ??= { path };
child = child.children[segment] as AttributeDiffTree;
}
}
child.diff = diff;
}
// Set the top level path correctly to / (kind of a special case)
tree.path = "/";
return tree;
}
function shouldIncludeDiff(
attributePathSegments: string[],
diff: AttributeDiff,
) {
// If the values and sources are equal (which could happen in some cases on component
// upgrade), don't show this diff.
if (_.isEqual(diff.new, diff.old)) return false;
if (!diff.old || !diff.new) {
const { $source, $value } = diff.old ?? diff.new;
// Don't show "uninteresting" default values (static values, or empty values from functions).
if ($source.fromSchema) {
if (_.isObject($value) && _.isEmpty($value)) return false;
if (_.isArray($value) && _.isEmpty($value)) return false;
if ($value === "" || $value === 0) return false;
if ($value === undefined || $value === null) return false;
}
// Don't show new objects if they are fields of an object (otherwise we see a bunch of {}).
// NOTE: If it's under a top-level path then we *know* it's a field of an object and can safely
// not show it! We can't do the same for deeply nested fields until the MV tells us whether
// the parent prop is an object or not (we can *only* avoid showing object fields).
if (attributePathSegments.length <= 2) {
if ("value" in $source && _.isObject($source.value)) return false;
}
}
return true;
}
/**
* Augment AttributeSourceAndValue with component name.
*
* This is where we put any fixups we need while working in the frontend; any changes here need
* to move to the backend MV.
*/
function fixAttributeSourceAndValue(
sourceAndValue?: null | AttributeSourceAndValue,
) {
if (!sourceAndValue) return undefined;
const { $source } = sourceAndValue;
// Add componentName to $source
if ("component" in $source) {
const component = $source.component;
const componentName = rawComponentList.value.find(
(c) => c.id === component,
)?.name;
return {
...sourceAndValue,
$source: {
...$source,
componentName,
},
} as AttributeSourceAndValue;
}
// Make $value match $source (the only time it doesn't is object field defaults, which we don't want to show!)
if ("value" in $source) {
if (_.isObject($source.value)) {
return {
$source,
$value: $source.value,
};
}
}
return sourceAndValue;
}
/** Overall (non-filtered) component counts for each diff status */
// const componentCounts = computed(() => {
// const result = {
// Added: 0,
// Modified: 0,
// None: 0,
// Removed: 0,
// };
// for (const component of componentList.value) {
// result[component.diffStatus] += 1;
// }
// return result;
// });
/** The currently-selected component data, including diffs */
const selectedComponent = computed(
() =>
componentList.value.find((c) => c.id === selectedComponentId.value) || null,
);
const disableRevert = computed(
() => selectedComponent.value?.diffStatus === "Removed",
);
// When absolutely anything in the selected component changes, or the selection itself changes,
// invalidate the audit logs query for that component.
watch(
selectedComponent,
(newSelectedComponent) => {
if (newSelectedComponent) {
queryClient.invalidateQueries({
queryKey: key(EntityKind.AuditLogsForComponent, newSelectedComponent.id)
.value,
});
}
},
{ deep: true },
);
watch(
() => ctx.onHead.value,
(onHead) => {
if (onHead) {
exitReview();
}
},
);
// Watch for selected component changes and scroll it into view
watch(selectedComponentId, (newSelectedId) => {
if (newSelectedId) {
scrollSelectedComponentIntoView();
}
});
const selectComponent = (componentId: ComponentId) => {
selectedComponentId.value = componentId;
// Push component ID to URL for deep linking
router.push({
name: route.name,
params: route.params,
query: {
...route.query,
component: componentId,
},
});
};
const deselectComponent = () => {
selectedComponentId.value = undefined;
// Remove component from URL
const { ...queryWithoutComponent } = route.query;
router.push({
name: route.name,
params: route.params,
query: queryWithoutComponent,
});
};
const scrollSelectedComponentIntoView = async () => {
if (!selectedComponentId.value || !componentListRef.value) return;
// First, wait one tick for the dom classes to update
await nextTick();
// Then, see if the element exists in the DOM
const el = componentListRef.value.querySelector(
`[data-component-id="${selectedComponentId.value}"]`,
);
if (el) {
// If it does, scroll it to the center
el.scrollIntoView({
block: "center",
});
} else {
// Otherwise, we need to scroll using the virtualizer
const selectionIndex = filteredAndOrderedComponentList.value.findIndex(
(c) => c.id === selectedComponentId.value,
);
if (selectionIndex >= 0) {
virtualList.value.scrollToIndex(selectionIndex, { align: "center" });
} else {
// scroll to the top when search is selected
virtualList.value.scrollToIndex(0);
}
}
};
const searchRef = ref<InstanceType<typeof SiSearch>>();
const componentListRef = ref<HTMLDivElement>();
const searchString = ref("");
/** Components, filtered by the search string */
const filteredComponentList = useComponentSearch(searchString, componentList);
const filteredAndOrderedComponentList = computed(() => {
if (filteredComponentList.value) {
return [
...filteredComponentList.value.filter((c) => c.diffStatus === "Added"),
...filteredComponentList.value.filter((c) => c.diffStatus === "Modified"),
...filteredComponentList.value.filter((c) => c.diffStatus === "Removed"),
];
}
return [];
});
// Watch for componentList changes and reinitialize from URL if needed
watch(
filteredAndOrderedComponentList,
(newList) => {
if (newList && newList.length > 0 && !selectedComponentId.value) {
initializeFromUrl();
}
},
{ immediate: true },
);
// Calculate action counts for the selected component, only including actions with count > 0
const { actionPrototypeViews, actionByPrototype } =
useComponentActions(selectedComponent);
const actionCounts = computed(() => {
const results: Record<string, { count: number; hasFailed: boolean }> = {};
if (!selectedComponentId.value) return results;
for (const actionPrototype of actionPrototypeViews.value) {
const action = actionByPrototype.value[actionPrototype.id];
if (action) {
if (!action.componentId) continue;
// Group Other actions with Manual
let actionName = action.name;
if (
actionName.toLowerCase() === "other" ||
action.kind?.toLowerCase() === "other"
) {
actionName = "Manual";
}
if (!results[actionName]) {
results[actionName] = { count: 0, hasFailed: false };
}
results[actionName]!.count += 1;
// Track if any action in this group has failed
if (action.state === ActionState.Failed) {
results[actionName]!.hasFailed = true;
}
}
// Remove the else block that creates entries with count: 0
}
// Filter out entries with count of 0
return Object.fromEntries(
Object.entries(results).filter(([_, value]) => value.count > 0),
);
});
const exitReview = () => {
router.push({
name: "new-hotness",
});
};
// Navigation logic for back/forward buttons
const currentComponentIndex = computed(() => {
if (!selectedComponentId.value) return -1;
return (
filteredAndOrderedComponentList.value?.findIndex(
(component) => component.id === selectedComponentId.value,
) ?? -1
);
});
const canGoBack = computed(() => {
// Disabled if there are no changes or only 1 change
return (filteredAndOrderedComponentList.value?.length ?? 0) > 1;
});
const canGoForward = computed(() => {
// Disabled if there are no changes or only 1 change
return (filteredAndOrderedComponentList.value?.length ?? 0) > 1;
});
const goToPreviousComponent = (e?: Event) => {
e?.preventDefault();
e?.stopPropagation();
// Clear any text selection
if (window.getSelection) {
window.getSelection()?.removeAllRanges();
}
if (!selectedComponentId.value) {
// No component selected - select the first one
const firstComponent = filteredAndOrderedComponentList.value?.[0];
if (firstComponent) {
selectComponent(firstComponent.id);
}
} else if (currentComponentIndex.value > 0) {
// Go to previous component
const prevComponent =
filteredAndOrderedComponentList.value?.[currentComponentIndex.value - 1];
if (prevComponent) {
selectComponent(prevComponent.id);
}
} else {
// At first component, wrap around to last component
const lastComponent =
filteredAndOrderedComponentList.value?.[
filteredAndOrderedComponentList.value.length - 1
];
if (lastComponent) {
selectComponent(lastComponent.id);
}
}
};
const goToNextComponent = (e?: Event) => {
e?.preventDefault();
e?.stopPropagation();
// Clear any text selection
if (window.getSelection) {
window.getSelection()?.removeAllRanges();
}
if (!selectedComponentId.value) {
// No component selected - select the first one
const firstComponent = filteredAndOrderedComponentList.value?.[0];
if (firstComponent) {
selectComponent(firstComponent.id);
}
} else if (
currentComponentIndex.value <
(filteredAndOrderedComponentList.value?.length ?? 0) - 1
) {
// Go to next component
const nextComponent =
filteredAndOrderedComponentList.value?.[currentComponentIndex.value + 1];
if (nextComponent) {
selectComponent(nextComponent.id);
}
} else {
// At last component, wrap around to first component
const firstComponent = filteredAndOrderedComponentList.value?.[0];
if (firstComponent) {
selectComponent(firstComponent.id);
}
}
};
const focusSearchFirst = () => {
if (
!selectedComponentId.value &&
searchRef.value?.inputDOMEl !== document.activeElement
) {
searchRef.value?.focusSearch();
return true;
}
return false;
};
const controlUp = () => {
if (focusSearchFirst()) {
return;
}
if (currentComponentIndex.value - 1 > -1) {
selectedComponentId.value =
filteredAndOrderedComponentList.value[
currentComponentIndex.value - 1
]!.id;
searchRef.value?.blurSearch();
} else if (searchRef.value?.inputDOMEl !== document.activeElement) {
deselectComponent();
searchRef.value?.focusSearch();
} else {
selectedComponentId.value =
filteredAndOrderedComponentList.value[
filteredAndOrderedComponentList.value.length - 1
]!.id;
searchRef.value?.blurSearch();
}
};
const controlDown = () => {
if (focusSearchFirst()) {
return;
}
if (
currentComponentIndex.value + 1 <
filteredAndOrderedComponentList.value.length
) {
selectedComponentId.value =
filteredAndOrderedComponentList.value[
currentComponentIndex.value + 1
]!.id;
searchRef.value?.blurSearch();
} else if (searchRef.value?.inputDOMEl !== document.activeElement) {
deselectComponent();
searchRef.value?.focusSearch();
} else {
selectedComponentId.value = filteredAndOrderedComponentList.value[0]!.id;
searchRef.value?.blurSearch();
}
};
const onEscape = () => {
if (selectedComponentId.value) {
deselectComponent();
} else {
exitReview();
}
};
const onTab = (e: KeyDetails["Tab"]) => {
e.preventDefault();
if (e.shiftKey) {
controlUp();
} else {
controlDown();
}
};
const onSearchTab = (e: KeyboardEvent) => {
e.preventDefault();
if (e.shiftKey) {
controlUp();
} else {
controlDown();
}
};
const onArrowUp = (e: KeyDetails["ArrowUp"]) => {
e.preventDefault();
controlUp();
};
const onArrowDown = (e: KeyDetails["ArrowDown"]) => {
e.preventDefault();
controlDown();
};
const onArrowLeft = (e: KeyDetails["ArrowLeft"]) => {
e.preventDefault();
goToPreviousComponent();
};
const onArrowRight = (e: KeyDetails["ArrowRight"]) => {
e.preventDefault();
goToNextComponent();
};
const { restoreComponents } = useComponentDeletion(undefined, true);
/**
* Status of restoring the current component
*
* This is undefined if no restore is happening or has happened for the current component.
*/
const restoreComponentStatus = ref<"inProgress" | "succeeded">();
// When you switch components, restoring gets set to `undefined` again so you can hit the button again.
watch(
() => selectedComponent.value?.id,
() => {
restoreComponentStatus.value = undefined;
},
);
/** Restore the current component */
const restoreComponent = async () => {
if (restoreComponentStatus.value) return;
restoreComponentStatus.value = "inProgress";
try {
if (!selectedComponent.value) return;
await sleep(1000);
const result = await restoreComponents([selectedComponent.value.id]);
restoreComponentStatus.value = result.success ? "succeeded" : undefined;
} catch (e) {
restoreComponentStatus.value = undefined;
}
};
onMounted(() => {
keyEmitter.on("Escape", onEscape);
keyEmitter.on("Tab", onTab);
keyEmitter.on("ArrowUp", onArrowUp);
keyEmitter.on("ArrowDown", onArrowDown);
keyEmitter.on("ArrowLeft", onArrowLeft);
keyEmitter.on("ArrowRight", onArrowRight);
// Initialize component selection from URL
initializeFromUrl();
});
onBeforeUnmount(() => {
keyEmitter.off("Escape", onEscape);
keyEmitter.off("Tab", onTab);
keyEmitter.off("ArrowUp", controlUp);
keyEmitter.off("ArrowDown", controlDown);
keyEmitter.off("ArrowLeft", onArrowLeft);
keyEmitter.off("ArrowRight", onArrowRight);
});
const noAVDiffs = computed(
() =>
// If no component is selected, this will return false
selectedComponent.value &&
!selectedComponent.value?.attributeDiffTree?.children?.si?.children?.name &&
!selectedComponent.value?.attributeDiffTree?.children?.domain?.children &&
!selectedComponent.value?.attributeDiffTree?.children?.secrets?.children,
);
const selectedComponentErased = computed(
() =>
selectedComponent.value?.diffStatus === "Removed" &&
!selectedComponent.value.toDelete,
);
const goToComponentDetails = () => {
if (!selectedComponentId.value || selectedComponentErased.value) return;
router.push({
name: "new-hotness-component",
params: {
workspacePk: route.params.workspacePk,
changeSetId: route.params.changeSetId,
componentId: selectedComponentId.value,
},
});
};
const actionsRef = ref<InstanceType<typeof CollapsingFlexItem>>();
const actionsPanelRef = ref<InstanceType<typeof ActionsPanel>>();
const fixActionsPanelState = () => {
if (selectedComponentId.value) {
if (noAVDiffs.value && actionsRef.value) {
// Opens the actions panel if there are noAVDiffs
actionsRef.value.openState.open.value = true;
} else {
nextTick(() => {
if (
actionsRef.value &&
!noAVDiffs.value &&
actionsPanelRef.value?.actionPrototypeViews &&
actionsPanelRef.value?.actionPrototypeViews.length === 0
) {
// Closes the actions panel if there are AVDiffs and no actions
actionsRef.value.openState.open.value = false;
}
});
}
}
};
watch(selectedComponentId, () => {
fixActionsPanelState();
});
// VIRTUALIZER for the component list
const virtualizerOptions = computed(() => ({
count: filteredAndOrderedComponentList.value?.length ?? 0,
getScrollElement: () => componentListRef.value!,
estimateSize: () => 32,
getItemKey: (i: number) => {
return filteredAndOrderedComponentList.value[i]!.id;
},
overscan: 5,
}));
const virtualList = useVirtualizer(virtualizerOptions);
const virtualListHeight = computed(() => virtualList.value.getTotalSize());
const virtualItems = computed(() => virtualList.value.getVirtualItems());
</script>
<style lang="css" scoped>
section.grid.review {
grid-template-columns: minmax(0, 25%) minmax(0, 50%) minmax(0, 25%);
grid-template-rows: 100%;
grid-template-areas: "left main right";
}
div.main {
grid-area: "main";
}
div.left {
grid-area: "left";
}
div.right {
grid-area: "right";
}
</style>