mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-09 09:46:44 +08:00
fix(dashboard): deactivate charts before route leave (#733)
This commit is contained in:
@@ -547,11 +547,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/composables/useSetupWizard.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/composables/useSharedDialog.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -823,16 +818,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/setup/MediaServerSettingsStep.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/setup/PreferencesSettingsStep.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/views/subscribe/FullCalendarView.vue": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@@ -130,9 +130,7 @@ let dashboardLoadGeneration = 0
|
||||
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
||||
|
||||
// 仪表盘远程组件共享源码,但所有插件动态 API 保持实例级隔离。
|
||||
const scopedPluginApi = computed(() =>
|
||||
createPluginInstanceApi(props.config?.id || '', props.config?.source_plugin_id),
|
||||
)
|
||||
const scopedPluginApi = computed(() => createPluginInstanceApi(props.config?.id || '', props.config?.source_plugin_id))
|
||||
|
||||
// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载。
|
||||
const pluginDashboardIdentity = computed(
|
||||
@@ -241,7 +239,7 @@ onUnmounted(() => {
|
||||
<AnalyticsStorage v-if="config?.id === 'storage'" />
|
||||
<AnalyticsMediaStatistic v-else-if="config?.id === 'mediaStatistic'" />
|
||||
<MediaRecommend v-else-if="config?.id === 'mediaRecommend'" />
|
||||
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" />
|
||||
<AnalyticsWeeklyOverview v-else-if="config?.id === 'weeklyOverview'" :allow-refresh="props.allowRefresh" />
|
||||
<AnalyticsSpeed v-else-if="config?.id === 'speed'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsScheduler v-else-if="config?.id === 'scheduler'" :allowRefresh="props.allowRefresh" />
|
||||
<AnalyticsCpu v-else-if="config?.id === 'cpu'" :allowRefresh="props.allowRefresh" />
|
||||
@@ -274,7 +272,12 @@ onUnmounted(() => {
|
||||
<div v-if="props.config?.attrs.border === false">
|
||||
<VCard>
|
||||
<VCardText class="p-0">
|
||||
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||
<DashboardRender
|
||||
v-for="(item, index) in props.config?.elements"
|
||||
:key="index"
|
||||
:active="props.allowRefresh"
|
||||
:config="item"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
@@ -287,7 +290,12 @@ onUnmounted(() => {
|
||||
<VCardSubtitle v-if="props.config?.attrs?.subtitle"> {{ props.config?.attrs?.subtitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<DashboardRender v-for="(item, index) in props.config?.elements" :key="index" :config="item" />
|
||||
<DashboardRender
|
||||
v-for="(item, index) in props.config?.elements"
|
||||
:key="index"
|
||||
:active="props.allowRefresh"
|
||||
:config="item"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
@@ -4,31 +4,44 @@ import { type PropType } from 'vue'
|
||||
|
||||
// 输入参数
|
||||
const elementProps = defineProps({
|
||||
// 仪表盘失活时仅卸载依赖已连接 DOM 的图表,保留静态结构和其他控件状态。
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
config: Object as PropType<RenderProps>,
|
||||
})
|
||||
|
||||
const canRenderComponent = computed(() => elementProps.active || elementProps.config?.component !== 'VApexChart')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="elementProps.config?.component"
|
||||
v-if="!elementProps.config?.html"
|
||||
v-if="canRenderComponent && !elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
>
|
||||
{{ elementProps.config?.text }}
|
||||
<template v-for="(content, name) in elementProps.config?.slots || []" :key="name" v-slot:[name]="{ _props }">
|
||||
<slot :name="name" v-bind="_props">
|
||||
<DashboardRender v-for="(slotItem, slotIndex) in content || []" :key="slotIndex" :config="slotItem" />
|
||||
<DashboardRender
|
||||
v-for="(slotItem, slotIndex) in content || []"
|
||||
:key="slotIndex"
|
||||
:active="elementProps.active"
|
||||
:config="slotItem"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<DashboardRender
|
||||
v-for="(innerItem, innerIndex) in elementProps.config?.content || []"
|
||||
:key="innerIndex"
|
||||
:active="elementProps.active"
|
||||
:config="innerItem"
|
||||
/>
|
||||
</Component>
|
||||
<Component
|
||||
:is="elementProps.config?.component"
|
||||
v-if="elementProps.config?.html"
|
||||
v-if="canRenderComponent && elementProps.config?.html"
|
||||
v-bind="elementProps.config?.props"
|
||||
v-html="elementProps.config?.html"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const ApexChartStub = defineComponent({
|
||||
name: 'VApexChart',
|
||||
setup: () => () => h('div', { 'data-testid': 'plugin-chart' }),
|
||||
})
|
||||
|
||||
describe('DashboardRender', () => {
|
||||
it('recreates nested plugin charts across dashboard activation without removing surrounding content', async () => {
|
||||
const view = await renderWithProviders(DashboardRender, {
|
||||
props: {
|
||||
active: true,
|
||||
config: {
|
||||
component: 'VRow',
|
||||
content: [
|
||||
{ component: 'VCol', content: [{ component: 'VApexChart' }] },
|
||||
{ component: 'VCol', text: '保留的插件内容' },
|
||||
],
|
||||
},
|
||||
},
|
||||
global: { stubs: { VApexChart: ApexChartStub } },
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('plugin-chart')).toBeInTheDocument()
|
||||
expect(screen.getByText('保留的插件内容')).toBeInTheDocument()
|
||||
|
||||
await view.rerender({ active: false })
|
||||
|
||||
expect(screen.queryByTestId('plugin-chart')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('保留的插件内容')).toBeInTheDocument()
|
||||
|
||||
await view.rerender({ active: true })
|
||||
|
||||
expect(screen.getByTestId('plugin-chart')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -46,6 +46,7 @@ const mocks = vi.hoisted(() => {
|
||||
grid,
|
||||
gridInit: vi.fn<(options: unknown, element: unknown) => unknown>(() => grid),
|
||||
openSharedDialog: vi.fn(),
|
||||
routeLeaveGuard: undefined as undefined | (() => Promise<void>),
|
||||
themeName: undefined as unknown as { value: string },
|
||||
useDynamicButton: vi.fn(),
|
||||
}
|
||||
@@ -121,6 +122,13 @@ vi.mock('@/api', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vue-router')>()),
|
||||
onBeforeRouteLeave: (guard: () => Promise<void>) => {
|
||||
mocks.routeLeaveGuard = guard
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', async importOriginal => {
|
||||
const { ref } = await import('vue')
|
||||
mocks.displayWidth = ref(1512)
|
||||
@@ -161,6 +169,7 @@ vi.mock('@/components/misc/DashboardElement.vue', async () => {
|
||||
default: defineComponent({
|
||||
name: 'DashboardElement',
|
||||
props: {
|
||||
allowRefresh: Boolean,
|
||||
config: { type: Object, required: true },
|
||||
},
|
||||
emits: ['loaded'],
|
||||
@@ -174,6 +183,7 @@ vi.mock('@/components/misc/DashboardElement.vue', async () => {
|
||||
'section',
|
||||
{
|
||||
'data-dashboard-id': (props.config as { id: string }).id,
|
||||
'data-refresh-enabled': String(props.allowRefresh),
|
||||
'data-testid': 'dashboard-item',
|
||||
onClick: () => {
|
||||
showLayoutSizeSource.value = !showLayoutSizeSource.value
|
||||
@@ -289,6 +299,7 @@ describe('dashboard page initial layout', () => {
|
||||
mocks.grid.setAnimation.mockClear()
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.routeLeaveGuard = undefined
|
||||
mocks.displayWidth.value = 1512
|
||||
mocks.themeName.value = 'light'
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock)
|
||||
@@ -1029,6 +1040,30 @@ describe('dashboard page initial layout', () => {
|
||||
expect(mocks.grid.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables dashboard refreshers before the router detaches the cached page', async () => {
|
||||
localStorage.setItem(
|
||||
'MP_DASHBOARD_GRID_LAYOUT',
|
||||
JSON.stringify({ enabled: enabledOnlyLibrary, items: { library: { x: 0, y: 0, w: 12 } }, updatedAt: 10 }),
|
||||
)
|
||||
mocks.apiGet.mockImplementation((url: string) => {
|
||||
if (url === '/user/config/DashboardOrder') return { data: { value: [{ id: 'library', key: '' }] } }
|
||||
if (url === '/user/config/DashboardGridLayout') {
|
||||
return { data: { value: { enabled: enabledOnlyLibrary, items: { library: { x: 0, y: 0, w: 12 } } } } }
|
||||
}
|
||||
if (url === '/plugin/dashboard/meta') return []
|
||||
throw new Error('Unexpected GET ' + url)
|
||||
})
|
||||
|
||||
await renderDashboard()
|
||||
const dashboardItem = await screen.findByTestId('dashboard-item')
|
||||
expect(dashboardItem).toHaveAttribute('data-refresh-enabled', 'true')
|
||||
if (!mocks.routeLeaveGuard) throw new Error('未注册仪表盘路由离开守卫')
|
||||
|
||||
await mocks.routeLeaveGuard()
|
||||
|
||||
expect(dashboardItem).toHaveAttribute('data-refresh-enabled', 'false')
|
||||
})
|
||||
|
||||
it('applies a newer remote profile and refreshes the local first-frame cache', async () => {
|
||||
const remoteProfile = deferred<unknown>()
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { usePluginRuntimeStore, useUserStore } from '@/stores'
|
||||
import { buildUserPermissionContext, hasPermission } from '@/utils/permission'
|
||||
import { useDisplay, useTheme } from 'vuetify'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
const ContentToggleSettingsDialog = defineAsyncComponent(
|
||||
() => import('@/components/dialog/ContentToggleSettingsDialog.vue'),
|
||||
@@ -1896,6 +1897,12 @@ onMounted(() => {
|
||||
initializeDashboardGrid()
|
||||
})
|
||||
|
||||
// KeepAlive 会先移动页面 DOM 再触发 deactivated;图表必须在容器仍连接时完成卸载,避免异步 watcher 访问失效根节点。
|
||||
onBeforeRouteLeave(async () => {
|
||||
isRequest.value = false
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
isRequest.value = true
|
||||
isDashboardPageActive = true
|
||||
|
||||
@@ -172,7 +172,7 @@ const { loading, refresh } = useDataRefresh(
|
||||
'analytics-cpu',
|
||||
loadCpuData,
|
||||
2000, // 2秒间隔
|
||||
true // 立即执行
|
||||
true, // 立即执行
|
||||
)
|
||||
|
||||
useKeepAliveRefresh(refresh)
|
||||
@@ -183,15 +183,19 @@ useKeepAliveRefresh(refresh)
|
||||
<VCardItem>
|
||||
<template #prepend><VIcon icon="mdi-cpu-64-bit" size="20" class="me-2" /></template>
|
||||
<VCardTitle>{{ t('dashboard.cpuUsage') }}</VCardTitle>
|
||||
<template #append><strong class="dashboard-chart-current">{{ animatedCurrentText }}%</strong></template>
|
||||
<template #append
|
||||
><strong class="dashboard-chart-current">{{ animatedCurrentText }}%</strong></template
|
||||
>
|
||||
</VCardItem>
|
||||
<VCardText class="dashboard-chart-content">
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
<VApexChart v-if="props.allowRefresh" type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<div class="dashboard-chart-footer">
|
||||
<span>{{ t('dashboard.averageUsage') }}</span>
|
||||
<span v-for="item in averageUsages" :key="item.label"><strong>{{ item.value }}</strong> {{ item.label }}</span>
|
||||
<span v-for="item in averageUsages" :key="item.label"
|
||||
><strong>{{ item.value }}</strong> {{ item.label }}</span
|
||||
>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
@@ -236,5 +240,4 @@ useKeepAliveRefresh(refresh)
|
||||
gap: 0.6rem;
|
||||
padding-block-start: 0.55rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -186,7 +186,7 @@ const { loading, refresh } = useDataRefresh(
|
||||
'analytics-memory',
|
||||
loadMemoryData,
|
||||
3000, // 3秒间隔
|
||||
true // 立即执行
|
||||
true, // 立即执行
|
||||
)
|
||||
|
||||
useKeepAliveRefresh(refresh)
|
||||
@@ -197,7 +197,9 @@ useKeepAliveRefresh(refresh)
|
||||
<VCardItem>
|
||||
<template #prepend><VIcon icon="mdi-memory" size="20" class="me-2" /></template>
|
||||
<VCardTitle>{{ t('dashboard.memoryUsage') }}</VCardTitle>
|
||||
<template #append><strong class="dashboard-chart-current">{{ memoryUsage.toFixed(1) }}%</strong></template>
|
||||
<template #append
|
||||
><strong class="dashboard-chart-current">{{ memoryUsage.toFixed(1) }}%</strong></template
|
||||
>
|
||||
</VCardItem>
|
||||
<VCardText class="dashboard-chart-content">
|
||||
<div class="dashboard-memory-value">
|
||||
@@ -205,12 +207,20 @@ useKeepAliveRefresh(refresh)
|
||||
<span>/ {{ totalMemoryText }}</span>
|
||||
</div>
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
<VApexChart v-if="props.allowRefresh" type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<div class="dashboard-chart-footer">
|
||||
<span><i class="memory-dot memory-dot--used" />{{ t('dashboard.memoryUsed') }} {{ animatedUsedMemoryText }}</span>
|
||||
<span><i class="memory-dot memory-dot--cached" />{{ t('dashboard.memoryCached') }} {{ animatedCachedMemoryText }}</span>
|
||||
<span><i class="memory-dot memory-dot--available" />{{ t('dashboard.memoryAvailable') }} {{ animatedAvailableMemoryText }}</span>
|
||||
<span
|
||||
><i class="memory-dot memory-dot--used" />{{ t('dashboard.memoryUsed') }} {{ animatedUsedMemoryText }}</span
|
||||
>
|
||||
<span
|
||||
><i class="memory-dot memory-dot--cached" />{{ t('dashboard.memoryCached') }}
|
||||
{{ animatedCachedMemoryText }}</span
|
||||
>
|
||||
<span
|
||||
><i class="memory-dot memory-dot--available" />{{ t('dashboard.memoryAvailable') }}
|
||||
{{ animatedAvailableMemoryText }}</span
|
||||
>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
@@ -174,6 +174,9 @@ async function getNetworkUsage() {
|
||||
feedback: 'silent',
|
||||
skipNavigationCancellation: true,
|
||||
})) ?? [0, 0]
|
||||
// 网络采样允许跨路由完成,但失活页面不得把在途响应提交给已脱离 DOM 的图表。
|
||||
if (!props.allowRefresh) return
|
||||
|
||||
currentUpload.value = Number(data[0]) || 0
|
||||
currentDownload.value = Number(data[1]) || 0
|
||||
|
||||
@@ -213,7 +216,7 @@ useKeepAliveRefresh(refresh)
|
||||
</VCardItem>
|
||||
<VCardText class="dashboard-chart-content">
|
||||
<div class="dashboard-chart-plot">
|
||||
<VApexChart type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
<VApexChart v-if="props.allowRefresh" type="area" :options="chartOptions" :series="series" height="100%" />
|
||||
</div>
|
||||
<div class="dashboard-chart-footer">
|
||||
<span
|
||||
|
||||
@@ -8,6 +8,14 @@ import { useI18n } from 'vue-i18n'
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
/** 页面失活时卸载图表实例,卡片数据仍由 KeepAlive 保留。 */
|
||||
allowRefresh: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const vuetifyTheme = useTheme()
|
||||
const WEEKLY_BAR_RADIUS = 8
|
||||
|
||||
@@ -180,6 +188,7 @@ onActivated(() => {
|
||||
<VCardText class="dashboard-work-content" :data-layout-size-source="isWeeklyLayoutReady ? '' : undefined">
|
||||
<div class="dashboard-work-chart dashboard-chart-plot">
|
||||
<VApexChart
|
||||
v-if="props.allowRefresh"
|
||||
type="bar"
|
||||
:options="options"
|
||||
:series="series"
|
||||
|
||||
@@ -98,6 +98,15 @@ function getSeries() {
|
||||
return JSON.parse(serialized) as NetworkSeries[]
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
const promise = new Promise<T>(promiseResolve => {
|
||||
resolve = promiseResolve
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function renderNetwork(props: { allowRefresh?: boolean } = {}) {
|
||||
return renderWithProviders(AnalyticsNetwork, {
|
||||
props,
|
||||
@@ -129,7 +138,7 @@ describe('AnalyticsNetwork', () => {
|
||||
await registration.refresh()
|
||||
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
expect(getSeries().map(item => item.data)).toEqual([[0], [0]])
|
||||
expect(screen.queryByTestId('network-series')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('normalizes rates and updates current values and series through refresh and KeepAlive', async () => {
|
||||
@@ -170,6 +179,32 @@ describe('AnalyticsNetwork', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('discards a response that finishes while the dashboard is inactive and refreshes after reactivation', async () => {
|
||||
const inactiveResponse = deferred<[number, number]>()
|
||||
mocks.apiGet.mockReturnValueOnce(inactiveResponse.promise).mockResolvedValueOnce([4096, 8192])
|
||||
const view = await renderNetwork()
|
||||
|
||||
const registration = getRefreshRegistration()
|
||||
const pendingRefresh = registration.refresh()
|
||||
await view.rerender({ allowRefresh: false })
|
||||
|
||||
inactiveResponse.resolve([1024, 2048])
|
||||
await pendingRefresh
|
||||
|
||||
expect(screen.queryByTestId('network-series')).not.toBeInTheDocument()
|
||||
|
||||
await view.rerender({ allowRefresh: true })
|
||||
expect(getSeries().map(item => item.data)).toEqual([[0], [0]])
|
||||
if (!mocks.keepAliveHandler) throw new Error('未注册 KeepAlive 刷新回调')
|
||||
await mocks.keepAliveHandler()
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
|
||||
expect(getSeries().map(item => item.data)).toEqual([
|
||||
[0, 4096],
|
||||
[0, 8192],
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps only the latest 30 samples in both network series', async () => {
|
||||
let sample = 0
|
||||
mocks.apiGet.mockImplementation(async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import AnalyticsWeeklyOverview from '@/views/dashboard/AnalyticsWeeklyOverview.vue'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { waitFor } from '@testing-library/vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, onMounted, watch } from 'vue'
|
||||
|
||||
@@ -46,4 +46,22 @@ describe('analytics weekly overview', () => {
|
||||
expect(container.querySelector('.dashboard-work-chart')).toHaveClass('dashboard-chart-plot')
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('dashboard/transfer')
|
||||
})
|
||||
|
||||
it('recreates the chart after the dashboard becomes active while retaining card state', async () => {
|
||||
mocks.apiGet.mockResolvedValue([1, 2, 3, 4, 5, 6, 7])
|
||||
|
||||
const view = await renderWithProviders(AnalyticsWeeklyOverview, {
|
||||
props: { allowRefresh: false },
|
||||
global: { stubs: { VApexChart: ApexChartStub } },
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('weekly-chart')).not.toBeInTheDocument()
|
||||
|
||||
await view.rerender({ allowRefresh: true })
|
||||
|
||||
expect(await screen.findByTestId('weekly-chart')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(view.container.querySelector('.dashboard-work-content')).toHaveAttribute('data-layout-size-source')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user