@nocobase/client-v2 2.2.0-beta.1 → 2.2.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/es/BaseApplication.d.ts +3 -0
  2. package/es/PluginSettingsManager.d.ts +33 -0
  3. package/es/RouteRepository.d.ts +29 -6
  4. package/es/RouterManager.d.ts +15 -0
  5. package/es/authRedirect.d.ts +1 -0
  6. package/es/collection-field-interface/CollectionFieldInterface.d.ts +1 -0
  7. package/es/collection-field-interface/CollectionFieldInterfaceManager.d.ts +1 -0
  8. package/es/components/form/JsonTextArea.d.ts +2 -1
  9. package/es/components/form/VariableJsonTextArea.d.ts +19 -0
  10. package/es/components/form/index.d.ts +1 -0
  11. package/es/components/index.d.ts +1 -0
  12. package/es/entry-actions/EntryActionManager.d.ts +24 -0
  13. package/es/entry-actions/index.d.ts +9 -0
  14. package/es/flow/actions/afterSuccess.d.ts +8 -1
  15. package/es/flow/actions/index.d.ts +1 -1
  16. package/es/flow/admin-shell/BaseLayoutModel.d.ts +6 -0
  17. package/es/flow/admin-shell/BaseLayoutRouteCoordinator.d.ts +7 -0
  18. package/es/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.d.ts +2 -0
  19. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.d.ts +2 -1
  20. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuModels.d.ts +2 -0
  21. package/es/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.d.ts +3 -2
  22. package/es/flow/admin-shell/admin-layout/AdminLayoutSlotModels.d.ts +5 -0
  23. package/es/flow/admin-shell/admin-layout/AppListRender.d.ts +2 -1
  24. package/es/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.d.ts +24 -0
  25. package/es/flow/admin-shell/admin-layout/index.d.ts +1 -0
  26. package/es/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.d.ts +6 -0
  27. package/es/flow/admin-shell/admin-layout/useApplications.d.ts +7 -2
  28. package/es/flow/components/FieldAssignRulesEditor.d.ts +3 -1
  29. package/es/flow/models/base/ActionModelCore.d.ts +2 -0
  30. package/es/flow/models/base/GridModel.d.ts +1 -1
  31. package/es/flow/models/blocks/filter-form/FilterFormBlockModel.d.ts +5 -0
  32. package/es/flow/models/blocks/form/FormBlockModel.d.ts +4 -0
  33. package/es/flow/models/blocks/form/QuickEditFormModel.d.ts +17 -2
  34. package/es/flow/models/blocks/form/submitHandler.d.ts +1 -1
  35. package/es/flow/models/blocks/form/value-runtime/rules.d.ts +21 -0
  36. package/es/flow/models/blocks/form/value-runtime/runtime.d.ts +30 -0
  37. package/es/flow/models/blocks/form/value-runtime/types.d.ts +2 -2
  38. package/es/flow/models/blocks/js-block/JSBlock.d.ts +1 -0
  39. package/es/flow/models/blocks/table/TableActionsColumnModel.d.ts +1 -0
  40. package/es/flow/models/blocks/table/TableBlockModel.d.ts +1 -0
  41. package/es/flow/models/blocks/table/dragSort/dragSortHooks.d.ts +1 -1
  42. package/es/flow/models/blocks/table/dragSort/dragSortSettings.d.ts +2 -1
  43. package/es/flow/models/blocks/table/dragSort/dragSortUtils.d.ts +6 -4
  44. package/es/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.d.ts +2 -0
  45. package/es/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.d.ts +6 -0
  46. package/es/flow/resolveViewParamsToViewList.d.ts +1 -1
  47. package/es/flow/routeTransientInputArgs.d.ts +14 -0
  48. package/es/flow/utils/dataScopeRowSnapshot.d.ts +59 -0
  49. package/es/flow/utils/dateTimeDisplayProps.d.ts +49 -0
  50. package/es/flow/utils/formValueDeps.d.ts +32 -0
  51. package/es/flow-compat/FieldValidation.d.ts +2 -1
  52. package/es/index.d.ts +4 -1
  53. package/es/index.mjs +315 -167
  54. package/es/utils/markdownSanitize.d.ts +11 -0
  55. package/lib/index.js +315 -167
  56. package/package.json +8 -7
  57. package/src/BaseApplication.tsx +12 -5
  58. package/src/PluginSettingsManager.ts +53 -0
  59. package/src/RouteRepository.ts +151 -24
  60. package/src/RouterManager.tsx +142 -1
  61. package/src/__tests__/PluginSettingsManager.test.ts +13 -0
  62. package/src/__tests__/RouteRepository.test.ts +239 -0
  63. package/src/__tests__/RouterManager.test.ts +125 -0
  64. package/src/__tests__/app.test.tsx +55 -0
  65. package/src/__tests__/authRedirect.test.ts +17 -0
  66. package/src/__tests__/exports.test.ts +16 -0
  67. package/src/__tests__/nocobase-buildin-plugin-auth.test.tsx +199 -0
  68. package/src/__tests__/plugin-manager.test.tsx +44 -2
  69. package/src/__tests__/settings-center.test.tsx +40 -1
  70. package/src/authRedirect.ts +38 -0
  71. package/src/collection-field-interface/CollectionFieldInterface.ts +1 -0
  72. package/src/collection-field-interface/CollectionFieldInterfaceManager.ts +1 -0
  73. package/src/collection-manager/field-configure.ts +1 -1
  74. package/src/collection-manager/interfaces/id.ts +1 -1
  75. package/src/collection-manager/interfaces/m2m.tsx +2 -2
  76. package/src/collection-manager/interfaces/m2o.tsx +2 -2
  77. package/src/collection-manager/interfaces/nanoid.ts +1 -1
  78. package/src/collection-manager/interfaces/o2m.tsx +2 -2
  79. package/src/collection-manager/interfaces/obo.tsx +2 -2
  80. package/src/collection-manager/interfaces/oho.tsx +2 -2
  81. package/src/collection-manager/interfaces/properties/index.ts +2 -2
  82. package/src/collection-manager/interfaces/uuid.ts +1 -1
  83. package/src/components/AppComponents.tsx +19 -3
  84. package/src/components/form/JsonTextArea.tsx +21 -11
  85. package/src/components/form/ScanInput/__tests__/useCodeScanner.test.tsx +145 -2
  86. package/src/components/form/ScanInput/useCodeScanner.ts +154 -2
  87. package/src/components/form/VariableJsonTextArea.tsx +175 -0
  88. package/src/components/form/__tests__/JsonTextArea.test.tsx +43 -0
  89. package/src/components/form/__tests__/VariableJsonTextArea.test.tsx +86 -0
  90. package/src/components/form/index.tsx +1 -0
  91. package/src/components/index.ts +1 -0
  92. package/src/entry-actions/EntryActionManager.ts +76 -0
  93. package/src/entry-actions/index.ts +10 -0
  94. package/src/flow/FlowPage.tsx +38 -3
  95. package/src/flow/__tests__/FlowPage.test.tsx +201 -27
  96. package/src/flow/__tests__/FlowRoute.test.tsx +1088 -105
  97. package/src/flow/__tests__/getKey.test.ts +7 -0
  98. package/src/flow/__tests__/resolveViewParamsToViewList.test.ts +34 -0
  99. package/src/flow/actions/__tests__/afterSuccess.test.ts +73 -0
  100. package/src/flow/actions/__tests__/dataScopeFilter.test.ts +120 -0
  101. package/src/flow/actions/__tests__/dataScopeFormValueClear.test.ts +1022 -0
  102. package/src/flow/actions/__tests__/fieldLinkageRules.scopeDepth.test.ts +150 -1
  103. package/src/flow/actions/__tests__/linkageAssignField.legacy.test.ts +135 -0
  104. package/src/flow/actions/__tests__/linkageRules.hiddenSync.restore.test.ts +19 -5
  105. package/src/flow/actions/__tests__/linkageRules.subFormSetFieldProps.test.ts +92 -0
  106. package/src/flow/actions/__tests__/linkageRulesRefresh.test.ts +7 -3
  107. package/src/flow/actions/__tests__/openView.defineProps.route.test.tsx +164 -0
  108. package/src/flow/actions/__tests__/subFormFieldLinkageRules.inputArgs.test.ts +2 -2
  109. package/src/flow/actions/afterSuccess.tsx +142 -3
  110. package/src/flow/actions/customVariable.tsx +1 -2
  111. package/src/flow/actions/dataScopeFilter.ts +20 -1
  112. package/src/flow/actions/dateTimeFormat.tsx +42 -28
  113. package/src/flow/actions/index.ts +1 -1
  114. package/src/flow/actions/linkageRules.tsx +123 -35
  115. package/src/flow/actions/linkageRulesFormValueRefresh.ts +22 -130
  116. package/src/flow/actions/linkageRulesRefresh.tsx +2 -6
  117. package/src/flow/actions/openView.tsx +59 -5
  118. package/src/flow/actions/runjs.tsx +2 -14
  119. package/src/flow/actions/validation.tsx +62 -30
  120. package/src/flow/admin-shell/BaseLayoutModel.tsx +149 -3
  121. package/src/flow/admin-shell/BaseLayoutRouteCoordinator.ts +187 -42
  122. package/src/flow/admin-shell/__tests__/AdminLayoutRouteCoordinator.test.ts +1020 -110
  123. package/src/flow/admin-shell/admin-layout/AdminLayoutComponent.tsx +74 -13
  124. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.test.tsx +486 -0
  125. package/src/flow/admin-shell/admin-layout/AdminLayoutEntryGuard.tsx +64 -8
  126. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuFlowUtils.ts +5 -3
  127. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuModels.tsx +61 -9
  128. package/src/flow/admin-shell/admin-layout/AdminLayoutMenuUtils.tsx +71 -8
  129. package/src/flow/admin-shell/admin-layout/AdminLayoutModel.tsx +1 -1
  130. package/src/flow/admin-shell/admin-layout/AdminLayoutSlotModels.tsx +23 -9
  131. package/src/flow/admin-shell/admin-layout/AppListRender.tsx +13 -2
  132. package/src/flow/admin-shell/admin-layout/AppSwitcherActionPanelModel.tsx +289 -0
  133. package/src/flow/admin-shell/admin-layout/HelpLite.tsx +1 -3
  134. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutComponent.test.tsx +188 -0
  135. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutMenuModels.test.ts +99 -0
  136. package/src/flow/admin-shell/admin-layout/__tests__/AdminLayoutModel.test.tsx +518 -2
  137. package/src/flow/admin-shell/admin-layout/__tests__/TopbarActionsBar.test.tsx +214 -2
  138. package/src/flow/admin-shell/admin-layout/index.ts +1 -0
  139. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.test.ts +20 -0
  140. package/src/flow/admin-shell/admin-layout/resolveAdminRouteRuntimeTarget.ts +33 -5
  141. package/src/flow/admin-shell/admin-layout/useApplications.tsx +81 -12
  142. package/src/flow/common/Markdown/Display.tsx +14 -3
  143. package/src/flow/common/Markdown/Edit.tsx +19 -7
  144. package/src/flow/components/DefaultValue.tsx +1 -2
  145. package/src/flow/components/DynamicFlowsIcon.tsx +447 -126
  146. package/src/flow/components/FieldAssignRulesEditor.tsx +128 -21
  147. package/src/flow/components/FieldAssignValueInput.tsx +15 -11
  148. package/src/flow/components/FlowRoute.tsx +186 -25
  149. package/src/flow/components/__tests__/DynamicFlowsIcon.test.tsx +148 -2
  150. package/src/flow/components/__tests__/FieldAssignRulesEditor.test.tsx +508 -4
  151. package/src/flow/components/__tests__/FieldAssignValueInput.context.test.tsx +134 -0
  152. package/src/flow/components/__tests__/fieldAssignOptions.test.ts +151 -3
  153. package/src/flow/components/code-editor/__tests__/useCodeRunner.test.tsx +2 -17
  154. package/src/flow/components/code-editor/hooks/useCodeRunner.ts +2 -14
  155. package/src/flow/components/code-editor/runjsDiagnostics.ts +8 -45
  156. package/src/flow/components/fieldAssignOptions.ts +155 -27
  157. package/src/flow/flows/editMarkdownFlow.tsx +1 -1
  158. package/src/flow/getViewDiffAndUpdateHidden.tsx +1 -0
  159. package/src/flow/index.ts +1 -1
  160. package/src/flow/internal/components/Markdown/util.ts +2 -1
  161. package/src/flow/internal/utils/operatorSchemaHelper.ts +15 -1
  162. package/src/flow/models/actions/JSActionModel.tsx +2 -7
  163. package/src/flow/models/actions/JSCollectionActionModel.tsx +2 -7
  164. package/src/flow/models/actions/JSItemActionModel.tsx +2 -7
  165. package/src/flow/models/actions/JSRecordActionModel.tsx +2 -7
  166. package/src/flow/models/base/ActionModel.tsx +21 -8
  167. package/src/flow/models/base/ActionModelCore.tsx +11 -4
  168. package/src/flow/models/base/GridModel.tsx +38 -7
  169. package/src/flow/models/base/PageModel/PageModel.tsx +4 -1
  170. package/src/flow/models/base/PageModel/__tests__/PageModel.test.ts +28 -4
  171. package/src/flow/models/base/__tests__/ActionModel.test.ts +83 -0
  172. package/src/flow/models/base/__tests__/ActionModelCore.render.test.tsx +37 -0
  173. package/src/flow/models/base/__tests__/GridModel.dragSnapshotContainer.test.ts +239 -1
  174. package/src/flow/models/base/__tests__/transformRowsToSingleColumn.test.ts +48 -0
  175. package/src/flow/models/blocks/filter-form/FilterFormBlockModel.tsx +32 -3
  176. package/src/flow/models/blocks/filter-form/FilterFormItemModel.tsx +27 -12
  177. package/src/flow/models/blocks/filter-form/FilterFormJSActionModel.tsx +2 -7
  178. package/src/flow/models/blocks/filter-form/__tests__/FilterFormItemModel.defineChildren.test.ts +36 -0
  179. package/src/flow/models/blocks/filter-form/__tests__/defaultValues.wiring.test.ts +111 -0
  180. package/src/flow/models/blocks/filter-form/fields/FilterFormCustomFieldModel.tsx +1 -1
  181. package/src/flow/models/blocks/filter-manager/flow-actions/__tests__/customizeFilterRender.test.tsx +56 -1
  182. package/src/flow/models/blocks/form/EditFormModel.tsx +1 -0
  183. package/src/flow/models/blocks/form/FormActionModel.tsx +1 -1
  184. package/src/flow/models/blocks/form/FormBlockModel.tsx +7 -0
  185. package/src/flow/models/blocks/form/JSFormActionModel.tsx +2 -7
  186. package/src/flow/models/blocks/form/QuickEditFormModel.tsx +189 -36
  187. package/src/flow/models/blocks/form/__tests__/FormBlockModel.test.tsx +17 -0
  188. package/src/flow/models/blocks/form/__tests__/QuickEditFormModel.quickEdit.test.ts +350 -2
  189. package/src/flow/models/blocks/form/__tests__/submitHandler.test.ts +54 -1
  190. package/src/flow/models/blocks/form/submitHandler.ts +17 -3
  191. package/src/flow/models/blocks/form/value-runtime/__tests__/runtime.test.ts +940 -75
  192. package/src/flow/models/blocks/form/value-runtime/rules.ts +445 -13
  193. package/src/flow/models/blocks/form/value-runtime/runtime.ts +348 -13
  194. package/src/flow/models/blocks/form/value-runtime/types.ts +2 -2
  195. package/src/flow/models/blocks/js-block/JSBlock.tsx +225 -9
  196. package/src/flow/models/blocks/js-block/__tests__/JSBlockModel.test.tsx +150 -0
  197. package/src/flow/models/blocks/table/JSColumnModel.tsx +1 -9
  198. package/src/flow/models/blocks/table/TableActionsColumnModel.tsx +38 -14
  199. package/src/flow/models/blocks/table/TableBlockModel.tsx +38 -17
  200. package/src/flow/models/blocks/table/TableColumnModel.tsx +42 -5
  201. package/src/flow/models/blocks/table/__tests__/TableActionsColumnModel.test.tsx +111 -1
  202. package/src/flow/models/blocks/table/__tests__/TableBlockModel.dragSort.test.ts +127 -0
  203. package/src/flow/models/blocks/table/__tests__/TableBlockModel.mobileSettingsButtons.test.tsx +78 -0
  204. package/src/flow/models/blocks/table/__tests__/TableBlockModel.rowSelection.test.tsx +1 -0
  205. package/src/flow/models/blocks/table/__tests__/TableColumnModel.test.tsx +210 -1
  206. package/src/flow/models/blocks/table/dragSort/dragSortHooks.tsx +28 -17
  207. package/src/flow/models/blocks/table/dragSort/dragSortSettings.ts +13 -6
  208. package/src/flow/models/blocks/table/dragSort/dragSortUtils.ts +15 -2
  209. package/src/flow/models/fields/AssociationFieldModel/AssociationFieldModel.tsx +1 -1
  210. package/src/flow/models/fields/AssociationFieldModel/PopupSubTableFieldModel/PopupSubTableFieldModel.tsx +30 -6
  211. package/src/flow/models/fields/AssociationFieldModel/RecordSelectFieldModel.tsx +61 -17
  212. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableColumnModel.tsx +141 -19
  213. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/SubTableField.tsx +13 -1
  214. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableColumnModel.rowRecord.test.ts +197 -0
  215. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/__tests__/SubTableFieldModel.reset.test.ts +180 -0
  216. package/src/flow/models/fields/AssociationFieldModel/SubTableFieldModel/index.tsx +6 -4
  217. package/src/flow/models/fields/AssociationFieldModel/__tests__/AssociationFieldModel.updateAssociation.test.ts +72 -0
  218. package/src/flow/models/fields/AssociationFieldModel/__tests__/RecordPickerFieldModel.itemContext.test.ts +23 -0
  219. package/src/flow/models/fields/ClickableFieldModel.tsx +5 -0
  220. package/src/flow/models/fields/DisplayDateTimeFieldModel.tsx +29 -1
  221. package/src/flow/models/fields/InputFieldModel.tsx +40 -2
  222. package/src/flow/models/fields/JSEditableFieldModel.tsx +2 -11
  223. package/src/flow/models/fields/JSFieldModel.tsx +2 -7
  224. package/src/flow/models/fields/JSItemModel.tsx +2 -14
  225. package/src/flow/models/fields/SelectFieldModel.tsx +6 -4
  226. package/src/flow/models/fields/TextareaFieldModel.tsx +72 -3
  227. package/src/flow/models/fields/__tests__/DisplayDateTimeFieldModel.test.tsx +96 -0
  228. package/src/flow/models/fields/__tests__/InputFieldModel.test.tsx +117 -1
  229. package/src/flow/models/fields/__tests__/SelectFieldModel.test.tsx +43 -0
  230. package/src/flow/models/fields/__tests__/TextareaFieldModel.test.tsx +131 -0
  231. package/src/flow/models/fields/mobile-components/MobileLazySelect.tsx +20 -10
  232. package/src/flow/models/fields/mobile-components/MobileSelect.tsx +24 -12
  233. package/src/flow/models/topbar/TopbarActionModel.tsx +168 -10
  234. package/src/flow/resolveViewParamsToViewList.tsx +11 -3
  235. package/src/flow/routeTransientInputArgs.ts +27 -0
  236. package/src/flow/utils/__tests__/dateTimeFormat.test.ts +300 -0
  237. package/src/flow/utils/dataScopeFormValueClear.ts +218 -81
  238. package/src/flow/utils/dataScopeRowSnapshot.ts +616 -0
  239. package/src/flow/utils/dateTimeDisplayProps.ts +135 -0
  240. package/src/flow/utils/formValueDeps.ts +170 -0
  241. package/src/flow-compat/FieldValidation.tsx +122 -60
  242. package/src/flow-compat/Popover.tsx +43 -4
  243. package/src/flow-compat/__tests__/Popover.test.tsx +34 -0
  244. package/src/index.ts +10 -1
  245. package/src/layout-manager/__tests__/LayoutRoute.test.tsx +41 -1
  246. package/src/nocobase-buildin-plugin/index.tsx +51 -15
  247. package/src/settings-center/AdminSettingsLayout.tsx +10 -2
  248. package/src/settings-center/SystemSettingsPage.tsx +1 -2
  249. package/src/settings-center/plugin-manager/PluginCard.tsx +2 -2
  250. package/src/settings-center/plugin-manager/index.tsx +3 -0
  251. package/src/settings-center/utils.tsx +0 -6
  252. package/src/utils/markdownSanitize.ts +88 -0
