Repository: directus/directus
Pull request #24809: Only Load Flow Log Complete Data when Opening Single Log
Author: login: connorwinston
Created: 2025-03-10T15:48:41Z | Merged: 2025-03-19T18:57:23Z

## Scope

What's changed:

- When listing flow logs, only include relevant fields, add options to use full revisions
- When opening flow log, fetch all the fields for the one item only
- Move Log Drawer to its own component
- Create new useRevision composable

## Potential Risks / Drawbacks

- Unknown

## Review Notes / Questions

- Set network throttle to 3G to see the loading indicator when opening the log drawer
- Ensure nothing changed to revisions in items (not flows)

---

Fixes #WEB-621



Diff:
diff --git a/.changeset/orange-dingos-ring.md b/.changeset/orange-dingos-ring.md
new file mode 100644
index 0000000000000..29c05d8745352
--- /dev/null
+++ b/.changeset/orange-dingos-ring.md
@@ -0,0 +1,5 @@
+---
+"@directus/app": patch
+---
+
+Fixed Flow Logs loading all step data when listing logs
diff --git a/app/src/composables/use-revision.ts b/app/src/composables/use-revision.ts
new file mode 100644
index 0000000000000..f6df95105ffcd
--- /dev/null
+++ b/app/src/composables/use-revision.ts
@@ -0,0 +1,84 @@
+import api from '@/api';
+import type { Revision } from '@/types/revisions';
+import { localizedFormat } from '@/utils/localized-format';
+import { localizedFormatDistance } from '@/utils/localized-format-distance';
+import { unexpectedError } from '@/utils/unexpected-error';
+import { format, parseISO } from 'date-fns';
+import { Ref, ref, unref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+export function useRevision(primaryKey: Ref<number | undefined>) {
+	const { t } = useI18n();
+
+	const revision = ref<(Revision & { timestampFormatted: string; timeRelative: string }) | null>(null);
+	const loading = ref(false);
+
+	watch([primaryKey], () => {
+		if (unref(primaryKey) !== unref(revision)?.id) {
+			revision.value = null;
+		}
+
+		if (typeof unref(primaryKey) === 'undefined') return;
+		refresh();
+	});
+
+	return {
+		revision,
+		getRevision,
+		loading,
+		refresh,
+	};
+
+	async function getRevision() {
+		if (typeof unref(primaryKey) === 'undefined') return;
+
+		loading.value = true;
+
+		try {
+			type RevisionResponse = { data: Revision };
+
+			const response = await api.get<RevisionResponse>(`/revisions/${unref(primaryKey)}`, {
+				params: {
+					fields: [
+						'id',
+						'data',
+						'delta',
+						'collection',
+						'item',
+						'activity.action',
+						'activity.timestamp',
+						'activity.user.id',
+						'activity.user.email',
+						'activity.user.first_name',
+						'activity.user.last_name',
+						'activity.ip',
+						'activity.user_agent',
+						'activity.origin',
+					],
+				},
+			});
+
+			revision.value = {
+				...response.data.data,
+				timestampFormatted: `${localizedFormat(
+					new Date(response.data.data.activity?.timestamp),
+					String(t('date-fns_date_short')),
+				)} (${localizedFormat(new Date(response.data.data.activity?.timestamp), String(t('date-fns_time')))})`,
+				timeRelative: `${format(
+					new Date(response.data.data.activity?.timestamp),
+					String(t('date-fns_time')),
+				)} (${localizedFormatDistance(parseISO(response.data.data.activity?.timestamp), new Date(), {
+					addSuffix: true,
+				})})`,
+			};
+		} catch (error) {
+			unexpectedError(error);
+		} finally {
+			loading.value = false;
+		}
+	}
+
+	async function refresh() {
+		await getRevision();
+	}
+}
diff --git a/app/src/composables/use-revisions.ts b/app/src/composables/use-revisions.ts
index ad73aa5a61e0f..d1d5d28f1fa78 100644
--- a/app/src/composables/use-revisions.ts
+++ b/app/src/composables/use-revisions.ts
@@ -1,6 +1,6 @@
 import api from '@/api';
 import { useServerStore } from '@/stores/server';
-import type { Revision, RevisionWithTime, RevisionsByDate } from '@/types/revisions';
+import type { Revision, RevisionPartial, RevisionWithTime, RevisionsByDate } from '@/types/revisions';
 import { localizedFormat } from '@/utils/localized-format';
 import { localizedFormatDistance } from '@/utils/localized-format-distance';
 import { unexpectedError } from '@/utils/unexpected-error';
@@ -13,6 +13,7 @@ import { useI18n } from 'vue-i18n';
 
 type UseRevisionsOptions = {
 	action?: Action;
+	full?: boolean;
 };
 
 export function useRevisions(
@@ -24,12 +25,12 @@ export function useRevisions(
 	const { t } = useI18n();
 	const { info } = useServerStore();
 
-	const revisions = ref<Revision[] | null>(null);
+	const revisions = ref<RevisionPartial[] | null>(null);
 	const revisionsByDate = ref<RevisionsByDate[] | null>(null);
 	const loading = ref(false);
 	const loadingCount = ref(false);
 	const revisionsCount = ref(0);
-	const created = ref<Revision>();
+	const created = ref<{ id: number }>();
 	const pagesCount = ref(0);
 
 	watch([collection, primaryKey, version], () => refresh());
@@ -86,7 +87,7 @@ export function useRevisions(
 				});
 			}
 
-			type RevisionResponse = { data: Revision[]; meta: { filter_count: number } };
+			type RevisionResponse = { data: Revision[] | RevisionPartial[]; meta: { filter_count: number } };
 
 			const response = await api.get<RevisionResponse>(`/revisions`, {
 				params: {
@@ -96,8 +97,6 @@ export function useRevisions(
 					page,
 					fields: [
 						'id',
-						'data',
-						'delta',
 						'collection',
 						'item',
 						'activity.action',
@@ -109,12 +108,13 @@ export function useRevisions(
 						'activity.ip',
 						'activity.user_agent',
 						'activity.origin',
+						...(options?.full ? ['data', 'delta'] : []),
 					],
 				},
 			});
 
 			if (!created.value) {
-				const createdResponse = await api.get<RevisionResponse>(`/revisions`, {
+				const createdResponse = await api.get<{ data: { id: number }[] }>(`/revisions`, {
 					params: {
 						filter: {
 							collection: {
@@ -132,22 +132,7 @@ export function useRevisions(
 						},
 						sort: '-id',
 						limit: 1,
-						fields: [
-							'id',
-							'data',
-							'delta',
-							'collection',
-							'item',
-							'activity.action',
-							'activity.timestamp',
-							'activity.user.id',
-							'activity.user.email',
-							'activity.user.first_name',
-							'activity.user.last_name',
-							'activity.ip',
-							'activity.user_agent',
-							'activity.origin',
-						],
+						fields: ['id'],
 					},
 				});
 
@@ -183,14 +168,16 @@ export function useRevisions(
 				for (const revision of value) {
 					revisions.push({
 						...revision,
-						timestampFormatted: await getFormattedDate(revision.activity?.timestamp),
-						timeRelative: `${getTime(revision.activity?.timestamp)} (${localizedFormatDistance(
-							parseISO(revision.activity?.timestamp),
-							new Date(),
-							{
-								addSuffix: true,
-							},
-						)})`,
+						timestampFormatted: `${localizedFormat(
+							new Date(revision.activity?.timestamp),
+							String(t('date-fns_date_short')),
+						)} (${localizedFormat(new Date(revision.activity?.timestamp), String(t('date-fns_time')))})`,
+						timeRelative: `${format(
+							new Date(revision.activity?.timestamp),
+							String(t('date-fns_time')),
+						)} (${localizedFormatDistance(parseISO(revision.activity?.timestamp), new Date(), {
+							addSuffix: true,
+						})})`,
 					});
 				}
 
@@ -270,15 +257,4 @@ export function useRevisions(
 		await getRevisionsCount();
 		await getRevisions(page);
 	}
-
-	function getTime(timestamp: string) {
-		return format(new Date(timestamp), String(t('date-fns_time')));
-	}
-
-	async function getFormattedDate(timestamp: string) {
-		const date = localizedFormat(new Date(timestamp), String(t('date-fns_date_short')));
-		const time = localizedFormat(new Date(timestamp), String(t('date-fns_time')));
-
-		return `${date} (${time})`;
-	}
 }
diff --git a/app/src/modules/settings/routes/flows/components/logs-drawer.vue b/app/src/modules/settings/routes/flows/components/logs-drawer.vue
new file mode 100644
index 0000000000000..4dbfa045be328
--- /dev/null
+++ b/app/src/modules/settings/routes/flows/components/logs-drawer.vue
@@ -0,0 +1,273 @@
+<script setup lang="ts">
+import { useExtensions } from '@/extensions';
+import { computed, toRefs, unref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { getTriggers } from '../triggers';
+import { FlowRaw } from '@directus/types';
+import { useRevision } from '@/composables/use-revision';
+
+const props = defineProps<{
+	revisionId?: number;
+	flow: FlowRaw;
+}>();
+
+const { revisionId, flow } = toRefs(props);
+
+const { t } = useI18n();
+
+const { triggers } = getTriggers();
+const { operations } = useExtensions();
+
+const usedTrigger = computed(() => {
+	return triggers.find((trigger) => trigger.id === unref(flow).trigger);
+});
+
+const { revision, loading } = useRevision(revisionId);
+
+const triggerData = computed(() => {
+	if (!unref(revision)?.data) return { trigger: null, accountability: null, options: null };
+
+	const { data } = unref(revision)?.data as any;
+
+	return {
+		trigger: data.$trigger,
+		accountability: data.$accountability,
+		options: props.flow.options,
+	};
+});
+
+const emit = defineEmits(['close']);
+
+const steps = computed(() => {
+	if (!revision.value || !unref(revision)?.data?.steps) return [];
+	const { steps } = revision.value.data as any;
+
+	return steps.map(
+		({
+			operation,
+			status,
+			key,
+			options,
+		}: {
+			operation: string;
+			status: 'reject' | 'resolve' | 'unknown';
+			key: string;
+			options: Record<string, any>;
+		}) => {
+			const operationConfiguration = props.flow.operations.find((operationConfig) => operationConfig.id === operation);
+
+			const operationType = operations.value.find((operation) => operation.id === operationConfiguration?.type);
+			return {
+				id: operation,
+				name: operationConfiguration?.name ?? key,
+				data: unref(revision)?.data?.data?.[key] ?? null,
+				options: options ?? null,
+				operationType: operationType?.name ?? operationConfiguration?.type ?? '--',
+				key,
+				status,
+			};
+		},
+	);
+});
+</script>
+
+<template>
+	<v-drawer
+		:model-value="!!revisionId"
+		:title="revision ? revision.timestampFormatted : t('logs')"
+		icon="fact_check"
+		@cancel="emit('close')"
+		@esc="emit('close')"
+	>
+		<div class="content">
+			<v-progress-linear v-if="!revision && loading" indeterminate />
+			<div v-else class="steps">
+				<div class="step">
+					<div class="header">
+						<span class="dot" />
+						<span class="type-label">
+							{{ t('trigger') }}
+							<span class="subdued">&nbsp;{{ usedTrigger?.name }}</span>
+						</span>
+					</div>
+
+					<div class="inset">
+						<v-detail v-if="triggerData.options" :label="t('options')">
+							<pre class="json selectable">{{ triggerData.options }}</pre>
+						</v-detail>
+
+						<v-detail v-if="triggerData.trigger" :label="t('payload')">
+							<pre class="json selectable">{{ triggerData.trigger }}</pre>
+						</v-detail>
+
+						<v-detail v-if="triggerData.accountability" :label="t('accountability')">
+							<pre class="json selectable">{{ triggerData.accountability }}</pre>
+						</v-detail>
+					</div>
+				</div>
+
+				<div v-for="step of steps" :key="step.id" class="step">
+					<div class="header">
+						<span class="dot" :class="step.status" />
+						<span v-tooltip="step.key" class="type-label">
+							{{ step.name }}
+							<span class="subdued">&nbsp;{{ step.operationType }}</span>
+						</span>
+					</div>
+
+					<div class="inset">
+						<v-detail v-if="step.options" :label="t('options')">
+							<pre class="json selectable">{{ step.options }}</pre>
+						</v-detail>
+
+						<v-detail v-if="step.data !== null" :label="t('payload')">
+							<pre class="json selectable">{{ step.data }}</pre>
+						</v-detail>
+					</div>
+				</div>
+			</div>
+		</div>
+	</v-drawer>
+</template>
+
+<style lang="scss" scoped>
+.v-progress-linear {
+	margin: 24px 0;
+}
+
+.content {
+	padding: var(--content-padding);
+}
+
+.log {
+	position: relative;
+	display: block;
+
+	button {
+		position: relative;
+		z-index: 2;
+		display: block;
+		width: 100%;
+		text-align: left;
+	}
+
+	&::before {
+		position: absolute;
+		top: -4px;
+		left: -4px;
+		z-index: 1;
+		width: calc(100% + 8px);
+		height: calc(100% + 8px);
+		background-color: var(--theme--background-accent);
+		border-radius: var(--theme--border-radius);
+		opacity: 0;
+		transition: opacity var(--fast) var(--transition);
+		content: '';
+		pointer-events: none;
+	}
+
+	&:hover {
+		cursor: pointer;
+
+		.header {
+			.dot {
+				border-color: var(--theme--background-accent);
+			}
+		}
+
+		&::before {
+			opacity: 1;
+		}
+	}
+
+	& + & {
+		margin-top: 8px;
+	}
+}
+
+.json {
+	background-color: var(--theme--background-subdued);
+	font-family: var(--theme--fonts--monospace--font-family);
+	border-radius: var(--theme--border-radius);
+	padding: 20px;
+	margin-top: 20px;
+	white-space: pre-wrap;
+	overflow-wrap: break-word;
+}
+
+.steps {
+	position: relative;
+
+	.step {
+		position: relative;
+
+		&::after {
+			content: '';
+			position: absolute;
+			width: var(--theme--border-width);
+			left: -11px;
+			top: 0;
+			background-color: var(--theme--border-color-subdued);
+			height: 100%;
+		}
+
+		&:first-child::after {
+			top: 8px;
+			height: calc(100% - 8px);
+		}
+
+		&:last-child::after {
+			height: 12px;
+		}
+
+		.inset {
+			padding-top: 12px;
+			padding-bottom: 32px;
+
+			.v-detail + .v-detail {
+				margin-top: 12px;
+			}
+		}
+
+		.subdued {
+			color: var(--theme--foreground-subdued);
+		}
+	}
+
+	.mono {
+		font-family: var(--theme--fonts--monospace--font-family);
+		color: var(--theme--foreground-subdued);
+	}
+
+	.dot {
+		position: absolute;
+		top: 6px;
+		left: -16px;
+		z-index: 2;
+		width: 12px;
+		height: 12px;
+		background-color: var(--theme--primary);
+		border: var(--theme--border-width) solid var(--theme--background);
+		border-radius: 8px;
+
+		&.resolve {
+			background-color: var(--theme--primary);
+		}
+
+		&.reject {
+			background-color: var(--theme--secondary);
+		}
+	}
+}
+
+.empty {
+	margin-left: 2px;
+	color: var(--theme--foreground-subdued);
+	font-style: italic;
+}
+
+.v-pagination {
+	justify-content: center;
+	margin-top: 24px;
+}
+</style>
diff --git a/app/src/modules/settings/routes/flows/components/logs-sidebar-detail.vue b/app/src/modules/settings/routes/flows/components/logs-sidebar-detail.vue
index fdad86089786b..76d702b3cb5e4 100644
--- a/app/src/modules/settings/routes/flows/components/logs-sidebar-detail.vue
+++ b/app/src/modules/settings/routes/flows/components/logs-sidebar-detail.vue
@@ -1,13 +1,12 @@
 <script setup lang="ts">
 import { useRevisions } from '@/composables/use-revisions';
-import { useExtensions } from '@/extensions';
 import { useGroupable } from '@directus/composables';
 import { Action } from '@directus/constants';
 import type { FlowRaw } from '@directus/types';
 import { abbreviateNumber } from '@directus/utils';
 import { computed, onMounted, ref, toRefs, unref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
-import { getTriggers } from '../triggers';
+import LogsDrawer from './logs-drawer.vue';
 
 const props = defineProps<{
 	flow: FlowRaw;
@@ -24,13 +23,6 @@ const { active: open } = useGroupable({
 	group: 'sidebar-detail',
 });
 
-const { triggers } = getTriggers();
-const { operations } = useExtensions();
-
-const usedTrigger = computed(() => {
-	return triggers.find((trigger) => trigger.id === unref(flow).trigger);
-});
-
 const page = ref<number>(1);
 
 const { revisionsByDate, getRevisions, revisionsCount, getRevisionsCount, loading, loadingCount, pagesCount, refresh } =
@@ -57,51 +49,6 @@ onMounted(() => {
 
 const previewing = ref();
 
-const triggerData = computed(() => {
-	if (!unref(previewing)?.data) return { trigger: null, accountability: null, options: null };
-
-	const { data } = unref(previewing).data;
-
-	return {
-		trigger: data.$trigger,
-		accountability: data.$accountability,
-		options: props.flow.options,
-	};
-});
-
-const steps = computed(() => {
-	if (!unref(previewing)?.data?.steps) return [];
-	const { steps } = unref(previewing).data;
-
-	return steps.map(
-		({
-			operation,
-			status,
-			key,
-			options,
-		}: {
-			operation: string;
-			status: 'reject' | 'resolve' | 'unknown';
-			key: string;
-			options: Record<string, any>;
-		}) => {
-			const operationConfiguration = props.flow.operations.find((operationConfig) => operationConfig.id === operation);
-
-			const operationType = operations.value.find((operation) => operation.id === operationConfiguration?.type);
-
-			return {
-				id: operation,
-				name: operationConfiguration?.name ?? key,
-				data: unref(previewing).data?.data?.[key] ?? null,
-				options: options ?? null,
-				operationType: operationType?.name ?? operationConfiguration?.type ?? '--',
-				key,
-				status,
-			};
-		},
-	);
-});
-
 function onToggle(open: boolean) {
 	if (open && revisionsByDate.value === null) getRevisions();
 }
@@ -139,61 +86,7 @@ function onToggle(open: boolean) {
 		<v-pagination v-if="pagesCount > 1" v-model="page" :length="pagesCount" :total-visible="3" />
 	</sidebar-detail>
 
-	<v-drawer
-		:model-value="!!previewing"
-		:title="previewing ? previewing.timestampFormatted : t('logs')"
-		icon="fact_check"
-		@cancel="previewing = null"
-		@esc="previewing = null"
-	>
-		<div class="content">
-			<div class="steps">
-				<div class="step">
-					<div class="header">
-						<span class="dot" />
-						<span class="type-label">
-							{{ t('trigger') }}
-							<span class="subdued">&nbsp;{{ usedTrigger?.name }}</span>
-						</span>
-					</div>
-
-					<div class="inset">
-						<v-detail v-if="triggerData.options" :label="t('options')">
-							<pre class="json selectable">{{ triggerData.options }}</pre>
-						</v-detail>
-
-						<v-detail v-if="triggerData.trigger" :label="t('payload')">
-							<pre class="json selectable">{{ triggerData.trigger }}</pre>
-						</v-detail>
-
-						<v-detail v-if="triggerData.accountability" :label="t('accountability')">
-							<pre class="json selectable">{{ triggerData.accountability }}</pre>
-						</v-detail>
-					</div>
-				</div>
-
-				<div v-for="step of steps" :key="step.id" class="step">
-					<div class="header">
-						<span class="dot" :class="step.status" />
-						<span v-tooltip="step.key" class="type-label">
-							{{ step.name }}
-							<span class="subdued">&nbsp;{{ step.operationType }}</span>
-						</span>
-					</div>
-
-					<div class="inset">
-						<v-detail v-if="step.options" :label="t('options')">
-							<pre class="json selectable">{{ step.options }}</pre>
-						</v-detail>
-
-						<v-detail v-if="step.data !== null" :label="t('payload')">
-							<pre class="json selectable">{{ step.data }}</pre>
-						</v-detail>
-					</div>
-				</div>
-			</div>
-		</div>
-	</v-drawer>
+	<LogsDrawer :revision-id="previewing?.id" :flow="props.flow" @close="previewing = null" />
 </template>
 
 <style lang="scss" scoped>
@@ -251,81 +144,6 @@ function onToggle(open: boolean) {
 	}
 }
 
-.json {
-	background-color: var(--theme--background-subdued);
-	font-family: var(--theme--fonts--monospace--font-family);
-	border-radius: var(--theme--border-radius);
-	padding: 20px;
-	margin-top: 20px;
-	white-space: pre-wrap;
-	overflow-wrap: break-word;
-}
-
-.steps {
-	position: relative;
-
-	.step {
-		position: relative;
-
-		&::after {
-			content: '';
-			position: absolute;
-			width: var(--theme--border-width);
-			left: -11px;
-			top: 0;
-			background-color: var(--theme--border-color-subdued);
-			height: 100%;
-		}
-
-		&:first-child::after {
-			top: 8px;
-			height: calc(100% - 8px);
-		}
-
-		&:last-child::after {
-			height: 12px;
-		}
-
-		.inset {
-			padding-top: 12px;
-			padding-bottom: 32px;
-
-			.v-detail + .v-detail {
-				margin-top: 12px;
-			}
-		}
-
-		.subdued {
-			color: var(--theme--foreground-subdued);
-		}
-	}
-
-	.mono {
-		font-family: var(--theme--fonts--monospace--font-family);
-		color: var(--theme--foreground-subdued);
-	}
-
-	.dot {
-		position: absolute;
-		top: 6px;
-		left: -16px;
-		z-index: 2;
-		width: 12px;
-		height: 12px;
-		background-color: var(--theme--primary);
-		border: var(--theme--border-width) solid var(--theme--background);
-		border-radius: 8px;
-
-		&.resolve {
-			background-color: var(--theme--primary);
-		}
-
-		&.reject {
-			background-color: var(--theme--secondary);
-		}
-	}
-}
-
 .empty {
 	margin-left: 2px;
 	color: var(--theme--foreground-subdued);
diff --git a/app/src/types/revisions.ts b/app/src/types/revisions.ts
index bfab3f14a6345..b8da05e8cb59c 100644
--- a/app/src/types/revisions.ts
+++ b/app/src/types/revisions.ts
@@ -1,7 +1,10 @@
 export type Revision = {
-	id: number;
 	data: Record<string, any> | null;
 	delta: Record<string, any> | null;
+} & RevisionPartial;
+
+export type RevisionPartial = {
+	id: number;
 	collection: string;
 	item: string | number;
 	activity: {
@@ -21,7 +24,7 @@ export type Revision = {
 	};
 };
 
-export type RevisionWithTime = Revision & {
+export type RevisionWithTime = RevisionPartial & {
 	timestampFormatted: string;
 	timeRelative: string;
 };
diff --git a/app/src/views/private/components/revisions-drawer-detail.vue b/app/src/views/private/components/revisions-drawer-detail.vue
index ad4adea941d4c..426c38d5a90e4 100644
--- a/app/src/views/private/components/revisions-drawer-detail.vue
+++ b/app/src/views/private/components/revisions-drawer-detail.vue
@@ -42,7 +42,7 @@ const {
 	getRevisions,
 	loadingCount,
 	getRevisionsCount,
-} = useRevisions(collection, primaryKey, version);
+} = useRevisions(collection, primaryKey, version, { full: true });
 
 onMounted(() => {
 	getRevisionsCount();


Reviews:
user: login: AlexGaillard
body: Just some small thoughts and requests here. Functionally this seems to work fine.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-14T20:26:38Z
user: login: AlexGaillard
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T20:28:33Z
user: login: connorwinston
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-14T20:57:54Z
user: login: AlexGaillard
body: :shipit: 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T18:57:13Z

Review comments:
diff_hunk: @@ -1,4 +1,9 @@
 export type Revision = {
+	data: Record<string, any> | null;
+	delta: Record<string, any> | null;
+} & RevisionPartial;
+
+export type RevisionPartial = {
 	id: number;
 	data: Record<string, any> | null;
 	delta: Record<string, any> | null;
path: app/src/types/revisions.ts
user: login: AlexGaillard
body: Are these not optional or excluded given that they aren't included when the `full` prop isn't passed?

created_at: 2025-03-14T19:25:05Z
author_association: MEMBER
path: app/src/modules/settings/routes/flows/components/logs-sidebar-detail.vue
user: login: AlexGaillard
body: There's a bunch of defunct styles at the bottom of this file that is no longer relevant since the drawer has been moved to its own component.

I can't highlight them specifically as they're not part of the changed code.
created_at: 2025-03-14T19:57:05Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,93 @@
+import api from '@/api';
+import type { Revision } from '@/types/revisions';
+import { localizedFormat } from '@/utils/localized-format';
+import { localizedFormatDistance } from '@/utils/localized-format-distance';
+import { unexpectedError } from '@/utils/unexpected-error';
+import { format, parseISO } from 'date-fns';
+import { Ref, ref, unref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+export function useRevision(primaryKey: Ref<number | undefined>) {
+	const { t } = useI18n();
+
+	const revision = ref<(Revision & { timestampFormatted: string; timeRelative: string }) | null>(null);
+	const loading = ref(false);
+
+	watch([primaryKey], () => {
+		if (unref(primaryKey) !== unref(revision)?.id) {
+			revision.value = null;
+		}
+
+		if (typeof unref(primaryKey) === 'undefined') return;
+		refresh();
+	});
+
+	return {
+		revision,
+		getRevision,
+		loading,
+		refresh,
+	};
+
+	async function getRevision() {
+		if (typeof unref(primaryKey) === 'undefined') return;
+
+		loading.value = true;
+
+		try {
+			type RevisionResponse = { data: Revision };
+
+			const response = await api.get<RevisionResponse>(`/revisions/${unref(primaryKey)}`, {
+				params: {
+					fields: [
+						'id',
+						'data',
+						'delta',
+						'collection',
+						'item',
+						'activity.action',
+						'activity.timestamp',
+						'activity.user.id',
+						'activity.user.email',
+						'activity.user.first_name',
+						'activity.user.last_name',
+						'activity.ip',
+						'activity.user_agent',
+						'activity.origin',
+					],
+				},
+			});
+
+			revision.value = {
+				...response.data.data,
+				timestampFormatted: await getFormattedDate(response.data.data.activity?.timestamp),
+				timeRelative: `${getTime(response.data.data.activity?.timestamp)} (${localizedFormatDistance(
+					parseISO(response.data.data.activity?.timestamp),
+					new Date(),
+					{
+						addSuffix: true,
+					},
+				)})`,
+			};
+		} catch (error) {
+			unexpectedError(error);
+		} finally {
+			loading.value = false;
+		}
+	}
+
+	async function refresh() {
+		await getRevision();
+	}
+
+	function getTime(timestamp: string) {
+		return format(new Date(timestamp), String(t('date-fns_time')));
+	}
+
+	async function getFormattedDate(timestamp: string) {
+		const date = localizedFormat(new Date(timestamp), String(t('date-fns_date_short')));
+		const time = localizedFormat(new Date(timestamp), String(t('date-fns_time')));
+
+		return `${date} (${time})`;
+	}
path: app/src/composables/use-revision.ts
user: login: AlexGaillard
body: We've got quite a lot of repeated logic here between `use-revisions` and `use-revision`.

Should we maybe not move these into a common composable shared between the two?
created_at: 2025-03-14T20:25:16Z
author_association: MEMBER
diff_hunk: @@ -132,22 +132,7 @@ export function useRevisions(
 						},
 						sort: '-id',
 						limit: 1,
-						fields: [
-							'id',
-							'data',
-							'delta',
-							'collection',
-							'item',
-							'activity.action',
-							'activity.timestamp',
-							'activity.user.id',
-							'activity.user.email',
-							'activity.user.first_name',
-							'activity.user.last_name',
-							'activity.ip',
-							'activity.user_agent',
-							'activity.origin',
-						],
+						fields: ['id'],
path: app/src/composables/use-revisions.ts
user: login: AlexGaillard
body: Just make sure I understand this correctly. The extra fields were removed because `created` is only used to assert existence of a revision in `revisions-drawer-detail`, and thus only `id` is needed?
created_at: 2025-03-14T20:28:33Z
author_association: MEMBER
diff_hunk: @@ -132,22 +132,7 @@ export function useRevisions(
 						},
 						sort: '-id',
 						limit: 1,
-						fields: [
-							'id',
-							'data',
-							'delta',
-							'collection',
-							'item',
-							'activity.action',
-							'activity.timestamp',
-							'activity.user.id',
-							'activity.user.email',
-							'activity.user.first_name',
-							'activity.user.last_name',
-							'activity.ip',
-							'activity.user_agent',
-							'activity.origin',
-						],
+						fields: ['id'],
path: app/src/composables/use-revisions.ts
user: login: connorwinston
body: Yes! That is correct.
created_at: 2025-03-14T20:57:54Z
author_association: MEMBER

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-03-10T15:48:45Z
body: ###  🦋  Changeset detected

Latest commit: 8010a52bcc9c5ede04012084e5d2f0468f2794e6

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 3 packages</summary>

  | Name          | Type  |
| ------------- | ----- |
| @directus/app | Patch |
| @directus/api | Patch |
| directus      | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/directus/directus/new/connorwinston/eng-26?filename=.changeset/two-zebras-hide.md&value=---%0A%22%40directus%2Fapp%22%3A%20patch%0A---%0A%0AOnly%20Load%20Flow%20Log%20Complete%20Data%20when%20Opening%20Single%20Log%0A)


author_association: NONE
user: login: linear[bot]
created_at: 2025-03-10T15:48:57Z
body: <p><a href="https://linear.app/directus/issue/WEB-621/error-on-loading-flow-logs">WEB-621 Error on loading Flow Logs</a></p>
author_association: NONE
user: login: joselcvarela
created_at: 2025-03-21T16:11:10Z
body: Related https://github.com/directus/directus/issues/24620
author_association: MEMBER