diff --git a/apps/desktop/src/lib/ai/butlerClient.ts b/apps/desktop/src/lib/ai/butlerClient.ts index 8dbe79560d..95b79af5ad 100644 --- a/apps/desktop/src/lib/ai/butlerClient.ts +++ b/apps/desktop/src/lib/ai/butlerClient.ts @@ -7,7 +7,7 @@ import { import { ModelKind, type AIClient, type AIEvalOptions, type Prompt } from '$lib/ai/types'; import { andThenAsync, ok, wrapAsync, type Result } from '$lib/result'; import { stringStreamGenerator } from '$lib/utils/promise'; -import type { HttpClient } from '@gitbutler/shared/httpClient'; +import type { HttpClient } from '@gitbutler/shared/network/httpClient'; function splitPromptMessagesIfNecessary( modelKind: ModelKind, diff --git a/apps/desktop/src/lib/ai/service.test.ts b/apps/desktop/src/lib/ai/service.test.ts index 870b66f6e7..a37f494337 100644 --- a/apps/desktop/src/lib/ai/service.test.ts +++ b/apps/desktop/src/lib/ai/service.test.ts @@ -24,7 +24,7 @@ import { import { buildFailureFromAny, ok, unwrap, type Result } from '$lib/result'; import { TokenMemoryService } from '$lib/stores/tokenMemoryService'; import { Hunk } from '$lib/vbranches/types'; -import { HttpClient } from '@gitbutler/shared/httpClient'; +import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { plainToInstance } from 'class-transformer'; import { get } from 'svelte/store'; import { expect, test, describe, vi } from 'vitest'; diff --git a/apps/desktop/src/lib/ai/service.ts b/apps/desktop/src/lib/ai/service.ts index fd5c46c776..d32c092381 100644 --- a/apps/desktop/src/lib/ai/service.ts +++ b/apps/desktop/src/lib/ai/service.ts @@ -21,7 +21,7 @@ import { get } from 'svelte/store'; import type { GitConfigService } from '$lib/backend/gitConfigService'; import type { SecretsService } from '$lib/secrets/secretsService'; import type { TokenMemoryService } from '$lib/stores/tokenMemoryService'; -import type { HttpClient } from '@gitbutler/shared/httpClient'; +import type { HttpClient } from '@gitbutler/shared/network/httpClient'; const maxDiffLengthLimitForAPI = 5000; const prDescriptionTokenLimit = 4096; diff --git a/apps/desktop/src/lib/backend/projectCloudSync.svelte.ts b/apps/desktop/src/lib/backend/projectCloudSync.svelte.ts index 439a9493b7..044a61df2b 100644 --- a/apps/desktop/src/lib/backend/projectCloudSync.svelte.ts +++ b/apps/desktop/src/lib/backend/projectCloudSync.svelte.ts @@ -2,7 +2,7 @@ import { registerInterest } from '@gitbutler/shared/interest/registerInterestFun import { projectsSelectors } from '@gitbutler/shared/organizations/projectsSlice'; import { readableToReactive } from '@gitbutler/shared/reactiveUtils.svelte'; import type { ProjectService, ProjectsService } from '$lib/backend/projects'; -import type { HttpClient } from '@gitbutler/shared/httpClient'; +import type { HttpClient } from '@gitbutler/shared/network/httpClient'; import type { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService'; import type { AppProjectsState } from '@gitbutler/shared/redux/store.svelte'; @@ -25,14 +25,17 @@ export function projectCloudSync( registerInterest(cloudProjectInterest); }); - const cloudProject = $derived( + const loadableCloudProject = $derived( project.current?.api ? projectsSelectors.selectById(appState.projects, project.current.api.repository_id) : undefined ); $effect(() => { - if (!project.current?.api || !cloudProject) return; + if (!project.current?.api || !loadableCloudProject || loadableCloudProject.status !== 'found') + return; + + const cloudProject = loadableCloudProject.value; const persistedProjectUpdatedAt = new Date(project.current.api.updated_at).getTime(); const cloudProjectUpdatedAt = new Date(cloudProject.updatedAt).getTime(); if (persistedProjectUpdatedAt >= cloudProjectUpdatedAt) return; diff --git a/apps/desktop/src/lib/backend/projects.ts b/apps/desktop/src/lib/backend/projects.ts index 29669657d9..b11ffbb5b6 100644 --- a/apps/desktop/src/lib/backend/projects.ts +++ b/apps/desktop/src/lib/backend/projects.ts @@ -6,7 +6,7 @@ import { persisted } from '@gitbutler/shared/persisted'; import { open } from '@tauri-apps/plugin-dialog'; import { plainToInstance } from 'class-transformer'; import { derived, get, writable, type Readable } from 'svelte/store'; -import type { HttpClient } from '@gitbutler/shared/httpClient'; +import type { HttpClient } from '@gitbutler/shared/network/httpClient'; import { goto } from '$app/navigation'; export type KeyType = 'gitCredentialsHelper' | 'local' | 'systemExecutable'; diff --git a/apps/desktop/src/lib/components/ShareIssueModal.svelte b/apps/desktop/src/lib/components/ShareIssueModal.svelte index ec9659ff2e..4b3ecf2384 100644 --- a/apps/desktop/src/lib/components/ShareIssueModal.svelte +++ b/apps/desktop/src/lib/components/ShareIssueModal.svelte @@ -4,7 +4,7 @@ import { User } from '$lib/stores/user'; import * as toasts from '$lib/utils/toasts'; import { getContext, getContextStore } from '@gitbutler/shared/context'; - import { HttpClient } from '@gitbutler/shared/httpClient'; + import { HttpClient } from '@gitbutler/shared/network/httpClient'; import Button from '@gitbutler/ui/Button.svelte'; import Checkbox from '@gitbutler/ui/Checkbox.svelte'; import Modal from '@gitbutler/ui/Modal.svelte'; diff --git a/apps/desktop/src/lib/feeds/CreatePost.svelte b/apps/desktop/src/lib/feeds/CreatePost.svelte index dd16578fde..6055a17994 100644 --- a/apps/desktop/src/lib/feeds/CreatePost.svelte +++ b/apps/desktop/src/lib/feeds/CreatePost.svelte @@ -73,13 +73,13 @@ // Post creation let newPostContent = $derived(persisted('', `postContent--${feedIdentity}--${replyTo}`)); function createPost() { - if (!feedIdentity?.current) return; - if (!parentProject?.current) return; + if (feedIdentity?.current.status !== 'found') return; + if (parentProject?.current?.status !== 'found') return; feedService.createPost( $newPostContent, - parentProject.current.repositoryId, - feedIdentity.current, + parentProject.current.value.repositoryId, + feedIdentity.current.value, replyTo, picture ); diff --git a/apps/desktop/src/lib/feeds/Post.svelte b/apps/desktop/src/lib/feeds/Post.svelte index 6e06c6205c..52b10cf6ae 100644 --- a/apps/desktop/src/lib/feeds/Post.svelte +++ b/apps/desktop/src/lib/feeds/Post.svelte @@ -6,6 +6,7 @@ import { postsSelectors } from '@gitbutler/shared/feeds/postsSlice'; import { FeedService } from '@gitbutler/shared/feeds/service'; import { registerInterestInView } from '@gitbutler/shared/interest/registerInterestFunction.svelte'; + import Loading from '@gitbutler/shared/network/Loading.svelte'; import { AppState } from '@gitbutler/shared/redux/store.svelte'; import { UserService } from '@gitbutler/shared/users/userService'; import Button from '@gitbutler/ui/Button.svelte'; @@ -42,12 +43,16 @@
- -

{author.current?.name}

+ + {#snippet children(author)} + +

{author.name}

+ {/snippet} +
diff --git a/apps/desktop/src/lib/settings/userPreferences/CloudProjectSettings.svelte b/apps/desktop/src/lib/settings/userPreferences/CloudProjectSettings.svelte index c36d0f684c..c7094b547a 100644 --- a/apps/desktop/src/lib/settings/userPreferences/CloudProjectSettings.svelte +++ b/apps/desktop/src/lib/settings/userPreferences/CloudProjectSettings.svelte @@ -7,6 +7,7 @@ import * as toasts from '$lib/utils/toasts'; import { getContext, getContextStore } from '@gitbutler/shared/context'; import RegisterInterest from '@gitbutler/shared/interest/RegisterInterest.svelte'; + import Loading from '@gitbutler/shared/network/Loading.svelte'; import { OrganizationService } from '@gitbutler/shared/organizations/organizationService'; import { organizationsSelectors } from '@gitbutler/shared/organizations/organizationsSlice'; import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService'; @@ -138,36 +139,44 @@ {/snippet} - {#if !cloudProject?.parentProjectRepositoryId} -
- {#snippet title()} - Link your project with an organization - {/snippet} - - - -
- {#each usersOrganizations as organization, index} - - {#snippet children()} -
{organization.name || organization.slug}
- {/snippet} - {#snippet actions()} - - {/snippet} -
- {/each} -
-
- {/if} + + {#snippet children(cloudProject)} +
+ {#snippet title()} + Link your project with an organization + {/snippet} + + + +
+ {#each usersOrganizations as loadableOrganization, index} + + {#snippet children()} + + {#snippet children(organization)} +
+ {organization.name || organization.slug} +
+ {/snippet} +
+ {/snippet} + {#snippet actions()} + + {/snippet} +
+ {/each} +
+
+ {/snippet} +
{:else if !$project?.api?.repository_id}
diff --git a/apps/desktop/src/lib/settings/userPreferences/ProjectConnectModal.svelte b/apps/desktop/src/lib/settings/userPreferences/ProjectConnectModal.svelte index ad3b64c091..7b47275ede 100644 --- a/apps/desktop/src/lib/settings/userPreferences/ProjectConnectModal.svelte +++ b/apps/desktop/src/lib/settings/userPreferences/ProjectConnectModal.svelte @@ -1,6 +1,7 @@ - + - {#if organization} - {#each organizationProjects as { project, projectInterest }, index} - - - - {#if project} -
{project.name}
- - - {:else} -

Loading...

- {/if} -
- {/each} - {/if} + {#each organizationProjects as { project: organizationProject, interest }, index} + + + + + {#snippet children(organizationProject)} +
{organizationProject.name}
+ + + {/snippet} +
+
+ {/each}
diff --git a/apps/desktop/src/lib/stores/user.ts b/apps/desktop/src/lib/stores/user.ts index 6eaa6b9cad..83fb8f6527 100644 --- a/apps/desktop/src/lib/stores/user.ts +++ b/apps/desktop/src/lib/stores/user.ts @@ -4,7 +4,7 @@ import { showError } from '$lib/notifications/toasts'; import { copyToClipboard } from '$lib/utils/clipboard'; import { sleep } from '$lib/utils/sleep'; import { openExternalUrl } from '$lib/utils/url'; -import { type HttpClient } from '@gitbutler/shared/httpClient'; +import { type HttpClient } from '@gitbutler/shared/network/httpClient'; import { plainToInstance } from 'class-transformer'; import { derived, writable } from 'svelte/store'; import type { PostHogWrapper } from '$lib/analytics/posthog'; diff --git a/apps/desktop/src/routes/+layout.svelte b/apps/desktop/src/routes/+layout.svelte index 6c2548ffc7..db39bfca8a 100644 --- a/apps/desktop/src/routes/+layout.svelte +++ b/apps/desktop/src/routes/+layout.svelte @@ -39,7 +39,7 @@ import * as events from '$lib/utils/events'; import { unsubscribe } from '$lib/utils/unsubscribe'; import { FeedService } from '@gitbutler/shared/feeds/service'; - import { HttpClient } from '@gitbutler/shared/httpClient'; + import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { OrganizationService } from '@gitbutler/shared/organizations/organizationService'; import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService'; import { AppDispatch, AppState } from '@gitbutler/shared/redux/store.svelte'; diff --git a/apps/desktop/src/routes/+layout.ts b/apps/desktop/src/routes/+layout.ts index 8162b628a3..409da88912 100644 --- a/apps/desktop/src/routes/+layout.ts +++ b/apps/desktop/src/routes/+layout.ts @@ -15,7 +15,7 @@ import { RemotesService } from '$lib/remotes/service'; import { RustSecretService } from '$lib/secrets/secretsService'; import { TokenMemoryService } from '$lib/stores/tokenMemoryService'; import { UserService } from '$lib/stores/user'; -import { HttpClient } from '@gitbutler/shared/httpClient'; +import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { LineManagerFactory } from '@gitbutler/ui/commitLines/lineManager'; import { LineManagerFactory as StackingLineManagerFactory } from '@gitbutler/ui/commitLines/lineManager'; import lscache from 'lscache'; diff --git a/apps/desktop/src/routes/[projectId]/+layout.svelte b/apps/desktop/src/routes/[projectId]/+layout.svelte index 0cc3c3c77a..39d234b264 100644 --- a/apps/desktop/src/routes/[projectId]/+layout.svelte +++ b/apps/desktop/src/routes/[projectId]/+layout.svelte @@ -37,7 +37,7 @@ import { VirtualBranchService } from '$lib/vbranches/virtualBranch'; import { CloudBranchesService } from '@gitbutler/shared/cloud/stacks/service'; import { getContext } from '@gitbutler/shared/context'; - import { HttpClient } from '@gitbutler/shared/httpClient'; + import { HttpClient } from '@gitbutler/shared/network/httpClient'; import { ProjectService as CloudProjectService } from '@gitbutler/shared/organizations/projectService'; import { AppState } from '@gitbutler/shared/redux/store.svelte'; import { DesktopRoutesService, getRoutesService } from '@gitbutler/shared/sharedRoutes'; diff --git a/apps/desktop/src/routes/[projectId]/feed/+page.svelte b/apps/desktop/src/routes/[projectId]/feed/+page.svelte index 9a35090ef7..748c2bd6e0 100644 --- a/apps/desktop/src/routes/[projectId]/feed/+page.svelte +++ b/apps/desktop/src/routes/[projectId]/feed/+page.svelte @@ -22,18 +22,25 @@ : undefined ); - const feed = $derived(getFeed(appState, feedService, feedIdentity?.current)); + const feed = $derived.by(() => { + if (feedIdentity?.current.status !== 'found') return; + return getFeed(appState, feedService, feedIdentity?.current.value); + }); // Infinite scrolling - const lastPost = $derived(getFeedLastPost(appState, feedService, feed.current)); + const lastPost = $derived(getFeedLastPost(appState, feedService, feed?.current)); let lastElement = $state(); $effect(() => { if (!lastElement) return; const observer = new IntersectionObserver((entries) => { - if (entries[0]?.isIntersecting && lastPost.current?.createdAt && feedIdentity?.current) { - feedService.getFeedPage(feedIdentity.current, lastPost.current.createdAt); + if ( + entries[0]?.isIntersecting && + lastPost.current?.createdAt && + feedIdentity?.current.status === 'found' + ) { + feedService.getFeedPage(feedIdentity.current.value, lastPost.current.createdAt); } }); @@ -51,7 +58,7 @@
- {#if feed.current} + {#if feed?.current} {#each feed.current.postIds as postId, index (postId)}
{#if index < feed.current.postIds.length - 1 && lastPost.current && feedIdentity?.current} diff --git a/apps/desktop/src/routes/[projectId]/feed/[postId]/+page.svelte b/apps/desktop/src/routes/[projectId]/feed/[postId]/+page.svelte index 38d1af2d26..2a311e0009 100644 --- a/apps/desktop/src/routes/[projectId]/feed/[postId]/+page.svelte +++ b/apps/desktop/src/routes/[projectId]/feed/[postId]/+page.svelte @@ -8,6 +8,7 @@ import { postsSelectors } from '@gitbutler/shared/feeds/postsSlice'; import { FeedService } from '@gitbutler/shared/feeds/service'; import { registerInterest } from '@gitbutler/shared/interest/registerInterestFunction.svelte'; + import Loading from '@gitbutler/shared/network/Loading.svelte'; import { AppState } from '@gitbutler/shared/redux/store.svelte'; import { UserService } from '@gitbutler/shared/users/userService'; import Button from '@gitbutler/ui/Button.svelte'; @@ -48,12 +49,16 @@
- -

{author?.current?.name}

+ + {#snippet children(author)} + +

{author.name}

+ {/snippet} +
diff --git a/apps/desktop/src/routes/settings/organizations/+page.svelte b/apps/desktop/src/routes/settings/organizations/+page.svelte index 332ecf0de4..4ce7cbb43e 100644 --- a/apps/desktop/src/routes/settings/organizations/+page.svelte +++ b/apps/desktop/src/routes/settings/organizations/+page.svelte @@ -1,8 +1,9 @@ + +{#if !loadable} +

Uninitialized...

+{:else if loadable.status === 'found'} + {@render children(loadable.value)} +{:else if loadable.status === 'loading'} +

Loading...

+{:else if loadable.status === 'not-found'} +

Not found

+{:else if loadable.status === 'error'} +

{loadable.error.message}

+{:else} +

Unknown state

+{/if} diff --git a/packages/shared/src/lib/httpClient.ts b/packages/shared/src/lib/network/httpClient.ts similarity index 95% rename from packages/shared/src/lib/httpClient.ts rename to packages/shared/src/lib/network/httpClient.ts index 60ea55af6d..60803e11a2 100644 --- a/packages/shared/src/lib/httpClient.ts +++ b/packages/shared/src/lib/network/httpClient.ts @@ -1,3 +1,4 @@ +import { ApiError } from '$lib/network/types'; import { derived, get, type Readable } from 'svelte/store'; export const DEFAULT_HEADERS = { @@ -100,7 +101,7 @@ async function parseResponseJSON(response: Response) { if (response.status === 204 || response.status === 205) { return null; } else if (response.status >= 400) { - throw new Error(`HTTP Error ${response.statusText}: ${await response.text()}`); + throw new ApiError(`HTTP Error ${response.statusText}: ${await response.text()}`, response); } else { return await response.json(); } diff --git a/packages/shared/src/lib/network/loadable.ts b/packages/shared/src/lib/network/loadable.ts new file mode 100644 index 0000000000..6d6ba4c9ef --- /dev/null +++ b/packages/shared/src/lib/network/loadable.ts @@ -0,0 +1,62 @@ +import { ApiError, type Loadable, type LoadableData } from '$lib/network/types'; +import type { EntityId, EntityAdapter, EntityState } from '@reduxjs/toolkit'; + +export function isFound(loadable?: Loadable): loadable is { + status: 'found'; + value: T; +} { + return loadable?.status === 'found'; +} + +export function errorToLoadable(error: unknown, id: Id): LoadableData { + if (error instanceof Error) { + if (error instanceof ApiError && error.response.status === 404) { + return { status: 'not-found', id }; + } + + return { status: 'error', id, error }; + } + + return { status: 'error', id, error: new Error(String(error)) }; +} + +export function loadableUpsert( + adapter: EntityAdapter, Id> +) { + return ( + state: EntityState, Id>, + action: { payload: LoadableData } + ) => { + loadableUpsertMany(adapter)(state, { payload: [action.payload] }); + }; +} + +export function loadableUpsertMany( + adapter: EntityAdapter, Id> +) { + return ( + state: EntityState, Id>, + action: { payload: LoadableData[] } + ) => { + const values = action.payload.map((payload) => { + const value = state.entities[payload.id]; + if (value === undefined) { + return payload; + } + + if (!(value.status === 'found' && payload.status === 'found')) { + return payload; + } + + const newValue: LoadableData = { + status: 'found', + id: payload.id, + value: { ...value, ...payload.value } + }; + + return newValue; + }); + + adapter.setMany(state, values); + }; +} diff --git a/packages/shared/src/lib/network/types.ts b/packages/shared/src/lib/network/types.ts new file mode 100644 index 0000000000..11063c34c7 --- /dev/null +++ b/packages/shared/src/lib/network/types.ts @@ -0,0 +1,15 @@ +export class ApiError extends Error { + constructor( + message: string, + readonly response: Response + ) { + super(message); + } +} + +export type Loadable = + | { status: 'loading' | 'not-found' } + | { status: 'found'; value: T } + | { status: 'error'; error: Error }; + +export type LoadableData = Loadable & { id: Id }; diff --git a/packages/shared/src/lib/organizations/OrganizationModal.svelte b/packages/shared/src/lib/organizations/OrganizationModal.svelte index 8a8f953a08..c6b758f507 100644 --- a/packages/shared/src/lib/organizations/OrganizationModal.svelte +++ b/packages/shared/src/lib/organizations/OrganizationModal.svelte @@ -1,21 +1,19 @@ - - - -
Users:
- {#if organization?.inviteCode} -
-

Invite code:

- -
- {/if} - -
- {#each users as user, index} - - - - -

{user.user?.name}

-
- {/each} -
- -
Projects:
-
- {#each projects as { project, projectInterest }, index} - - - -

{project?.name}

-
- {/each} -
+ + + {#snippet children(organization)} + {#if organization.inviteCode} +
+

Invite code:

+ +
+ {/if} + + {#if organization.memberLogins} +
Users:
+ +
+ {#each organization.memberLogins as login, index} + {@const user = getUserByLogin(appState, userService, login)} + + + + {#snippet children(user)} + +

{user?.name}

+ {/snippet} +
+
+ {/each} +
+ {/if} + + {#if organization.projectRepositoryIds} +
Projects:
+
+ {#each organization.projectRepositoryIds as repositoryId, index} + {@const project = getProjectByRepositoryId(appState, projectService, repositoryId)} + + + + {#snippet children(project)} +

{project.name}

+ {/snippet} +
+
+ {/each} +
+ {/if} + {/snippet} +
diff --git a/packages/shared/src/lib/organizations/organizationService.ts b/packages/shared/src/lib/organizations/organizationService.ts index 877f8bde3d..8f382ecb3c 100644 --- a/packages/shared/src/lib/organizations/organizationService.ts +++ b/packages/shared/src/lib/organizations/organizationService.ts @@ -1,15 +1,22 @@ import { InterestStore, type Interest } from '$lib/interest/intrestStore'; -import { upsertOrganization, upsertOrganizations } from '$lib/organizations/organizationsSlice'; +import { type HttpClient } from '$lib/network/httpClient'; +import { errorToLoadable } from '$lib/network/loadable'; +import { + addOrganization, + upsertOrganization, + upsertOrganizations +} from '$lib/organizations/organizationsSlice'; import { upsertProjects } from '$lib/organizations/projectsSlice'; import { apiToOrganization, apiToProject, type ApiOrganization, type ApiOrganizationWithDetails, + type LoadableOrganization, + type LoadableProject, type Organization } from '$lib/organizations/types'; import { POLLING_REGULAR, POLLING_SLOW } from '$lib/polling'; -import type { HttpClient } from '$lib/httpClient'; import type { AppDispatch } from '$lib/redux/store.svelte'; export class OrganizationService { @@ -25,7 +32,11 @@ export class OrganizationService { return this.organizationListingInterests .findOrCreateSubscribable(undefined, async () => { const apiOrganizations = await this.httpClient.get('organization'); - const organizations = apiOrganizations.map(apiToOrganization); + const organizations = apiOrganizations.map((apiOrganizations) => ({ + status: 'found', + id: apiOrganizations.slug, + value: apiToOrganization(apiOrganizations) + })); this.appDispatch.dispatch(upsertOrganizations(organizations)); }) @@ -35,14 +46,30 @@ export class OrganizationService { getOrganizationWithDetailsInterest(slug: string): Interest { return this.orgnaizationInterests .findOrCreateSubscribable({ slug }, async () => { - const apiOrganization = await this.httpClient.get( - `organization/${slug}` - ); - const organization = apiToOrganization(apiOrganization); - const projects = apiOrganization.projects.map(apiToProject); + this.appDispatch.dispatch(addOrganization({ status: 'loading', id: slug })); + + try { + const apiOrganization = await this.httpClient.get( + `organization/${slug}` + ); + + const projects = apiOrganization.projects.map((apiProject) => ({ + status: 'found', + id: apiProject.repository_id, + value: apiToProject(apiProject) + })); + this.appDispatch.dispatch(upsertProjects(projects)); - this.appDispatch.dispatch(upsertOrganization(organization)); - this.appDispatch.dispatch(upsertProjects(projects)); + this.appDispatch.dispatch( + upsertOrganization({ + status: 'found', + id: slug, + value: apiToOrganization(apiOrganization) + }) + ); + } catch (error: unknown) { + this.appDispatch.dispatch(upsertOrganization(errorToLoadable(error, slug))); + } }) .createInterest(); } @@ -59,10 +86,12 @@ export class OrganizationService { description } }); - const orgnaization = apiToOrganization(apiOrganization); - this.appDispatch.dispatch(upsertOrganization(orgnaization)); + const organization = apiToOrganization(apiOrganization); + this.appDispatch.dispatch( + upsertOrganization({ status: 'found', id: slug, value: organization }) + ); - return orgnaization; + return organization; } async joinOrganization(slug: string, joinCode: string) { @@ -73,9 +102,11 @@ export class OrganizationService { } ); - const orgnaization = apiToOrganization(apiOrganization); - this.appDispatch.dispatch(upsertOrganization(orgnaization)); + const organization = apiToOrganization(apiOrganization); + this.appDispatch.dispatch( + upsertOrganization({ status: 'found', id: slug, value: organization }) + ); - return orgnaization; + return organization; } } diff --git a/packages/shared/src/lib/organizations/organizationsPreview.svelte.ts b/packages/shared/src/lib/organizations/organizationsPreview.svelte.ts new file mode 100644 index 0000000000..6f16cb7627 --- /dev/null +++ b/packages/shared/src/lib/organizations/organizationsPreview.svelte.ts @@ -0,0 +1,21 @@ +import { registerInterest } from '$lib/interest/registerInterestFunction.svelte'; +import { organizationsSelectors } from '$lib/organizations/organizationsSlice'; +import type { OrganizationService } from '$lib/organizations/organizationService'; +import type { LoadableOrganization } from '$lib/organizations/types'; +import type { AppOrganizationsState } from '$lib/redux/store.svelte'; +import type { Reactive } from '$lib/storeUtils'; + +export function getOrganizationBySlug( + appState: AppOrganizationsState, + organizationService: OrganizationService, + slug: string +): Reactive { + registerInterest(organizationService.getOrganizationWithDetailsInterest(slug)); + const current = $derived(organizationsSelectors.selectById(appState.organizations, slug)); + + return { + get current() { + return current; + } + }; +} diff --git a/packages/shared/src/lib/organizations/organizationsSlice.ts b/packages/shared/src/lib/organizations/organizationsSlice.ts index 87883f2235..b654fd502a 100644 --- a/packages/shared/src/lib/organizations/organizationsSlice.ts +++ b/packages/shared/src/lib/organizations/organizationsSlice.ts @@ -1,9 +1,9 @@ +import { loadableUpsert, loadableUpsertMany } from '$lib/network/loadable'; import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; -import type { Organization } from '$lib/organizations/types'; +import type { LoadableOrganization } from '$lib/organizations/types'; -const organizationsAdapter = createEntityAdapter({ - selectId: (organization: Organization) => organization.slug, - sortComparer: (a: Organization, b: Organization) => a.slug.localeCompare(b.slug) +const organizationsAdapter = createEntityAdapter({ + selectId: (organization: LoadableOrganization) => organization.id }); const organizationsSlice = createSlice({ @@ -14,8 +14,8 @@ const organizationsSlice = createSlice({ addOrganizations: organizationsAdapter.addMany, removeOrganization: organizationsAdapter.removeOne, removeOrganizations: organizationsAdapter.removeMany, - upsertOrganization: organizationsAdapter.upsertOne, - upsertOrganizations: organizationsAdapter.upsertMany + upsertOrganization: loadableUpsert(organizationsAdapter), + upsertOrganizations: loadableUpsertMany(organizationsAdapter) } }); diff --git a/packages/shared/src/lib/organizations/projectService.ts b/packages/shared/src/lib/organizations/projectService.ts index d6913ee296..1b7f8b4a23 100644 --- a/packages/shared/src/lib/organizations/projectService.ts +++ b/packages/shared/src/lib/organizations/projectService.ts @@ -1,8 +1,9 @@ import { InterestStore, type Interest } from '$lib/interest/intrestStore'; -import { upsertProject } from '$lib/organizations/projectsSlice'; +import { errorToLoadable } from '$lib/network/loadable'; +import { addProject, upsertProject } from '$lib/organizations/projectsSlice'; import { type ApiProject, apiToProject } from '$lib/organizations/types'; import { POLLING_REGULAR } from '$lib/polling'; -import type { HttpClient } from '$lib/httpClient'; +import type { HttpClient } from '$lib/network/httpClient'; import type { AppDispatch } from '$lib/redux/store.svelte'; export class ProjectService { @@ -16,10 +17,17 @@ export class ProjectService { getProjectInterest(repositoryId: string): Interest { return this.projectInterests .findOrCreateSubscribable({ repositoryId }, async () => { - const apiProject = await this.httpClient.get(`projects/${repositoryId}`); - const project = apiToProject(apiProject); + this.appDispatch.dispatch(addProject({ status: 'loading', id: repositoryId })); - this.appDispatch.dispatch(upsertProject(project)); + try { + const apiProject = await this.httpClient.get(`projects/${repositoryId}`); + + this.appDispatch.dispatch( + upsertProject({ status: 'found', id: repositoryId, value: apiToProject(apiProject) }) + ); + } catch (error: unknown) { + this.appDispatch.dispatch(upsertProject(errorToLoadable(error, repositoryId))); + } }) .createInterest(); } @@ -30,7 +38,10 @@ export class ProjectService { }); const project = apiToProject(apiProject); - this.appDispatch.dispatch(upsertProject(project)); + this.appDispatch.dispatch( + upsertProject({ status: 'found', id: project.repositoryId, value: project }) + ); + return project; } @@ -47,7 +58,10 @@ export class ProjectService { }); const project = apiToProject(apiProject); - this.appDispatch.dispatch(upsertProject(project)); + this.appDispatch.dispatch( + upsertProject({ status: 'found', id: project.repositoryId, value: project }) + ); + return project; } } diff --git a/packages/shared/src/lib/organizations/projectsPreview.svelte.ts b/packages/shared/src/lib/organizations/projectsPreview.svelte.ts index 85ca3d6b1f..673ae01658 100644 --- a/packages/shared/src/lib/organizations/projectsPreview.svelte.ts +++ b/packages/shared/src/lib/organizations/projectsPreview.svelte.ts @@ -1,28 +1,47 @@ import { registerInterest } from '$lib/interest/registerInterestFunction.svelte'; +import { isFound } from '$lib/network/loadable'; import { projectsSelectors } from '$lib/organizations/projectsSlice'; +import type { Loadable } from '$lib/network/types'; import type { ProjectService } from '$lib/organizations/projectService'; -import type { Project } from '$lib/organizations/types'; +import type { LoadableProject } from '$lib/organizations/types'; import type { AppOrganizationsState, AppProjectsState } from '$lib/redux/store.svelte'; import type { Reactive } from '$lib/storeUtils'; +export function getProjectByRepositoryId( + appState: AppProjectsState, + projectService: ProjectService, + projectRepositoryId: string +): Reactive { + registerInterest(projectService.getProjectInterest(projectRepositoryId)); + const current = $derived(projectsSelectors.selectById(appState.projects, projectRepositoryId)); + + return { + get current() { + return current; + } + }; +} + export function getParentForRepositoryId( appState: AppProjectsState & AppOrganizationsState, projectService: ProjectService, projectRepositoryId: string -): Reactive { +): Reactive { const current = $derived.by(() => { - registerInterest(projectService.getProjectInterest(projectRepositoryId)); - const project = projectsSelectors.selectById(appState.projects, projectRepositoryId); + const project = getProjectByRepositoryId(appState, projectService, projectRepositoryId); - if (!project || !project.parentProjectRepositoryId) return; + if (!isFound(project.current) || !project.current.value.parentProjectRepositoryId) return; - registerInterest(projectService.getProjectInterest(project.parentProjectRepositoryId)); - return projectsSelectors.selectById(appState.projects, project.parentProjectRepositoryId); + return getProjectByRepositoryId( + appState, + projectService, + project.current.value.parentProjectRepositoryId + ); }); return { get current() { - return current; + return current?.current; } }; } @@ -31,15 +50,18 @@ export function getFeedIdentityForRepositoryId( appState: AppProjectsState & AppOrganizationsState, projectService: ProjectService, projectRepositoryId: string -): Reactive { +): Reactive> { const parentProject = $derived( getParentForRepositoryId(appState, projectService, projectRepositoryId) ); - const current = $derived.by(() => { - if (!parentProject.current) return; + const current = $derived.by>(() => { + if (!isFound(parentProject.current)) return parentProject.current || { status: 'loading' }; - return `${parentProject.current.owner}/${parentProject.current.slug}`; + return { + status: 'found', + value: `${parentProject.current.value.owner}/${parentProject.current.value.slug}` + }; }); return { diff --git a/packages/shared/src/lib/organizations/projectsSlice.ts b/packages/shared/src/lib/organizations/projectsSlice.ts index d7faa810bf..b4933fe3d4 100644 --- a/packages/shared/src/lib/organizations/projectsSlice.ts +++ b/packages/shared/src/lib/organizations/projectsSlice.ts @@ -1,9 +1,9 @@ +import { loadableUpsert, loadableUpsertMany } from '$lib/network/loadable'; import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; -import type { Project } from '$lib/organizations/types'; +import type { LoadableProject } from '$lib/organizations/types'; -const projectsAdapter = createEntityAdapter({ - selectId: (project: Project) => project.repositoryId, - sortComparer: (a: Project, b: Project) => a.slug.localeCompare(b.slug) +const projectsAdapter = createEntityAdapter({ + selectId: (project: LoadableProject) => project.id }); const projectsSlice = createSlice({ @@ -14,8 +14,8 @@ const projectsSlice = createSlice({ addProjects: projectsAdapter.addMany, removeProject: projectsAdapter.removeOne, removeProjects: projectsAdapter.removeMany, - upsertProject: projectsAdapter.upsertOne, - upsertProjects: projectsAdapter.upsertMany + upsertProject: loadableUpsert(projectsAdapter), + upsertProjects: loadableUpsertMany(projectsAdapter) } }); diff --git a/packages/shared/src/lib/organizations/types.ts b/packages/shared/src/lib/organizations/types.ts index a69dd679d4..acf8342b5e 100644 --- a/packages/shared/src/lib/organizations/types.ts +++ b/packages/shared/src/lib/organizations/types.ts @@ -1,3 +1,5 @@ +import type { LoadableData } from '$lib/network/types'; + export type ApiProject = { slug: string; owner: string; @@ -32,6 +34,8 @@ export type Project = { updatedAt: string; }; +export type LoadableProject = LoadableData; + export function apiToProject(apiProject: ApiProject): Project { return { repositoryId: apiProject.repository_id, @@ -78,6 +82,8 @@ export type Organization = { projectRepositoryIds?: string[]; }; +export type LoadableOrganization = LoadableData; + export function apiToOrganization( apiOrganization: ApiOrganization | ApiOrganizationWithDetails ): Organization { diff --git a/packages/shared/src/lib/reactiveUtils.svelte.ts b/packages/shared/src/lib/reactiveUtils.svelte.ts index 773855430a..6a0529099c 100644 --- a/packages/shared/src/lib/reactiveUtils.svelte.ts +++ b/packages/shared/src/lib/reactiveUtils.svelte.ts @@ -1,5 +1,5 @@ -import type { Reactive } from '$lib/storeUtils'; -import type { Readable } from 'svelte/store'; +import type { Reactive, WritableReactive } from '$lib/storeUtils'; +import type { Readable, Writable } from 'svelte/store'; export function readableToReactive(readable: Readable): Reactive { let current = $state(); @@ -18,3 +18,27 @@ export function readableToReactive(readable: Readable): Reactive(writable: Writable): WritableReactive { + let current = $state(); + + $effect(() => { + const unsubscribe = writable.subscribe((value) => { + current = value; + }); + + return unsubscribe; + }); + + return { + get current() { + return current; + }, + + set current(value: T | undefined) { + if (value !== undefined) { + writable.set(value); + } + } + }; +} diff --git a/packages/shared/src/lib/storeUtils.ts b/packages/shared/src/lib/storeUtils.ts index 073e7e7bbf..9500554dd8 100644 --- a/packages/shared/src/lib/storeUtils.ts +++ b/packages/shared/src/lib/storeUtils.ts @@ -56,7 +56,8 @@ export function writableDerived( return derivedStore; } -export type Reactive = { current: T }; +export type Reactive = { readonly current: T }; +export type WritableReactive = { current: T }; export async function guardReadableTrue(target: Readable): Promise { return await new Promise((resolve) => { diff --git a/packages/shared/src/lib/users/types.ts b/packages/shared/src/lib/users/types.ts index 01e3cec2a6..670c819033 100644 --- a/packages/shared/src/lib/users/types.ts +++ b/packages/shared/src/lib/users/types.ts @@ -1,3 +1,5 @@ +import type { LoadableData } from '$lib/network/types'; + export type ApiUser = { id: number; login: string; @@ -13,6 +15,8 @@ export type User = { avatarUrl?: string; }; +export type LoadableUser = LoadableData; + export function apiToUser(apiUser: ApiUser): User { return { login: apiUser.login, diff --git a/packages/shared/src/lib/users/userService.ts b/packages/shared/src/lib/users/userService.ts index 7b4ebcdcad..cbed3df6fa 100644 --- a/packages/shared/src/lib/users/userService.ts +++ b/packages/shared/src/lib/users/userService.ts @@ -1,8 +1,9 @@ import { InterestStore, type Interest } from '$lib/interest/intrestStore'; +import { errorToLoadable } from '$lib/network/loadable'; import { POLLING_SLOW } from '$lib/polling'; import { apiToUser, type ApiUser } from '$lib/users/types'; -import { upsertUser } from '$lib/users/usersSlice'; -import type { HttpClient } from '$lib/httpClient'; +import { addUser, upsertUser } from '$lib/users/usersSlice'; +import type { HttpClient } from '$lib/network/httpClient'; import type { AppDispatch } from '$lib/redux/store.svelte'; export class UserService { @@ -16,9 +17,15 @@ export class UserService { getUserInterest(login: string): Interest { return this.userInterests .findOrCreateSubscribable({ login }, async () => { - const apiUser = await this.httpClient.get(`user/${login}`); - const user = apiToUser(apiUser); - this.appDispatch.dispatch(upsertUser(user)); + this.appDispatch.dispatch(addUser({ status: 'loading', id: login })); + + try { + const apiUser = await this.httpClient.get(`user/${login}`); + const user = apiToUser(apiUser); + this.appDispatch.dispatch(upsertUser({ status: 'found', id: login, value: user })); + } catch (error: unknown) { + this.appDispatch.dispatch(upsertUser(errorToLoadable(error, login))); + } }) .createInterest(); } diff --git a/packages/shared/src/lib/users/usersPreview.svelte.ts b/packages/shared/src/lib/users/usersPreview.svelte.ts new file mode 100644 index 0000000000..5af6ffce4a --- /dev/null +++ b/packages/shared/src/lib/users/usersPreview.svelte.ts @@ -0,0 +1,21 @@ +import { registerInterest } from '$lib/interest/registerInterestFunction.svelte'; +import { UserService } from '$lib/users/userService'; +import { usersSelectors } from '$lib/users/usersSlice'; +import type { AppUsersState } from '$lib/redux/store.svelte'; +import type { Reactive } from '$lib/storeUtils'; +import type { LoadableUser } from '$lib/users/types'; + +export function getUserByLogin( + appState: AppUsersState, + userService: UserService, + login: string +): Reactive { + registerInterest(userService.getUserInterest(login)); + const current = $derived(usersSelectors.selectById(appState.users, login)); + + return { + get current() { + return current; + } + }; +} diff --git a/packages/shared/src/lib/users/usersSlice.ts b/packages/shared/src/lib/users/usersSlice.ts index 641aa450cd..7c7cc91770 100644 --- a/packages/shared/src/lib/users/usersSlice.ts +++ b/packages/shared/src/lib/users/usersSlice.ts @@ -1,9 +1,9 @@ +import { loadableUpsert, loadableUpsertMany } from '$lib/network/loadable'; import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; -import type { User } from '$lib/users/types'; +import type { LoadableUser } from '$lib/users/types'; -const usersAdapter = createEntityAdapter({ - selectId: (user: User) => user.login, - sortComparer: (a: User, b: User) => a.login.localeCompare(b.login) +const usersAdapter = createEntityAdapter({ + selectId: (user: LoadableUser) => user.id }); const usersSlice = createSlice({ @@ -14,8 +14,8 @@ const usersSlice = createSlice({ addUsers: usersAdapter.addMany, removeUser: usersAdapter.removeOne, removeUsers: usersAdapter.removeMany, - upsertUser: usersAdapter.upsertOne, - upsertUsers: usersAdapter.upsertMany + upsertUser: loadableUpsert(usersAdapter), + upsertUsers: loadableUpsertMany(usersAdapter) } });