diff --git a/docs/core/api/useSuspense.md b/docs/core/api/useSuspense.md index bf6866d0a99c..ba31ecd81810 100644 --- a/docs/core/api/useSuspense.md +++ b/docs/core/api/useSuspense.md @@ -134,12 +134,12 @@ render(); Cache policy is [Stale-While-Revalidate](https://tools.ietf.org/html/rfc5861) by default but also [configurable](../concepts/expiry-policy.md). -| Expiry Status | Fetch | Suspend | Error | Conditions | -| ------------- | --------------- | ------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Expiry Status | Fetch | Suspend | Error | Conditions | +| ------------- | --------------- | ------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Invalid | yes1 | yes | no | not in store, [deletion](/rest/api/resource#delete), [invalidation](./Controller.md#invalidate), [invalidIfStale](../concepts/expiry-policy.md#endpointinvalidifstale) | -| Stale | yes1 | no | no | (first-render, arg change) & [expiry < now](../concepts/expiry-policy.md) | -| Valid | no | no | maybe2 | fetch completion | -| | no | no | no | `null` used as second argument | +| Stale | yes1 | no | no | (first-render, arg change) & [expiry < now](../concepts/expiry-policy.md) | +| Valid | no | no | maybe2 | fetch completion | +| | no | no | no | `null` used as second argument | :::note @@ -348,11 +348,7 @@ export const getPosts = new RestEndpoint({ import { getPosts } from './api/Post'; export default function ArticleList({ page }: { page: string }) { - const { - posts, - nextPage, - lastPage, - } = useSuspense(getPosts, { page }); + const { posts, nextPage, lastPage } = useSuspense(getPosts, { page }); return (
{posts.map(post => ( @@ -388,4 +384,4 @@ less intrusive _loading bar_, like [YouTube](https://youtube.com) and [Robinhood -If you need help adding this to your own custom router, check out the [official React guide](https://react.dev/reference/react/useTransition#building-a-suspense-enabled-router) \ No newline at end of file +If you need help adding this to your own custom router, check out the [official React guide](https://react.dev/reference/react/useTransition#building-a-suspense-enabled-router) diff --git a/docs/core/api/useSuspense.vue.md b/docs/core/api/useSuspense.vue.md new file mode 100644 index 000000000000..477b14866141 --- /dev/null +++ b/docs/core/api/useSuspense.vue.md @@ -0,0 +1,367 @@ +--- +title: useSuspense() - Simplified data fetching for Vue +sidebar_label: useSuspense() +description: High performance async data rendering without overfetching. useSuspense() is like await for Vue components. +--- + + + + + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import GenericsTabs from '@site/src/components/GenericsTabs'; +import ConditionalDependencies from '../shared/\_conditional_dependencies.vue.mdx'; +import PaginationDemo from '../shared/\_pagination.vue.mdx'; +import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; +import { detailFixtures, listFixtures } from '@site/src/fixtures/profiles'; + +# useSuspense() + +

+High performance async data rendering without overfetching. +

+ +[await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await) `useSuspense()` in Vue components. This means the remainder of the component only runs after the data has loaded, avoiding the complexity of handling loading and error conditions. Instead, fallback handling is +[centralized](../getting-started/data-dependency.md#boundaries) with Vue's built-in [Suspense](https://vuejs.org/guide/built-ins/suspense.html). + +`useSuspense()` is reactive to data [mutations](../getting-started/mutations.md); rerendering only when necessary. + +## Usage + + + + + + +```typescript title="ProfileResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```html title="ProfileDetail.vue" + + + +``` + + + + + + + + +```typescript title="Profile" collapsed +import { Endpoint } from '@data-client/endpoint'; + +export const getProfile = new Endpoint( + (id: number) => + Promise.resolve({ + id, + fullName: 'Jing Chen', + bio: 'Creator of Flux Architecture', + avatar: 'https://avatars.githubusercontent.com/u/5050204?v=4', + }), + { + key(id) { + return `getProfile${id}`; + }, + }, +); +``` + +```html title="ProfileDetail.vue" + + + +``` + + + + + + +## Behavior + +Cache policy is [Stale-While-Revalidate](https://tools.ietf.org/html/rfc5861) by default but also [configurable](../concepts/expiry-policy.md). + +| Expiry Status | Fetch | Suspend | Error | Conditions | +| ------------- | --------------- | ------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Invalid | yes1 | yes | no | not in store, [deletion](/rest/api/resource#delete), [invalidation](./Controller.md#invalidate), [invalidIfStale](../concepts/expiry-policy.md#endpointinvalidifstale) | +| Stale | yes1 | no | no | (first-render, arg change) & [expiry < now](../concepts/expiry-policy.md) | +| Valid | no | no | maybe2 | fetch completion | +| | no | no | no | `null` used as second argument | + +:::note + +1. Identical fetches are automatically deduplicated +2. [Hard errors](../concepts/error-policy.md#hard) to be [caught](../getting-started/data-dependency#async-fallbacks) by [Error Boundaries](./AsyncBoundary.md) + +::: + + + +## Types + + + +```typescript +function useSuspense( + endpoint: ReadEndpoint, + ...args: Parameters | [null] +): Denormalize; +``` + +```typescript +function useSuspense< + E extends EndpointInterface< + FetchFunction, + Schema | undefined, + undefined + >, + Args extends readonly [...Parameters] | readonly [null], +>( + endpoint: E, + ...args: Args +): E['schema'] extends Exclude + ? Denormalize + : ReturnType; +``` + + + +## Examples + +### List + + + +```typescript title="ProfileResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```html title="ProfileList.vue" + + + +``` + + + +### Pagination + +Reactive [pagination](/rest/guides/pagination) is achieved with [mutable schemas](/rest/api/Collection) + + + +### Sequential + +When fetch parameters depend on data from another resource. + +```html + +``` + +### Conditional + +`null` will avoid binding and fetching data + + + +```ts title="Resources" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + userId = 0; + title = ''; + body = ''; + + static key = 'Post'; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); +``` + +```html title="PostWithAuthor.vue" {10-16} + + + +``` + + + +### Embedded data + +When entities are stored in [nested structures](/rest/guides/relational-data#nesting), that structure will remain. + + + +```typescript title="api/Post" {12-16} +export class PaginatedPost extends Entity { + id = ''; + title = ''; + content = ''; + + static key = 'PaginatedPost'; +} + +export const getPosts = new RestEndpoint({ + path: '/post', + searchParams: { page: '' }, + schema: { + posts: new schema.Collection([PaginatedPost]), + nextPage: '', + lastPage: '', + }, +}); +``` + +```html title="ArticleList.vue" + + + +``` + + + diff --git a/docs/core/getting-started/data-dependency.md b/docs/core/getting-started/data-dependency.md index f3f0d76f47a9..96d7b9544b5d 100644 --- a/docs/core/getting-started/data-dependency.md +++ b/docs/core/getting-started/data-dependency.md @@ -203,8 +203,7 @@ bound components immediately upon [data change](./mutations.md). This is known a ## Loading and Error {#async-fallbacks} -You might have noticed the return type shows the value is always there. [useSuspense()](../api/useSuspense.md) operates very much -like [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). This enables +You might have noticed the return type shows the value is always there. [useSuspense()](../api/useSuspense.md) operates very much like [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). This enables us to make error/loading disjoint from data usage. ### Async Boundaries {#boundaries} @@ -214,7 +213,7 @@ routes, or [modals](https://www.appcues.com/blog/modal-dialog-windows)**. -React 18's [useTransition](https://react.dev/reference/react/useTransition) and [Server Side Rendering](../guides/ssr.md) +React 18+'s [useTransition](https://react.dev/reference/react/useTransition) and [Server Side Rendering](../guides/ssr.md) powered routers or navigation means never seeing a loading fallback again. In React 16 and 17 fallbacks can be centralized to eliminate redundant loading indicators while keeping components reusable. @@ -278,7 +277,7 @@ render(); Since [useDLE](../api/useDLE.md) does not [useSuspense](../api/useSuspense.md), you won't be able to easily centrally -orchestrate loading and error code. Additionally, React 18 features like [useTransition](https://react.dev/reference/react/useTransition), +orchestrate loading and error code. Additionally, React 18+ features like [useTransition](https://react.dev/reference/react/useTransition), and [incrementally streaming SSR](../guides/ssr.md) won't work with components that use it. ## Conditional diff --git a/docs/core/getting-started/data-dependency.vue.md b/docs/core/getting-started/data-dependency.vue.md new file mode 100644 index 000000000000..dbac3e383a29 --- /dev/null +++ b/docs/core/getting-started/data-dependency.vue.md @@ -0,0 +1,254 @@ +--- +title: Rendering Asynchronous Data in Vue +sidebar_label: Render Data +--- + + + + + +import ThemedImage from '@theme/ThemedImage'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; +import ConditionalDependencies from '../shared/\_conditional_dependencies.vue.mdx'; +import { postFixtures } from '@site/src/fixtures/posts'; +import { detailFixtures, listFixtures } from '@site/src/fixtures/profiles'; +import UseLive from '../shared/\_useLive.vue.mdx'; +import AsyncBoundaryExamples from '../shared/\_AsyncBoundary.vue.mdx'; + +# Rendering Asynchronous Data + +Make your components reusable by binding the data where you **use** it with the one-line [useSuspense()](../api/useSuspense.md), +which guarantees data with [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). + + + +```ts title="Resources" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + static key = 'User'; +} +export const UserResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/users/:id', + schema: User, +}); + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'page', +}); +``` + +```html title="PostDetail.vue" collapsed + + + +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="PostList.vue" + + + +``` + + + + + + + +Do not [prop drill](https://react.dev/learn/passing-data-deeply-with-context#the-problem-with-passing-props). Instead, [useSuspense()](../api/useSuspense.md) in the components that render the data from it. This is +known as _data co-location_. + +Instead of writing complex update functions or invalidations cascades, Reactive Data Client automatically updates +bound components immediately upon [data change](./mutations.md). This is known as _reactive programming_. + +## Loading and Error {#async-fallbacks} + +You might have noticed the return type shows the value is always there. [useSuspense()](../api/useSuspense.md) operates very much with [await](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await). This enables +us to make error/loading disjoint from data usage. + +### Async Boundaries {#boundaries} + +Instead we place [<AsyncBoundary /\>](../api/AsyncBoundary.md) to handling loading and error conditions at or above navigational boundaries like **pages, +routes, or [modals](https://www.appcues.com/blog/modal-dialog-windows)**. + + + +AsyncBoundary's [error fallback](../api/AsyncBoundary.md#errorcomponent) and [loading fallback](../api/AsyncBoundary.md#fallback) can both +be customized. + +### Stateful + +You may find cases where it's still useful to use a stateful approach to fallbacks when using React 16 and 17. +For these cases, or compatibility with some component libraries, [useDLE()](../api/useDLE.md) - [D]ata [L]oading [E]rror - is provided. + + + +```typescript title="ProfileResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Profile extends Entity { + id: number | undefined = undefined; + avatar = ''; + fullName = ''; + bio = ''; + + static key = 'Profile'; +} + +export const ProfileResource = resource({ + path: '/profiles/:id', + schema: Profile, +}); +``` + +```html title="ProfileList.vue" + + + +``` + + + +Since [useDLE](../api/useDLE.md) does not [useSuspense](../api/useSuspense.md), you won't be able to easily centrally +orchestrate loading and error code. + +## Conditional + + + +## Subscriptions + +When data is likely to change due to external factor; [useSubscription()](../api/useSubscription.md) +ensures continual updates while a component is mounted. [useLive()](../api/useLive.md) calls both +[useSubscription()](../api/useSubscription.md) and [useSuspense()](../api/useSuspense.md), making it quite +easy to use fresh data. + + + +Subscriptions are orchestrated by [Managers](../api/Manager.md). Out of the box, +polling based subscriptions can be used by adding [pollFrequency](/rest/api/Endpoint#pollfrequency) to an Endpoint or Resource. +For pushed based networking protocols like SSE and websockets, see the [example stream manager](../concepts/managers.md#data-stream). + +```typescript +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + // highlight-next-line + pollFrequency: 2000, +}); +``` + diff --git a/docs/core/getting-started/mutations.md b/docs/core/getting-started/mutations.md index 44f881adfc18..22b9dbcc68fc 100644 --- a/docs/core/getting-started/mutations.md +++ b/docs/core/getting-started/mutations.md @@ -4,7 +4,6 @@ sidebar_label: Mutate Data description: Safe and high performance data mutations without refetching or writing state management. --- -import ProtocolTabs from '@site/src/components/ProtocolTabs'; import HooksPlayground from '@site/src/components/HooksPlayground'; import { TodoResource } from '@site/src/components/Demo/code/todo-app/rest/resources'; import { todoFixtures } from '@site/src/fixtures/todos'; diff --git a/docs/core/getting-started/mutations.vue.md b/docs/core/getting-started/mutations.vue.md new file mode 100644 index 000000000000..55d1f2d7cc0f --- /dev/null +++ b/docs/core/getting-started/mutations.vue.md @@ -0,0 +1,155 @@ +--- +title: Mutating Asynchronous Data in Vue +sidebar_label: Mutate Data +description: Safe and high performance data mutations without refetching or writing state management. +--- + +import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; +import { TodoResource } from '@site/src/components/Demo/code/todo-app/rest/resources'; +import { todoFixtures } from '@site/src/fixtures/todos'; +import { RestEndpoint } from '@data-client/rest'; +import UseLoading from '../shared/\_useLoading.vue.mdx'; +import VoteDemo from '../shared/\_VoteDemo.vue.mdx'; + + + + + +# Data mutations + +Using our [Create, Update, and Delete](/docs/concepts/atomic-mutations) endpoints with +[Controller.fetch()](../api/Controller.md#fetch) reactively updates _all_ appropriate components atomically (at the same time). + +[useController()](../api/useController.md) gives components access to this global supercharged [setState()](https://react.dev/reference/react/useState#setstate). + +[//]: # 'TODO: Add create, and delete examples as well (in tabs)' + + + +```ts title="TodoResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Todo extends Entity { + id = 0; + userId = 0; + title = ''; + completed = false; + + static key = 'Todo'; +} +export const TodoResource = resource({ + urlPrefix: 'https://jsonplaceholder.typicode.com', + path: '/todos/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Todo, + optimistic: true, +}); +``` + +```html title="TodoItem.vue" {8-12,14-16} + + + +``` + +```html title="CreateTodo.vue" {10-13} collapsed + + + +``` + +```html title="TodoList.vue" collapsed + + + +``` + + + +Rather than triggering invalidation cascades or using manually written update functions, +Data Client reactively updates appropriate components using the fetch response. + +## Optimistic mutations based on previous state {#optimistic-updates} + + + +[getOptimisticResponse](/rest/guides/optimistic-updates) is just like [setState with an updater function](https://react.dev/reference/react/useState#updating-state-based-on-the-previous-state). [Snapshot](../api/Snapshot.md) provides typesafe access to the previous store value, +which we use to return the _expected_ fetch response. + +Reactive Data Client ensures [data integrity against any possible networking failure or race condition](/rest/guides/optimistic-updates#optimistic-transforms), so don't +worry about network failures, multiple mutation calls editing the same data, or other common +problems in asynchronous programming. + +## Tracking mutation loading + +[useLoading()](../api/useLoading.md) enhances async functions by tracking their loading and error states. + + + diff --git a/docs/core/shared/_AsyncBoundary.vue.mdx b/docs/core/shared/_AsyncBoundary.vue.mdx new file mode 100644 index 000000000000..9a784459ae17 --- /dev/null +++ b/docs/core/shared/_AsyncBoundary.vue.mdx @@ -0,0 +1,34 @@ +Vue has built-in [Suspense](https://vuejs.org/guide/built-ins/suspense.html) for async components. Pair it with an error boundary for complete async handling: + +```html title="Dashboard.vue" + + + +``` + diff --git a/docs/core/shared/_VoteDemo.vue.mdx b/docs/core/shared/_VoteDemo.vue.mdx new file mode 100644 index 000000000000..9181947890b4 --- /dev/null +++ b/docs/core/shared/_VoteDemo.vue.mdx @@ -0,0 +1,130 @@ +import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; + + + +```ts title="Post" collapsed +import { Entity, schema } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = { id: 0 }; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + static schema = { + author: EntityMixin( + class User { + id = 0; + }, + ), + }; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +``` + +```ts title="PostResource" {15-22} +import { resource } from '@data-client/rest'; +import { Post } from './Post'; + +export { Post }; + +export const PostResource = resource({ + path: '/posts/:id', + searchParams: {} as { userId?: string | number } | undefined, + schema: Post, +}).extend('vote', { + path: '/posts/:id/vote', + method: 'POST', + body: undefined, + schema: Post, + getOptimisticResponse(snapshot, { id }) { + const post = snapshot.get(Post, { id }); + if (!post) throw snapshot.abort; + return { + id, + votes: post.votes + 1, + }; + }, +}); +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="TotalVotes.vue" collapsed + + + +``` + +```html title="PostList.vue" collapsed + + + +``` + + + diff --git a/docs/core/shared/_conditional_dependencies.vue.mdx b/docs/core/shared/_conditional_dependencies.vue.mdx new file mode 100644 index 000000000000..76c25ce0b0a5 --- /dev/null +++ b/docs/core/shared/_conditional_dependencies.vue.mdx @@ -0,0 +1,14 @@ +import CodeBlock from '@theme/CodeBlock'; + +:::tip Conditional Dependencies + +Use `null` as the second argument to any Data Client hook means "do nothing." + + + {`// todo could be undefined if id is undefined +const todo = await ${props.hook ?? 'useSuspense'}(TodoResource.get, computed(() => id ? { id } : null)));`} + + +::: + diff --git a/docs/core/shared/_pagination.vue.mdx b/docs/core/shared/_pagination.vue.mdx new file mode 100644 index 000000000000..31fa894978a3 --- /dev/null +++ b/docs/core/shared/_pagination.vue.mdx @@ -0,0 +1,113 @@ +import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; + + + +```ts title="User" collapsed +import { Entity } from '@data-client/rest'; + +export class User extends Entity { + id = 0; + name = ''; + username = ''; + email = ''; + phone = ''; + website = ''; + + get profileImage() { + return `https://i.pravatar.cc/64?img=${this.id + 4}`; + } + + pk() { + return this.id; + } + static key = 'User'; +} +``` + +```ts title="Post" {22,24} collapsed +import { Entity, resource } from '@data-client/rest'; +import { User } from './User'; + +export class Post extends Entity { + id = 0; + author = User.fromJS(); + title = ''; + body = ''; + + pk() { + return this.id; + } + static key = 'Post'; + + static schema = { + author: User, + }; +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, + paginationField: 'cursor', +}).extend('getList', { + schema: { posts: new schema.Collection([Post]), cursor: '' }, +}); +``` + +```html title="PostItem.vue" collapsed + + + +``` + +```html title="LoadMore.vue" + + + +``` + +```html title="PostList.vue" collapsed + + + +``` + + + diff --git a/docs/core/shared/_useLive.vue.mdx b/docs/core/shared/_useLive.vue.mdx new file mode 100644 index 000000000000..8edd825a0bf6 --- /dev/null +++ b/docs/core/shared/_useLive.vue.mdx @@ -0,0 +1,62 @@ +import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; + + + +```typescript title="Ticker" {32} collapsed +import { Entity, RestEndpoint } from '@data-client/rest'; + +export class Ticker extends Entity { + product_id = ''; + trade_id = 0; + price = 0; + size = '0'; + time = Temporal.Instant.fromEpochMilliseconds(0); + bid = '0'; + ask = '0'; + volume = ''; + + pk(): string { + return this.product_id; + } + static key = 'Ticker'; + + static schema = { + price: Number, + time: Temporal.Instant.from, + }; +} + +export const getTicker = new RestEndpoint({ + urlPrefix: 'https://api.exchange.coinbase.com', + path: '/products/:productId/ticker', + schema: Ticker, + process(value, { productId }) { + value.product_id = productId; + return value; + }, + pollFrequency: 2000, +}); +``` + +```html title="AssetPrice.vue" + + + +``` + + + diff --git a/docs/core/shared/_useLoading.vue.mdx b/docs/core/shared/_useLoading.vue.mdx new file mode 100644 index 000000000000..b1d09fba7e4e --- /dev/null +++ b/docs/core/shared/_useLoading.vue.mdx @@ -0,0 +1,109 @@ +import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; + + + +```ts title="PostResource" collapsed +import { Entity, resource } from '@data-client/rest'; + +export class Post extends Entity { + id = 0; + author = 0; + title = ''; + body = ''; + votes = 0; + + static key = 'Post'; + + get img() { + return `//loremflickr.com/96/72/kitten,cat?lock=${this.id % 16}`; + } +} +export const PostResource = resource({ + path: '/posts/:id', + schema: Post, +}); +``` + +```html title="PostDetail.vue" collapsed + + + +``` + +```html title="PostForm.vue" collapsed + + + +``` + +```html title="PostCreate.vue" + + + +``` + + + diff --git a/website/src/components/FrameworkSelector.module.css b/website/src/components/FrameworkSelector.module.css new file mode 100644 index 000000000000..1bd8cd0a9205 --- /dev/null +++ b/website/src/components/FrameworkSelector.module.css @@ -0,0 +1,105 @@ +.container { + position: relative; + display: inline-flex; + align-items: center; +} + +.logo { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.chevron { + width: 12px; + height: 12px; + flex-shrink: 0; + transition: transform 0.2s; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 0.4rem; + appearance: none; + background-color: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + color: var(--ifm-font-color-base); + cursor: pointer; + font-family: var(--ifm-font-family-base); + font-size: 0.875rem; + font-weight: 500; + padding: 0.35rem 0.5rem 0.35rem 0.6rem; + transition: + border-color 0.2s, + box-shadow 0.2s; +} + +.trigger:hover { + border-color: var(--ifm-color-primary); +} + +.trigger:focus { + outline: none; + border-color: var(--ifm-color-primary); + box-shadow: 0 0 0 2px var(--ifm-color-primary-light); +} + +.trigger[aria-expanded='true'] .chevron { + transform: rotate(180deg); +} + +.dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 100; + min-width: 100%; + margin: 0; + padding: 0.25rem; + list-style: none; + background-color: var(--ifm-background-surface-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1); +} + +.option { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.4rem 0.6rem; + border: none; + border-radius: calc(var(--ifm-global-radius) - 2px); + background: transparent; + color: var(--ifm-font-color-base); + font-family: var(--ifm-font-family-base); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; +} + +.option:hover { + background-color: var(--ifm-color-emphasis-100); +} + +.option.selected { + background-color: var(--ifm-color-primary-lightest); + color: var(--ifm-color-primary-darkest); +} + +html[data-theme='dark'] .option.selected { + background-color: var(--ifm-color-primary-darkest); + color: var(--ifm-color-primary-lightest); +} + +html[data-theme='dark'] .dropdown { + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.3), + 0 2px 4px -2px rgb(0 0 0 / 0.3); +} diff --git a/website/src/components/FrameworkSelector.tsx b/website/src/components/FrameworkSelector.tsx new file mode 100644 index 000000000000..bf3508a95108 --- /dev/null +++ b/website/src/components/FrameworkSelector.tsx @@ -0,0 +1,112 @@ +import { useStorageSlot } from '@docusaurus/theme-common'; +import React, { useCallback, useState, useRef, useEffect } from 'react'; + +import styles from './FrameworkSelector.module.css'; + +export type Framework = 'react' | 'vue'; + +const STORAGE_KEY = 'docusaurus.tab.framework'; + +// React logo SVG +const ReactLogo = () => ( + + + +); + +// Vue logo SVG +const VueLogo = () => ( + + + +); + +const frameworks: { value: Framework; label: string; Logo: React.FC }[] = [ + { value: 'react', label: 'React', Logo: ReactLogo }, + { value: 'vue', label: 'Vue', Logo: VueLogo }, +]; + +export function useFrameworkStorage() { + const [value, storageSlot] = useStorageSlot(STORAGE_KEY); + + const setValue = useCallback( + (newValue: Framework) => { + storageSlot.set(newValue); + }, + [storageSlot], + ); + + // Default to 'react' if no value is set + const framework: Framework = value === 'vue' ? 'vue' : 'react'; + + return [framework, setValue] as const; +} + +export default function FrameworkSelector() { + const [framework, setFramework] = useFrameworkStorage(); + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const currentFramework = frameworks.find(f => f.value === framework)!; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelect = (value: Framework) => { + setFramework(value); + setIsOpen(false); + }; + + return ( +
+ + {isOpen && ( +
    + {frameworks.map(({ value, label, Logo }) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/website/src/components/FrameworkTabs.tsx b/website/src/components/FrameworkTabs.tsx new file mode 100644 index 000000000000..576f8a99cc61 --- /dev/null +++ b/website/src/components/FrameworkTabs.tsx @@ -0,0 +1,55 @@ +import React, { Children, ReactNode, isValidElement } from 'react'; + +import { useFrameworkStorage, Framework } from './FrameworkSelector'; + +interface TabItemProps { + value: Framework; + children: ReactNode; +} + +// Helper component for specifying framework-specific content +export function TabItem({ children }: TabItemProps): JSX.Element { + return <>{children}; +} + +interface FrameworkTabsProps { + children: ReactNode; +} + +/** + * FrameworkTabs component that conditionally renders React or Vue content + * based on the global framework selector state. + * + * Usage in MDX: + * ```jsx + * + * + * {/* React example *\/} + * + * + * {/* Vue example *\/} + * + * + * ``` + */ +export default function FrameworkTabs({ + children, +}: FrameworkTabsProps): JSX.Element | null { + const [framework] = useFrameworkStorage(); + + // Find the child that matches the current framework + const childArray = Children.toArray(children); + + for (const child of childArray) { + if (isValidElement(child) && child.props.value === framework) { + // Key forces React to unmount/remount children when framework changes + // This ensures editors and other stateful components reset properly + return ( + {child.props.children} + ); + } + } + + // No matching tab found - render nothing + return null; +} diff --git a/website/src/theme/DocBreadcrumbs/index.tsx b/website/src/theme/DocBreadcrumbs/index.tsx new file mode 100644 index 000000000000..768c593aa6f8 --- /dev/null +++ b/website/src/theme/DocBreadcrumbs/index.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { useLocation } from '@docusaurus/router'; + +import DocBreadcrumbs from '@theme-original/DocBreadcrumbs'; +import type DocBreadcrumbsType from '@theme/DocBreadcrumbs'; +import type { WrapperProps } from '@docusaurus/types'; + +import FrameworkSelector from '@site/src/components/FrameworkSelector'; +import styles from './styles.module.css'; + +type Props = WrapperProps; + +export default function DocBreadcrumbsWrapper(props: Props): JSX.Element { + const location = useLocation(); + // Only show framework selector on main /docs pages (not /rest or /graphql) + const showSelector = location.pathname.startsWith('/docs'); + + if (!showSelector) { + return ; + } + + return ( +
+
+ +
+
+ +
+
+ ); +} + diff --git a/website/src/theme/DocBreadcrumbs/styles.module.css b/website/src/theme/DocBreadcrumbs/styles.module.css new file mode 100644 index 000000000000..bee1fa58d34b --- /dev/null +++ b/website/src/theme/DocBreadcrumbs/styles.module.css @@ -0,0 +1,21 @@ +.wrapper { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +/* Left side: native Docusaurus breadcrumbs */ +.left { + min-width: 0; + flex: 1; +} + +/* Right side: framework selector */ +.right { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + diff --git a/website/src/theme/MDXComponents.js b/website/src/theme/MDXComponents.js index 90640295a062..522d6140cbfc 100644 --- a/website/src/theme/MDXComponents.js +++ b/website/src/theme/MDXComponents.js @@ -2,7 +2,13 @@ import { RestEndpoint } from '@data-client/rest'; import MDXComponents from '@theme-original/MDXComponents'; +import FrameworkTabs, { + TabItem as FrameworkTabItem, +} from '@site/src/components/FrameworkTabs'; + export default { ...MDXComponents, RestEndpoint, + FrameworkTabs, + FrameworkTabItem, }; diff --git a/website/src/theme/TOC/index.tsx b/website/src/theme/TOC/index.tsx new file mode 100644 index 000000000000..582d3b1b9d2a --- /dev/null +++ b/website/src/theme/TOC/index.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useLocation } from '@docusaurus/router'; + +import TOC from '@theme-original/TOC'; +import type TOCType from '@theme/TOC'; +import type { WrapperProps } from '@docusaurus/types'; + +import FrameworkSelector from '@site/src/components/FrameworkSelector'; +import styles from './styles.module.css'; + +type Props = WrapperProps; + +// Separate component for the fixed selector to avoid any wrapper interference +function FixedFrameworkSelector() { + const [showSelector, setShowSelector] = useState(false); + const [selectorPosition, setSelectorPosition] = useState<{ + top: number; + right: number; + } | null>(null); + const location = useLocation(); + + // Only show on main /docs pages (not /rest or /graphql) + const isDocsPage = location.pathname.startsWith('/docs'); + + useEffect(() => { + if (!isDocsPage) return; + + const breadcrumbsWrapper = document.querySelector( + '[data-framework-selector-anchor]', + ); + const tocElement = document.querySelector('.theme-doc-toc-desktop'); + + if (!breadcrumbsWrapper || !tocElement) return; + + // Update position based on TOC element (only needed on resize) + const updatePosition = () => { + const tocRect = tocElement.getBoundingClientRect(); + setSelectorPosition({ + top: Math.max(tocRect.top, 70), // 70px accounts for navbar height + right: window.innerWidth - tocRect.right, + }); + }; + + // Use IntersectionObserver - much more efficient than scroll events + // Only fires when visibility state changes, not on every scroll frame + const observer = new IntersectionObserver( + ([entry]) => { + // Show selector when breadcrumbs are NOT intersecting (scrolled out of view) + const shouldShow = !entry.isIntersecting; + setShowSelector(shouldShow); + if (shouldShow) { + updatePosition(); + } + }, + { + // Trigger when element fully leaves the viewport (top edge) + threshold: 0, + rootMargin: '0px', + }, + ); + + observer.observe(breadcrumbsWrapper); + + // Only need resize listener for position updates (not scroll) + window.addEventListener('resize', updatePosition, { passive: true }); + + // Initial position calculation + updatePosition(); + + return () => { + observer.disconnect(); + window.removeEventListener('resize', updatePosition); + }; + }, [isDocsPage, location.pathname]); + + if (!isDocsPage || !selectorPosition) return null; + + // Use portal to render outside the TOC hierarchy + return createPortal( +
+ +
, + document.body, + ); +} + +export default function TOCWrapper(props: Props): JSX.Element { + return ( + <> + + + + ); +} + diff --git a/website/src/theme/TOC/styles.module.css b/website/src/theme/TOC/styles.module.css new file mode 100644 index 000000000000..938eca8c45d9 --- /dev/null +++ b/website/src/theme/TOC/styles.module.css @@ -0,0 +1,24 @@ +.fixedSelector { + position: fixed; + opacity: 0; + transform: translateY(-10px); + pointer-events: none; + transition: + opacity 0.2s ease, + transform 0.2s ease; + z-index: 100; +} + +.fixedSelector.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +/* Hide on mobile where there's no TOC sidebar */ +@media (max-width: 996px) { + .fixedSelector { + display: none; + } +} +