Skip to content
Merged
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
36 changes: 12 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# FRCDesignApp

This repo hosts the code for the FRCDesign Onshape App.
This repo hosts the code for the FRCDesign Onshape App, which lets users browse
the FRCDesign part libraries and insert parts into their documents.

The app is a Cloudflare Worker (`src/backend`) serving an API and a React SPA
(`src/frontend`), which runs both in Onshape's element right panel and directly
in a browser. See `AGENTS.md` for how `src` is laid out.

## Overview

Expand Down Expand Up @@ -32,6 +37,10 @@ ACCESS_LEVEL_OVERRIDE=admin

# One of admin, editor, or user. The level the app is viewed as by default (client-side).
VITE_DEFAULT_ACCESS_LEVEL=admin

# Signs you in as a fake user, so signed-in UI can be tested without an Onshape
# session. Onshape calls it reveals won't work, so leave it unset normally.
FORCE_SIGNED_IN=false
```

## Onshape OAuth App Setup
Expand Down Expand Up @@ -124,35 +133,14 @@ You should now be able to run the `Launch dev` VSCode task to launch Vite.
You should then be able to launch the FRC Design App from the right panel of any Onshape Part Studio or Assembly and see the FRC Design App UI appear.

To see documents, add one or more documents and push a new app version to rebuild the search database.

To view the state of Cloudflare, type `e` in Vite to launch the local Cloudflare UI instance.

## Standalone (not-signed-in) mode

The app also runs without an Onshape login. Opening it directly (e.g.
`https://localhost:3000/`, rather than launching it from the Onshape panel)
serves the read-only library UI: browse groups, search, and open the
configuration menu. Anything that needs Onshape — inserting/deriving, favorites,
saving settings server-side, and live configuration previews — is hidden and
guarded server-side behind sign-in. Settings (theme/library) fall back to
`localStorage`, and the configuration menu uses default units and the stored
(static) thumbnail.

This needs a populated database. Import a dump of the loaded cert DB into local
D1, and (optionally) its thumbnails into local R2:
Alternatively, import a dump of the loaded cert database into local D1, and (optionally) its thumbnails into local R2:

```
npx wrangler d1 execute DB --local --file=<cert-dump>.sql
npx wrangler r2 object put frc-design-app-dev-thumbnails/thumbnails/<size>/<elementId> --file=<thumb>.gif
```

To exercise the signed-in-only UI (favorites, insert button) without a real
Onshape session, set `FORCE_SIGNED_IN=true` in your `.env`. This is a
testing-only escape hatch — it uses a fake user id and Onshape calls it reveals
won't actually work, so leave it unset normally. Combine with
`ACCESS_LEVEL_OVERRIDE=admin` to also show editor/admin controls — editor
routes require a session as well as the access level, so the override alone
does not reach them.
To view the state of Cloudflare, type `e` in Vite to launch the local Cloudflare UI instance.

# Troubleshooting

Expand Down
10 changes: 7 additions & 3 deletions src/frontend/features/auth/access-level.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ export function useAccessData(): ResolvedAccessData {
}, [serverData, chosenLevel, isPending]);
}

