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
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ cypress/results
cypress/screenshots
cypress/videos
cypress/downloads
tests/offline/results
coverage
.nyc_output
.nyc_merged
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
"serve": "vite preview",
"test-local": "cypress open --e2e --browser chrome",
"test": "cypress run",
"test:offline": "node tests/offline/runner.js",
"test:offline:smoke": "node tests/offline/smoke-online.js && node tests/offline/smoke-online-people.js && node tests/offline/smoke-online-shared-computer.js",
"coverage:merge": "rm -rf .nyc_merged && mkdir -p .nyc_merged && cp coverage/baseline.json .nyc_merged/baseline.json && cp .nyc_output/out.json .nyc_merged/out.json && nyc report --nycrc-path \"$PWD/.nycrc.json\" --temp-dir \"$PWD/.nyc_merged\" --cwd \"${COVERAGE_ROOT:-$PWD}\" --report-dir \"$PWD/coverage\""
},
"dependencies": {
Expand Down Expand Up @@ -42,6 +44,7 @@
"cypress": "14.5.1",
"mocha-junit-reporter": "^2.2.1",
"nyc": "^18.0.0",
"playwright": "^1.62.1",
"postcss": "^8.4.5",
"prettier": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.8",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
flip isFinished back to false and unmount the open settings dialog. -->
<SettingsDialog v-if="$session.isLoggedIn && usersReady" />
<component :is="DevUserSwitcher" v-if="DevUserSwitcher && $session.isLoggedIn && usersReady" />
<OfflineIndicator />
</FrappeUIProvider>
</template>

Expand All @@ -27,6 +28,7 @@ import { useTheme } from '@/utils/useTheme'
import { useCursorStyle } from '@/utils/useCursorStyle'
import NewTaskDialog from './components/NewTaskDialog/NewTaskDialog.vue'
import SettingsDialog from './components/Settings/SettingsDialog.vue'
import OfflineIndicator from './components/OfflineIndicator.vue'
import { settingsBackgroundPath } from './components/Settings'
import { getHomeRoute } from '@/router'

Expand Down
24 changes: 23 additions & 1 deletion frontend/src/components/CommentsArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ import { tags } from '@/data/tags'
import { isNewCommentOpen } from '@/data/newComment'
import { useRichQuotes } from '@/components/RichQuoteExtension/useRichQuotes'
import { useDraftSync } from '@/data/useDraftSync'
import { onReconnect } from '@/data/online'
import { useSessionUser } from '@/data/users'
import type { Space } from '@/data/spaces'
import { useIsMobile } from 'frappe-ui'
Expand Down Expand Up @@ -388,7 +389,11 @@ const composerStorageKey = computed(() => {

const comments = useList<GPComment>({
doctype: 'GP Comment',
cacheKey: ['Comments', props.doctype, props.name],
// Scoped to the session user: a discussion's comments can live in a private space,
// so a second account on the same browser must not see them cached offline before
// its own permission-checked fetch resolves (review finding from PR #516).
cacheKey: ['Comments', props.doctype, props.name, sessionUser.name],
staleOnError: true,
fields: [
'name',
'content',
Expand Down Expand Up @@ -424,6 +429,8 @@ const comments = useList<GPComment>({

const activities = useList<GPActivity>({
doctype: 'GP Activity',
cacheKey: ['Activities', props.doctype, props.name, sessionUser.name],
staleOnError: true,
fields: ['name', 'user', 'action', 'data', 'creation'],
filters: {
reference_doctype: props.doctype,
Expand Down Expand Up @@ -455,6 +462,8 @@ watch(

const polls = useList<GPPoll>({
doctype: 'GP Poll',
cacheKey: ['Polls', props.name, sessionUser.name],
staleOnError: true,
fields: [
'name',
'title',
Expand Down Expand Up @@ -489,6 +498,19 @@ watchEffect(() => {
}
})

// US5 (seamless recovery): while offline, comments/activity/polls posted by
// other users never arrive — the socket that normally pushes them is down too.
// Reload this discussion's timeline once the browser comes back online.
// Unregistered on unmount: this callback closes over lists owned by this
// component instance, and there's no reason to keep refetching an open
// discussion the user has already navigated away from.
const unregisterReconnect = onReconnect(() => {
comments.reload()
activities.reload()
polls.reload()
})
onUnmounted(unregisterReconnect)

// Computed
const timelineItems = computed(() => {
let items: Array<GPComment | GPActivity | GPPoll> = []
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/components/CommentsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ import { subscribeToDoc, useSocket, type NewActivityEvent } from '@/socket'
import { GPActivity, GPComment } from '@/types/doctypes'
import type { Space } from '@/data/spaces'
import { useDraftSync } from '@/data/useDraftSync'
import { onReconnect } from '@/data/online'
import { session } from '@/data/session'

interface Props {
doctype: string
Expand Down Expand Up @@ -171,7 +173,11 @@ const comments = useList<
>
>({
doctype: 'GP Comment',
cacheKey: ['Comments', props.doctype, props.name],
// Scoped to the session user: a discussion's comments can live in a private space,
// so a second account on the same browser must not see them cached offline before
// its own permission-checked fetch resolves (review finding from PR #516).
cacheKey: ['Comments', props.doctype, props.name, session.user],
staleOnError: true,
fields: [
'name',
'content',
Expand Down Expand Up @@ -212,6 +218,8 @@ interface Activity extends Pick<GPActivity, 'name' | 'user' | 'action' | 'creati

const activities = useList<Activity>({
doctype: 'GP Activity',
cacheKey: ['Activities', props.doctype, props.name, session.user],
staleOnError: true,
fields: ['name', 'user', 'action', 'data', 'creation'],
filters: {
reference_doctype: props.doctype,
Expand All @@ -228,6 +236,14 @@ const activities = useList<Activity>({
},
})

// US5 (seamless recovery): mirrors the same reconnect reload in CommentsArea.vue
// (discussion comments) for this task's comment/activity timeline.
const unregisterReconnect = onReconnect(() => {
comments.reload()
activities.reload()
})
onUnmounted(unregisterReconnect)

// Computed
type GroupedActivity = {
doctype: 'GP Activity'
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/components/DiscussionView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,16 @@
again.
</p>
</EmptyStateBox>
<!-- Same catch-all as below, but for the offline/network case: name the actual reason
(never cached, can't reach the server) instead of the generic "something went
wrong", and offer a Retry rather than telling the user to refresh. -->
<OfflineContentFallback
v-else-if="discussion.isFinished && isOfflineFailure"
class="mx-auto mt-14 max-w-2xl px-6"
title="This discussion isn't available offline"
message="It hasn't been saved for offline use yet. Reconnect and retry to load it."
@retry="discussion.reload()"
/>
<!-- Fetch finished, but there is no doc and no recognised not-found/forbidden error.
Fail visibly instead of rendering a blank page. Gated on isFinished so the
pre-fetch tick (useFetch defers its first execute by a microtask) doesn't flash
Expand Down Expand Up @@ -339,7 +349,9 @@ import UserProfileLink from './UserProfileLink.vue'
const RevisionsDialog = defineAsyncComponent(() => import('./RevisionsDialog.vue'))
import SpaceBreadcrumbs from './SpaceBreadcrumbs.vue'
import EmptyStateBox from './EmptyStateBox.vue'
import OfflineContentFallback from './OfflineContentFallback.vue'
import { copyToClipboard, isEditorContentEmpty } from '@/utils'
import { isBrowserOffline, isNetworkError } from '@/offline'
import { getSpace, useSpace } from '@/data/spaces'
import { useCommunity } from '@/data/communities'
import { useGroupedSpaceOptions } from '@/data/groupedSpaces'
Expand Down Expand Up @@ -390,6 +402,9 @@ function isMissingOrForbidden(error: unknown): boolean {
const type = (error as { type?: string } | null)?.type
return type === 'DoesNotExistError' || type === 'PermissionError'
}
// A network failure (offline, or the request never reached the server) deserves its own
// copy and a Retry — telling someone offline to "refresh" is misleading busywork.
const isOfflineFailure = computed(() => isBrowserOffline() || isNetworkError(discussion.error))
const showTitleInMobileHeader = ref(false)
const mobileHeaderTitle = computed(() =>
showTitleInMobileHeader.value ? discussion.doc?.title || 'Discussion' : 'Discussion',
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/LastPostReminder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ import { useCall } from 'frappe-ui'
import { dayjs } from 'frappe-ui'
import { computed } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { session } from '@/data/session'
let lastPostAt = useCall<string>({
url: `/api/v2/method/GP User Profile/get_last_post`,
cacheKey: 'last_post_at',
// Scoped to the session user so a second account on the same browser can't read the
// first account's cached "last post" date while offline (review finding from PR #516).
cacheKey: ['last_post_at', session.user],
staleOnError: true,
})

const daysSinceLastPost = computed(() => {
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/OfflineContentFallback.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<template>
<EmptyStateBox>
<LucideTriangleAlert class="mb-3 size-7 text-ink-gray-4" />
<div class="text-base text-ink-gray-7">{{ title }}</div>
<p class="mt-2 max-w-md text-center text-p-sm text-ink-gray-5">{{ message }}</p>
<Button class="mt-4" icon-left="lucide-refresh-cw" @click="emit('retry')"> Retry </Button>
</EmptyStateBox>
</template>

<script setup lang="ts">
// Presentational-only: callers decide *when* to show this (loading/empty/error
// state belongs to the resource, not this component) and *what* to say (offline
// vs. generic copy, via isBrowserOffline()/isNetworkError() from '@/offline').
// Kept generic so any failed useList/useDoc fetch can reuse it instead of each
// page hand-rolling its own dead-end card.
import EmptyStateBox from './EmptyStateBox.vue'

defineProps<{
title: string
message: string
}>()

const emit = defineEmits<{ retry: [] }>()
</script>
29 changes: 29 additions & 0 deletions frontend/src/components/OfflineIndicator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<!-- Top-center, out of the way of the comment composer (bottom) and the dev
user switcher (bottom-left). Teleported to body so it sits above any
page-local `overflow-hidden`/`relative` ancestor. -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 -translate-y-1"
leave-active-class="transition duration-150 ease-in"
leave-to-class="opacity-0 -translate-y-1"
>
<div
v-if="!isOnline"
class="pointer-events-none fixed inset-x-0 top-2 z-50 flex justify-center"
>
<div
class="pointer-events-auto flex items-center gap-2 rounded-full bg-surface-gray-8 px-3 py-1.5 text-sm text-ink-white shadow-lg"
>
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-surface-gray-4"></span>
You're offline — showing saved content
</div>
</div>
</Transition>
</Teleport>
</template>

<script setup lang="ts">
import { isOnline } from '@/data/online'
</script>
96 changes: 90 additions & 6 deletions frontend/src/components/ProfileBento/profileBentoSource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { call } from 'frappe-ui'
import { call, useCall } from 'frappe-ui'
import { computed, MaybeRefOrGetter, toValue, watch } from 'vue'
import type { ProfileBentoCard } from './types'
import type { ProfileBentoCardSource } from './useProfileBentoCustomization'
import { session } from '@/data/session'

interface ProfileBentoResponse {
profile: string
Expand Down Expand Up @@ -49,14 +51,96 @@ export async function resetProfileBentoCards() {
return getLoadResultFromResponse(response)
}

export async function getProfileBentoCards(profile: string) {
let response = await call<ProfileBentoResponse>(getProfileBentoCardsMethod, { profile })
return getLoadResultFromResponse(response)
}

function getLoadResultFromResponse(response: ProfileBentoResponse): ProfileBentoLoadResult {
return {
cards: response.cards || [],
isDefault: response.is_default,
}
}

// One `get_bento_cards` fetch per profile, keyed by profile name + session user (an
// offline cache scoped any other way could leak one account's cached cards to a second
// account sharing the browser - review finding from PR #516). Shared by `useProfileBento`
// (the live page) and `prefetchProfileBento` (the background cache warmer) so both read
// and write the same IndexedDB entry instead of racing two independent requests for the
// same profile.
const bentoCalls: Record<string, ReturnType<typeof createProfileBentoCall>> = {}

function createProfileBentoCall(profile: string) {
return useCall<ProfileBentoResponse>({
// `useCall` takes `url` verbatim - unlike `call()` (used by createServerProfileBentoSource
// below), it does not prefix a dotted method path with /api/method/ itself. A bare method
// name here would resolve relative to whatever page the app is currently on and hit the
// SPA's own catch-all route (200, HTML) instead of the API - see the same trap called out
// in Notifications.vue's markAllAsRead.
url: `/api/v2/method/${getProfileBentoCardsMethod}`,
params: { profile },
cacheKey: ['ProfileBento', profile, session.user],
staleOnError: true,
immediate: false,
})
}

function getProfileBentoCall(profile: string) {
if (!bentoCalls[profile]) {
bentoCalls[profile] = createProfileBentoCall(profile)
}
return bentoCalls[profile]
}

/**
* Reactive bento-card read for one profile (`PersonProfile.vue`'s Profile tab). Cards come
* straight off the shared per-profile call above instead of being copied into local refs,
* so revisiting a profile already viewed this session reuses its resource rather than
* re-racing a fresh request against whatever that earlier visit's request was doing - the
* out-of-order-response guard the old `loadProfileBentoCards` needed is gone because each
* profile now owns an isolated resource instead of sharing one mutable ref.
*/
export function useProfileBento(profile: MaybeRefOrGetter<string | undefined>) {
const bentoCall = computed(() => {
let name = toValue(profile)
return name ? getProfileBentoCall(name) : null
})

const cards = computed<ProfileBentoCard[]>(() => bentoCall.value?.data?.cards || [])
const isDefault = computed(() => bentoCall.value?.data?.is_default ?? true)
// Resolves on failure too (not just success) - the forever-skeleton bug this replaces
// came from the old plain `call()` throwing uncaught and never flipping its "loaded" ref.
// `data != null` lets a cache hit show immediately without waiting for the network leg
// that staleOnError may still be racing in the background.
const loaded = computed(() => {
let current = bentoCall.value
if (!current) return false
return current.data != null || Boolean(current.isFinished)
})
const failed = computed(() => {
let current = bentoCall.value
return Boolean(current && current.error && current.data == null)
})
const error = computed(() => bentoCall.value?.error ?? null)

watch(
() => toValue(profile),
(name) => {
if (!name) return
let current = getProfileBentoCall(name)
if (!current.isFinished && !current.loading) current.reload()
},
{ immediate: true },
)

function reload() {
return bentoCall.value?.reload()
}

return { cards, isDefault, loaded, failed, error, reload }
}

/**
* Warms a profile's bento-card cache ahead of a visit, for the background prefetcher.
* Same cache key path as `useProfileBento`, so a later visit to this profile reads
* whatever this fetched.
*/
export function prefetchProfileBento(personId: string) {
return getProfileBentoCall(personId).reload()
}
7 changes: 6 additions & 1 deletion frontend/src/components/TaskList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ import { getSpace } from '@/data/spaces'
import { UseListOptions } from 'frappe-ui'
import DropdownMoreOptions from './DropdownMoreOptions.vue'
import { useSessionUser } from '@/data/users'
import { session } from '@/data/session'
import { canDeleteContent } from '@/utils/permissions'

interface Props {
Expand Down Expand Up @@ -178,7 +179,11 @@ const tasks = useList<GPTask>({
filters: props.listOptions.filters,
orderBy: props.listOptions.orderBy,
limit: props.listOptions.pageLength,
cacheKey: ['Tasks', props.listOptions],
// Scoped to the session user: a space's tasks can be private, so a second account on
// the same browser must not see them cached offline before its own permission-checked
// fetch resolves (review finding from PR #516).
cacheKey: ['Tasks', props.listOptions, session.user],
staleOnError: true,
})

const tasksByStatus = computed(() => {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/data/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface AppInfo {
export const installedApps = useCall<AppInfo[]>({
url: '/api/v2/method/frappe.apps.get_apps',
cacheKey: 'apps',
staleOnError: true,
immediate: true,
transform(data) {
let _apps = [
Expand Down
Loading
Loading