@@ -18,6 +18,19 @@ class TestAclPlugin extends Plugin {
18
18
  }
19
19
  }
20
20
 
21
+ class TestSettingsLinkPlugin extends Plugin {
22
+ async load() {
23
+ this.app.pluginSettingsManager.addMenuItem({ key: 'shared-settings', title: 'Shared settings' });
24
+ this.app.pluginSettingsManager.addPageTabItem({
25
+ menuKey: 'shared-settings',
26
+ key: 'target',
27
+ title: 'Target settings',
28
+ Component: () => <div>Target settings page</div>,
29
+ });
30
+ this.app.pluginSettingsManager.setPluginSettingsLink('demo-plugin', 'shared-settings.target');
31
+ }
32
+ }
33
+
21
34
  type MockClientApplication = ReturnType<typeof createMockClient>;
22
35
 
23
36
  const renderApp = (app: MockClientApplication) => {
@@ -35,9 +48,9 @@ const waitForGetRequests = async (app: MockClientApplication, urls: string[]) =>
35
48
  );
36
49
  };
37
50
 
38
- const setupApp = (pmList: any[]) => {
51
+ const setupApp = (pmList: any[], plugins: Array<typeof Plugin> = []) => {
39
52
  const app = createMockClient({
40
- plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
53
+ plugins: [NocoBaseBuildInPlugin, TestAclPlugin, ...plugins],
41
54
  router: { type: 'memory', initialEntries: ['/admin/settings/plugin-manager'] },
42
55
  });
43
56
 
@@ -174,4 +187,33 @@ describe('plugin-manager page', () => {
174
187
  expect(removeCall?.params).toMatchObject({ filterByTk: 'demo-plugin' });
175
188
  });
176
189
  });