/** Whether the caller is signed in to Onshape (from access-data). */
export function useIsSignedIn(): boolean {
return useAccessData().signedIn;
/**
* Whether the caller is signed in to Onshape, or undefined until access-data
* lands: the placeholder says signed out, which is not yet an answer.
*/
export function useIsSignedIn(): boolean | undefined {
const { signedIn, isPending } = useAccessData();
return isPending ? undefined : signedIn;
}

interface RequireAccessLevelProps extends PropsWithChildren {
Expand Down
28 changes: 18 additions & 10 deletions src/frontend/features/favorites/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,33 @@ import { queryOptions, useQuery } from "@tanstack/react-query";
import { apiGet } from "../../lib/api-client";
import { type FavoritesData } from "@backend/features/favorites/contract";
import { LibraryId } from "@backend/features/library/library-id";
import { useAccessData } from "../auth/access-level";
import { getAccessDataQuery } from "../auth/access-level";
import { useLibraryId } from "../library/library-path";
import { queryClient } from "../../lib/query-client";
import { favoritesQueryKey } from "../../lib/query-keys";

const EMPTY_FAVORITES: FavoritesData = { favorites: {}, favoriteOrder: [] };

export function getFavoritesQuery(libraryId: LibraryId, enabled = true) {
/**
* Resolved in the query rather than gating it with `enabled`: a disabled query
* reports pending forever, and the access-data placeholder says signed out, so
* a signed-in caller would be shown no favorites until both land.
*/
export function getFavoritesQuery(libraryId: LibraryId) {
return queryOptions<FavoritesData>({
queryKey: favoritesQueryKey(libraryId),
queryFn: () => apiGet("/favorites/library/" + libraryId),
enabled,
// Not signed in: the endpoint 401s, so present no favorites.
placeholderData: EMPTY_FAVORITES
queryFn: async () => {
const { signedIn } =
await queryClient.ensureQueryData(getAccessDataQuery());
// Not signed in: no favorites, and the endpoint 401s.
if (!signedIn) {
return EMPTY_FAVORITES;
}
return apiGet("/favorites/library/" + libraryId);
}
});
}

export function useFavoritesQuery() {
const libraryId = useLibraryId();
// Favorites require sign-in; don't fetch (or display) them otherwise.
const signedIn = useAccessData().signedIn;
return useQuery(getFavoritesQuery(libraryId, signedIn));
return useQuery(getFavoritesQuery(useLibraryId()));
}
8 changes: 5 additions & 3 deletions src/frontend/features/insert/components/configurations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) {
// Units come from the current document; empty when not connected to one, in
// which case each quantity renders in its own unit (see getEvaluateOptions).
const isConnected = useIsConnectedToOnshape();
const unitInfo =
useUnitInfoQuery(search, isConnected).data ?? EMPTY_UNIT_INFO;
const unitInfoQuery = useUnitInfoQuery(search, isConnected);
const unitInfo = unitInfoQuery.data ?? EMPTY_UNIT_INFO;

useEffect(() => {
// Doing this in a useEffect rather than a .then inside useQuery to prevent some buggy behavior
Expand Down Expand Up @@ -125,7 +125,9 @@ export function ConfigurationWrapper(props: ConfigurationWrapperProps) {
onRecord?.(findRecordForConfiguration(configuration, records));
}, [records, configuration, onRecord]);

if (query.isPending || !configuration) {
// isLoading, not isPending: the units query sits disabled (and so forever
// pending) when there is no document to ask.
if (query.isPending || unitInfoQuery.isLoading || !configuration) {
return (
<Center my="md">
<Loader />
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/features/insert/components/insert-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ export function InsertMenuContent(props: InsertMenuContentProps): ReactNode {
}, [modalId, insertable.name, record, soleRecord]);

useEffect(() => {
if (!isSignedIn) {
// Only once known: pending reads as signed out, which would prompt a
// signed-in caller to sign in.
if (isSignedIn === false) {
showSignInPreviewToast();
}
}, [isSignedIn]);
Expand Down
4 changes: 1 addition & 3 deletions src/frontend/features/insert/queries.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../../lib/api-client";
import {
EMPTY_UNIT_INFO,
type ConfigurationResult,
type UnitInfo
} from "@backend/features/configurations/models";
Expand All @@ -24,8 +23,7 @@ export function useUnitInfoQuery(instancePath: InstancePath, enabled = true) {
instanceType: instancePath.instanceType
}
}),
enabled,
placeholderData: EMPTY_UNIT_INFO
enabled
});
}

Expand Down
28 changes: 27 additions & 1 deletion src/frontend/features/settings/components/settings-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import { Theme } from "@backend/features/settings/settings";
import { hasEditorAccess } from "@backend/features/auth/access-level";
import { isWithinAccessLevel } from "@backend/features/auth/access-level";
import { AccessLevel } from "@backend/features/auth/access-level";
import { LibraryId } from "@backend/features/library/library-id";
import { useSaveSettings } from "../settings";
import { OpenUrlButton } from "../../../components/open-url-button";
import { RequireAccessLevel, useAccessData } from "../../auth/access-level";
import { useUiState } from "../../../lib/ui-state";
import { FEEDBACK_FORM_URL } from "../../../lib/url";
import { useIsConnectedToOnshape } from "../../../lib/onshape-params";
import { useLibraryId } from "../../library/library-path";
import { AppSelect } from "../../../components/app-select";
import {
makeSelectOption,
Expand Down Expand Up @@ -69,11 +72,14 @@ function UserSettings(): ReactNode {
const location = useRouterState({ select: (state) => state.location });
const navigate = useNavigate();
const saveSettings = useSaveSettings();
const libraryId = useLibraryId();
const isConnected = useIsConnectedToOnshape();
const theme = location.search.theme ?? DEFAULT_SETTINGS.theme;

return (
<>
<ThemeSelect
theme={location.search.theme ?? DEFAULT_SETTINGS.theme}
theme={theme}
onThemeSelect={(theme) => {
// The url renders it; the write-behind decides what the
// entry redirect seeds next time.
Expand All @@ -84,13 +90,33 @@ function UserSettings(): ReactNode {
});
}}
/>
{/* Only worth offering from inside Onshape's panel, which is what
the standalone app is roomier than. */}
{isConnected && (
<SettingRow label="Open outside Onshape">
<OpenUrlButton
text="Open app"
url={standaloneUrl(libraryId, theme)}
/>
</SettingRow>
)}
<SettingRow label="Submit feedback">
<OpenUrlButton text="Open form" url={FEEDBACK_FORM_URL} />
</SettingRow>
</>
);
}

/**
* The app's own url for the current library, free of the params Onshape
* launches it with — carrying those over is what would keep it embedded.
*/
function standaloneUrl(libraryId: LibraryId, theme: Theme): string {
const url = new URL(`/app/library/${libraryId}`, window.location.origin);
url.searchParams.set("theme", theme);
return url.toString();
}

interface ThemeSelectProps {
theme: Theme;
onThemeSelect: Dispatch<Theme>;
Expand Down
12 changes: 11 additions & 1 deletion src/frontend/features/thumbnails/components/thumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,21 @@ export function PreviewImage(props: PreviewImageProps): ReactNode {
refetchInterval: (query) =>
query.state.data?.isFallback ? PREVIEW_POLL_MS : false,
retry: 2,
enabled: !isFetchingConfiguration && isSignedIn
enabled: !isFetchingConfiguration && isSignedIn === true
});

const heightAndWidth = getHeightAndWidth(size, 0.7);

// Not known yet: the stored thumbnail would be swapped for the live preview
// a moment later.
if (isSignedIn === undefined) {
return (
<Center w={heightAndWidth.width} h={heightAndWidth.height}>
<Loader size={36} />
</Center>
);
}

// Not signed in: no live Onshape preview, so show the stored thumbnail
// (Thumbnail falls back to a placeholder when there's none).
if (!isSignedIn) {
Expand Down
12 changes: 1 addition & 11 deletions src/frontend/routes/app/library/$libraryId/route.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
import { queryClient } from "../../../../lib/query-client";
import { getAccessDataQuery } from "../../../../features/auth/access-level";
import { getFavoritesQuery } from "../../../../features/favorites/queries";
import {
getLibraryQuery,
Expand Down Expand Up @@ -49,15 +48,6 @@ export const Route = createFileRoute("/app/library/$libraryId")({
void queryClient.prefetchQuery(
getSearchDbQuery(libraryId, cacheVersion)
);
// Favorites are per-user, so the endpoint 401s a signed-out caller.
void queryClient
.ensureQueryData(getAccessDataQuery())
.then((accessData) => {
if (accessData.signedIn) {
void queryClient.prefetchQuery(
getFavoritesQuery(libraryId)
);
}
});
void queryClient.prefetchQuery(getFavoritesQuery(libraryId));
}
});
Loading