Available mock server types should consider organizations storage rule

This commit is contained in:
yaoweiprc
2024-11-08 15:24:10 +08:00
parent 890d1b6d7e
commit cd35be925f
5 changed files with 126 additions and 97 deletions

View File

@@ -24,6 +24,7 @@ import {
isRemoteProject,
type Project,
} from '../../../models/project';
import { ORG_STORAGE_RULE, type OrgStorageRuleType } from '../../routes/organization';
import { Icon } from '../icon';
import { showAlert, showModal } from '../modals';
import { AskModal } from '../modals/ask-modal';
@@ -31,7 +32,7 @@ import { AskModal } from '../modals/ask-modal';
interface Props {
project: Project & { hasUncommittedOrUnpushedChanges?: boolean };
organizationId: string;
storage: 'cloud_only' | 'local_only' | 'cloud_plus_local';
storage: OrgStorageRuleType;
}
interface ProjectActionItem {
@@ -48,10 +49,10 @@ export const ProjectDropdown: FC<Props> = ({ project, organizationId, storage })
const updateProjectFetcher = useFetcher();
const [projectType, setProjectType] = useState<'local' | 'remote' | ''>('');
const isRemoteProjectInconsistent = isRemoteProject(project) && storage === 'local_only';
const isLocalProjectInconsistent = !isRemoteProject(project) && storage === 'cloud_only';
const isRemoteProjectInconsistent = isRemoteProject(project) && storage === ORG_STORAGE_RULE.LOCAL_ONLY;
const isLocalProjectInconsistent = !isRemoteProject(project) && storage === ORG_STORAGE_RULE.CLOUD_ONLY;
const isProjectInconsistent = isRemoteProjectInconsistent || isLocalProjectInconsistent;
const showStorageRestrictionMessage = storage !== 'cloud_plus_local';
const showStorageRestrictionMessage = storage !== ORG_STORAGE_RULE.CLOUD_PLUS_LOCAL;
const projectActionList: ProjectActionItem[] = [
{
@@ -123,7 +124,7 @@ export const ProjectDropdown: FC<Props> = ({ project, organizationId, storage })
offset={4}
className="border select-none text-sm max-w-xs border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] text-[--color-font] px-4 py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none"
>
{`This project type is not allowed by the organization owner. You can manually convert it to use ${storage === 'cloud_only' ? 'Cloud Sync' : 'Local Vault'}.`}
{`This project type is not allowed by the organization owner. You can manually convert it to use ${storage === ORG_STORAGE_RULE.CLOUD_ONLY ? 'Cloud Sync' : 'Local Vault'}.`}
</Tooltip>
</TooltipTrigger>
}
@@ -238,13 +239,13 @@ export const ProjectDropdown: FC<Props> = ({ project, organizationId, storage })
className="py-1 placeholder:italic w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors"
/>
</TextField>
<RadioGroup name="type" defaultValue={storage === 'cloud_plus_local' ? project.remoteId ? 'remote' : 'local' : storage !== 'cloud_only' ? 'local' : 'remote'} className="flex flex-col gap-2">
<RadioGroup name="type" defaultValue={storage === ORG_STORAGE_RULE.CLOUD_PLUS_LOCAL ? project.remoteId ? 'remote' : 'local' : storage !== ORG_STORAGE_RULE.CLOUD_ONLY ? 'local' : 'remote'} className="flex flex-col gap-2">
<Label className="text-sm text-[--hl]">
Project type
</Label>
<div className="flex gap-2">
<Radio
isDisabled={storage === 'local_only'}
isDisabled={storage === ORG_STORAGE_RULE.LOCAL_ONLY}
value="remote"
className="data-[selected]:border-[--color-surprise] flex-1 data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
@@ -257,7 +258,7 @@ export const ProjectDropdown: FC<Props> = ({ project, organizationId, storage })
</p>
</Radio>
<Radio
isDisabled={storage === 'cloud_only'}
isDisabled={storage === ORG_STORAGE_RULE.CLOUD_ONLY}
value="local"
className="data-[selected]:border-[--color-surprise] flex-1 data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>

View File

@@ -1,25 +1,50 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Button, Dialog, Heading, Input, Label, Link, Modal, ModalOverlay, Radio, RadioGroup, TextField } from 'react-aria-components';
import { useFetcher, useParams, useRouteLoaderData } from 'react-router-dom';
import { invariant } from '../../../utils/invariant';
import type { OrganizationLoaderData } from '../../routes/organization';
import { fetchAndCacheOrganizationStorageRule, ORG_STORAGE_RULE, type OrganizationLoaderData, type OrgStorageRuleType } from '../../routes/organization';
import type { ProjectIdLoaderData } from '../../routes/project';
import { Icon } from '../icon';
import { showModal } from '.';
import { AlertModal } from './alert-modal';
export const MockServerSettingsModal = ({ onClose }: { onClose: () => void }) => {
export function useAvailableMockServerType(isLocalProject: boolean) {
const { organizationId, projectId } = useParams<{ organizationId: string; projectId: string }>();
const { currentPlan } = useRouteLoaderData('/organization') as OrganizationLoaderData;
const [orgStorageRule, setOrgStorageRule] = useState<OrgStorageRuleType>(ORG_STORAGE_RULE.CLOUD_PLUS_LOCAL);
useEffect(() => {
fetchAndCacheOrganizationStorageRule(organizationId as string).then(setOrgStorageRule);
}, [organizationId]);
const isEnterprise = currentPlan?.type.includes('enterprise');
const isSelfHostedDisabled = !isEnterprise || orgStorageRule === ORG_STORAGE_RULE.CLOUD_ONLY;
const isCloudProjectDisabled = isLocalProject || orgStorageRule === ORG_STORAGE_RULE.LOCAL_ONLY;
return {
isSelfHostedDisabled,
isCloudProjectDisabled,
organizationId,
projectId,
isEnterprise,
isLocalProject,
};
}
export const MockServerSettingsModal = ({ onClose }: { onClose: () => void }) => {
// file://./../../routes/project.tsx#projectIdLoader
const projectData = useRouteLoaderData('/project/:projectId') as ProjectIdLoaderData | null;
const isLocalProject = !projectData?.activeProject?.remoteId;
const {
isSelfHostedDisabled,
isCloudProjectDisabled,
organizationId,
projectId,
isEnterprise,
} = useAvailableMockServerType(isLocalProject);
const fetcher = useFetcher({
key: `${organizationId}-create-mock-server`,
});
const { currentPlan } = useRouteLoaderData('/organization') as OrganizationLoaderData;
const projectData = useRouteLoaderData('/project/:projectId') as ProjectIdLoaderData | null;
const isLocalProject = !projectData?.activeProject?.remoteId;
const isEnterprise = currentPlan?.type.includes('enterprise');
const isSelfHostedDisabled = !isEnterprise;
const isCloudProjectDisabled = isLocalProject;
const canOnlyCreateSelfHosted = isLocalProject && isEnterprise;
const defaultServerType = canOnlyCreateSelfHosted ? 'self-hosted' : 'cloud';
const [serverType, setServerType] = useState<'self-hosted' | 'cloud'>(defaultServerType);
@@ -83,6 +108,7 @@ export const MockServerSettingsModal = ({ onClose }: { onClose: () => void }) =>
}
}
// file://./../../routes/actions.tsx#createNewWorkspaceAction
fetcher.submit(
{
name,

View File

@@ -1,7 +1,6 @@
import React from 'react';
import { Button, Dialog, Heading, Input, Label, Modal, ModalOverlay, Radio, RadioGroup, TextField } from 'react-aria-components';
import { useFetcher, useRouteLoaderData } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { database as db } from '../../../common/database';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
@@ -9,13 +8,14 @@ import * as models from '../../../models/index';
import type { MockServer } from '../../../models/mock-server';
import { isRequest } from '../../../models/request';
import { isEnvironment, isMockServer, isScratchpad, type Workspace } from '../../../models/workspace';
import type { OrganizationLoaderData } from '../../routes/organization';
import type { WorkspaceLoaderData } from '../../routes/workspace';
import { Link } from '../base/link';
import { PromptButton } from '../base/prompt-button';
import { Icon } from '../icon';
import { MarkdownEditor } from '../markdown-editor';
import { showModal } from '.';
import { AlertModal } from './alert-modal';
import { useAvailableMockServerType } from './mock-server-settings-modal';
interface Props {
onClose: () => void;
@@ -24,14 +24,20 @@ interface Props {
}
export const WorkspaceSettingsModal = ({ workspace, mockServer, onClose }: Props) => {
// file://./../../routes/workspace.tsx#workspaceLoader
const workspaceLoaderData = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData | null;
const isLocalProject = !workspaceLoaderData?.activeProject?.remoteId;
const {
isSelfHostedDisabled,
isCloudProjectDisabled,
organizationId,
projectId,
isEnterprise,
} = useAvailableMockServerType(isLocalProject);
const isScratchpadWorkspace = isScratchpad(workspace);
const { currentPlan } = useRouteLoaderData('/organization') as OrganizationLoaderData;
const isEnterprise = currentPlan?.type.includes('enterprise');
const isSelfHostedDisabled = !isEnterprise;
const activeWorkspaceName = workspace.name;
const { organizationId, projectId } = useParams<{ organizationId: string; projectId: string }>();
const workspaceFetcher = useFetcher();
const mockServerFetcher = useFetcher();
const workspacePatcher = (workspaceId: string, patch: Partial<Workspace>) => {
@@ -42,6 +48,7 @@ export const WorkspaceSettingsModal = ({ workspace, mockServer, onClose }: Props
});
};
const mockServerPatcher = (mockServerId: string, patch: Partial<MockServer>) => {
// file://./../../routes/actions.tsx#updateMockServerAction
mockServerFetcher.submit({ ...patch, mockServerId }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/mock-server/update`,
method: 'post',
@@ -148,6 +155,7 @@ export const WorkspaceSettingsModal = ({ workspace, mockServer, onClose }: Props
<div className="flex gap-2">
<Radio
value="cloud"
isDisabled={isCloudProjectDisabled}
className="flex-1 data-[selected]:border-[--color-surprise] data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] data-[disabled]:opacity-25 hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
<div className='flex items-center gap-2'>
@@ -160,6 +168,7 @@ export const WorkspaceSettingsModal = ({ workspace, mockServer, onClose }: Props
</Radio>
<Radio
value="self-hosted"
isDisabled={isSelfHostedDisabled}
className="flex-1 data-[selected]:border-[--color-surprise] data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] data-[disabled]:opacity-25 hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
<div className="flex items-center gap-2">

View File

@@ -249,22 +249,6 @@ async function migrateProjectsUnderOrganization(personalOrganizationId: string,
}
};
async function syncStorageRule(sessionId: string, organizationId: string) {
try {
const storageRule = await insomniaFetch<StorageRule | undefined>({
method: 'GET',
path: `/v1/organizations/${organizationId}/storage-rule`,
sessionId,
});
invariant(storageRule, 'Failed to load storageRule');
inMemoryStorageRuleCache.set(organizationId, storageRule);
} catch (error) {
console.log('[storageRule] Failed to load storage rules', error);
}
}
export const indexLoader: LoaderFunction = async () => {
const { id: sessionId, accountId } = await userSession.getOrCreate();
if (sessionId) {
@@ -307,20 +291,6 @@ export const syncOrganizationsAction: ActionFunction = async () => {
return null;
};
export const syncOrganizationStorageRuleAction: ActionFunction = async ({ params }) => {
const { organizationId } = params;
invariant(organizationId, 'Organization ID is required');
const { id: sessionId } = await userSession.getOrCreate();
if (sessionId) {
await syncStorageRule(sessionId, organizationId);
}
return null;
};
export interface OrganizationLoaderData {
organizations: Organization[];
user?: UserProfileResponse;
@@ -366,9 +336,17 @@ export interface Billing {
accessDenied: boolean;
}
export const DefaultStorage = 'cloud_plus_local';
export enum ORG_STORAGE_RULE {
CLOUD_PLUS_LOCAL = 'cloud_plus_local',
CLOUD_ONLY = 'cloud_only',
LOCAL_ONLY = 'local_only',
};
// https://stackoverflow.com/a/59496175/5714454
export type OrgStorageRuleType = `${ORG_STORAGE_RULE}`;
export interface StorageRule {
storage: 'cloud_plus_local' | 'cloud_only' | 'local_only';
storage: OrgStorageRuleType;
isOverridden: boolean;
}
@@ -377,7 +355,7 @@ export interface OrganizationFeatureLoaderData {
billingPromise: Promise<Billing>;
}
export interface OrganizationStorageLoaderData {
storagePromise: Promise<'cloud_plus_local' | 'cloud_only' | 'local_only'>;
storagePromise: Promise<OrgStorageRuleType>;
}
// Create an in-memory storage to store the storage rules
@@ -385,39 +363,53 @@ export const inMemoryStorageRuleCache: Map<string, StorageRule> = new Map<string
export const organizationStorageLoader: LoaderFunction = async ({ params }): Promise<OrganizationStorageLoaderData> => {
const { organizationId } = params as { organizationId: string };
return {
storagePromise: fetchAndCacheOrganizationStorageRule(organizationId),
};
};
export const syncOrganizationStorageRuleAction: ActionFunction = async ({ params }) => {
const { organizationId } = params;
await fetchAndCacheOrganizationStorageRule(organizationId, true);
return null;
};
export async function fetchAndCacheOrganizationStorageRule(
organizationId: string | undefined,
forceFetch: boolean = false,
): Promise<OrgStorageRuleType> {
invariant(organizationId, 'Organization ID is required');
if (isScratchpadOrganizationId(organizationId)) {
return ORG_STORAGE_RULE.LOCAL_ONLY;
}
if (!forceFetch) {
const storageRule = inMemoryStorageRuleCache.get(organizationId);
if (storageRule) {
return storageRule.storage;
}
}
const { id: sessionId } = await userSession.getOrCreate();
const storageRule = inMemoryStorageRuleCache.get(organizationId);
if (storageRule) {
return {
storagePromise: Promise.resolve(storageRule.storage),
};
}
// Otherwise fetch from the API
try {
const storageRuleResponse = insomniaFetch<StorageRule | undefined>({
method: 'GET',
path: `/v1/organizations/${organizationId}/storage-rule`,
sessionId,
});
// Return the value
return {
storagePromise: storageRuleResponse.then(res => {
if (res) {
inMemoryStorageRuleCache.set(organizationId, res);
}
return res?.storage || DefaultStorage;
}),
};
} catch (err) {
return {
storagePromise: Promise.resolve(DefaultStorage),
};
}
};
return await insomniaFetch<StorageRule | undefined>({
method: 'GET',
path: `/v1/organizations/${organizationId}/storage-rule`,
sessionId,
onlyResolveOnSuccess: true,
}).then(
res => {
if (res) {
inMemoryStorageRuleCache.set(organizationId, res);
}
return res?.storage || ORG_STORAGE_RULE.CLOUD_PLUS_LOCAL;
},
err => {
console.log('[storageRule] Failed to load storage rules', err.message);
return ORG_STORAGE_RULE.CLOUD_PLUS_LOCAL;
}
);
}
export const organizationPermissionsLoader: LoaderFunction = async ({ params }): Promise<OrganizationFeatureLoaderData> => {
const { organizationId } = params as { organizationId: string };

View File

@@ -90,7 +90,7 @@ import { TimeFromNow } from '../components/time-from-now';
import { useInsomniaEventStreamContext } from '../context/app/insomnia-event-stream-context';
import { useLoaderDeferData } from '../hooks/use-loader-defer-data';
import { useOrganizationPermissions } from '../hooks/use-organization-features';
import { DefaultStorage, type OrganizationLoaderData, type OrganizationStorageLoaderData, useOrganizationLoaderData } from './organization';
import { ORG_STORAGE_RULE, type OrganizationLoaderData, type OrganizationStorageLoaderData, useOrganizationLoaderData } from './organization';
import { useRootLoaderData } from './root';
interface TeamProject {
@@ -642,6 +642,7 @@ const ProjectRoute: FC = () => {
useEffect(() => {
if (!isScratchpadOrganizationId(organizationId)) {
const load = storageRuleFetcher.load;
// file://./organization.tsx#organizationStorageLoader
load(`/organization/${organizationId}/storage-rule`);
}
}, [organizationId, storageRuleFetcher.load]);
@@ -650,7 +651,7 @@ const ProjectRoute: FC = () => {
const { storagePromise } = storageRuleFetcher.data || {};
const [storage = DefaultStorage] = useLoaderDeferData(storagePromise);
const [storage = ORG_STORAGE_RULE.CLOUD_PLUS_LOCAL] = useLoaderDeferData(storagePromise);
const [projectListFilter, setProjectListFilter] = useLocalStorage(`${organizationId}:project-list-filter`, '');
const [workspaceListFilter, setWorkspaceListFilter] = useLocalStorage(`${projectId}:workspace-list-filter`, '');
@@ -960,11 +961,11 @@ const ProjectRoute: FC = () => {
},
},
];
const defaultStorageSelection = storage === 'local_only' ? 'local' : 'remote';
const isRemoteProjectInconsistent = activeProject && isRemoteProject(activeProject) && storage === 'local_only';
const isLocalProjectInconsistent = activeProject && !isRemoteProject(activeProject) && storage === 'cloud_only';
const defaultStorageSelection = storage === ORG_STORAGE_RULE.LOCAL_ONLY ? 'local' : 'remote';
const isRemoteProjectInconsistent = activeProject && isRemoteProject(activeProject) && storage === ORG_STORAGE_RULE.LOCAL_ONLY;
const isLocalProjectInconsistent = activeProject && !isRemoteProject(activeProject) && storage === ORG_STORAGE_RULE.CLOUD_ONLY;
const isProjectInconsistent = isRemoteProjectInconsistent || isLocalProjectInconsistent;
const showStorageRestrictionMessage = storage !== 'cloud_plus_local';
const showStorageRestrictionMessage = storage !== ORG_STORAGE_RULE.CLOUD_PLUS_LOCAL;
useEffect(() => {
window.main.landingPageRendered(LandingPage.ProjectDashboard);
@@ -1593,7 +1594,7 @@ const ProjectRoute: FC = () => {
</Label>
<div className="flex gap-2">
<Radio
isDisabled={storage === 'local_only'}
isDisabled={storage === ORG_STORAGE_RULE.LOCAL_ONLY}
value="remote"
className="flex-1 data-[selected]:border-[--color-surprise] data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] data-[disabled]:opacity-25 hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
@@ -1606,7 +1607,7 @@ const ProjectRoute: FC = () => {
</p>
</Radio>
<Radio
isDisabled={storage === 'cloud_only'}
isDisabled={storage === ORG_STORAGE_RULE.CLOUD_ONLY}
value="local"
className="flex-1 data-[selected]:border-[--color-surprise] data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] data-[disabled]:opacity-25 hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
@@ -1723,13 +1724,13 @@ const ProjectRoute: FC = () => {
className="py-1 placeholder:italic w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors"
/>
</TextField>
<RadioGroup name="type" defaultValue={storage === 'cloud_plus_local' ? activeProject?.remoteId ? 'remote' : 'local' : storage !== 'cloud_only' ? 'local' : 'remote'} className="flex flex-col gap-2">
<RadioGroup name="type" defaultValue={storage === ORG_STORAGE_RULE.CLOUD_PLUS_LOCAL ? activeProject?.remoteId ? 'remote' : 'local' : storage !== ORG_STORAGE_RULE.CLOUD_ONLY ? 'local' : 'remote'} className="flex flex-col gap-2">
<Label className="text-sm text-[--hl]">
Project type
</Label>
<div className="flex gap-2">
<Radio
isDisabled={storage === 'local_only'}
isDisabled={storage === ORG_STORAGE_RULE.LOCAL_ONLY}
value="remote"
className="data-[selected]:border-[--color-surprise] flex-1 data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
@@ -1742,7 +1743,7 @@ const ProjectRoute: FC = () => {
</p>
</Radio>
<Radio
isDisabled={storage === 'cloud_only'}
isDisabled={storage === ORG_STORAGE_RULE.CLOUD_ONLY}
value="local"
className="data-[selected]:border-[--color-surprise] flex-1 data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>