mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-21 05:44:51 +08:00
fix(client-v2): improve mobile popup spacing (#10020)
* fix(client-v2): improve mobile popup spacing * fix(client-v2): address mobile popup review
This commit is contained in:
@@ -30,6 +30,37 @@ import { FormItemModel } from './FormItemModel';
|
||||
export const QUICK_EDIT_POPOVER_MAX_HEIGHT = 'calc(100vh - 96px)';
|
||||
export const QUICK_EDIT_FORM_MAX_HEIGHT = 'calc(100vh - 160px)';
|
||||
export const QUICK_EDIT_MARKDOWN_HEIGHT = 'min(480px, calc(100vh - 320px))';
|
||||
const QUICK_EDIT_MOBILE_DRAWER_HEIGHT = '50vh';
|
||||
const QUICK_EDIT_MOBILE_FORM_MAX_HEIGHT = 'calc(50vh - var(--nb-mobile-page-header-height, 40px) - 132px)';
|
||||
const QUICK_EDIT_MOBILE_CONTENT_PADDING = '8px var(--nb-mobile-page-tabs-content-padding, 12px) 0';
|
||||
const QUICK_EDIT_MOBILE_ACTIONS_PADDING =
|
||||
'8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))';
|
||||
const QUICK_EDIT_MOBILE_MEDIA_QUERY = '(max-width: 768px)';
|
||||
|
||||
type QuickEditViewBeforeClosePayload = {
|
||||
result?: unknown;
|
||||
force?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type QuickEditViewBeforeCloseHandler = (
|
||||
payload: QuickEditViewBeforeClosePayload,
|
||||
) => Promise<boolean | void> | boolean | void;
|
||||
|
||||
type QuickEditViewUpdateConfig = {
|
||||
preventClose?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type QuickEditViewContainer = {
|
||||
close: (result?: unknown, force?: boolean) => Promise<boolean | void> | boolean | void;
|
||||
update?: (newConfig: QuickEditViewUpdateConfig) => unknown;
|
||||
beforeClose?: QuickEditViewBeforeCloseHandler;
|
||||
};
|
||||
|
||||
type QuickEditViewContext = {
|
||||
defineProperty?: (key: string, options: { value: unknown }) => void;
|
||||
};
|
||||
|
||||
export function getQuickEditFieldProps(collectionField: CollectionField, fieldProps?: Record<string, any>) {
|
||||
const nextProps = { ...collectionField.getComponentProps(), ...(fieldProps || {}) };
|
||||
@@ -39,13 +70,92 @@ export function getQuickEditFieldProps(collectionField: CollectionField, fieldPr
|
||||
return nextProps;
|
||||
}
|
||||
|
||||
function getQuickEditMobileLayoutState(flowEngine: FlowEngine, sourceFieldModel?: FlowModel) {
|
||||
const sourceMobileLayout = sourceFieldModel?.context?.isMobileLayout;
|
||||
if (typeof sourceMobileLayout === 'boolean') {
|
||||
return { isMobileLayout: sourceMobileLayout, inheritsMobileContext: sourceMobileLayout };
|
||||
}
|
||||
|
||||
const engineMobileLayout = flowEngine.context?.isMobileLayout;
|
||||
if (typeof engineMobileLayout === 'boolean') {
|
||||
return { isMobileLayout: engineMobileLayout, inheritsMobileContext: engineMobileLayout };
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return { isMobileLayout: false, inheritsMobileContext: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isMobileLayout: window.matchMedia(QUICK_EDIT_MOBILE_MEDIA_QUERY).matches,
|
||||
inheritsMobileContext: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getQuickEditTitle(
|
||||
flowEngine: FlowEngine,
|
||||
dataSourceKey: string,
|
||||
collectionName: string,
|
||||
fieldPath: string,
|
||||
sourceFieldModel?: FlowModel,
|
||||
fieldProps?: Record<string, unknown>,
|
||||
): React.ReactNode {
|
||||
const sourceColumnTitle = sourceFieldModel?.parent?.props?.title;
|
||||
if (sourceColumnTitle) {
|
||||
return sourceColumnTitle;
|
||||
}
|
||||
|
||||
const fieldPropsTitle = fieldProps?.title;
|
||||
if (fieldPropsTitle) {
|
||||
return fieldPropsTitle as React.ReactNode;
|
||||
}
|
||||
|
||||
const collectionField = flowEngine.context.dataSourceManager.getCollectionField(
|
||||
`${dataSourceKey}.${collectionName}.${fieldPath}`,
|
||||
) as CollectionField | undefined;
|
||||
return collectionField?.title || fieldPath;
|
||||
}
|
||||
|
||||
function createQuickEditViewContainer(view: QuickEditViewContainer): QuickEditViewContainer {
|
||||
let preventClose = false;
|
||||
let nextBeforeClose = view.beforeClose;
|
||||
const beforeClose: QuickEditViewBeforeCloseHandler = async (payload) => {
|
||||
if (preventClose && !payload?.force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await nextBeforeClose?.(payload);
|
||||
return result !== false;
|
||||
};
|
||||
view.beforeClose = beforeClose;
|
||||
|
||||
return {
|
||||
...view,
|
||||
close(result, force) {
|
||||
return view.close(result, force);
|
||||
},
|
||||
update(newConfig) {
|
||||
if (Object.prototype.hasOwnProperty.call(newConfig, 'preventClose')) {
|
||||
preventClose = !!newConfig.preventClose;
|
||||
}
|
||||
return view.update?.(newConfig);
|
||||
},
|
||||
get beforeClose() {
|
||||
return view.beforeClose;
|
||||
},
|
||||
set beforeClose(value) {
|
||||
nextBeforeClose = value;
|
||||
view.beforeClose = beforeClose;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class QuickEditFormModel extends FlowModel {
|
||||
fieldPath: string;
|
||||
|
||||
declare resource: SingleRecordResource;
|
||||
declare collection: Collection;
|
||||
|
||||
viewContainer: any;
|
||||
declare viewContainer: QuickEditViewContainer;
|
||||
__onSubmitSuccess;
|
||||
_fieldProps: any;
|
||||
_onOk: any;
|
||||
@@ -103,6 +213,59 @@ export class QuickEditFormModel extends FlowModel {
|
||||
sourceFieldModel ? { delegate: sourceFieldModel.context } : undefined,
|
||||
) as QuickEditFormModel;
|
||||
|
||||
const bodyStyles = {
|
||||
maxHeight: QUICK_EDIT_POPOVER_MAX_HEIGHT,
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
};
|
||||
const mobileBodyStyles = {
|
||||
...bodyStyles,
|
||||
maxHeight: QUICK_EDIT_MOBILE_DRAWER_HEIGHT,
|
||||
};
|
||||
const content = (view: QuickEditViewContainer, viewContext?: QuickEditViewContext) => {
|
||||
if (mobileLayoutState.isMobileLayout) {
|
||||
viewContext?.defineProperty?.('isMobileLayout', { value: true });
|
||||
}
|
||||
model.viewContainer = createQuickEditViewContainer(view);
|
||||
model.__onSubmitSuccess = onSuccess;
|
||||
model._fieldProps = fieldProps;
|
||||
model._onOk = onOk;
|
||||
return (
|
||||
<FlowModelRenderer
|
||||
fallback={<Skeleton.Input size="small" />}
|
||||
model={model}
|
||||
inputArgs={{ filterByTk, record, sourceFieldModelUid }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const mobileLayoutState = getQuickEditMobileLayoutState(flowEngine, sourceFieldModel);
|
||||
if (mobileLayoutState.isMobileLayout) {
|
||||
model.context.defineProperty('isMobileLayout', { value: true });
|
||||
const viewer = sourceFieldModel?.context?.viewer || flowEngine.context.viewer;
|
||||
const title = getQuickEditTitle(
|
||||
flowEngine,
|
||||
dataSourceKey,
|
||||
collectionName,
|
||||
fieldPath,
|
||||
sourceFieldModel,
|
||||
fieldProps,
|
||||
);
|
||||
await viewer.open({
|
||||
type: 'drawer',
|
||||
title,
|
||||
closable: true,
|
||||
placement: 'bottom',
|
||||
inputArgs: {
|
||||
isMobileLayout: true,
|
||||
},
|
||||
styles: {
|
||||
body: mobileBodyStyles,
|
||||
},
|
||||
content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await flowEngine.context.viewer.open({
|
||||
type: 'popover',
|
||||
target,
|
||||
@@ -110,24 +273,10 @@ export class QuickEditFormModel extends FlowModel {
|
||||
styles: {
|
||||
body: {
|
||||
width: 420,
|
||||
maxHeight: QUICK_EDIT_POPOVER_MAX_HEIGHT,
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
...bodyStyles,
|
||||
},
|
||||
},
|
||||
content: (popover) => {
|
||||
model.viewContainer = popover;
|
||||
model.__onSubmitSuccess = onSuccess;
|
||||
model._fieldProps = fieldProps;
|
||||
model._onOk = onOk;
|
||||
return (
|
||||
<FlowModelRenderer
|
||||
fallback={<Skeleton.Input size="small" />}
|
||||
model={model}
|
||||
inputArgs={{ filterByTk, record, sourceFieldModelUid }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,13 +317,15 @@ export class QuickEditFormModel extends FlowModel {
|
||||
}
|
||||
|
||||
render() {
|
||||
const isMobileLayout = this.context.isMobileLayout;
|
||||
return (
|
||||
<FormComponent model={this}>
|
||||
<div
|
||||
style={{
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
maxHeight: QUICK_EDIT_FORM_MAX_HEIGHT,
|
||||
maxHeight: isMobileLayout ? QUICK_EDIT_MOBILE_FORM_MAX_HEIGHT : QUICK_EDIT_FORM_MAX_HEIGHT,
|
||||
padding: isMobileLayout ? QUICK_EDIT_MOBILE_CONTENT_PADDING : undefined,
|
||||
}}
|
||||
>
|
||||
{this.mapSubModels('fields', (field) => {
|
||||
@@ -191,7 +342,14 @@ export class QuickEditFormModel extends FlowModel {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Space style={{ display: 'flex', justifyContent: 'flex-end', flexShrink: 0 }}>
|
||||
<Space
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
flexShrink: 0,
|
||||
padding: isMobileLayout ? QUICK_EDIT_MOBILE_ACTIONS_PADDING : undefined,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
this.viewContainer.close();
|
||||
|
||||
+320
-1
@@ -7,17 +7,54 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { FlowEngine, FlowModel, SingleRecordResource } from '@nocobase/flow-engine';
|
||||
import { QuickEditFormModel } from '../QuickEditFormModel';
|
||||
|
||||
describe('QuickEditFormModel - quick edit save triggers API (regression)', () => {
|
||||
let engine: FlowEngine;
|
||||
const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia');
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new FlowEngine();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalMatchMediaDescriptor) {
|
||||
Object.defineProperty(window, 'matchMedia', originalMatchMediaDescriptor);
|
||||
return;
|
||||
}
|
||||
delete (window as unknown as { matchMedia?: Window['matchMedia'] }).matchMedia;
|
||||
});
|
||||
|
||||
const mockMatchMedia = (matches: boolean) => {
|
||||
const matchMediaMock = vi.fn((query: string) => {
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
} as unknown as MediaQueryList;
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
...(originalMatchMediaDescriptor || { configurable: true, writable: true }),
|
||||
value: matchMediaMock,
|
||||
});
|
||||
};
|
||||
|
||||
const addUsersCollection = () => {
|
||||
const ds = engine.context.dataSourceManager.getDataSource('main');
|
||||
ds.addCollection({
|
||||
name: 'users',
|
||||
fields: [{ name: 'name', type: 'string', interface: 'input', uiSchema: { title: 'Name' } }],
|
||||
});
|
||||
};
|
||||
|
||||
it('uses source field context when opening quick edit', async () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
engine.context.defineProperty('pageActive', { value: { value: false } });
|
||||
@@ -47,6 +84,288 @@ describe('QuickEditFormModel - quick edit save triggers API (regression)', () =>
|
||||
expect(quickEditModel?.context.pageActive.value).toBe(true);
|
||||
});
|
||||
|
||||
it('opens quick edit in a mobile drawer when the source field layout is mobile', async () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
addUsersCollection();
|
||||
const engineOpen = vi.fn(async () => undefined);
|
||||
const sourceOpen = vi.fn(async () => undefined);
|
||||
engine.context.defineProperty('isMobileLayout', { value: false });
|
||||
engine.context.defineProperty('viewer', { value: { open: engineOpen } });
|
||||
const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field' });
|
||||
source.context.defineProperty('isMobileLayout', { value: true });
|
||||
source.context.defineProperty('viewer', { value: { open: sourceOpen } });
|
||||
|
||||
await QuickEditFormModel.open({
|
||||
flowEngine: engine,
|
||||
target: document.createElement('div'),
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
fieldPath: 'name',
|
||||
record: {},
|
||||
sourceFieldModelUid: source.uid,
|
||||
});
|
||||
|
||||
expect(engineOpen).not.toHaveBeenCalled();
|
||||
expect(sourceOpen).toHaveBeenCalledTimes(1);
|
||||
expect(sourceOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'drawer',
|
||||
title: 'Name',
|
||||
closable: true,
|
||||
placement: 'bottom',
|
||||
styles: {
|
||||
body: expect.objectContaining({
|
||||
maxHeight: '50vh',
|
||||
}),
|
||||
},
|
||||
inputArgs: {
|
||||
isMobileLayout: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let quickEditModel: QuickEditFormModel | undefined;
|
||||
engine.forEachModel((model) => {
|
||||
if (model instanceof QuickEditFormModel) {
|
||||
quickEditModel = model;
|
||||
}
|
||||
});
|
||||
expect(quickEditModel?.context.isMobileLayout).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the source table column title for the mobile drawer header', async () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
addUsersCollection();
|
||||
const sourceOpen = vi.fn(async () => undefined);
|
||||
engine.context.defineProperty('viewer', { value: { open: vi.fn(async () => undefined) } });
|
||||
const column = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'table-column' });
|
||||
column.setProps({ title: 'Custom marital status' });
|
||||
const source = engine.createModel<FlowModel>({ use: 'FlowModel', uid: 'source-field', parentId: column.uid });
|
||||
source.context.defineProperty('isMobileLayout', { value: true });
|
||||
source.context.defineProperty('viewer', { value: { open: sourceOpen } });
|
||||
|
||||
await QuickEditFormModel.open({
|
||||
flowEngine: engine,
|
||||
target: document.createElement('div'),
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
fieldPath: 'name',
|
||||
record: {},
|
||||
sourceFieldModelUid: source.uid,
|
||||
});
|
||||
|
||||
expect(sourceOpen).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'drawer',
|
||||
title: 'Custom marital status',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the mobile drawer fallback on narrow viewports', async () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
addUsersCollection();
|
||||
mockMatchMedia(true);
|
||||
const viewContext = {
|
||||
defineProperty: vi.fn(),
|
||||
};
|
||||
const open = vi.fn(
|
||||
async (config: { content: (view: { close: () => void }, context: typeof viewContext) => unknown }) => {
|
||||
config.content({ close: vi.fn() }, viewContext);
|
||||
},
|
||||
);
|
||||
engine.context.defineProperty('viewer', { value: { open } });
|
||||
|
||||
await QuickEditFormModel.open({
|
||||
flowEngine: engine,
|
||||
target: document.createElement('div'),
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
fieldPath: 'name',
|
||||
record: {},
|
||||
});
|
||||
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(viewContext.defineProperty).toHaveBeenCalledWith('isMobileLayout', { value: true });
|
||||
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 768px)');
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'drawer',
|
||||
title: 'Name',
|
||||
closable: true,
|
||||
placement: 'bottom',
|
||||
styles: {
|
||||
body: expect.objectContaining({
|
||||
maxHeight: '50vh',
|
||||
}),
|
||||
},
|
||||
inputArgs: {
|
||||
isMobileLayout: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
let quickEditModel: QuickEditFormModel | undefined;
|
||||
engine.forEachModel((model) => {
|
||||
if (model instanceof QuickEditFormModel) {
|
||||
quickEditModel = model;
|
||||
}
|
||||
});
|
||||
expect(quickEditModel?.context.isMobileLayout).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the engine mobile layout context without relying on matchMedia', async () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
addUsersCollection();
|
||||
mockMatchMedia(false);
|
||||
const open = vi.fn(async () => undefined);
|
||||
engine.context.defineProperty('isMobileLayout', { value: true });
|
||||
engine.context.defineProperty('viewer', { value: { open } });
|
||||
|
||||
await QuickEditFormModel.open({
|
||||
flowEngine: engine,
|
||||
target: document.createElement('div'),
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
fieldPath: 'name',
|
||||
record: {},
|
||||
});
|
||||
|
||||
expect(window.matchMedia).not.toHaveBeenCalled();
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'drawer',
|
||||
title: 'Name',
|
||||
placement: 'bottom',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an explicit non-mobile layout context ahead of the narrow viewport fallback', async () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
addUsersCollection();
|
||||
mockMatchMedia(true);
|
||||
const open = vi.fn(async () => undefined);
|
||||
const target = document.createElement('div');
|
||||
engine.context.defineProperty('isMobileLayout', { value: false });
|
||||
engine.context.defineProperty('viewer', { value: { open } });
|
||||
|
||||
await QuickEditFormModel.open({
|
||||
flowEngine: engine,
|
||||
target,
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
fieldPath: 'name',
|
||||
record: {},
|
||||
});
|
||||
|
||||
expect(window.matchMedia).not.toHaveBeenCalled();
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'popover',
|
||||
target,
|
||||
placement: 'rightTop',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps dynamic preventClose effective for mobile quick edit drawer containers', async () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
addUsersCollection();
|
||||
mockMatchMedia(true);
|
||||
const originalBeforeClose = vi.fn(async () => true);
|
||||
const drawerView = {
|
||||
close: vi.fn(),
|
||||
update: vi.fn(),
|
||||
beforeClose: originalBeforeClose,
|
||||
};
|
||||
const open = vi.fn(async (config: { content: (view: typeof drawerView) => unknown }) => {
|
||||
config.content(drawerView);
|
||||
});
|
||||
engine.context.defineProperty('viewer', { value: { open } });
|
||||
|
||||
await QuickEditFormModel.open({
|
||||
flowEngine: engine,
|
||||
target: document.createElement('div'),
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
fieldPath: 'name',
|
||||
record: {},
|
||||
});
|
||||
|
||||
let quickEditModel: QuickEditFormModel | undefined;
|
||||
engine.forEachModel((model) => {
|
||||
if (model instanceof QuickEditFormModel) {
|
||||
quickEditModel = model;
|
||||
}
|
||||
});
|
||||
|
||||
quickEditModel?.viewContainer.update?.({ preventClose: true });
|
||||
expect(drawerView.update).toHaveBeenCalledWith({ preventClose: true });
|
||||
await expect(drawerView.beforeClose?.({ force: false })).resolves.toBe(false);
|
||||
expect(originalBeforeClose).not.toHaveBeenCalled();
|
||||
|
||||
quickEditModel?.viewContainer.update?.({ preventClose: false });
|
||||
expect(drawerView.update).toHaveBeenCalledWith({ preventClose: false });
|
||||
await expect(drawerView.beforeClose?.({ force: false })).resolves.toBe(true);
|
||||
expect(originalBeforeClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps desktop quick edit in the existing right-top popover', async () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
mockMatchMedia(false);
|
||||
const open = vi.fn(async () => undefined);
|
||||
const target = document.createElement('div');
|
||||
engine.context.defineProperty('viewer', { value: { open } });
|
||||
|
||||
await QuickEditFormModel.open({
|
||||
flowEngine: engine,
|
||||
target,
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'users',
|
||||
fieldPath: 'name',
|
||||
record: {},
|
||||
});
|
||||
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'popover',
|
||||
target,
|
||||
placement: 'rightTop',
|
||||
styles: {
|
||||
body: expect.objectContaining({
|
||||
width: 420,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps mobile quick edit content naturally sized with half-window scroll bounds', () => {
|
||||
engine.registerModels({ QuickEditFormModel });
|
||||
const model = engine.createModel<QuickEditFormModel>({
|
||||
use: QuickEditFormModel,
|
||||
uid: 'quick-edit-style',
|
||||
});
|
||||
model.context.defineProperty('isMobileLayout', { value: true });
|
||||
|
||||
const result = QuickEditFormModel.prototype.render.call(model) as any;
|
||||
const formBody = result.props.children[0];
|
||||
const actions = result.props.children[1];
|
||||
|
||||
expect(formBody.props.style).toMatchObject({
|
||||
maxHeight: 'calc(50vh - var(--nb-mobile-page-header-height, 40px) - 132px)',
|
||||
overflowY: 'auto',
|
||||
padding: '8px var(--nb-mobile-page-tabs-content-padding, 12px) 0',
|
||||
});
|
||||
expect(formBody.props.style.minHeight).toBe(0);
|
||||
expect(actions.props.style).toMatchObject({
|
||||
justifyContent: 'flex-end',
|
||||
padding: '8px var(--nb-mobile-page-tabs-content-padding, 12px) calc(80px + env(safe-area-inset-bottom, 0px))',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls update with filterByTk and merges primary key from ctx.collection/record', async () => {
|
||||
// 1) 准备数据源与集合(含主键字段)
|
||||
const dsm = engine.context.dataSourceManager;
|
||||
|
||||
+20
-10
@@ -23,6 +23,20 @@ import {
|
||||
} from '../AssociationFieldModel/recordSelectShared';
|
||||
import _ from 'lodash';
|
||||
|
||||
const mobileSelectSafeAreaPaddingBottom = 'calc(12px + env(safe-area-inset-bottom, 0px))';
|
||||
|
||||
const mobileSelectConfirmFooterStyle: CSSProperties = {
|
||||
paddingBottom: mobileSelectSafeAreaPaddingBottom,
|
||||
};
|
||||
|
||||
function getMobileSelectListStyle(hasConfirmFooter: boolean): CSSProperties {
|
||||
return {
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
paddingBottom: hasConfirmFooter ? undefined : mobileSelectSafeAreaPaddingBottom,
|
||||
};
|
||||
}
|
||||
|
||||
const labelClassName = css`
|
||||
div {
|
||||
white-space: nowrap !important;
|
||||
@@ -280,13 +294,7 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
|
||||
showCancelButton
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div style={getMobileSelectListStyle(isMultiple)} onScroll={handleScroll}>
|
||||
<CheckList multiple={isMultiple} value={selectedValueIds} onChange={handleListChange}>
|
||||
{realOptions.map((item) => {
|
||||
const optionValue = item?.[valueKey];
|
||||
@@ -311,9 +319,11 @@ export function MobileLazySelect(props: Readonly<LazySelectProps>) {
|
||||
)}
|
||||
</div>
|
||||
{isMultiple && (
|
||||
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
<div style={mobileSelectConfirmFooterStyle}>
|
||||
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
</>
|
||||
|
||||
@@ -12,8 +12,23 @@ import { Select } from 'antd';
|
||||
import { Button, CheckList, Popup, SearchBar } from 'antd-mobile';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const mobileSelectSafeAreaPaddingBottom = 'calc(12px + env(safe-area-inset-bottom, 0px))';
|
||||
|
||||
const mobileSelectConfirmFooterStyle: React.CSSProperties = {
|
||||
paddingBottom: mobileSelectSafeAreaPaddingBottom,
|
||||
};
|
||||
|
||||
function getMobileSelectListStyle(hasConfirmFooter: boolean): React.CSSProperties {
|
||||
return {
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
paddingBottom: hasConfirmFooter ? undefined : mobileSelectSafeAreaPaddingBottom,
|
||||
};
|
||||
}
|
||||
|
||||
export function MobileSelect(props) {
|
||||
const { value, displayValue, onChange, onChangeComplete, disabled, options = [], mode } = props;
|
||||
const isMultiple = ['multiple', 'tags'].includes(mode);
|
||||
const ctx = useFlowModelContext();
|
||||
const t = ctx.t;
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -64,17 +79,12 @@ export function MobileSelect(props) {
|
||||
<div style={{ margin: '10px' }}>
|
||||
<SearchBar placeholder={t('search')} value={searchText} onChange={(v) => setSearchText(v)} showCancelButton />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div style={getMobileSelectListStyle(isMultiple)}>
|
||||
<CheckList
|
||||
multiple={['multiple', 'tags'].includes(mode)}
|
||||
multiple={isMultiple}
|
||||
value={Array.isArray(selected) ? selected : [selected]}
|
||||
onChange={(val) => {
|
||||
if (['multiple', 'tags'].includes(mode)) {
|
||||
if (isMultiple) {
|
||||
setSelected(val);
|
||||
} else {
|
||||
setSelected(val[0]);
|
||||
@@ -91,10 +101,12 @@ export function MobileSelect(props) {
|
||||
))}
|
||||
</CheckList>
|
||||
</div>
|
||||
{['multiple', 'tags'].includes(mode) && (
|
||||
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
{isMultiple && (
|
||||
<div style={mobileSelectConfirmFooterStyle}>
|
||||
<Button block color="primary" onClick={handleConfirm} style={{ marginTop: '16px' }}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
</>
|
||||
|
||||
@@ -124,7 +124,7 @@ const genStyleHook = <ComponentName extends OverrideComponent>(
|
||||
// 等 https://github.com/ant-design/cssinjs/pull/176 合并后,可以去掉这层缓存
|
||||
const memoizedWrapSSR = useMemo(() => {
|
||||
return wrapSSR;
|
||||
}, [theme, token, hashId, prefixCls, iconPrefixCls, rootPrefixCls, props]);
|
||||
}, [wrapSSR]);
|
||||
|
||||
return {
|
||||
wrapSSR: memoizedWrapSSR,
|
||||
@@ -147,7 +147,7 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
||||
borderBottom: `1px solid ${token.colorSplit}`,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
backgroundColor: 'white',
|
||||
backgroundColor: token.colorBgContainer,
|
||||
zIndex: 1000,
|
||||
|
||||
// to match the button named 'Add block'
|
||||
@@ -159,12 +159,23 @@ export const useMobileActionDrawerStyle = genStyleHook('nb-mobile-action-drawer'
|
||||
'.nb-mobile-action-drawer-placeholder': {
|
||||
display: 'inline-block',
|
||||
padding: 12,
|
||||
flex: '0 0 auto',
|
||||
visibility: 'hidden',
|
||||
},
|
||||
|
||||
'.nb-mobile-action-drawer-title': {
|
||||
flex: '1 1 auto',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textAlign: 'center',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
|
||||
'.nb-mobile-action-drawer-close-icon': {
|
||||
display: 'inline-block',
|
||||
padding: 12,
|
||||
flex: '0 0 auto',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { ConfigProvider } from 'antd';
|
||||
import React, { FC, ReactNode, useMemo } from 'react';
|
||||
import React, { FC, ReactNode, useCallback, useMemo } from 'react';
|
||||
import { useMobileActionDrawerStyle } from './MobilePopup.style';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { lazy } from '../lazy-helper';
|
||||
@@ -31,11 +31,33 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const { componentCls, hashId } = useMobileActionDrawerStyle();
|
||||
|
||||
const style = useMemo(() => {
|
||||
const bodyStyles = (props as MobilePopupProps & { styles?: { body?: React.CSSProperties } }).styles?.body;
|
||||
const popupStyle = useMemo(() => {
|
||||
return {
|
||||
minHeight,
|
||||
minHeight: bodyStyles?.minHeight ?? minHeight,
|
||||
height: bodyStyles?.height,
|
||||
maxHeight: bodyStyles?.maxHeight,
|
||||
};
|
||||
}, [minHeight]);
|
||||
}, [bodyStyles?.height, bodyStyles?.maxHeight, bodyStyles?.minHeight, minHeight]);
|
||||
|
||||
const bodyStyle = useMemo(() => {
|
||||
return {
|
||||
padding: 0,
|
||||
...bodyStyles,
|
||||
};
|
||||
}, [bodyStyles]);
|
||||
|
||||
const handleCloseKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
closePopup();
|
||||
},
|
||||
[closePopup],
|
||||
);
|
||||
|
||||
const theme = useMemo(() => {
|
||||
return {
|
||||
@@ -57,11 +79,8 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
||||
onClose={closePopup}
|
||||
onMaskClick={closePopup}
|
||||
bodyClassName="nb-mobile-action-drawer-body"
|
||||
bodyStyle={{
|
||||
padding: 0,
|
||||
}}
|
||||
maskStyle={style}
|
||||
style={style}
|
||||
bodyStyle={bodyStyle}
|
||||
style={popupStyle}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="nb-mobile-action-drawer-header">
|
||||
@@ -69,13 +88,14 @@ export const MobilePopup: FC<MobilePopupProps> = (props) => {
|
||||
<span className="nb-mobile-action-drawer-placeholder">
|
||||
<CloseOutline />
|
||||
</span>
|
||||
<span>{title}</span>
|
||||
<span className="nb-mobile-action-drawer-title">{title}</span>
|
||||
<span
|
||||
className="nb-mobile-action-drawer-close-icon"
|
||||
onClick={closePopup}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('Close')}
|
||||
onKeyDown={handleCloseKeyDown}
|
||||
>
|
||||
<CloseOutline />
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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 React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MobilePopup } from '../MobilePopup';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../MobilePopup.style', () => ({
|
||||
useMobileActionDrawerStyle: () => ({
|
||||
componentCls: 'nb-mobile-action-drawer',
|
||||
hashId: 'hash',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../lazy-helper', () => ({
|
||||
lazy: (_loader: unknown, name: string) => {
|
||||
if (name === 'Popup') {
|
||||
const Popup = ({
|
||||
children,
|
||||
bodyStyle,
|
||||
maskStyle,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
bodyStyle?: React.CSSProperties;
|
||||
maskStyle?: React.CSSProperties;
|
||||
style?: React.CSSProperties;
|
||||
}) => (
|
||||
<div data-testid="mobile-popup" style={style}>
|
||||
<div data-testid="mobile-popup-mask" style={maskStyle} />
|
||||
<div data-testid="mobile-popup-body" style={bodyStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { Popup };
|
||||
}
|
||||
|
||||
return {
|
||||
CloseOutline: () => <span data-testid="close-outline" />,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MobilePopup', () => {
|
||||
const MobilePopupWithDrawerStyles = MobilePopup as React.ComponentType<
|
||||
React.ComponentProps<typeof MobilePopup> & { styles?: { body?: React.CSSProperties } }
|
||||
>;
|
||||
|
||||
it('applies drawer body styles as max bounds without forcing fixed half-window height', () => {
|
||||
render(
|
||||
<MobilePopupWithDrawerStyles visible title="Title" styles={{ body: { maxHeight: '50vh' } }} onClose={vi.fn()}>
|
||||
body
|
||||
</MobilePopupWithDrawerStyles>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('mobile-popup')).toHaveStyle({
|
||||
maxHeight: '50vh',
|
||||
});
|
||||
expect(screen.getByTestId('mobile-popup-body')).toHaveStyle({
|
||||
maxHeight: '50vh',
|
||||
});
|
||||
expect(screen.getByTestId('mobile-popup-mask')).not.toHaveStyle({
|
||||
maxHeight: '50vh',
|
||||
});
|
||||
});
|
||||
|
||||
it('closes from the header close icon with Enter or Space', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MobilePopup visible title="Title" onClose={onClose}>
|
||||
body
|
||||
</MobilePopup>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' });
|
||||
fireEvent.keyDown(closeButton, { key: 'Enter' });
|
||||
fireEvent.keyDown(closeButton, { key: ' ' });
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps long header titles in a constrained title element separate from the close button', () => {
|
||||
const longTitle = 'A very long table column title that should not push the close button outside the drawer';
|
||||
|
||||
render(
|
||||
<MobilePopup visible title={longTitle} onClose={vi.fn()}>
|
||||
body
|
||||
</MobilePopup>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(longTitle)).toHaveClass('nb-mobile-action-drawer-title');
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toHaveClass('nb-mobile-action-drawer-close-icon');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user