Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -374,11 +374,11 @@ test('GenericGrid Table Visualisation Test - two column - link on second', async
await expect(numberWidget).toHaveValue('2')
})

/*
/*
These tests pair with the Enso tests found at test/Visualization_Tests/src/Table_Visualisation_Integration_Spec.enso
Those tests check the json produced by prepare_visualization matches a baseline
These tests check that json data then renders correctly in an AG Grid in the GUI
If you change the json API you can regen the reference json by commenting in the line of code in
If you change the json API you can regen the reference json by commenting in the line of code in
check_equal in Table_Visualisation_Integration_Spec.enso and running those tests
Then run the js prettier
Remember to comment the write back out
Expand Down Expand Up @@ -536,6 +536,6 @@ function getCellLocator(page: Page, colId: string, rowIndex: number) {
// Helper function to check cell values in a column
async function expectCellDataToBe(page: Page, colId: string, expectedValues: string[]) {
for (let i = 0; i < expectedValues.length; i++) {
expect(getCellLocator(page, colId, i)).toContainText(expectedValues[i] ?? '')
await expect(getCellLocator(page, colId, i)).toHaveText(expectedValues[i]!)
}
}
9 changes: 7 additions & 2 deletions app/gui/src/dashboard/utilities/LocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,12 @@ export default class LocalStorage {
/** React hook for viewing whole `LocalStorage` contents as a state variable. */
export function useLocalStorageValues(storage: LocalStorage): Partial<LocalStorageData> {
return useVueValue(
useCallback(() => storage['values'], [storage]),
true,
useCallback(() => {
// NOTE: `values` is shallowReactive. Create a shallow snapshot to:
// - avoid deep traversal (stack overflow risk),
// - provide a new reference so React re-renders.
const values = storage['values']
return { ...values }
}, [storage]),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useGraphStore, useProjectStore } from '$/components/WithCurrentProject.
import { type NodeId } from '$/providers/openedProjects/graph/graphDatabase'
import { TypeInfo } from '$/providers/openedProjects/project/computedValueRegistry'
import type { NodeVisualizationConfiguration } from '$/providers/openedProjects/project/executionContext'
import { visualizationConfigPreprocessorEqual } from '$/providers/openedProjects/project/executionContext'
import type { ToValue } from '$/utils/reactivity'
import LoadingErrorVisualization from '@/components/visualizations/LoadingErrorVisualization.vue'
import LoadingVisualization from '@/components/visualizations/LoadingVisualization.vue'
Expand Down Expand Up @@ -61,8 +62,10 @@ export function useVisualizationData({
const graph = useGraphStore()

// Flag used to prevent rendering the visualization with a stale preprocessor while the new preprocessor is being
// prepared asynchronously.
const preprocessorLoading = ref(false)
// prepared asynchronously or while the first result for a newly attached node visualization is still pending.
const moduleLoading = ref(false)
const nodeDataLoading = ref(false)
const preprocessorLoading = computed(() => moduleLoading.value || nodeDataLoading.value)

const configForGettingDefaultVisualization = computed<NodeVisualizationConfiguration | undefined>(
() => {
Expand Down Expand Up @@ -134,14 +137,41 @@ export function useVisualizationData({
return false
})

const nodeVisualizationData = projectStore.useVisualizationData(() => {
const nodeVisualizationConfig = computed<NodeVisualizationConfiguration | undefined>(() => {
const dataSourceValue = toValue(dataSource)
if (dataSourceValue?.type !== 'node') return
return {
...visPreprocessor.value,
expressionId: dataSourceValue.nodeId,
}
})
const nodeVisualizationData = projectStore.useVisualizationData(nodeVisualizationConfig)

// When a node visualization switches preprocessors, the old payload can remain visible until the
// new attachment starts producing updates. Keep the visualization in loading state during that gap.
watch(
nodeVisualizationConfig,
(config, oldConfig) => {
if (config == null) {
nodeDataLoading.value = false
return
}
if (oldConfig == null || !visualizationConfigPreprocessorEqual(config, oldConfig)) {
nodeDataLoading.value = true
}
},
{ immediate: true },
)

watch(
nodeVisualizationData,
(data) => {
if (nodeDataLoading.value && data != null) {
nodeDataLoading.value = false
}
},
{ immediate: true },
)

const expressionVisualizationData = computedAsync(
() => {
Expand Down Expand Up @@ -188,6 +218,7 @@ export function useVisualizationData({
const name = currentVisualization.value?.name
if (dataSourceValue?.type === 'raw') return dataSourceValue.data
if (vueError.value) return { name, error: vueError.value }
if (dataSourceValue?.type === 'node' && preprocessorLoading.value) return
const visualizationData = nodeVisualizationData.value ?? expressionVisualizationData.value
if (!visualizationData) return
if (visualizationData.ok) return visualizationData.value
Expand All @@ -212,10 +243,10 @@ export function useVisualizationData({
)

watchEffect(async () => {
preprocessorLoading.value = true
if (currentVisualization.value == null) return
visualization.value = undefined
moduleLoading.value = true
try {
if (currentVisualization.value == null) return
visualization.value = undefined
const module = await visualizationStore.get(currentVisualization.value).value
if (module) {
if (module.defaultPreprocessor != null) {
Expand Down Expand Up @@ -248,16 +279,18 @@ export function useVisualizationData({
}
} catch (caughtError) {
vueError.value = toError(caughtError)
} finally {
moduleLoading.value = false
}
preprocessorLoading.value = false
})

const allVisualizations = computed(() => Array.from(visualizationStore.byType(toValue(typeinfo))))

const effectiveVisualization = computed(() => {
const visualizationIsReady = toValue(dataSource)?.type !== 'node' || !preprocessorLoading.value
if (
vueError.value ||
(nodeVisualizationData.value && !nodeVisualizationData.value.ok) ||
(!visualizationIsReady && nodeVisualizationData.value && !nodeVisualizationData.value.ok) ||
(expressionVisualizationData.value && !expressionVisualizationData.value.ok)
) {
return LoadingErrorVisualization
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
const { src, title } = defineProps<{ src: string; title: string }>()
const { src, title } = defineProps<{ src: string; title: string | undefined }>()
</script>

<template>
Expand All @@ -8,7 +8,7 @@ const { src, title } = defineProps<{ src: string; title: string }>()
<iframe
class="youtube-video"
:src="src"
:title="title"
:title="title ?? 'Video'"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ export function decorateImageWithRendered(
if (nodeRef.name === 'Image') {
const parsed = parseLinkLike(nodeRef, doc)
if (!parsed) return
const { text, url } = parsed
const { text, url, title } = parsed
const alt = doc.sliceString(text.from, text.to)
const widget = new MediaWidget({ src: url, alt }, vueHost)
const widget = new MediaWidget({ src: url, alt, title }, vueHost)
emitDecoration(
Range.emptyAt(nodeRef.to),
Decoration.widget({
Expand All @@ -133,9 +133,10 @@ export function decorateImageWithRendered(
}

class MediaWidget extends VueDecorationWidget<{ alt: string; src: string }> {
constructor(props: { alt: string; src: string }, vueHost: VueHost) {
constructor(props: { alt: string; src: string; title?: string | undefined }, vueHost: VueHost) {
const isVideo = props.src.match(/https:\/\/www\.youtube(-nocookie)?\.com\/embed\/[^/]+/)
const component = isVideo ? DocumentationVideo : DocumentationImage
if (!isVideo) delete props.title
super(component, props, vueHost, 'cm-media-rendered', 'span')
}

Expand Down
25 changes: 18 additions & 7 deletions app/gui/src/project-view/composables/syncLocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export function useSyncLocalStorage<StoredState extends object>(
// The default (`pre`) cannot be used because state captured during `beforeMount` would never
// be flushed.
flush: 'sync',
// `deep: true` would traverse stored state recursively; keep it shallow to avoid stack
// overflows if something unexpectedly non-JSON leaks into view state.
deep: false,
})

/**
Expand Down Expand Up @@ -112,16 +115,19 @@ export function useSyncLocalStorage<StoredState extends object>(
})

function saveState(storageKey: string, state: StoredState) {
storageMap.value.set(storageKey, state)
const next = new Map(storageMap.value)
next.set(storageKey, state)
// Ensure that the storage doesn't grow forever by periodically removing least recently
// written half of entries when we reach a limit.
if (storageMap.value.size > MAX_STORED_GRAPH_STATES) {
let toRemove = storageMap.value.size - MAX_STORED_GRAPH_STATES / 2
for (const key of storageMap.value.keys()) {
if (next.size > MAX_STORED_GRAPH_STATES) {
let toRemove = next.size - MAX_STORED_GRAPH_STATES / 2
for (const key of next.keys()) {
if (toRemove-- <= 0) break
storageMap.value.delete(key)
next.delete(key)
}
}
// storageMap is observed by a shallow watcher which would not pick up internal changes, so we replace the whole value.
storageMap.value = next
}

async function restoreState(storageKey: string) {
Expand All @@ -136,7 +142,9 @@ export function useSyncLocalStorage<StoredState extends object>(
await restoreStateInCtx(restored, restoreAbort.signal)
} catch (e) {
// Ignore promise rejections caused by aborted scope. Those are expected to happen.
if (!restoreAbort.signal.aborted) throw e
if (!restoreAbort.signal.aborted) {
throw e
}
} finally {
if (restoreIdInProgress.value === thisRestoreId) restoreIdInProgress.value = undefined
}
Expand All @@ -152,7 +160,10 @@ export function useSyncLocalStorage<StoredState extends object>(
const stateBlob = storageMap.value.get(oldKey)
if (stateBlob != null) {
const newKey = encodeKey(newKeyEncoder)
storageMap.value.set(newKey, stateBlob)
const next = new Map(storageMap.value)
next.set(newKey, stateBlob)
// storageMap is observed by a shallow watcher which would not pick up internal changes, so we replace the whole value.
storageMap.value = next
}
},
}
Expand Down
6 changes: 4 additions & 2 deletions app/gui/src/providers/openedProjects/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,11 @@ export function createProjectStore(
visId.value = undefined
return
}
// Regenerate the visualization ID when the preprocessor changes.
if (!visualizationConfigPreprocessorEqual(config, oldConfig))

if (!visualizationConfigPreprocessorEqual(config, oldConfig) || visId.value == null) {
visId.value = crypto.randomUUID()
}

const id = visId.value!
executionContext.setVisualization(id, config)
onCleanup(() => executionContext.setVisualization(id, null))
Expand Down
Loading