190
+
191
+ it('shows Settings for a plugin linked to a different settings page', async () => {
192
+ const app = setupApp(
193
+ [
194
+ {
195
+ name: 'demo-plugin',
196
+ packageName: '@nocobase/demo-plugin',
197
+ displayName: 'Demo plugin',
198
+ description: 'A demo',
199
+ enabled: true,
200
+ builtIn: false,
201
+ removable: false,
202
+ version: '0.1.0',
203
+ isCompatible: true,
204
+ keywords: [],
205
+ },
206
+ ],
207
+ [TestSettingsLinkPlugin],
208
+ );
209
+
210
+ renderApp(app);
211
+ await waitForGetRequests(app, ['/auth:check', 'roles:check', 'pm:list']);
212
+
213
+ const card = await screen.findByRole('button', { name: 'Demo plugin' });
214
+ const settingsLink = within(card.closest('.ant-card') as HTMLElement).getByText('Settings');
215
+ fireEvent.click(settingsLink);
216
+
217
+ expect(await screen.findByText('Target settings page')).toBeInTheDocument();
218
+ });
177
219
  });
@@ -14,7 +14,7 @@ import { message } from 'antd';
14
14
  import { AdminSettingsLayoutModel as ClientV2AdminSettingsLayoutModel } from '../settings-center';
