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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: Lint
run: pnpm lint

- name: Database behaviour tests
run: pnpm test:db

- name: Build
run: pnpm build
env:
Expand Down
12 changes: 12 additions & 0 deletions dev-plans/utility-share-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ utility iframe; apps should verify `event.origin` the same way.
| Message | When | Payload |
|---|---|---|
| `{type: 'cas:restore-state', version, state}` | In response to `cas:share-support`, if the URL carries a valid `?calc=` token | `version`: schema version of the decoded envelope; `state`: the decoded state |
| `{type: 'cas:report-defaults', version: 1, defaults}` | After `cas:share-support` for signed-in users in a workspace, and again whenever the user changes the "Save to" target (issue #122) | `defaults`: `{companyName, preferredStandards}` (each a string or `null`) from the selected workspace, or `null` when saving to personal history |

`cas:report-defaults` is optional for apps. An app that prints title blocks or
reports should use `companyName` as the default for its company field and may
preselect `preferredStandards` where it offers a standard choice. **User
input always wins**: never overwrite a value the user has typed, and treat
`defaults: null` as "no workspace defaults", not as "clear the fields". Like
restored state, treat both values as untrusted text: render them as text,
never as HTML.

## App-side reference implementation

Expand Down Expand Up @@ -79,6 +88,9 @@ saved entry builds a `?calc=` link from it and navigates to the tool, so
restore always goes through the validation path documented here — apps need no
extra work to support history beyond speaking this protocol.

Workspace history (issue #122) stores the same object in
`public.workspace_calculations`, so shared entries reopen the same way.

## Wiring status

| App | Repo | Status |
Expand Down
13 changes: 11 additions & 2 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,17 @@ const config: Config = {
filename: 'sitemap.xml',
// Search result pages are noindex-style utility pages, keep them
// out of the sitemap (locale variants included).
// Embed routes are noindex widgets for third-party sites (issue #121).
ignorePatterns: ['/admin/**', '/search/**', '/*/search/**', '/embed/**', '/*/embed/**'],
// Embed routes are noindex widgets for third-party sites (issue #121);
// workspace invite pages carry one-time codes (issue #122).
ignorePatterns: [
'/admin/**',
'/search/**',
'/*/search/**',
'/embed/**',
'/*/embed/**',
'/workspaces/**',
'/*/workspaces/**',
],
},
theme: {
customCss: ['./src/css/custom.css', './src/css/light-theme.css'],
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"lint": "eslint src api scripts *.ts *.js *.mjs"
"lint": "eslint src api scripts *.ts *.js *.mjs",
"test:db": "node supabase/tests/workspaces.test.mjs"
},
"dependencies": {
"@dimforge/rapier3d-compat": "^0.14.0",
Expand Down Expand Up @@ -71,6 +72,7 @@
"@docusaurus/module-type-aliases": "3.10.2",
"@docusaurus/tsconfig": "3.10.2",
"@docusaurus/types": "3.10.2",
"@electric-sql/pglite": "^0.5.8",
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^26.0.1",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

78 changes: 71 additions & 7 deletions src/components/Utilities/UtilityShellPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ import homeStyles from '@site/src/pages/index.module.css';
import UtilityErrorBoundary from './UtilityErrorBoundary';
import {saveCalculation} from '@site/src/shared/calculation-history';
import {
listMyWorkspaces,
PERSONAL_TARGET,
readSaveTarget,
reportDefaultsOf,
saveWorkspaceCalculation,
writeSaveTarget,
type Workspace,
} from '@site/src/shared/workspaces';
import {
SHARE_MESSAGE_REPORT_DEFAULTS,
SHARE_MESSAGE_RESTORE,
SHARE_MESSAGE_STATE_UPDATE,
SHARE_MESSAGE_SUPPORT,
Expand Down Expand Up @@ -140,15 +150,58 @@ export default function UtilityShellPage({tool, ...config}: UtilityShellPageProp

const userId = user?.id ?? null;

// Team workspaces (issue #122): a signed-in member can save to a workspace's
// shared history instead of their personal one. The selected workspace also
// supplies report defaults to apps that accept them.
const [workspaces, setWorkspaces] = React.useState<Workspace[]>([]);
const [saveTarget, setSaveTarget] = React.useState<string>(PERSONAL_TARGET);

React.useEffect(() => {
if (!shareSupported || !userId) {
setWorkspaces([]);
return undefined;
}
let isMounted = true;
void listMyWorkspaces()
.then((rows) => {
if (!isMounted) return;
setWorkspaces(rows);
const remembered = readSaveTarget();
setSaveTarget(rows.some((w) => w.id === remembered) ? remembered : PERSONAL_TARGET);
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Unable to load workspaces.';
logger.warn('[Workspaces] Unable to load workspaces for save target', message);
});
return () => {
isMounted = false;
};
}, [shareSupported, userId]);

const targetWorkspace = workspaces.find((w) => w.id === saveTarget) ?? null;

React.useEffect(() => {
const frame = iframeRef.current?.contentWindow;
if (!shareSupported || !frame || workspaces.length === 0) return;
Comment thread
YurMil marked this conversation as resolved.
frame.postMessage(
{type: SHARE_MESSAGE_REPORT_DEFAULTS, version: 1, defaults: reportDefaultsOf(targetWorkspace)},
window.location.origin,
);
}, [shareSupported, workspaces.length, targetWorkspace]);

const changeSaveTarget = (target: string) => {
setSaveTarget(target);
writeSaveTarget(target);
};

const saveCurrentCalculation = React.useCallback(() => {
if (!userId || latestToolState.current === null) return;
setSaveState('saving');
void saveCalculation({
userId,
utilityId: slug,
state: latestToolState.current,
label: title,
})
const snapshot = {utilityId: slug, state: latestToolState.current, label: title};
const request = targetWorkspace
? saveWorkspaceCalculation({...snapshot, userId, workspaceId: targetWorkspace.id})
: saveCalculation({...snapshot, userId});
void request
.then(() => {
setSaveState('saved');
window.setTimeout(() => setSaveState('idle'), 2000);
Expand All @@ -159,7 +212,7 @@ export default function UtilityShellPage({tool, ...config}: UtilityShellPageProp
const message = err instanceof Error ? err.message : 'Unable to save calculation.';
logger.error('[CalculationHistory] Unable to save calculation', message);
});
}, [slug, title, userId]);
}, [slug, title, userId, targetWorkspace]);

React.useEffect(() => {
document.body.classList.add('utility-shell-page');
Expand Down Expand Up @@ -315,6 +368,17 @@ export default function UtilityShellPage({tool, ...config}: UtilityShellPageProp
{linkCopied ? t('utility.linkCopied') : t('utility.copyLink')}
</button>
) : null}
{shareSupported && isAuthenticated && workspaces.length > 0 ? (
<label className="utility-save-target">
<span>{t('workspaces.saveTo')}</span>
<select value={targetWorkspace ? targetWorkspace.id : PERSONAL_TARGET} onChange={(e) => changeSaveTarget(e.target.value)}>
<option value={PERSONAL_TARGET}>{t('workspaces.personalHistory')}</option>
{workspaces.map((w) => (
<option key={w.id} value={w.id}>{w.name}</option>
))}
</select>
</label>
) : null}
{shareSupported && isAuthenticated ? (
<button
className="utility-toggle"
Expand Down
Loading
Loading