mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
Merge branch 'next' into develop
This commit is contained in:
@@ -16,53 +16,127 @@ export interface ExtendCollectionsProviderProps {
|
||||
dataSource?: string;
|
||||
/** Collections to surface for the lifetime of this provider's subtree. */
|
||||
collections: CollectionOptions[];
|
||||
/**
|
||||
* When `true`, re-sync the data source whenever the `collections` prop
|
||||
* reference changes after mount: add entries newly present in the prop and
|
||||
* remove entries no longer present (only those this provider registered).
|
||||
* The diff runs in the same render as the prop change so children see the
|
||||
* new state on their first render — at the cost of one observable mutation
|
||||
* per change.
|
||||
*
|
||||
* Defaults to `false`. Most pages pass a stable (often module-level)
|
||||
* `collections` list and don't need this; leaving it off avoids accidental
|
||||
* re-registration when callers forget to memoize. Enable only when your
|
||||
* collection list legitimately varies during the provider's lifetime.
|
||||
*/
|
||||
syncOnChange?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount-scoped collection injector. Adds the given `collections` to the target data source on mount and removes them on unmount. Survives mid-session reloads via `dataSource:loaded` events.
|
||||
* Mount-scoped collection injector. Adds the given `collections` to the target
|
||||
* data source on first render — synchronously, so children can read
|
||||
* `getCollection(name)` on their own first render — and removes them on
|
||||
* unmount. Survives mid-session data-source reloads via the
|
||||
* `dataSource:loaded` event by re-registering only the names this provider
|
||||
* owns.
|
||||
*
|
||||
* Use this for client-only collections — e.g. a `schema-only` server collection that isn't auto-published to the v2 data source, or a pure UI-side mirror — so downstream components (like `<CollectionFilter>`) can resolve the collection by name.
|
||||
* Use this for client-only collections — e.g. a `schema-only` server
|
||||
* collection that isn't auto-published to the v2 data source, or a pure
|
||||
* UI-side mirror — so downstream components (like `<CollectionFilter>`) can
|
||||
* resolve the collection by name.
|
||||
*
|
||||
* Default behavior is "static-at-mount": subsequent changes to the
|
||||
* `collections` prop are ignored. Pass `syncOnChange` to opt into diffing on
|
||||
* prop change.
|
||||
*/
|
||||
export const ExtendCollectionsProvider: FC<ExtendCollectionsProviderProps> = ({
|
||||
dataSource = 'main',
|
||||
collections,
|
||||
syncOnChange = false,
|
||||
children,
|
||||
}) => {
|
||||
const app = useApp();
|
||||
const ownedRef = useRef<Set<string>>(new Set());
|
||||
// Lazy-ref init guard. `ownedRef.current === null` only on the first render;
|
||||
// once populated, StrictMode dev's second render and any subsequent
|
||||
// re-render see a non-null ref and skip re-registering. React docs bless
|
||||
// this idiom for "init exactly once on mount" — see "Avoiding recreating
|
||||
// the ref contents".
|
||||
const ownedRef = useRef<CollectionOptions[] | null>(null);
|
||||
// Identity of the `collections` reference we last reacted to; gates the
|
||||
// opt-in diff so StrictMode's double-render doesn't diff twice.
|
||||
const lastCollectionsRef = useRef<CollectionOptions[] | null>(null);
|
||||
|
||||
const apply = () => {
|
||||
if (ownedRef.current === null) {
|
||||
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
|
||||
if (!ds) return;
|
||||
for (const collection of collections) {
|
||||
if (ds.getCollection?.(collection.name)) continue;
|
||||
ds.addCollection?.(collection);
|
||||
ownedRef.current.add(collection.name);
|
||||
const owned: CollectionOptions[] = [];
|
||||
if (ds) {
|
||||
for (const c of collections) {
|
||||
if (ds.getCollection?.(c.name)) continue;
|
||||
ds.addCollection?.(c);
|
||||
owned.push(c);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
apply();
|
||||
ownedRef.current = owned;
|
||||
lastCollectionsRef.current = collections;
|
||||
} else if (syncOnChange && lastCollectionsRef.current !== collections) {
|
||||
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
|
||||
if (ds) {
|
||||
const nextNames = new Set(collections.map((c) => c.name));
|
||||
const prevOwned = new Map(ownedRef.current.map((c) => [c.name, c]));
|
||||
for (const name of prevOwned.keys()) {
|
||||
if (!nextNames.has(name)) ds.removeCollection?.(name);
|
||||
}
|
||||
const nextOwned: CollectionOptions[] = [];
|
||||
for (const c of collections) {
|
||||
const previous = prevOwned.get(c.name);
|
||||
if (previous) {
|
||||
// First-registered wins: keep the existing options object, mirroring
|
||||
// the original behavior where re-adding a present name was a no-op.
|
||||
// Callers who need to update a collection should remount the
|
||||
// provider (e.g. `key={signature}`).
|
||||
nextOwned.push(previous);
|
||||
continue;
|
||||
}
|
||||
if (ds.getCollection?.(c.name)) continue;
|
||||
ds.addCollection?.(c);
|
||||
nextOwned.push(c);
|
||||
}
|
||||
ownedRef.current = nextOwned;
|
||||
}
|
||||
lastCollectionsRef.current = collections;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onLoaded = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ dataSourceKey: string }>).detail?.dataSourceKey;
|
||||
if (key === dataSource || key === '*') apply();
|
||||
if (key !== dataSource && key !== '*') return;
|
||||
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
|
||||
if (!ds || !ownedRef.current) return;
|
||||
// dataSource was just reloaded from the server — our owned client-only
|
||||
// entries got wiped. Re-add only the ones we own, using the snapshot in
|
||||
// the ref so we don't accidentally seize names this provider never
|
||||
// registered.
|
||||
for (const c of ownedRef.current) {
|
||||
if (ds.getCollection?.(c.name)) continue;
|
||||
ds.addCollection?.(c);
|
||||
}
|
||||
};
|
||||
app.eventBus?.addEventListener('dataSource:loaded', onLoaded);
|
||||
|
||||
return () => {
|
||||
app.eventBus?.removeEventListener('dataSource:loaded', onLoaded);
|
||||
const ds = app.dataSourceManager?.getDataSource?.(dataSource);
|
||||
const owned = ownedRef.current;
|
||||
ownedRef.current = new Set();
|
||||
const owned = ownedRef.current ?? [];
|
||||
ownedRef.current = null;
|
||||
lastCollectionsRef.current = null;
|
||||
if (!ds) return;
|
||||
for (const name of owned) {
|
||||
ds.removeCollection?.(name);
|
||||
}
|
||||
for (const c of owned) ds.removeCollection?.(c.name);
|
||||
};
|
||||
// `collections` / `syncOnChange` intentionally excluded — they drive the
|
||||
// render-phase init/diff above, not this effect. The listener reads
|
||||
// `ownedRef` each time it fires (async, never during render).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [app, dataSource, collections]);
|
||||
}, [app, dataSource]);
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import type { CollectionOptions } from '@nocobase/flow-engine';
|
||||
import { FlowEngineProvider } from '@nocobase/flow-engine';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import React, { useState } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createMockClient } from '../../MockApplication';
|
||||
import { ExtendCollectionsProvider } from '../ExtendCollectionsProvider';
|
||||
|
||||
// `MockApplication` isn't exported as a named type, so infer from the factory.
|
||||
type AppInstance = ReturnType<typeof createMockClient>;
|
||||
|
||||
function makeApp(): AppInstance {
|
||||
const app = createMockClient();
|
||||
// The mock client wires a lazy `appInfo` getter to `GET app:getInfo`; any
|
||||
// proxy iteration of the FlowEngineContext can trip it. Pre-stub so a 404
|
||||
// doesn't surface as an unhandled axios rejection during the test run.
|
||||
app.apiMock.onGet('app:getInfo').reply(200, { data: { version: 'test' } });
|
||||
return app;
|
||||
}
|
||||
|
||||
const LOCKED: CollectionOptions = {
|
||||
name: 'lockedUsers',
|
||||
fields: [{ name: 'id', type: 'integer', interface: 'integer' }],
|
||||
};
|
||||
|
||||
const USERS: CollectionOptions = {
|
||||
name: 'users',
|
||||
fields: [{ name: 'username', type: 'string', interface: 'input' }],
|
||||
};
|
||||
|
||||
const POSTS: CollectionOptions = {
|
||||
name: 'posts',
|
||||
fields: [{ name: 'title', type: 'string', interface: 'input' }],
|
||||
};
|
||||
|
||||
function mountWith(app: AppInstance, node: React.ReactNode) {
|
||||
return render(<FlowEngineProvider engine={app.flowEngine}>{node}</FlowEngineProvider>);
|
||||
}
|
||||
|
||||
function getMain(app: AppInstance) {
|
||||
return app.dataSourceManager.getDataSource('main');
|
||||
}
|
||||
|
||||
describe('ExtendCollectionsProvider', () => {
|
||||
// The defining contract: a page-inner that reads `getCollection(name)` in its
|
||||
// own render body (the LockedUsersPage pattern at LockedUsersPage.tsx:156-157)
|
||||
// must see the registered collection on its FIRST render, with no extra tick.
|
||||
it('lets children read the registered collection during their first render', () => {
|
||||
const app = makeApp();
|
||||
let firstRenderResult: { name?: string } | undefined;
|
||||
|
||||
const Child: React.FC = () => {
|
||||
// Read synchronously during render — this is the contract that forces
|
||||
// the provider to register collections in render-phase rather than in
|
||||
// an effect.
|
||||
const found = getMain(app)?.getCollection?.(LOCKED.name);
|
||||
firstRenderResult = found ? { name: found.name } : undefined;
|
||||
return <div>child</div>;
|
||||
};
|
||||
|
||||
mountWith(
|
||||
app,
|
||||
<ExtendCollectionsProvider collections={[LOCKED]}>
|
||||
<Child />
|
||||
</ExtendCollectionsProvider>,
|
||||
);
|
||||
|
||||
expect(firstRenderResult).toEqual({ name: LOCKED.name });
|
||||
});
|
||||
|
||||
it('removes only the collections it added when unmounted', () => {
|
||||
const app = makeApp();
|
||||
// Pre-existing collection in the data source — provider must not touch it.
|
||||
getMain(app).addCollection(USERS);
|
||||
|
||||
const { unmount } = mountWith(
|
||||
app,
|
||||
<ExtendCollectionsProvider collections={[LOCKED]}>
|
||||
<span>inside</span>
|
||||
</ExtendCollectionsProvider>,
|
||||
);
|
||||
|
||||
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
||||
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
|
||||
// The provider didn't add USERS, so it must leave it alone.
|
||||
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
||||
});
|
||||
|
||||
// Direct regression for the lazy-ref idempotency: even when the provider
|
||||
// re-renders many times (which is what StrictMode dev's double-render and
|
||||
// any caller-driven re-render look like to the provider), it must not
|
||||
// re-register the same collection or accumulate duplicate ownership.
|
||||
it('only calls addCollection once across many re-renders of the same mount', () => {
|
||||
const app = makeApp();
|
||||
let addCount = 0;
|
||||
const origAdd = getMain(app).addCollection.bind(getMain(app));
|
||||
getMain(app).addCollection = (c: CollectionOptions) => {
|
||||
addCount += 1;
|
||||
origAdd(c);
|
||||
};
|
||||
|
||||
const Host: React.FC = () => {
|
||||
const [, setTick] = useState(0);
|
||||
// Force a render burst on mount — covers StrictMode dev's second render
|
||||
// and any other parent-driven re-renders. The lazy-ref guard in the
|
||||
// provider must hold across all of them.
|
||||
React.useEffect(() => {
|
||||
setTick((n) => n + 1);
|
||||
setTick((n) => n + 1);
|
||||
}, []);
|
||||
return (
|
||||
<ExtendCollectionsProvider collections={[LOCKED]}>
|
||||
<span>inside</span>
|
||||
</ExtendCollectionsProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const { unmount } = mountWith(app, <Host />);
|
||||
|
||||
expect(addCount).toBe(1);
|
||||
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
||||
|
||||
unmount();
|
||||
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
|
||||
});
|
||||
|
||||
// Default semantics: "static-at-mount". Caller's prop changes are ignored.
|
||||
describe('with syncOnChange={false} (default)', () => {
|
||||
it('ignores a `collections` prop reference change after mount', () => {
|
||||
const app = makeApp();
|
||||
|
||||
const Host: React.FC = () => {
|
||||
const [list, setList] = useState<CollectionOptions[]>([LOCKED]);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setList([LOCKED, POSTS])}>
|
||||
add posts
|
||||
</button>
|
||||
<ExtendCollectionsProvider collections={list}>
|
||||
<span>inside</span>
|
||||
</ExtendCollectionsProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = mountWith(app, <Host />);
|
||||
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
||||
expect(getMain(app).getCollection(POSTS.name)).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
getByText('add posts').click();
|
||||
});
|
||||
|
||||
// POSTS must NOT have been registered — syncOnChange is off.
|
||||
expect(getMain(app).getCollection(POSTS.name)).toBeUndefined();
|
||||
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with syncOnChange={true}', () => {
|
||||
it('adds collections newly added to the prop and removes ones dropped from it', () => {
|
||||
const app = makeApp();
|
||||
|
||||
const Host: React.FC = () => {
|
||||
const [list, setList] = useState<CollectionOptions[]>([LOCKED, POSTS]);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setList([POSTS, USERS])}>
|
||||
swap
|
||||
</button>
|
||||
<ExtendCollectionsProvider collections={list} syncOnChange>
|
||||
<span>inside</span>
|
||||
</ExtendCollectionsProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = mountWith(app, <Host />);
|
||||
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
||||
expect(getMain(app).getCollection(POSTS.name)?.name).toBe(POSTS.name);
|
||||
expect(getMain(app).getCollection(USERS.name)).toBeUndefined();
|
||||
|
||||
act(() => {
|
||||
getByText('swap').click();
|
||||
});
|
||||
|
||||
// LOCKED was dropped from the prop → removed.
|
||||
expect(getMain(app).getCollection(LOCKED.name)).toBeUndefined();
|
||||
// POSTS was in both lists → kept.
|
||||
expect(getMain(app).getCollection(POSTS.name)?.name).toBe(POSTS.name);
|
||||
// USERS is new → added.
|
||||
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
||||
});
|
||||
|
||||
it('still leaves pre-existing collections it never owned alone after diff', () => {
|
||||
const app = makeApp();
|
||||
// Pre-existing in the data source before the provider mounts.
|
||||
getMain(app).addCollection(USERS);
|
||||
|
||||
const Host: React.FC = () => {
|
||||
const [list, setList] = useState<CollectionOptions[]>([LOCKED, USERS]);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setList([LOCKED])}>
|
||||
drop users
|
||||
</button>
|
||||
<ExtendCollectionsProvider collections={list} syncOnChange>
|
||||
<span>inside</span>
|
||||
</ExtendCollectionsProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText } = mountWith(app, <Host />);
|
||||
// USERS was already there, so the provider never owned it.
|
||||
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
||||
|
||||
act(() => {
|
||||
getByText('drop users').click();
|
||||
});
|
||||
|
||||
// The prop dropped USERS, but the provider doesn't own it — must not
|
||||
// accidentally remove it.
|
||||
expect(getMain(app).getCollection(USERS.name)?.name).toBe(USERS.name);
|
||||
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
||||
});
|
||||
});
|
||||
|
||||
// Mid-session reload: the data source manager wipes everything and reloads
|
||||
// from the server; client-only entries this provider registered would be
|
||||
// gone. The `dataSource:loaded` event handler re-adds owned entries.
|
||||
it('re-registers owned collections after a dataSource:loaded event', () => {
|
||||
const app = makeApp();
|
||||
|
||||
mountWith(
|
||||
app,
|
||||
<ExtendCollectionsProvider collections={[LOCKED]}>
|
||||
<span>inside</span>
|
||||
</ExtendCollectionsProvider>,
|
||||
);
|
||||
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
||||
|
||||
// Simulate the data source manager wiping then re-broadcasting the event.
|
||||
act(() => {
|
||||
getMain(app).removeCollection(LOCKED.name);
|
||||
app.eventBus.dispatchEvent(new CustomEvent('dataSource:loaded', { detail: { dataSourceKey: 'main' } }));
|
||||
});
|
||||
|
||||
expect(getMain(app).getCollection(LOCKED.name)?.name).toBe(LOCKED.name);
|
||||
});
|
||||
});
|
||||
+7
-2
@@ -392,10 +392,15 @@ export default function BasicAuthAdminSettings() {
|
||||
showIcon
|
||||
message={t('The authentication allows users to sign in via username or email.')}
|
||||
/>
|
||||
{/*
|
||||
`forceRender: true` 让所有 tab 的内容首屏就 mount —— 否则非 active tab 里的
|
||||
`Form.Item` 从未注册到父抽屉表单的 value store,提交时会把那些字段当成"用户清空",
|
||||
覆盖掉服务端已有的值。v1 用 Formily `FormTab` 默认就是全 mount,所以没这个坑。
|
||||
*/}
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'signup', label: t('Sign up settings'), children: <SignUpTab /> },
|
||||
{ key: 'forgot', label: t('Forgot password'), children: <ForgotPasswordTab /> },
|
||||
{ key: 'signup', label: t('Sign up settings'), forceRender: true, children: <SignUpTab /> },
|
||||
{ key: 'forgot', label: t('Forgot password'), forceRender: true, children: <ForgotPasswordTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Empty, Space, Spin, Tabs } from 'antd';
|
||||
import React, { lazy, Suspense, useContext, useMemo } from 'react';
|
||||
import { AuthenticatorsContext, type Authenticator } from '../authenticator';
|
||||
import { useDocumentTitle } from '../hooks';
|
||||
import { useAuthTranslation } from '../locale';
|
||||
import { useAuthTranslation, useT } from '../locale';
|
||||
import PluginAuthClientV2, { type AuthOptions } from '../plugin';
|
||||
|
||||
type LoaderMap<L> = Record<string, L>;
|
||||
@@ -44,6 +44,11 @@ function lazyByAuthType<P>(loaderMap: LoaderMap<() => Promise<{ default: React.C
|
||||
|
||||
export default function SignInPage() {
|
||||
const { t } = useAuthTranslation();
|
||||
// `authTypeTitle` 从服务端来时是 `tval` 生成的原始模板字符串
|
||||
// (`{{t("Password", {"ns":"@nocobase/plugin-auth"})}}`),不展开就会直出到 tab label
|
||||
// 上。v1 走 `Schema.compile(value, { t })` 展开;v2 用 `useT()`,它内部走
|
||||
// `flowEngine.context.t`,对纯字符串和模板字符串都安全(无模板时原样返回)。
|
||||
const compileT = useT();
|
||||
const authenticators = useContext(AuthenticatorsContext);
|
||||
const signInFormLoaders = useLoaderMap('signInFormLoader');
|
||||
const signInButtonLoaders = useLoaderMap('signInButtonLoader');
|
||||
@@ -60,9 +65,10 @@ export default function SignInPage() {
|
||||
if (!FormComponent) {
|
||||
return null;
|
||||
}
|
||||
const typeLabel = compileT(authenticator.authTypeTitle || authenticator.authType);
|
||||
return {
|
||||
key: authenticator.name,
|
||||
label: authenticator.title || `${t('Sign-in')} (${authenticator.authTypeTitle || authenticator.authType})`,
|
||||
label: authenticator.title || `${t('Sign-in')} (${typeLabel})`,
|
||||
children: (
|
||||
<Suspense fallback={<Spin />}>
|
||||
<FormComponent authenticator={authenticator} />
|
||||
@@ -71,7 +77,7 @@ export default function SignInPage() {
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [authenticators, resolveSignInForm, t]);
|
||||
}, [authenticators, resolveSignInForm, t, compileT]);
|
||||
|
||||
const buttons = useMemo(() => {
|
||||
return authenticators
|
||||
|
||||
Reference in New Issue
Block a user