15
15
  import { AdminSettingsLayoutModel as ClientV1AdminSettingsLayoutModel } from '../../../client/src/pm/AdminSettingsLayoutModel';
16
16
  import { NocoBaseBuildInPlugin } from '../nocobase-buildin-plugin';
17
- import { matchSettingsRoute } from '../settings-center/utils';
17
+ import { matchSettingsRoute, sortTopLevelSettings } from '../settings-center/utils';
18
18
 
19
19
  class TestAclPlugin extends Plugin {
20
20
  async load() {
@@ -168,6 +168,12 @@ describe('settings center', () => {
168
168
  });
169
169
  });
170
170
 
171
+ it('should sort system-settings with other top-level settings by normal ordering', () => {
172
+ const settings = [{ name: 'system-settings' }, { name: 'api-keys' }, { name: 'backups' }] as any;
173
+
174
+ expect(sortTopLevelSettings(settings).map((item) => item.name)).toEqual(['api-keys', 'backups', 'system-settings']);
175
+ });
176
+
171
177
  it('should redirect /admin/settings to system-settings by default', async () => {
172
178
  const app = createMockClient({
173
179
  plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
@@ -354,6 +360,39 @@ describe('settings center', () => {
354
360
  expect(screen.getByRole('menuitem', { name: 'Menu ACL Demo' })).toBeInTheDocument();
355
361
  });
356
362
 
363
+ it('should allow the settings sidebar menu to scroll independently', async () => {
364
+ class ManySettingsPlugin extends Plugin {
365
+ async load() {
366
+ for (let index = 0; index < 30; index += 1) {
367
+ this.pluginSettingsManager.addMenuItem({
368
+ key: `scroll-demo-${index}`,
369
+ title: `Scroll demo ${index}`,
370
+ });
371
+ this.pluginSettingsManager.addPageTabItem({
372
+ menuKey: `scroll-demo-${index}`,
373
+ key: 'index',
374
+ title: `Scroll demo ${index}`,
375
+ Component: () => <div>{`Scroll demo page ${index}`}</div>,
376
+ });
377
+ }
378
+ }
379
+ }
380
+
381
+ const app = createMockClient({
382
+ plugins: [NocoBaseBuildInPlugin, TestAclPlugin, ManySettingsPlugin],
383
+ router: { type: 'memory', initialEntries: ['/admin/settings/scroll-demo-29'] },
384
+ });
385
+ mockAdminRuntime(app);
386
+
387
+ await renderApp(app);
388
+ await waitForGetRequests(app, ['/auth:check', 'roles:check']);
389
+
390
+ expect(await screen.findByText('Scroll demo page 29')).toBeInTheDocument();
391
+
392
+ const sidebar = screen.getByRole('menuitem', { name: 'Scroll demo 29' }).closest('.ant-layout-sider');
393
+ expect(sidebar).toHaveStyle({ overflowY: 'auto' });
394
+ });
395
+
357
396
  it('should save system settings through systemSettings:put', async () => {
358
397
  const app = createMockClient({
359
398
  plugins: [NocoBaseBuildInPlugin, TestAclPlugin],
@@ -204,6 +204,44 @@ function getDefaultV2AdminRedirectPath(app: AppLike) {
204
204
  return joinRootRelativePath(getV2EffectiveBasePath(app), '/admin');
205
205
  }
206
206
 
207
+ function isSafeRootRelativePath(value?: string | null) {
208
+ return !!value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
209
+ }
210
+
211
+ function preserveTrailingSlash(originalPathname: string, value: string) {
212
+ if (originalPathname !== '/' && originalPathname.endsWith('/') && !value.endsWith('/')) {
213
+ return `${value}/`;
214
+ }
215
+ return value;
216
+ }
217
+
218
+ export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fallbackPath = '/admin/') {
219
+ // In a v2 sub-app, publicPath can be `/v/` while the active router
220
+ // basename is `/v/apps/a/`. Redirects must resolve under the basename.
221
+ const basePath = trimTrailingSlashes(getV2EffectiveBasePath(app)) || '/';
222
+ const fallbackTarget = isSafeRootRelativePath(fallbackPath) ? fallbackPath : '/admin/';
223
+ const rawTarget = isSafeRootRelativePath(target) ? target : fallbackTarget;
224
+ let { pathname, search, hash } = splitPathLike(rawTarget);
225
+ let normalizedPathname = normalizePathname(pathname);
226
+
227
+ // Already under the current v2 runtime, e.g. `/v/apps/a/admin/`.
228
+ if (basePath === '/' || normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) {
229
+ return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
230
+ }
231
+
232
+ const publicPath = trimTrailingSlashes(getV2PublicPath(app)) || '/';
233
+ // Under v2 publicPath but outside the current basename, e.g. `/v/admin/`
234
+ // inside `/v/apps/a/`; use fallback so it lands on `/v/apps/a/admin/`.
235
+ if (publicPath !== '/' && (normalizedPathname === publicPath || normalizedPathname.startsWith(`${publicPath}/`))) {
236
+ ({ pathname, search, hash } = splitPathLike(fallbackTarget));
237
+ normalizedPathname = normalizePathname(pathname);
238
+ }
239
+
240
+ // Basename-relative target, e.g. `/admin/`, becomes `/v/apps/a/admin/`.
241
+ const joinedPathname = preserveTrailingSlash(pathname, joinRootRelativePath(basePath, normalizedPathname));
242
+ return `${joinedPathname}${normalizeSearch(search)}${normalizeHash(hash)}`;
243
+ }
244
+
207
245
  /**
208
246
  * 将当前 v2 页面地址转换为根相对 redirect 路径。
209
247
  *
@@ -115,6 +115,7 @@ export abstract class CollectionFieldInterface {
115
115
  titleUsable?: boolean;
116
116
  usePathOptions?(field: any): any;
117
117
  hidden?: boolean;
118
+ creatable?: boolean;
118
119
 
119
120
  addComponentOption(componentOption: CollectionFieldInterfaceComponentOption) {
120
121
  if (!this.componentOptions) {
@@ -31,6 +31,7 @@ interface FieldInterfaceLike {
31
31
  title?: ReactNode;
32
32
  group?: string;
33
33
  order?: number;
34
+ creatable?: boolean;
34
35
  isAssociation?: boolean;
35
36
  supportDataSourceType?: string[];
36
37
  notSupportDataSourceType?: string[];
@@ -530,7 +530,7 @@ export function reverseFieldConfigureItems(): FieldConfigureItem[] {
530
530
  component: 'Input',
531
531
  required: true,
532
532
  description:
533
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
533
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
534
534
  hidden: ({ context, values }) => !context.showReverseFieldConfig && !get(values, 'autoCreateReverseField'),
535
535
  disabled: ({ context }) => !context.showReverseFieldConfig,
536
536
  },
@@ -48,7 +48,7 @@ export class IdFieldInterface extends CollectionFieldInterface {
48
48
  'x-decorator': 'FormItem',
49
49
  'x-component': 'Input',
50
50
  description:
51
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
51
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
52
52
  },
53
53
  };
54
54
  filterable = {
@@ -158,7 +158,7 @@ export class M2MFieldInterface extends CollectionFieldInterface {
158
158
  required: true,
159
159
  default: '{{ useNewId("f_") }}',
160
160
  description:
161
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
161
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
162
162
  'x-decorator': 'FormItem',
163
163
  'x-component': 'ForeignKey',
164
164
  'x-validator': 'uid',
@@ -192,7 +192,7 @@ export class M2MFieldInterface extends CollectionFieldInterface {
192
192
  required: true,
193
193
  default: '{{ useNewId("f_") }}',
194
194
  description:
195
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
195
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
196
196
  'x-decorator': 'FormItem',
197
197
  'x-component': 'ForeignKey',
198
198
  'x-validator': 'uid',
@@ -69,7 +69,7 @@ export class M2OFieldInterface extends CollectionFieldInterface {
69
69
  'x-decorator': 'FormItem',
70
70
  'x-component': 'Input',
71
71
  description:
72
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
72
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
73
73
  },
74
74
  type: relationshipType,
75
75
  grid: {
@@ -123,7 +123,7 @@ export class M2OFieldInterface extends CollectionFieldInterface {
123
123
  required: true,
124
124
  default: '{{ useNewId("f_") }}',
125
125
  description:
126
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
126
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
127
127
  'x-decorator': 'FormItem',
128
128
  'x-component': 'ForeignKey',
129
129
  'x-validator': 'uid',
@@ -42,7 +42,7 @@ export class NanoidFieldInterface extends CollectionFieldInterface {
42
42
  'x-component': 'Input',
43
43
  'x-disabled': '{{ !createOnly }}',
44
44
  description:
45
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
45
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
46
46
  },
47
47
  customAlphabet: {
48
48
  type: 'string',
@@ -68,7 +68,7 @@ export class O2MFieldInterface extends CollectionFieldInterface {
68
68
  'x-decorator': 'FormItem',
69
69
  'x-component': 'Input',
70
70
  description:
71
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
71
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
72
72
  },
73
73
  type: relationshipType,
74
74
  grid: {
@@ -135,7 +135,7 @@ export class O2MFieldInterface extends CollectionFieldInterface {
135
135
  required: true,
136
136
  default: '{{ useNewId("f_") }}',
137
137
  description:
138
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
138
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
139
139
  'x-decorator': 'FormItem',
140
140
  'x-component': 'ForeignKey',
141
141
  'x-validator': 'uid',
@@ -55,7 +55,7 @@ export class OBOFieldInterface extends CollectionFieldInterface {
55
55
  'x-decorator': 'FormItem',
56
56
  'x-component': 'Input',
57
57
  description:
58
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
58
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
59
59
  },
60
60
  type: relationshipType,
61
61
  grid: {
@@ -109,7 +109,7 @@ export class OBOFieldInterface extends CollectionFieldInterface {
109
109
  required: true,
110
110
  default: '{{ useNewId("f_") }}',
111
111
  description:
112
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
112
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
113
113
  'x-decorator': 'FormItem',
114
114
  'x-component': 'ForeignKey',
115
115
  'x-validator': 'uid',
@@ -55,7 +55,7 @@ export class OHOFieldInterface extends CollectionFieldInterface {
55
55
  'x-decorator': 'FormItem',
56
56
  'x-component': 'Input',
57
57
  description:
58
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
58
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
59
59
  },
60
60
  type: relationshipType,
61
61
  grid: {
@@ -123,7 +123,7 @@ export class OHOFieldInterface extends CollectionFieldInterface {
123
123
  required: true,
124
124
  default: '{{ useNewId("f_") }}',
125
125
  description:
126
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
126
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
127
127
  'x-decorator': 'FormItem',
128
128
  'x-component': 'ForeignKey',
129
129
  'x-validator': 'uid',
@@ -141,7 +141,7 @@ export const reverseFieldProperties: Record<string, ISchema> = {
141
141
  'x-validator': 'uid',
142
142
  'x-disabled': '{{ !showReverseFieldConfig }}',
143
143
  description:
144
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
144
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
145
145
  },
146
146
  },
147
147
  },
@@ -399,7 +399,7 @@ export const defaultProps = {
399
399
  'x-component': 'Input',
400
400
  'x-validator': 'uid',
401
401
  description:
402
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
402
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
403
403
  },
404
404
  };
405
405
 
@@ -46,7 +46,7 @@ export class UUIDFieldInterface extends CollectionFieldInterface {
46
46
  'x-component': 'Input',
47
47
  'x-disabled': '{{ !createOnly }}',
48
48
  description:
49
- "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with an letter.')}}",
49
+ "{{t('Randomly generated and can be modified. Support letters, numbers and underscores, must start with a letter.')}}",
50
50
  },
51
51
  autoFill,
52
52
  layout: {
@@ -21,7 +21,13 @@ import type { Application } from '../Application';
21
21
  interface AppErrorPayload {
22
22
  code?: string;
23
23
  message?: string;
24
- command?: { name: string };
24
+ command?: {
25
+ name: string;
26
+ components?: {
27
+ maintaining?: string;
28
+ maintainingDialog?: string;
29
+ };
30
+ };
25
31
  [key: string]: any;
26
32
  }
27
33
 
@@ -188,7 +194,12 @@ export const AppError: FC<{ error: Error & { title?: string }; app: Application
188
194
  );
189
195
 
190
196
  export const AppMaintaining: FC<{ app: Application; error: Error }> = observer(
191
- ({ app }) => {
197
+ ({ app, error }) => {
198
+ const component = (error as AppErrorPayload | undefined)?.command?.components?.maintaining;
199
+ if (component) {
200
+ return app.renderComponent(component, { app, error });
201
+ }
202
+
192
203
  const { icon, status, title, subTitle } = getProps(app);
193
204
  return (
194
205
  <div>
@@ -211,7 +222,12 @@ export const AppMaintaining: FC<{ app: Application; error: Error }> = observer(
211
222
  );
212
223
 
213
224
  export const AppMaintainingDialog: FC<{ app: Application; error: Error }> = observer(
214
- ({ app }) => {
225
+ ({ app, error }) => {
226
+ const component = (error as AppErrorPayload | undefined)?.command?.components?.maintainingDialog;
227
+ if (component) {
228
+ return app.renderComponent(component, { app, error });
229
+ }
230
+
215
231
  const { icon, status, title, subTitle } = getProps(app);
216
232
  return (
217
233
  <Modal open={true} footer={null} closable={false}>
@@ -10,8 +10,9 @@
10
10
  import { css, cx } from '@emotion/css';
11
11
  import { Input, Typography } from 'antd';
12
12
  import type { TextAreaProps } from 'antd/es/input';
13
+ import type { TextAreaRef } from 'antd/es/input/TextArea';
13
14
  import JSON5 from 'json5';
14
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
15
+ import React, { useCallback, useEffect, useMemo, useState, type ChangeEvent, type FocusEvent } from 'react';
15
16
 
16
17
  export interface JsonTextAreaProps extends Omit<TextAreaProps, 'value' | 'onChange'> {
17
18
  value?: unknown;
@@ -36,14 +37,14 @@ function stringifyJsonValue(value: unknown, json: typeof JSON | typeof JSON5, sp
36
37
  json.parse(value);
37
38
  return value;
38
39
  } catch {
39
- return json.stringify(value, undefined, space);
40
+ return json.stringify(value, undefined, space) ?? '';
40
41
  }
41
42
  }
42
43
 
43
- return json.stringify(value, undefined, space);
44
+ return json.stringify(value, undefined, space) ?? '';
44
45
  }
45
46
 
46
- export const JsonTextArea = React.memo((props: JsonTextAreaProps) => {
47
+ const JsonTextAreaComponent = React.forwardRef<TextAreaRef, JsonTextAreaProps>((props, ref) => {
47
48
  const {
48
49
  value,
49
50
  onChange,
@@ -74,11 +75,8 @@ export const JsonTextArea = React.memo((props: JsonTextAreaProps) => {
74
75
  [json],
75
76
  );
76
77
 
77
- const handleChange = useCallback(
78
- (event: React.ChangeEvent<HTMLTextAreaElement>) => {
79
- const nextText = event.target.value;
80
- setText(nextText);
81
-
78
+ const validateText = useCallback(
79
+ (nextText: string) => {
82
80
  try {
83
81
  parseText(nextText);
84
82
  setError(undefined);
@@ -89,12 +87,21 @@ export const JsonTextArea = React.memo((props: JsonTextAreaProps) => {
89
87
  [parseText],
90
88
  );
91
89
 
90
+ const handleChange = useCallback(
91
+ (event: ChangeEvent<HTMLTextAreaElement>) => {
92
+ const nextText = event.target.value;
93
+ setText(nextText);
94
+ validateText(nextText);
95
+ },
96
+ [validateText],
97
+ );
98
+
92
99
  const handleBlur = useCallback(
93
- (event: React.FocusEvent<HTMLTextAreaElement>) => {
100
+ (event: FocusEvent<HTMLTextAreaElement>) => {
94
101
  try {
95
102
  const parsed = parseText(event.target.value);
96
103
  setError(undefined);
97
- setText(parsed == null ? '' : json.stringify(parsed, undefined, space));
104
+ setText(parsed == null ? '' : json.stringify(parsed, undefined, space) ?? '');
98
105
  onChange?.(parsed);
99
106
  } catch (err) {
100
107
  setError(err instanceof Error ? err.message : String(err));
@@ -111,6 +118,7 @@ export const JsonTextArea = React.memo((props: JsonTextAreaProps) => {
111
118
  <>
112
119
  <Input.TextArea
113
120
  {...textAreaProps}
121
+ ref={ref}
114
122
  value={text}
115
123
  onChange={handleChange}
116
124
  onBlur={handleBlur}
@@ -126,4 +134,6 @@ export const JsonTextArea = React.memo((props: JsonTextAreaProps) => {
126
134
  );
127
135
  });
128
136
 
137
+ export const JsonTextArea = React.memo(JsonTextAreaComponent);
138
+
129
139
  JsonTextArea.displayName = 'JsonTextArea';
@@ -7,9 +7,9 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { render, waitFor } from '@testing-library/react';
10
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
11
11
  import React, { useCallback } from 'react';
12
- import { beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
13
13
  import { DEFAULT_CODE_FORMATS, getCodeScanBoxSize, useCodeScanner } from '../useCodeScanner';
14
14
 
15
15
  type MockScannerInstance = {
@@ -44,6 +44,12 @@ const mocks = vi.hoisted(() => {
44
44
  };
45
45
  });
46
46
 
47
+ const jsQrMocks = vi.hoisted(() => {
48
+ return {
49
+ default: vi.fn(),
50
+ };
51
+ });
52
+
47
53
  vi.mock('html5-qrcode', () => ({
48
54
  Html5Qrcode: mocks.Html5Qrcode,
49
55
  Html5QrcodeScannerState: {
@@ -66,6 +72,8 @@ vi.mock('html5-qrcode', () => ({
66
72
  },
67
73
  }));
68
74
 
75
+ vi.mock('jsqr', () => jsQrMocks);
76
+
69
77
  function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: number } } = {}) {
70
78
  const handleScanSuccess = useCallback(() => undefined, []);
71
79
 
@@ -79,9 +87,74 @@ function ScannerHost({ scanBoxSize }: { scanBoxSize?: { width: number; height: n
79
87
  return <div id="scanner" />;
80
88
  }
81
89
 
90
+ function FileScannerHost({ onScanSuccess }: { onScanSuccess: (text: string) => void }) {
91
+ const { startScanFile } = useCodeScanner({
92
+ elementId: 'scanner',
93
+ enabled: true,
94
+ onScanSuccess,
95
+ });
96
+
97
+ return (
98
+ <>
99
+ <div id="scanner" />
100
+ <button onClick={() => startScanFile(new File(['qr'], 'qr.png', { type: 'image/png' }))}>scan file</button>
101
+ </>
102
+ );
103
+ }
104
+
105
+ function stubSafariBrowser() {
106
+ vi.spyOn(window.navigator, 'vendor', 'get').mockReturnValue('Apple Computer, Inc.');
107
+ vi.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue(
108
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15',
109
+ );
110
+ }
111
+
112
+ function stubImageElement(size: { width: number; height: number } = { width: 600, height: 400 }) {
113
+ vi.stubGlobal(
114
+ 'Image',
115
+ class MockImage {
116
+ onabort: ((event: Event) => void) | null = null;
117
+ onerror: ((event: Event) => void) | null = null;
118
+ onload: (() => void) | null = null;
119
+ height = size.height;
120
+ naturalHeight = size.height;
121
+ naturalWidth = size.width;
122
+ width = size.width;
123
+
124
+ set src(_value: string) {
125
+ this.onload?.();
126
+ }
127
+ },
128
+ );
129
+ vi.stubGlobal('URL', {
130
+ ...URL,
131
+ createObjectURL: vi.fn(() => 'blob:qr'),
132
+ revokeObjectURL: vi.fn(),
133
+ });
134
+ }
135
+
136
+ function stubCanvas() {
137
+ const canvasContext = {
138
+ drawImage: vi.fn(),
139
+ getImageData: vi.fn((_x: number, _y: number, width: number, height: number) => ({
140
+ data: new Uint8ClampedArray(width * height * 4),
141
+ height,
142
+ width,
143
+ })),
144
+ } as unknown as CanvasRenderingContext2D;
145
+
146
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(canvasContext);
147
+ }
148
+
82
149
  describe('useCodeScanner', () => {
83
150
  beforeEach(() => {
84
151
  vi.clearAllMocks();
152
+ jsQrMocks.default.mockReturnValue(undefined);
153
+ });
154
+
155
+ afterEach(() => {
156
+ vi.unstubAllGlobals();
157
+ vi.restoreAllMocks();
85
158
  });
86
159
 
87
160
  it('starts scanning with QR code and barcode formats by default', async () => {
@@ -120,4 +193,74 @@ describe('useCodeScanner', () => {
120
193
 
121
194
  expect(config?.qrbox?.(1200, 844)).toEqual({ width: 319, height: 240 });
122
195
  });
196
+
197
+ it('uses jsQR first for Safari uploaded QR images', async () => {
198
+ const handleScanSuccess = vi.fn();
199
+ jsQrMocks.default.mockReturnValueOnce({ data: 'JSQR-CODE' });
200
+ stubSafariBrowser();
201
+ stubImageElement();
202
+ stubCanvas();
203
+
204
+ render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
205
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
206
+
207
+ fireEvent.click(screen.getByText('scan file'));
208
+
209
+ await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('JSQR-CODE'));
210
+ expect(jsQrMocks.default).toHaveBeenCalledWith(expect.any(Uint8ClampedArray), 600, 400, {
211
+ inversionAttempts: 'attemptBoth',
212
+ });
213
+ expect(mocks.scanFileV2).not.toHaveBeenCalled();
214
+ });
215
+
216
+ it('falls back to html5-qrcode for Safari uploaded files when jsQR does not decode a QR code', async () => {
217
+ const handleScanSuccess = vi.fn();
218
+ mocks.scanFileV2.mockResolvedValueOnce({ decodedText: 'BARCODE-CODE' });
219
+ stubSafariBrowser();
220
+ stubImageElement();
221
+ stubCanvas();
222
+
223
+ render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
224
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
225
+
226
+ fireEvent.click(screen.getByText('scan file'));
227
+
228
+ await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('BARCODE-CODE'));
229
+ expect(jsQrMocks.default).toHaveBeenCalled();
230
+ expect(mocks.scanFileV2).toHaveBeenCalled();
231
+ });
232
+
233
+ it('tries multiple image scales before falling back from Safari jsQR scanning', async () => {
234
+ const handleScanSuccess = vi.fn();
235
+ Array.from({ length: 8 }).forEach((_, index) => {
236
+ jsQrMocks.default.mockReturnValueOnce(index === 7 ? { data: 'SCALED-CODE' } : undefined);
237
+ });
238
+ stubSafariBrowser();
239
+ stubImageElement({ width: 4000, height: 3000 });
240
+ stubCanvas();
241
+
242
+ render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
243
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
244
+
245
+ fireEvent.click(screen.getByText('scan file'));
246
+
247
+ await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('SCALED-CODE'));
248
+ expect(jsQrMocks.default).toHaveBeenCalledTimes(8);
249
+ });
250
+
251
+ it('tries enhanced grayscale QR images for blurry Safari uploads', async () => {
252
+ const handleScanSuccess = vi.fn();
253
+ jsQrMocks.default.mockReturnValueOnce(undefined).mockReturnValueOnce({ data: 'ENHANCED-CODE' });
254
+ stubSafariBrowser();
255
+ stubImageElement();
256
+ stubCanvas();
257
+
258
+ render(<FileScannerHost onScanSuccess={handleScanSuccess} />);
259
+ await waitFor(() => expect(mocks.start).toHaveBeenCalled());
260
+
261
+ fireEvent.click(screen.getByText('scan file'));
262
+
263
+ await waitFor(() => expect(handleScanSuccess).toHaveBeenCalledWith('ENHANCED-CODE'));
264
+ expect(jsQrMocks.default).toHaveBeenCalledTimes(2);
265
+ });
123
266
  });