From 7c7917efaec6279293cd5c4067dbb2e23f52a2b2 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sat, 6 Jun 2026 08:12:33 +0200 Subject: [PATCH 1/7] feat: introduce new View/State/Service design for components --- docs/js/tag.md | 2 +- docs/js/theme-selector.md | 4 +- src/WebExpress.WebApp.Test/JsTest/README.md | 71 +++ .../JsTest/comment.composer.model.test.mjs | 86 ++++ .../JsTest/comment.composer.test.mjs | 57 +++ .../JsTest/comment.model.test.mjs | 107 ++++ .../JsTest/comment.test.mjs | 92 ++++ .../JsTest/dashboard.model.test.mjs | 89 ++++ .../JsTest/dom-stub.mjs | 192 +++++++ .../JsTest/dropdown.theme.model.test.mjs | 74 +++ .../JsTest/engine.test.mjs | 427 ++++++++++++++++ src/WebExpress.WebApp.Test/JsTest/harness.mjs | 132 +++++ .../JsTest/input.selection.model.test.mjs | 82 +++ .../JsTest/input.unique.model.test.mjs | 83 ++++ .../JsTest/kanban.model.test.mjs | 88 ++++ .../JsTest/list.model.test.mjs | 125 +++++ .../JsTest/restform.model.test.mjs | 158 ++++++ .../JsTest/restform.test.mjs | 67 +++ .../JsTest/restwizard.model.test.mjs | 85 ++++ src/WebExpress.WebApp.Test/JsTest/run.ps1 | 37 ++ .../JsTest/scrum.backlog.model.test.mjs | 159 ++++++ .../JsTest/scrum.backlog.test.mjs | 91 ++++ .../JsTest/scrum.sprint.test.mjs | 86 ++++ .../JsTest/selection.model.test.mjs | 79 +++ .../JsTest/tab.model.test.mjs | 117 +++++ .../JsTest/table.model.test.mjs | 141 ++++++ .../JsTest/tile.model.test.mjs | 99 ++++ .../JsTest/watcher.model.test.mjs | 99 ++++ .../JsTest/watcher.test.mjs | 88 ++++ .../JsTest/workflow.editor.model.test.mjs | 118 +++++ ...cs => TestFragmentControlDataDashboard.cs} | 4 +- ....cs => TestFragmentControlDataDropdown.cs} | 4 +- ...s => TestFragmentControlDataFormDelete.cs} | 4 +- ....cs => TestFragmentControlDataFormEdit.cs} | 4 +- ...w.cs => TestFragmentControlDataFormNew.cs} | 4 +- ... => TestFragmentControlDataQuickfilter.cs} | 4 +- ... => TestFragmentControlDataTabTemplate.cs} | 4 +- ...ble.cs => TestFragmentControlDataTable.cs} | 4 +- ...rd.cs => TestFragmentControlDataWizard.cs} | 4 +- ....cs => TestFragmentControlDataWorkflow.cs} | 4 +- .../UnitTestControlAvatarDropdown.cs | 4 +- .../WebControl/UnitTestControlComment.cs | 42 +- ...ard.cs => UnitTestControlDataDashboard.cs} | 30 +- ...down.cs => UnitTestControlDataDropdown.cs} | 10 +- ...RestForm.cs => UnitTestControlDataForm.cs} | 26 +- ...or.cs => UnitTestControlDataFormEditor.cs} | 16 +- ... UnitTestControlDataFormItemInputCheck.cs} | 22 +- ...itTestControlDataFormItemInputPassword.cs} | 24 +- ...tTestControlDataFormItemInputSelection.cs} | 16 +- ...UnitTestControlDataFormItemInputUnique.cs} | 34 +- ...Kanban.cs => UnitTestControlDataKanban.cs} | 30 +- ...RestList.cs => UnitTestControlDataList.cs} | 35 +- .../WebControl/UnitTestControlDataListData.cs | 114 +++++ .../UnitTestControlDataListService.cs | 88 ++++ ...orm.cs => UnitTestControlDataLoginForm.cs} | 10 +- ...r.cs => UnitTestControlDataQuickfilter.cs} | 6 +- ....cs => UnitTestControlDataScrumBacklog.cs} | 11 +- ...t.cs => UnitTestControlDataScrumSprint.cs} | 6 +- ...s => UnitTestControlDataSelectionTheme.cs} | 12 +- ...UnitTestControlDataServiceIslandRollout.cs | 110 +++++ ...olRestTab.cs => UnitTestControlDataTab.cs} | 32 +- ...e.cs => UnitTestControlDataTabTemplate.cs} | 14 +- ...estTile.cs => UnitTestControlDataTable.cs} | 27 +- .../UnitTestControlDataTableService.cs | 93 ++++ ...olRestTag.cs => UnitTestControlDataTag.cs} | 20 +- .../WebControl/UnitTestControlDataTile.cs | 39 ++ ...Wizard.cs => UnitTestControlDataWizard.cs} | 6 +- ...ge.cs => UnitTestControlDataWizardPage.cs} | 4 +- .../WebControl/UnitTestControlDataWorkflow.cs | 39 ++ ...mpt.cs => UnitTestControlDataWqlPrompt.cs} | 6 +- .../WebControl/UnitTestControlRestTable.cs | 88 ---- .../WebControl/UnitTestControlRestWorkflow.cs | 63 --- .../WebControl/UnitTestControlWatcher.cs | 14 +- .../WebData/UnitTestDataAuthoring.cs | 89 ++++ .../WebData/UnitTestDataServiceDescriptor.cs | 120 +++++ .../WebData/UnitTestDataState.cs | 63 +++ .../WebFragment/UnitTestFragmentManager.cs | 38 +- .../UnitTestCommentComposerModelAsset.cs | 75 +++ .../WebInclude/UnitTestCommentModelAsset.cs | 75 +++ .../WebInclude/UnitTestDashboardModelAsset.cs | 75 +++ .../UnitTestDropdownThemeModelAsset.cs | 75 +++ .../WebInclude/UnitTestEngineAssets.cs | 138 ++++++ .../UnitTestInputSelectionModelAsset.cs | 75 +++ .../UnitTestInputUniqueModelAsset.cs | 75 +++ .../WebInclude/UnitTestKanbanModelAsset.cs | 75 +++ .../WebInclude/UnitTestListModelAsset.cs | 77 +++ .../WebInclude/UnitTestRestFormModelAsset.cs | 72 +++ .../UnitTestRestWizardModelAsset.cs | 75 +++ .../UnitTestScrumBacklogModelAsset.cs | 75 +++ .../WebInclude/UnitTestSelectionModelAsset.cs | 75 +++ .../WebInclude/UnitTestTabModelAsset.cs | 75 +++ .../WebInclude/UnitTestTableModelAsset.cs | 75 +++ .../WebInclude/UnitTestTileModelAsset.cs | 75 +++ .../WebInclude/UnitTestWatcherModelAsset.cs | 75 +++ .../UnitTestWorkflowEditorModelAsset.cs | 75 +++ .../Assets/js/action/default.js | 21 +- .../Assets/js/intent/default.js | 30 ++ .../webexpress.webapp.panel.workflow.guard.js | 4 +- ...ress.webapp.panel.workflow.postfunction.js | 4 +- ...express.webapp.panel.workflow.validator.js | 4 +- .../Assets/js/service/default.js | 14 + .../js/webexpress.webapp.avatar.dropdown.js | 9 +- .../js/webexpress.webapp.comment.composer.js | 59 ++- ...ebexpress.webapp.comment.composer.model.js | 68 +++ .../Assets/js/webexpress.webapp.comment.js | 241 +++++---- .../js/webexpress.webapp.comment.model.js | 92 ++++ .../Assets/js/webexpress.webapp.dashboard.js | 51 +- .../js/webexpress.webapp.dashboard.model.js | 57 +++ .../Assets/js/webexpress.webapp.data.js | 195 ++++++++ .../Assets/js/webexpress.webapp.dropdown.js | 9 +- .../js/webexpress.webapp.dropdown.theme.js | 30 +- .../webexpress.webapp.dropdown.theme.model.js | 51 ++ .../js/webexpress.webapp.input.cascading.js | 4 +- .../js/webexpress.webapp.input.selection.js | 102 +--- ...webexpress.webapp.input.selection.model.js | 119 +++++ .../Assets/js/webexpress.webapp.input.tile.js | 4 +- .../js/webexpress.webapp.input.unique.js | 90 +--- .../webexpress.webapp.input.unique.model.js | 111 +++++ .../Assets/js/webexpress.webapp.intent.js | 121 +++++ .../Assets/js/webexpress.webapp.kanban.js | 135 ++--- .../js/webexpress.webapp.kanban.model.js | 70 +++ .../Assets/js/webexpress.webapp.list.js | 275 +++++------ .../Assets/js/webexpress.webapp.list.model.js | 128 +++++ .../Assets/js/webexpress.webapp.login.js | 129 ++--- .../js/webexpress.webapp.quickfilter.js | 9 +- .../Assets/js/webexpress.webapp.renderer.js | 397 +++++++++++++++ .../js/webexpress.webapp.restform.editor.js | 11 +- .../Assets/js/webexpress.webapp.restform.js | 282 ++++------- .../js/webexpress.webapp.restform.model.js | 190 +++++++ .../Assets/js/webexpress.webapp.restwizard.js | 72 ++- .../js/webexpress.webapp.restwizard.model.js | 59 +++ .../js/webexpress.webapp.scrum.backlog.js | 162 +++--- .../webexpress.webapp.scrum.backlog.model.js | 175 +++++++ .../js/webexpress.webapp.scrum.sprint.js | 57 ++- .../Assets/js/webexpress.webapp.selection.js | 75 +-- .../js/webexpress.webapp.selection.model.js | 93 ++++ .../Assets/js/webexpress.webapp.service.js | 467 ++++++++++++++++++ .../Assets/js/webexpress.webapp.store.js | 293 +++++++++++ .../Assets/js/webexpress.webapp.tab.js | 219 +++----- .../Assets/js/webexpress.webapp.tab.model.js | 109 ++++ .../Assets/js/webexpress.webapp.table.js | 384 +++++--------- .../js/webexpress.webapp.table.model.js | 221 +++++++++ .../Assets/js/webexpress.webapp.tag.js | 12 +- .../Assets/js/webexpress.webapp.tile.js | 82 +-- .../Assets/js/webexpress.webapp.tile.model.js | 97 ++++ .../Assets/js/webexpress.webapp.watcher.js | 99 ++-- .../js/webexpress.webapp.watcher.model.js | 77 +++ .../js/webexpress.webapp.workflow.editor.js | 71 +-- ...webexpress.webapp.workflow.editor.model.js | 112 +++++ .../Assets/js/webexpress.webapp.wql.prompt.js | 8 +- ...opdown.cs => ControlDataAvatarDropdown.cs} | 4 +- ...olRestComment.cs => ControlDataComment.cs} | 36 +- ...poser.cs => ControlDataCommentComposer.cs} | 6 +- ...stDashboard.cs => ControlDataDashboard.cs} | 30 +- ...RestDropdown.cs => ControlDataDropdown.cs} | 4 +- ...{ControlRestForm.cs => ControlDataForm.cs} | 4 +- ...olRestFormAdd.cs => ControlDataFormAdd.cs} | 4 +- ...stFormClone.cs => ControlDataFormClone.cs} | 4 +- ...FormDelete.cs => ControlDataFormDelete.cs} | 4 +- ...RestFormEdit.cs => ControlDataFormEdit.cs} | 4 +- ...FormEditor.cs => ControlDataFormEditor.cs} | 4 +- ...ck.cs => ControlDataFormItemInputCheck.cs} | 6 +- ...cs => ControlDataFormItemInputPassword.cs} | 6 +- ...s => ControlDataFormItemInputSelection.cs} | 6 +- ...e.cs => ControlDataFormItemInputUnique.cs} | 6 +- ...trolRestKanban.cs => ControlDataKanban.cs} | 30 +- ...{ControlRestList.cs => ControlDataList.cs} | 32 +- ...onItem.cs => ControlDataListOptionItem.cs} | 6 +- ...ontrolRestLogin.cs => ControlDataLogin.cs} | 4 +- ...ask.cs => ControlDataModalProgressTask.cs} | 4 +- ...ickfilter.cs => ControlDataQuickfilter.cs} | 4 +- ...mBacklog.cs => ControlDataScrumBacklog.cs} | 30 +- ...rumSprint.cs => ControlDataScrumSprint.cs} | 4 +- ...nTheme.cs => ControlDataSelectionTheme.cs} | 6 +- .../{ControlRestTab.cs => ControlDataTab.cs} | 45 +- ...bTemplate.cs => ControlDataTabTemplate.cs} | 10 +- ...ontrolRestTable.cs => ControlDataTable.cs} | 32 +- ...nItem.cs => ControlDataTableOptionItem.cs} | 6 +- .../{ControlRestTag.cs => ControlDataTag.cs} | 4 +- ...{ControlRestTile.cs => ControlDataTile.cs} | 30 +- ...olRestWatcher.cs => ControlDataWatcher.cs} | 4 +- ...trolRestWizard.cs => ControlDataWizard.cs} | 16 +- ...WizardPage.cs => ControlDataWizardPage.cs} | 10 +- ...RestWorkflow.cs => ControlDataWorkflow.cs} | 22 +- ...stWqlPrompt.cs => ControlDataWqlPrompt.cs} | 4 +- .../WebControl/ControlWebAppHeaderAvatar.cs | 2 +- .../WebControl/IControlData.cs | 14 + ...tDashboard.cs => IControlDataDashboard.cs} | 2 +- ...ormEditor.cs => IControlDataFormEditor.cs} | 2 +- ...rolRestKanban.cs => IControlDataKanban.cs} | 2 +- ...ControlRestList.cs => IControlDataList.cs} | 2 +- ...ckfilter.cs => IControlDataQuickfilter.cs} | 2 +- ...Backlog.cs => IControlDataScrumBacklog.cs} | 2 +- ...umSprint.cs => IControlDataScrumSprint.cs} | 2 +- ...{IControlRestTab.cs => IControlDataTab.cs} | 10 +- ...Template.cs => IControlDataTabTemplate.cs} | 8 +- ...ntrolRestTable.cs => IControlDataTable.cs} | 2 +- ...ControlRestTile.cs => IControlDataTile.cs} | 2 +- ...rolRestWizard.cs => IControlDataWizard.cs} | 10 +- ...izardPage.cs => IControlDataWizardPage.cs} | 8 +- ...estWorkflow.cs => IControlDataWorkflow.cs} | 2 +- ...tWqlPrompt.cs => IControlDataWqlPrompt.cs} | 2 +- .../WebControl/IControlRest.cs | 18 - .../WebControl/IControlSearch.cs | 2 +- .../WebData/DataAuthoringExtensions.cs | 59 +++ .../WebData/DataIslandExtensions.cs | 43 ++ .../WebData/DataServiceBuilder.cs | 194 ++++++++ .../WebData/DataServiceDescriptor.cs | 270 ++++++++++ src/WebExpress.WebApp/WebData/DataState.cs | 68 +++ src/WebExpress.WebApp/WebData/IDataIsland.cs | 36 ++ .../WebExpress.WebApp.csproj | 2 +- ...ard.cs => FragmentControlDataDashboard.cs} | 4 +- ...down.cs => FragmentControlDataDropdown.cs} | 4 +- ...RestForm.cs => FragmentControlDataForm.cs} | 4 +- ...rmAdd.cs => FragmentControlDataFormAdd.cs} | 4 +- ...one.cs => FragmentControlDataFormClone.cs} | 4 +- ...te.cs => FragmentControlDataFormDelete.cs} | 4 +- ...Edit.cs => FragmentControlDataFormEdit.cs} | 4 +- ...or.cs => FragmentControlDataFormEditor.cs} | 4 +- ...Kanban.cs => FragmentControlDataKanban.cs} | 4 +- ...RestList.cs => FragmentControlDataList.cs} | 4 +- ...r.cs => FragmentControlDataQuickfilter.cs} | 4 +- ...olRestTab.cs => FragmentControlDataTab.cs} | 4 +- ...e.cs => FragmentControlDataTabTemplate.cs} | 4 +- ...stTable.cs => FragmentControlDataTable.cs} | 4 +- ...RestTile.cs => FragmentControlDataTile.cs} | 4 +- ...Wizard.cs => FragmentControlDataWizard.cs} | 4 +- ...oew.cs => FragmentControlDataWorkfloew.cs} | 4 +- ....cs => IFragmentControlDataTabTemplate.cs} | 2 +- .../WebInclude/IncludeJavaScript.cs | 24 + .../WebPage/VisualTreeWebAppLogin.cs | 2 +- .../WebRestApi/RestApiFormEditorItem.cs | 2 +- .../WebRestApi/RestApiTheme.cs | 2 +- 233 files changed, 10987 insertions(+), 2358 deletions(-) create mode 100644 src/WebExpress.WebApp.Test/JsTest/README.md create mode 100644 src/WebExpress.WebApp.Test/JsTest/comment.composer.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/comment.composer.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/comment.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/comment.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/dashboard.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/dropdown.theme.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/engine.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/harness.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/input.selection.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/input.unique.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/kanban.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/list.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/restform.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/restform.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/restwizard.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/run.ps1 create mode 100644 src/WebExpress.WebApp.Test/JsTest/scrum.backlog.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/scrum.backlog.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/scrum.sprint.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/selection.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/tab.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/table.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/watcher.model.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/watcher.test.mjs create mode 100644 src/WebExpress.WebApp.Test/JsTest/workflow.editor.model.test.mjs rename src/WebExpress.WebApp.Test/{TestFragmentControlRestDashboard.cs => TestFragmentControlDataDashboard.cs} (77%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestWorkflow.cs => TestFragmentControlDataDropdown.cs} (77%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestFormDelete.cs => TestFragmentControlDataFormDelete.cs} (77%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestDropdown.cs => TestFragmentControlDataFormEdit.cs} (77%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestFormNew.cs => TestFragmentControlDataFormNew.cs} (77%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestQuickfilter.cs => TestFragmentControlDataQuickfilter.cs} (77%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestTabTemplate.cs => TestFragmentControlDataTabTemplate.cs} (76%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestTable.cs => TestFragmentControlDataTable.cs} (78%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestWizard.cs => TestFragmentControlDataWizard.cs} (78%) rename src/WebExpress.WebApp.Test/{TestFragmentControlRestFormEdit.cs => TestFragmentControlDataWorkflow.cs} (77%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestDashboard.cs => UnitTestControlDataDashboard.cs} (68%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestDropdown.cs => UnitTestControlDataDropdown.cs} (95%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestForm.cs => UnitTestControlDataForm.cs} (95%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestFormEditor.cs => UnitTestControlDataFormEditor.cs} (92%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestFormItemInputCheck.cs => UnitTestControlDataFormItemInputCheck.cs} (94%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestFormItemInputPassword.cs => UnitTestControlDataFormItemInputPassword.cs} (94%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestFormItemInputSelection.cs => UnitTestControlDataFormItemInputSelection.cs} (93%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestFormItemInputUnique.cs => UnitTestControlDataFormItemInputUnique.cs} (94%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestKanban.cs => UnitTestControlDataKanban.cs} (68%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestList.cs => UnitTestControlDataList.cs} (76%) create mode 100644 src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListData.cs create mode 100644 src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListService.cs rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestLoginForm.cs => UnitTestControlDataLoginForm.cs} (94%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestQuickfilter.cs => UnitTestControlDataQuickfilter.cs} (93%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestScrumBacklog.cs => UnitTestControlDataScrumBacklog.cs} (81%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestScrumSprint.cs => UnitTestControlDataScrumSprint.cs} (93%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestSelectionTheme.cs => UnitTestControlDataSelectionTheme.cs} (89%) create mode 100644 src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataServiceIslandRollout.cs rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestTab.cs => UnitTestControlDataTab.cs} (71%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestTabTemplate.cs => UnitTestControlDataTabTemplate.cs} (93%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestTile.cs => UnitTestControlDataTable.cs} (68%) create mode 100644 src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTableService.cs rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestTag.cs => UnitTestControlDataTag.cs} (95%) create mode 100644 src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTile.cs rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestWizard.cs => UnitTestControlDataWizard.cs} (94%) rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestWizardPage.cs => UnitTestControlDataWizardPage.cs} (92%) create mode 100644 src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWorkflow.cs rename src/WebExpress.WebApp.Test/WebControl/{UnitTestControlRestWqlPrompt.cs => UnitTestControlDataWqlPrompt.cs} (94%) delete mode 100644 src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTable.cs delete mode 100644 src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWorkflow.cs create mode 100644 src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs create mode 100644 src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs create mode 100644 src/WebExpress.WebApp.Test/WebData/UnitTestDataState.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestCommentComposerModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestCommentModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestDashboardModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestDropdownThemeModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestEngineAssets.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestInputSelectionModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestInputUniqueModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestKanbanModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestListModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestRestFormModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestRestWizardModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestScrumBacklogModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestSelectionModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestTabModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestTableModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestTileModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestWatcherModelAsset.cs create mode 100644 src/WebExpress.WebApp.Test/WebInclude/UnitTestWorkflowEditorModelAsset.cs create mode 100644 src/WebExpress.WebApp/Assets/js/intent/default.js create mode 100644 src/WebExpress.WebApp/Assets/js/service/default.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.composer.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.dashboard.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.data.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.theme.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.selection.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.unique.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.intent.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.kanban.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.renderer.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.restwizard.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.backlog.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.selection.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.service.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.store.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.tab.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.tile.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.watcher.model.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.workflow.editor.model.js rename src/WebExpress.WebApp/WebControl/{ControlRestAvatarDropdown.cs => ControlDataAvatarDropdown.cs} (92%) rename src/WebExpress.WebApp/WebControl/{ControlRestComment.cs => ControlDataComment.cs} (82%) rename src/WebExpress.WebApp/WebControl/{ControlRestCommentComposer.cs => ControlDataCommentComposer.cs} (96%) rename src/WebExpress.WebApp/WebControl/{ControlRestDashboard.cs => ControlDataDashboard.cs} (75%) rename src/WebExpress.WebApp/WebControl/{ControlRestDropdown.cs => ControlDataDropdown.cs} (95%) rename src/WebExpress.WebApp/WebControl/{ControlRestForm.cs => ControlDataForm.cs} (98%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormAdd.cs => ControlDataFormAdd.cs} (92%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormClone.cs => ControlDataFormClone.cs} (92%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormDelete.cs => ControlDataFormDelete.cs} (93%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormEdit.cs => ControlDataFormEdit.cs} (92%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormEditor.cs => ControlDataFormEditor.cs} (96%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormItemInputCheck.cs => ControlDataFormItemInputCheck.cs} (95%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormItemInputPassword.cs => ControlDataFormItemInputPassword.cs} (89%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormItemInputSelection.cs => ControlDataFormItemInputSelection.cs} (92%) rename src/WebExpress.WebApp/WebControl/{ControlRestFormItemInputUnique.cs => ControlDataFormItemInputUnique.cs} (96%) rename src/WebExpress.WebApp/WebControl/{ControlRestKanban.cs => ControlDataKanban.cs} (75%) rename src/WebExpress.WebApp/WebControl/{ControlRestList.cs => ControlDataList.cs} (65%) rename src/WebExpress.WebApp/WebControl/{ControlRestListOptionItem.cs => ControlDataListOptionItem.cs} (94%) rename src/WebExpress.WebApp/WebControl/{ControlRestLogin.cs => ControlDataLogin.cs} (94%) rename src/WebExpress.WebApp/WebControl/{ControlRestModalProgressTask.cs => ControlDataModalProgressTask.cs} (95%) rename src/WebExpress.WebApp/WebControl/{ControlRestQuickfilter.cs => ControlDataQuickfilter.cs} (94%) rename src/WebExpress.WebApp/WebControl/{ControlRestScrumBacklog.cs => ControlDataScrumBacklog.cs} (84%) rename src/WebExpress.WebApp/WebControl/{ControlRestScrumSprint.cs => ControlDataScrumSprint.cs} (92%) rename src/WebExpress.WebApp/WebControl/{ControlRestSelectionTheme.cs => ControlDataSelectionTheme.cs} (94%) rename src/WebExpress.WebApp/WebControl/{ControlRestTab.cs => ControlDataTab.cs} (78%) rename src/WebExpress.WebApp/WebControl/{ControlRestTabTemplate.cs => ControlDataTabTemplate.cs} (94%) rename src/WebExpress.WebApp/WebControl/{ControlRestTable.cs => ControlDataTable.cs} (70%) rename src/WebExpress.WebApp/WebControl/{ControlRestTableOptionItem.cs => ControlDataTableOptionItem.cs} (94%) rename src/WebExpress.WebApp/WebControl/{ControlRestTag.cs => ControlDataTag.cs} (97%) rename src/WebExpress.WebApp/WebControl/{ControlRestTile.cs => ControlDataTile.cs} (69%) rename src/WebExpress.WebApp/WebControl/{ControlRestWatcher.cs => ControlDataWatcher.cs} (97%) rename src/WebExpress.WebApp/WebControl/{ControlRestWizard.cs => ControlDataWizard.cs} (89%) rename src/WebExpress.WebApp/WebControl/{ControlRestWizardPage.cs => ControlDataWizardPage.cs} (95%) rename src/WebExpress.WebApp/WebControl/{ControlRestWorkflow.cs => ControlDataWorkflow.cs} (61%) rename src/WebExpress.WebApp/WebControl/{ControlRestWqlPrompt.cs => ControlDataWqlPrompt.cs} (93%) create mode 100644 src/WebExpress.WebApp/WebControl/IControlData.cs rename src/WebExpress.WebApp/WebControl/{IControlRestDashboard.cs => IControlDataDashboard.cs} (92%) rename src/WebExpress.WebApp/WebControl/{IControlRestFormEditor.cs => IControlDataFormEditor.cs} (93%) rename src/WebExpress.WebApp/WebControl/{IControlRestKanban.cs => IControlDataKanban.cs} (92%) rename src/WebExpress.WebApp/WebControl/{IControlRestList.cs => IControlDataList.cs} (85%) rename src/WebExpress.WebApp/WebControl/{IControlRestQuickfilter.cs => IControlDataQuickfilter.cs} (73%) rename src/WebExpress.WebApp/WebControl/{IControlRestScrumBacklog.cs => IControlDataScrumBacklog.cs} (97%) rename src/WebExpress.WebApp/WebControl/{IControlRestScrumSprint.cs => IControlDataScrumSprint.cs} (73%) rename src/WebExpress.WebApp/WebControl/{IControlRestTab.cs => IControlDataTab.cs} (85%) rename src/WebExpress.WebApp/WebControl/{IControlRestTabTemplate.cs => IControlDataTabTemplate.cs} (89%) rename src/WebExpress.WebApp/WebControl/{IControlRestTable.cs => IControlDataTable.cs} (94%) rename src/WebExpress.WebApp/WebControl/{IControlRestTile.cs => IControlDataTile.cs} (85%) rename src/WebExpress.WebApp/WebControl/{IControlRestWizard.cs => IControlDataWizard.cs} (83%) rename src/WebExpress.WebApp/WebControl/{IControlRestWizardPage.cs => IControlDataWizardPage.cs} (86%) rename src/WebExpress.WebApp/WebControl/{IControlRestWorkflow.cs => IControlDataWorkflow.cs} (74%) rename src/WebExpress.WebApp/WebControl/{IControlRestWqlPrompt.cs => IControlDataWqlPrompt.cs} (80%) delete mode 100644 src/WebExpress.WebApp/WebControl/IControlRest.cs create mode 100644 src/WebExpress.WebApp/WebData/DataAuthoringExtensions.cs create mode 100644 src/WebExpress.WebApp/WebData/DataIslandExtensions.cs create mode 100644 src/WebExpress.WebApp/WebData/DataServiceBuilder.cs create mode 100644 src/WebExpress.WebApp/WebData/DataServiceDescriptor.cs create mode 100644 src/WebExpress.WebApp/WebData/DataState.cs create mode 100644 src/WebExpress.WebApp/WebData/IDataIsland.cs rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestDashboard.cs => FragmentControlDataDashboard.cs} (89%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestDropdown.cs => FragmentControlDataDropdown.cs} (89%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestForm.cs => FragmentControlDataForm.cs} (91%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestFormAdd.cs => FragmentControlDataFormAdd.cs} (90%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestFormClone.cs => FragmentControlDataFormClone.cs} (90%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestFormDelete.cs => FragmentControlDataFormDelete.cs} (90%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestFormEdit.cs => FragmentControlDataFormEdit.cs} (90%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestFormEditor.cs => FragmentControlDataFormEditor.cs} (89%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestKanban.cs => FragmentControlDataKanban.cs} (89%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestList.cs => FragmentControlDataList.cs} (90%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestQuickfilter.cs => FragmentControlDataQuickfilter.cs} (89%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestTab.cs => FragmentControlDataTab.cs} (90%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestTabTemplate.cs => FragmentControlDataTabTemplate.cs} (89%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestTable.cs => FragmentControlDataTable.cs} (90%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestTile.cs => FragmentControlDataTile.cs} (90%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestWizard.cs => FragmentControlDataWizard.cs} (89%) rename src/WebExpress.WebApp/WebFragment/{FragmentControlRestWorkfloew.cs => FragmentControlDataWorkfloew.cs} (89%) rename src/WebExpress.WebApp/WebFragment/{IFragmentControlRestTabTemplate.cs => IFragmentControlDataTabTemplate.cs} (83%) diff --git a/docs/js/tag.md b/docs/js/tag.md index 71b62f7..765f58a 100644 --- a/docs/js/tag.md +++ b/docs/js/tag.md @@ -86,4 +86,4 @@ tagElement.addEventListener(webexpress.webapp.Event.TAG_ADDED_EVENT, (e) => { ## Read-only Mode -Setting `data-readonly="true"` (or `Readonly = _ => true` on the `ControlRestTag`) suppresses the "+" button, leaving a pure read-only chip display with no way to open the editing modal. This is useful for surfaces that should display tags without allowing edits. +Setting `data-readonly="true"` (or `Readonly = _ => true` on the `ControlDataTag`) suppresses the "+" button, leaving a pure read-only chip display with no way to open the editing modal. This is useful for surfaces that should display tags without allowing edits. diff --git a/docs/js/theme-selector.md b/docs/js/theme-selector.md index 0862c24..85cf01e 100644 --- a/docs/js/theme-selector.md +++ b/docs/js/theme-selector.md @@ -16,7 +16,7 @@ The `webexpress.webapp.DropdownTheme` is a REST-backed theme picker that extends ## Declarative Configuration -The control is rendered server-side by `ControlRestSelectionTheme`. Manual HTML usage is also supported: +The control is rendered server-side by `ControlDataSelectionTheme`. Manual HTML usage is also supported: |Attribute |Description |-------------------------|----------------------------------------------------------------- @@ -74,7 +74,7 @@ public sealed class ThemeApi : RestApiTheme } // 2. drop the selector onto a page - it is a standalone dropdown: -new ControlRestSelectionTheme("themeSelector") +new ControlDataSelectionTheme("themeSelector") { RestUri = _ => sitemapManager.GetUri(applicationContext) }; diff --git a/src/WebExpress.WebApp.Test/JsTest/README.md b/src/WebExpress.WebApp.Test/JsTest/README.md new file mode 100644 index 0000000..ad46855 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/README.md @@ -0,0 +1,71 @@ +# Headless tests for the View, State and Service engine + +This folder contains the headless unit tests for the phase zero engine of the +View, State and Service architecture. The engine modules live in +`src/WebExpress.WebUI/Assets/js` (store, service, renderer, intent and +component) and are described in +`WebExpress.WebApp/docs/architecture/view-state-service.md`. + +The tests load the real, shipped engine modules through a Node `vm` context with +a minimal DOM stub, so they exercise the same files that the framework embeds, +not a copy. + +## Requirements + +Node 18 or newer. No npm packages are installed; the harness uses only the Node +standard library (`node:test`, `node:assert`, `node:vm`, `node:fs`). The Node +that ships with the Visual Studio "Node.js development" component works as well, +even though it is not added to the system PATH. + +## Running + +From this folder, when node is on the PATH: + +``` +node --test +``` + +Node discovers and runs every `*.test.mjs` file. The expected output ends with a +pass summary and an exit code of zero. + +When node is not on the PATH, for example when it ships only with Visual Studio, +use the helper script, which locates node automatically: + +``` +./run.ps1 +``` + +## Layout + +| File | Purpose +|-----------------------|----------------------------------------------------------------- +| `harness.mjs` | Loads the engine modules (and optional application modules) into an isolated context with host stubs. +| `dom-stub.mjs` | A minimal DOM used by the renderer and the component tests. +| `engine.test.mjs` | Unit tests for the store, the service, the renderer, the intents and the component. +| `list.model.test.mjs` | Unit tests for the REST list model helpers (phase one), including an end to end path through a service. +| `table.model.test.mjs` | Unit tests for the REST table model helpers (phase two), including the query and the put update through a service. +| `restform.model.test.mjs` | Unit tests for the REST form model helpers (phase two): request shaping, response classification and error normalisation. +| `restwizard.model.test.mjs` | Unit tests for the REST wizard model helpers (phase two): step request shaping, cache decision and last step detection. +| `tab.model.test.mjs` | Unit tests for the REST tab model helpers (phase two): the list, create, reorder and close operations through a service. +| `comment.model.test.mjs` | Unit tests for the REST comment model helpers (phase two): endpoint url and path building and category normalisation. +| `kanban.model.test.mjs` | Unit tests for the REST kanban model helpers (phase two): board normalisation and the load and persist operations through a service. +| `watcher.model.test.mjs` | Unit tests for the watcher model helpers: list normalisation, user search url, candidate filtering, removal helpers and the load, add and remove operations through a service. +| `scrum.backlog.model.test.mjs` | Unit tests for the scrum backlog model helpers: board and sprint normalisation, sprint and item paths, rank bodies, the group filter and sort, the rank rewrite, the active sprint crossing and the persist operations through a service. +| `tile.model.test.mjs` | Unit tests for the REST tile model helpers: the page slice, the total reduction, the item to tile mapping and the load and persist operations through a service. +| `dashboard.model.test.mjs` | Unit tests for the REST dashboard model helpers: the column and widget normalisation and the load and persist operations through a service. +| `workflow.editor.model.test.mjs` | Unit tests for the workflow editor model helpers: the meta and catalog normalisation, the wire format read with its aliases and the wire payload build, plus the load and persist operations through a service. +| `comment.composer.model.test.mjs` | Unit tests for the comment composer model helpers: the categories url, the categories normalisation and the label parsing, plus the categories load and the comment post through a service. +| `input.unique.model.test.mjs` | Unit tests for the unique input model helpers: the header parsing, the request body shaping and the availability extraction with its field and status and code heuristics, plus a uniqueness check through the shared request. +| `selection.model.test.mjs` | Unit tests for the REST selection model helpers: the request url and init shaping and the response item mapping, plus a search through the shared request. +| `input.selection.model.test.mjs` | Unit tests for the REST input selection model helpers: the request url and init shaping and the item mapping with its data and aria tuples, plus a search through the shared request. +| `dropdown.theme.model.test.mjs` | Unit tests for the theme dropdown model helpers: the theme item mapping and the theme list normalisation, plus a themes load through the shared request. + +## Relationship to the .NET test suite + +The .NET test `WebExpress.WebUI.Test/WebInclude/UnitTestEngineAssets` verifies +that the engine modules are embedded as resources and registered in the correct +load order through the `IncludeJavaScript` Asset attributes. That test runs in +the normal xUnit suite and guards the build pipeline. The headless tests in this +folder guard the runtime behaviour of the engine and are intended to run wherever +Node is available, for example on a developer machine or in continuous +integration. diff --git a/src/WebExpress.WebApp.Test/JsTest/comment.composer.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/comment.composer.model.test.mjs new file mode 100644 index 0000000..e975056 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/comment.composer.model.test.mjs @@ -0,0 +1,86 @@ +/** + * Headless unit tests for the comment composer model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.comment.composer.js: + * the legacy descriptor, the categories url, the categories normalisation and + * the label parsing, plus an end to end path that loads the categories through + * the shared request and posts a new comment with the service create. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.comment.composer.model.js")] }, + options + )); +} + +test("legacy descriptor exposes the rest base uri", () => { + const { wxapp } = load(); + const descriptor = wxapp.commentComposerModel.legacyDescriptor("/api/comments"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/comments"); + assert.equal(descriptor.method, "GET"); +}); + +test("categories url appends the segment with a single slash", () => { + const { wxapp } = load(); + assert.equal(wxapp.commentComposerModel.categoriesUrl("/api/c"), "/api/c/categories"); + assert.equal(wxapp.commentComposerModel.categoriesUrl("/api/c/"), "/api/c/categories"); + assert.equal(wxapp.commentComposerModel.categoriesUrl(""), "/categories"); +}); + +test("normalize categories accepts an array or an object keyed by id", () => { + const { wxapp } = load(); + assert.deepEqual( + wxapp.commentComposerModel.normalizeCategories([{ id: "q", label: "Q" }, { label: "noid" }]), + { q: { id: "q", label: "Q" } } + ); + + const obj = { a: { id: "a" } }; + assert.equal(wxapp.commentComposerModel.normalizeCategories(obj), obj); + assert.deepEqual(wxapp.commentComposerModel.normalizeCategories(null), {}); +}); + +test("parse labels splits, trims and drops empty entries", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.commentComposerModel.parseLabels("a, b ,,c"), ["a", "b", "c"]); + assert.deepEqual(wxapp.commentComposerModel.parseLabels(""), []); + assert.deepEqual(wxapp.commentComposerModel.parseLabels(null), []); +}); + +test("model loads categories and posts a comment through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => [{ id: "q", label: "Q" }] }; + } + return { ok: true, status: 200, json: async () => ({ id: "c1" }) }; + }); + + // categories are loaded through the shared request from the categories url + const catRes = await wxapp.ServiceRegistry.request( + wxapp.commentComposerModel.categoriesUrl("/api/comments"), + { headers: { "Accept": "application/json" } } + ); + assert.equal(calls[0].url.endsWith("/categories"), true); + assert.deepEqual(wxapp.commentComposerModel.normalizeCategories(catRes.data), { q: { id: "q", label: "Q" } }); + + // the new comment is posted through the service create + const service = wxapp.ServiceRegistry.create(wxapp.commentComposerModel.legacyDescriptor("/api/comments")); + const created = await service.create({ body: "hi", category: "q", labels: wxapp.commentComposerModel.parseLabels("x, y") }); + assert.equal(calls[1].method, "POST"); + assert.deepEqual(JSON.parse(calls[1].body), { body: "hi", category: "q", labels: ["x", "y"] }); + assert.equal(created.data.id, "c1"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/comment.composer.test.mjs b/src/WebExpress.WebApp.Test/JsTest/comment.composer.test.mjs new file mode 100644 index 0000000..bcca248 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/comment.composer.test.mjs @@ -0,0 +1,57 @@ +/** + * Headless tests for the comment composer control after it was lifted onto the + * Data base (View, State and Service). The composer keeps its imperative form + * flow; the lift gives it the service map - a configured island service is + * preferred over the legacy descriptor - and the Data lifecycle teardown that + * aborts the service. The categories are preset so the constructor performs no + * load. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.comment.composer.model.js"), + webappAsset("webexpress.webapp.comment.composer.js") + ] + }, + options + )); +} + +const PRESET_CATEGORIES = JSON.stringify([{ id: "general", i18n: "", color: "#000", bg: "#fff" }]); + +test("comment composer extends the data base and resolves its service", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("div"); + element.dataset.uri = "/api/comments/INC-1"; + element.dataset.categories = PRESET_CATEGORIES; + + const ctrl = new wxapp.CommentComposerCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); + assert.ok(ctrl.useService("data")); +}); + +test("comment composer destroy tears down without throwing", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("div"); + element.dataset.uri = "/api/comments/INC-1"; + element.dataset.categories = PRESET_CATEGORIES; + + const ctrl = new wxapp.CommentComposerCtrl(element); + + assert.doesNotThrow(() => ctrl.destroy()); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/comment.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/comment.model.test.mjs new file mode 100644 index 0000000..364b68f --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/comment.model.test.mjs @@ -0,0 +1,107 @@ +/** + * Headless unit tests for the REST comment model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.comment.js, + * namely the endpoint url and path building and the category normalisation, + * plus an end to end path that drives the list, edit, like and delete + * operations through a service to confirm the urls survive the migration. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.comment.model.js")] }, + options + )); +} + +test("legacy descriptor lists with get and edits with put", () => { + const { wxapp } = load(); + const descriptor = wxapp.commentModel.legacyDescriptor("/api/comments/INC-1"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/comments/INC-1"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); +}); + +test("normalize categories accepts arrays and objects", () => { + const { wxapp } = load(); + + assert.deepEqual( + wxapp.commentModel.normalizeCategories([{ id: "a", color: "#1" }, { id: "b" }]), + { a: { id: "a", color: "#1" }, b: { id: "b" } } + ); + assert.deepEqual(wxapp.commentModel.normalizeCategories({ a: { id: "a" } }), { a: { id: "a" } }); + assert.deepEqual(wxapp.commentModel.normalizeCategories(null), {}); +}); + +test("categories url joins with a single slash", () => { + const { wxapp } = load(); + + assert.equal(wxapp.commentModel.categoriesUrl("/api/c"), "/api/c/categories"); + assert.equal(wxapp.commentModel.categoriesUrl("/api/c/"), "/api/c/categories"); +}); + +test("build users url appends encoded comma separated ids", () => { + const { wxapp } = load(); + + assert.equal(wxapp.commentModel.buildUsersUrl("/api/users", ["a", "b"]), "/api/users?ids=a,b"); + assert.equal(wxapp.commentModel.buildUsersUrl("/api/users?x=1", ["a"]), "/api/users?x=1&ids=a"); + assert.equal(wxapp.commentModel.buildUsersUrl("/api/users", ["a b"]), "/api/users?ids=a%20b"); +}); + +test("comment path and sub path encode the id", () => { + const { wxapp } = load(); + + assert.equal(wxapp.commentModel.commentPath("42"), "/42"); + assert.equal(wxapp.commentModel.commentPath("x/y"), "/x%2Fy"); + assert.equal(wxapp.commentModel.commentSubPath("42", "likes"), "/42/likes"); +}); + +test("model drives the comment operations through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ([{ id: "1" }]) }; + } + if (method === "PUT") { + return { ok: true, status: 200, json: async () => ({ id: "1", body: "edited" }) }; + } + if (method === "POST") { + return { ok: true, status: 200, json: async () => ({ likes: ["u1"] }) }; + } + return { ok: true, status: 204 }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.commentModel.legacyDescriptor("/api/c")); + + const list = await service.request("/api/c", { method: "GET", headers: { "Accept": "application/json" } }); + assert.equal(calls[0].method, "GET"); + assert.deepEqual(list.data, [{ id: "1" }]); + + const edit = await service.update({ body: "edited" }, { path: wxapp.commentModel.commentPath("1") }); + assert.equal(calls[1].method, "PUT"); + assert.match(calls[1].url, /\/api\/c\/1$/); + assert.deepEqual(JSON.parse(calls[1].body), { body: "edited" }); + assert.equal(edit.data.body, "edited"); + + const like = await service.create({ userId: "u1" }, { path: wxapp.commentModel.commentSubPath("1", "likes") }); + assert.equal(calls[2].method, "POST"); + assert.match(calls[2].url, /\/api\/c\/1\/likes$/); + assert.deepEqual(like.data.likes, ["u1"]); + + const removed = await service.remove({ path: wxapp.commentModel.commentPath("1") }); + assert.equal(calls[3].method, "DELETE"); + assert.match(calls[3].url, /\/api\/c\/1$/); + assert.equal(removed.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/comment.test.mjs b/src/WebExpress.WebApp.Test/JsTest/comment.test.mjs new file mode 100644 index 0000000..ab5271c --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/comment.test.mjs @@ -0,0 +1,92 @@ +/** + * Headless tests for the threaded comment control after it was lifted onto the + * Component base (View, State and Service). The control keeps its own imperative + * render flow; the lift gives it the component store (UI state plus the seedable + * comments), the service map and lifecycle. The tests assert that it extends + * Component, seeds its comments from the data-wx-state island and skips the + * comment load in that case, and otherwise loads from the service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.comment.model.js"), + webappAsset("webexpress.webapp.comment.js") + ] + }, + options + )); +} + +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const PRESET_CATEGORIES = JSON.stringify([{ id: "general", i18n: "", color: "#000", bg: "#fff" }]); + +const SEED_COMMENT = { + id: "c1", + author: { id: "u1", name: "Ann", initials: "AN", color: "#abc" }, + category: "general", + labels: [], + body: "

hi

", + created: "2026-01-01T00:00:00Z", + likes: [], + reactions: {}, + replies: [], + pinned: false +}; + +test("comment extends the component base", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => [] })); + + const element = createElement("div"); + const ctrl = new wxapp.CommentCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); +}); + +test("comment seeds its comments from the data-wx-state island and skips the load", async () => { + const { wxapp, createElement, setFetch } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => [] }; }); + + const element = createElement("div"); + element.setAttribute("data-wx-service", JSON.stringify({ name: "data", kind: "rest", baseUri: "/api/comments", method: "GET", updateMethod: "PUT" })); + element.dataset.categories = PRESET_CATEGORIES; + element.setAttribute("data-wx-state", JSON.stringify({ comments: [SEED_COMMENT] })); + + const ctrl = new wxapp.CommentCtrl(element); + + assert.equal(ctrl.value.length, 1); + assert.equal(ctrl.value[0].id, "c1"); + + await settle(); + assert.equal(fetchCount, 0); +}); + +test("comment loads from the service when no state island is present", async () => { + const { wxapp, createElement, setFetch } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => [] }; }); + + const element = createElement("div"); + element.setAttribute("data-wx-service", JSON.stringify({ name: "data", kind: "rest", baseUri: "/api/comments", method: "GET", updateMethod: "PUT" })); + element.dataset.categories = PRESET_CATEGORIES; + + const ctrl = new wxapp.CommentCtrl(element); + + await settle(); + assert.equal(fetchCount, 1); + assert.equal(ctrl.value.length, 0); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/dashboard.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/dashboard.model.test.mjs new file mode 100644 index 0000000..93996c2 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/dashboard.model.test.mjs @@ -0,0 +1,89 @@ +/** + * Headless unit tests for the REST dashboard model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.dashboard.js: the + * legacy descriptor and the column and widget normalisation, plus an end to end + * path that loads the dashboard with a query and persists the layout state with + * an update through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.dashboard.model.js")] }, + options + )); +} + +test("legacy descriptor loads with get and uses put for the update", () => { + const { wxapp } = load(); + const descriptor = wxapp.dashboardModel.legacyDescriptor("/api/dash"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/dash"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); +}); + +test("normalize columns maps columns and widgets with defaults", () => { + const { wxapp } = load(); + const cols = wxapp.dashboardModel.normalizeColumns({ + columns: [ + { id: "c1", widgets: [{ id: "w1" }, { id: "w2", removable: false, movable: false, html: "", params: { a: 1 } }] }, + { id: "c2", label: "L", size: "2fr" } + ] + }); + + assert.equal(cols.length, 2); + assert.equal(cols[0].size, "1fr"); + assert.equal(cols[1].size, "2fr"); + assert.equal(cols[1].label, "L"); + + assert.equal(cols[0].widgets.length, 2); + assert.equal(cols[0].widgets[0].removable, true); + assert.equal(cols[0].widgets[0].movable, true); + assert.equal(cols[0].widgets[1].removable, false); + assert.equal(cols[0].widgets[1].movable, false); + assert.equal(cols[0].widgets[1].html, ""); + assert.deepEqual(cols[0].widgets[1].params, { a: 1 }); + assert.equal(cols[0].widgets[0].instanceId.startsWith("wx_inst_c1_0_"), true); +}); + +test("normalize columns returns null when the response carries no columns", () => { + const { wxapp } = load(); + assert.equal(wxapp.dashboardModel.normalizeColumns({}), null); + assert.equal(wxapp.dashboardModel.normalizeColumns(null), null); +}); + +test("model loads the dashboard and persists the state through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ columns: [{ id: "c1", widgets: [{ id: "w1" }] }] }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.dashboardModel.legacyDescriptor("/api/dash")); + + const loaded = await service.query({}); + assert.equal(calls[0].method, "GET"); + const cols = wxapp.dashboardModel.normalizeColumns(loaded.data); + assert.equal(cols[0].id, "c1"); + assert.equal(cols[0].widgets[0].id, "w1"); + + const saved = await service.update({ action: "move" }); + assert.equal(calls[1].method, "PUT"); + assert.deepEqual(JSON.parse(calls[1].body), { action: "move" }); + assert.equal(saved.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs b/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs new file mode 100644 index 0000000..e435fd3 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs @@ -0,0 +1,192 @@ +/** + * Minimal DOM stub for the headless engine tests. + * + * It implements only the surface that the View, State and Service engine uses, + * which is element creation, child node manipulation, attributes, class list, + * dataset, a simple style object, text content and event listeners. It is not + * a browser and it is not jsdom. It exists so that the renderer and the + * component can be exercised in Node without a browser. + */ + +class TextNode { + constructor(text) { + this.nodeType = 3; + this.parentNode = null; + this._text = String(text); + } + get textContent() { return this._text; } + set textContent(value) { this._text = String(value); } + get nodeValue() { return this._text; } + set nodeValue(value) { this._text = String(value); } +} + +class ClassList { + constructor(owner) { this._owner = owner; } + add(name) { this._owner._classes.add(name); } + remove(name) { this._owner._classes.delete(name); } + contains(name) { return this._owner._classes.has(name); } + toggle(name, force) { + const has = this._owner._classes.has(name); + const shouldHave = force === undefined ? !has : !!force; + if (shouldHave) { this._owner._classes.add(name); } else { this._owner._classes.delete(name); } + return shouldHave; + } +} + +class Element { + constructor(tag) { + this.nodeType = 1; + this.tagName = String(tag).toUpperCase(); + this.childNodes = []; + this.parentNode = null; + this._attrs = new Map(); + this._classes = new Set(); + this._listeners = {}; + this._id = null; + this._innerHTML = undefined; + this.dataset = {}; + this.style = makeStyle(); + this.value = ""; + this.checked = false; + } + + get firstChild() { return this.childNodes[0] || null; } + + get id() { return this._id; } + set id(value) { this._id = value == null ? null : String(value); } + + get className() { return Array.from(this._classes).join(" "); } + set className(value) { this._classes = new Set(String(value || "").split(/\s+/).filter(Boolean)); } + + get classList() { return new ClassList(this); } + + appendChild(node) { + if (node.parentNode) { node.parentNode.removeChild(node); } + this.childNodes.push(node); + node.parentNode = this; + return node; + } + + insertBefore(node, reference) { + if (node.parentNode) { node.parentNode.removeChild(node); } + if (reference == null) { + this.childNodes.push(node); + node.parentNode = this; + return node; + } + const index = this.childNodes.indexOf(reference); + if (index === -1) { this.childNodes.push(node); } else { this.childNodes.splice(index, 0, node); } + node.parentNode = this; + return node; + } + + removeChild(node) { + const index = this.childNodes.indexOf(node); + if (index !== -1) { this.childNodes.splice(index, 1); } + node.parentNode = null; + return node; + } + + replaceChildren(...nodes) { + this.childNodes.forEach((n) => { n.parentNode = null; }); + this.childNodes = []; + for (const node of nodes) { this.appendChild(node); } + } + + replaceChild(newNode, oldNode) { + if (newNode.parentNode) { newNode.parentNode.removeChild(newNode); } + const index = this.childNodes.indexOf(oldNode); + if (index !== -1) { + this.childNodes.splice(index, 1, newNode); + newNode.parentNode = this; + oldNode.parentNode = null; + } + return oldNode; + } + + setAttribute(name, value) { + if (name === "id") { this._id = String(value); return; } + this._attrs.set(name, String(value)); + } + getAttribute(name) { + if (name === "id") { return this._id; } + if (name === "class") { return this.className; } + return this._attrs.has(name) ? this._attrs.get(name) : null; + } + hasAttribute(name) { + if (name === "id") { return this._id != null; } + return this._attrs.has(name); + } + removeAttribute(name) { + if (name === "id") { this._id = null; return; } + this._attrs.delete(name); + } + + querySelector() { return null; } + querySelectorAll() { return []; } + closest() { return null; } + + get textContent() { + return this.childNodes.map((n) => (n.nodeType === 3 ? n._text : n.textContent)).join(""); + } + set textContent(value) { + this.childNodes.forEach((n) => { n.parentNode = null; }); + this.childNodes = []; + if (value != null && value !== "") { this.appendChild(new TextNode(String(value))); } + } + + get innerHTML() { return this._innerHTML !== undefined ? this._innerHTML : ""; } + set innerHTML(value) { + this._innerHTML = String(value); + this.childNodes.forEach((n) => { n.parentNode = null; }); + this.childNodes = []; + } + + addEventListener(type, handler) { + (this._listeners[type] || (this._listeners[type] = new Set())).add(handler); + } + removeEventListener(type, handler) { + if (this._listeners[type]) { this._listeners[type].delete(handler); } + } + dispatchEvent(event) { + const set = this._listeners[event.type]; + if (set) { Array.from(set).forEach((fn) => fn(event)); } + return true; + } +} + +/** + * Builds a simple style object that accepts both cssText and individual + * property assignment. + * @returns {object} The style object. + */ +function makeStyle() { + const style = {}; + let cssText = ""; + Object.defineProperty(style, "cssText", { + get() { return cssText; }, + set(value) { cssText = String(value); }, + enumerable: false + }); + return style; +} + +/** + * Creates a fresh document stub. + * @returns {object} The document stub. + */ +export function createDocument() { + return { + baseURI: "http://localhost/", + readyState: "complete", + cookie: "", + createElement(tag) { return new Element(tag); }, + createElementNS(namespace, tag) { return new Element(tag); }, + createDocumentFragment() { return new Element("#document-fragment"); }, + createTextNode(text) { return new TextNode(text); }, + addEventListener() {}, + removeEventListener() {} + }; +} + +export { Element, TextNode }; diff --git a/src/WebExpress.WebApp.Test/JsTest/dropdown.theme.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/dropdown.theme.model.test.mjs new file mode 100644 index 0000000..8a9c5d3 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/dropdown.theme.model.test.mjs @@ -0,0 +1,74 @@ +/** + * Headless unit tests for the theme dropdown model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.dropdown.theme.js: + * the theme item mapping and the theme list normalisation, plus an end to end + * path that loads the themes through the shared request and normalises them. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.dropdown.theme.model.js")] }, + options + )); +} + +test("map item projects the menu item with a void uri and text aliases", () => { + const { wxapp } = load(); + const item = wxapp.dropdownThemeModel.mapItem({ id: 7, name: "Dark", icon: "fa" }); + assert.equal(item.id, "7"); + assert.equal(item.text, "Dark"); + assert.equal(item.uri, "javascript:void(0);"); + assert.equal(item.icon, "fa"); + assert.deepEqual(item.data, []); + + const fallback = wxapp.dropdownThemeModel.mapItem({ id: "x" }); + assert.equal(fallback.text, "x"); + + const empty = wxapp.dropdownThemeModel.mapItem(null); + assert.equal(empty.id, null); + assert.equal(empty.text, ""); +}); + +test("normalize themes maps the items and reads the selected id", () => { + const { wxapp } = load(); + const themes = wxapp.dropdownThemeModel.normalizeThemes({ + items: [{ id: "a", name: "A" }, { id: "b", name: "B" }], + selected: "b" + }); + assert.deepEqual(themes.items.map(i => i.id), ["a", "b"]); + assert.equal(themes.items[0].text, "A"); + assert.equal(themes.selected, "b"); + + const none = wxapp.dropdownThemeModel.normalizeThemes({}); + assert.deepEqual(none.items, []); + assert.equal(none.selected, null); + + const blank = wxapp.dropdownThemeModel.normalizeThemes({ items: [{ id: "a" }], selected: "" }); + assert.equal(blank.selected, null); +}); + +test("model loads and normalises the themes through the shared request end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: url, method: (init && init.method) || "GET" }); + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ items: [{ id: "light", name: "Light" }], selected: "light" }) }; + }); + + const res = await wxapp.ServiceRegistry.request("/api/themes", { method: "GET" }); + assert.equal(calls[0].method, "GET"); + + const themes = wxapp.dropdownThemeModel.normalizeThemes(res.data); + assert.equal(themes.items[0].id, "light"); + assert.equal(themes.items[0].text, "Light"); + assert.equal(themes.selected, "light"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs b/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs new file mode 100644 index 0000000..b2c75ea --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs @@ -0,0 +1,427 @@ +/** + * Headless unit tests for the View, State and Service engine (phase zero). + * + * Run with Node 18 or newer from the jstest folder: + * node --test + * + * The tests load the real engine modules from Assets/js through a vm context + * with a minimal DOM stub, so they exercise the shipped code rather than a copy. + */ + +import { test } from "node:test"; +// loose assert: objects produced inside the engine's vm context have a +// different Object.prototype than this test realm, so deepStrictEqual would +// reject structurally equal objects. Loose deepEqual compares by structure. +import assert from "node:assert"; +import { loadEngine } from "./harness.mjs"; + +// Store + +test("store applies a shallow patch and notifies once after flush", () => { + const { wxapp } = loadEngine(); + const store = new wxapp.Store({ a: 1, b: 2 }); + + let calls = 0; + let last = null; + store.subscribe((state) => { calls += 1; last = state; }); + + store.setState({ a: 10 }); + store.setState({ b: 20 }); + store.flush(); + + assert.equal(calls, 1); + assert.deepEqual(last, { a: 10, b: 20 }); + assert.equal(store.getState().a, 10); +}); + +test("store does not notify when nothing changes", () => { + const { wxapp } = loadEngine(); + const store = new wxapp.Store({ a: 1 }); + + let calls = 0; + store.subscribe(() => { calls += 1; }); + + store.setState({ a: 1 }); + store.flush(); + + assert.equal(calls, 0); +}); + +test("store watch fires only when the selected slice changes", () => { + const { wxapp } = loadEngine(); + const store = new wxapp.Store({ a: 1, b: 1 }); + + let aCalls = 0; + store.watch((state) => state.a, () => { aCalls += 1; }); + + store.setState({ b: 2 }); + store.flush(); + assert.equal(aCalls, 0); + + store.setState({ a: 5 }); + store.flush(); + assert.equal(aCalls, 1); +}); + +test("store registry reference counts shared stores", () => { + const { wxapp } = loadEngine(); + + const first = wxapp.StoreRegistry.acquire("x", { n: 0 }); + const second = wxapp.StoreRegistry.acquire("x"); + assert.equal(first, second); + + wxapp.StoreRegistry.release("x"); + assert.equal(wxapp.StoreRegistry.get("x"), first); + + wxapp.StoreRegistry.release("x"); + assert.equal(wxapp.StoreRegistry.get("x"), null); +}); + +// Service + +test("rest service maps parameters and normalises a success", async () => { + const { wxapp, setFetch } = loadEngine(); + let capturedUrl = null; + setFetch(async (url) => { + capturedUrl = url; + return { ok: true, status: 200, json: async () => ({ items: [1, 2, 3], total: 3 }) }; + }); + + const service = new wxapp.RestService({ + name: "data", + baseUri: "/api/orders", + method: "GET", + query: { search: "q", page: "p" }, + response: { items: "items", total: "total" } + }); + + const result = await service.query({ search: "abc", page: 2 }); + + assert.equal(result.ok, true); + assert.deepEqual(result.data, { items: [1, 2, 3], total: 3 }); + assert.match(capturedUrl, /\/api\/orders\?/); + assert.match(capturedUrl, /q=abc/); + assert.match(capturedUrl, /p=2/); + assert.deepEqual(service.project(result.data), { items: [1, 2, 3], total: 3 }); +}); + +test("rest service normalises an http error", async () => { + const { wxapp, setFetch } = loadEngine(); + setFetch(async () => ({ ok: false, status: 404 })); + + const service = new wxapp.RestService({ baseUri: "/api/orders" }); + const result = await service.query({}); + + assert.equal(result.ok, false); + assert.equal(result.error.kind, "http"); + assert.equal(result.error.status, 404); +}); + +test("rest service returns an empty body for a 204 delete", async () => { + const { wxapp, setFetch } = loadEngine(); + setFetch(async () => ({ ok: true, status: 204 })); + + const service = new wxapp.RestService({ baseUri: "/api/orders" }); + const result = await service.remove({ path: "/42" }); + + assert.equal(result.ok, true); + assert.equal(result.data, null); + assert.equal(result.status, 204); +}); + +test("rest service normalises a network error", async () => { + const { wxapp, setFetch } = loadEngine(); + setFetch(async () => { throw new TypeError("boom"); }); + + const service = new wxapp.RestService({ baseUri: "/api/orders" }); + const result = await service.query({}); + + assert.equal(result.ok, false); + assert.equal(result.error.kind, "network"); +}); + +test("service registry builds services from a data-wx-service island", () => { + const { wxapp, createElement } = loadEngine(); + const element = createElement("div"); + element.setAttribute("data-wx-service", JSON.stringify({ name: "data", kind: "rest", baseUri: "/api/x" })); + + const services = wxapp.ServiceRegistry.fromElement(element); + + assert.ok(services.data); + assert.equal(typeof services.data.query, "function"); +}); + +test("rest service request parses json by content type and passes init through", async () => { + const { wxapp, setFetch } = loadEngine(); + let capturedInit = null; + setFetch(async (url, init) => { + capturedInit = init; + return { + ok: true, + status: 200, + headers: { get: (h) => (h === "content-type" ? "application/json" : null) }, + json: async () => ({ a: 1 }) + }; + }); + + const service = new wxapp.RestService({ baseUri: "/x" }); + const result = await service.request("/api/form?id=1", { method: "GET", credentials: "same-origin" }); + + assert.equal(result.ok, true); + assert.equal(result.status, 200); + assert.equal(result.data.a, 1); + assert.equal(capturedInit.method, "GET"); + assert.equal(capturedInit.credentials, "same-origin"); +}); + +test("rest service request returns the body on a 400 for inspection", async () => { + const { wxapp, setFetch } = loadEngine(); + setFetch(async () => ({ + ok: false, + status: 400, + headers: { get: () => "application/json" }, + json: async () => ({ errors: { name: "required" } }) + })); + + const service = new wxapp.RestService({ baseUri: "/x" }); + const result = await service.request("/api/form", { method: "POST" }); + + assert.equal(result.ok, false); + assert.equal(result.status, 400); + assert.equal(result.data.errors.name, "required"); +}); + +test("service registry request routes one off calls through a shared service", async () => { + const { wxapp, setFetch } = loadEngine(); + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: url, method: (init && init.method) || "GET" }); + return { + ok: true, + status: 200, + headers: { get: (h) => (h === "content-type" ? "application/json" : null) }, + json: async () => ({ ok: true }) + }; + }); + + const first = await wxapp.ServiceRegistry.request("/api/themes", { method: "GET" }); + const second = await wxapp.ServiceRegistry.request("/api/themes", { method: "PUT", body: "{}" }); + + assert.equal(first.ok, true); + assert.equal(first.data.ok, true); + assert.equal(calls[0].url, "/api/themes"); + assert.equal(calls[1].method, "PUT"); + assert.equal(wxapp.ServiceRegistry._shared, wxapp.ServiceRegistry._shared); + assert.ok(wxapp.ServiceRegistry._shared, "the shared service is created lazily and reused"); +}); + +// Renderer + +test("renderer creates elements and text", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("span", { class: "a" }, "hello"), h("b", null, "x")]); + + assert.equal(root.childNodes.length, 2); + assert.equal(root.childNodes[0].tagName, "SPAN"); + assert.equal(root.childNodes[0].className, "a"); + assert.equal(root.childNodes[0].textContent, "hello"); + assert.equal(root.childNodes[1].tagName, "B"); +}); + +test("renderer updates props and text in place", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("span", { class: "a" }, "hi")]); + const span = root.childNodes[0]; + + wxapp.Renderer.patch(root, [h("span", { class: "b", "data-x": "1" }, "ho")]); + + assert.equal(root.childNodes[0], span); + assert.equal(span.className, "b"); + assert.equal(span.getAttribute("data-x"), "1"); + assert.equal(span.textContent, "ho"); +}); + +test("renderer reorders keyed nodes and preserves identity", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"), h("li", { key: "c" }, "C")]); + const a = root.childNodes[0]; + const b = root.childNodes[1]; + const c = root.childNodes[2]; + + wxapp.Renderer.patch(root, [h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")]); + + assert.equal(root.childNodes.length, 3); + assert.equal(root.childNodes[0], c); + assert.equal(root.childNodes[1], a); + assert.equal(root.childNodes[2], b); +}); + +test("renderer keep flag preserves a nested subtree", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("div", { class: "host", keep: true })]); + const host = root.childNodes[0]; + + const nested = createElement("span"); + host.appendChild(nested); + assert.equal(host.childNodes.length, 1); + + wxapp.Renderer.patch(root, [h("div", { class: "host2", keep: true })]); + + assert.equal(root.childNodes[0], host); + assert.equal(host.className, "host2"); + assert.equal(host.childNodes.length, 1); + assert.equal(host.childNodes[0], nested); +}); + +test("renderer removes stale nodes", () => { + const { wxapp, createElement } = loadEngine(); + const h = wxapp.h; + const root = createElement("div"); + + wxapp.Renderer.patch(root, [h("li", null, "1"), h("li", null, "2"), h("li", null, "3")]); + assert.equal(root.childNodes.length, 3); + + wxapp.Renderer.patch(root, [h("li", null, "1")]); + assert.equal(root.childNodes.length, 1); + assert.equal(root.childNodes[0].textContent, "1"); +}); + +// Intents + +test("intent reducer applies a patch to the store", () => { + const { wxapp } = loadEngine(); + const store = new wxapp.Store({ count: 0 }); + wxapp.Intents.register("inc", { reduce: (state, payload) => ({ count: state.count + (payload || 1) }) }); + + wxapp.Intents.dispatch("inc", { store, payload: 5 }); + + assert.equal(store.getState().count, 5); +}); + +test("intent effect runs and can dispatch a follow up", () => { + const { wxapp } = loadEngine(); + let effectRan = false; + + wxapp.Intents.register("load", { + reduce: () => ({ loading: true }), + effect: (context) => { effectRan = true; context.dispatch("done", { ok: true }); } + }); + wxapp.Intents.register("done", { reduce: (state, payload) => ({ loading: false, ok: payload.ok }) }); + + const store = new wxapp.Store({ loading: false }); + wxapp.Intents.dispatch("load", { store }); + + assert.equal(effectRan, true); + assert.equal(store.getState().loading, false); + assert.equal(store.getState().ok, true); +}); + +test("intent dispatch of an unknown intent does not throw", () => { + const { wxapp } = loadEngine(); + assert.doesNotThrow(() => wxapp.Intents.dispatch("nope", { store: new wxapp.Store({}) })); +}); + +// Component + +test("component seeds state, renders and re-renders", () => { + const { wxapp, createElement } = loadEngine(); + + class Counter extends wxapp.Data { + constructor(element) { + super(element); + this.mount(); + } + render(state) { + return wxapp.h("p", { class: "v" }, String(state.count)); + } + } + + const element = createElement("div"); + element.setAttribute("data-wx-state", JSON.stringify({ count: 7 })); + const component = new Counter(element); + + assert.equal(element.childNodes.length, 1); + assert.equal(element.childNodes[0].tagName, "P"); + assert.equal(element.childNodes[0].textContent, "7"); + + component.setState({ count: 8 }); + component.store.flush(); + + assert.equal(element.childNodes[0].textContent, "8"); +}); + +test("component readState parses the state island and tolerates its absence", () => { + const { wxapp, createElement } = loadEngine(); + + const withState = createElement("div"); + withState.setAttribute("data-wx-state", JSON.stringify({ a: 1 })); + assert.deepEqual(wxapp.Data.readState(withState), { a: 1 }); + + const withoutState = createElement("div"); + assert.deepEqual(wxapp.Data.readState(withoutState), {}); +}); + +test("component seeds its store from the literal c# data-wx-state island", () => { + const { wxapp, createElement } = loadEngine(); + + // the exact compact json that a c# ControlState (page 0, pageSize 50) emits + const island = '{"page":0,"pageSize":50}'; + + const probe = createElement("div"); + probe.setAttribute("data-wx-state", island); + assert.deepEqual(wxapp.Data.readState(probe), { page: 0, pageSize: 50 }); + + class ListComponent extends wxapp.Data { + constructor(element) { + super(element); + this.mount(); + } + render(state) { + return wxapp.h("span", { class: "p" }, String(state.page) + "/" + String(state.pageSize)); + } + } + + const element = createElement("div"); + element.setAttribute("data-wx-state", island); + const component = new ListComponent(element); + + assert.equal(element.childNodes[0].textContent, "0/50"); +}); + +test("component destroy stops further renders", () => { + const { wxapp, createElement } = loadEngine(); + + class Probe extends wxapp.Data { + constructor(element) { + super(element); + this.renders = 0; + this.mount(); + } + render(state) { + this.renders += 1; + return wxapp.h("span", null, String(state.n || 0)); + } + } + + const element = createElement("div"); + const component = new Probe(element); + const rendersAfterMount = component.renders; + + component.destroy(); + component.setState({ n: 5 }); + component.store.flush(); + + assert.equal(component.renders, rendersAfterMount); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/harness.mjs b/src/WebExpress.WebApp.Test/JsTest/harness.mjs new file mode 100644 index 0000000..fcbfd99 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/harness.mjs @@ -0,0 +1,132 @@ +/** + * Headless test harness for the View, State and Service engine. + * + * It loads the engine modules into an isolated vm context that carries the + * host globals the engine needs, which are console, queueMicrotask, URL, + * AbortController, fetch and a minimal document. A small Ctrl base is defined + * in the context so that the Component module can extend it without the full + * browser runtime. Each call to loadEngine returns a fresh, isolated engine, so + * that tests do not share state. + */ + +import vm from "node:vm"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createDocument } from "./dom-stub.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const assetsJs = path.resolve(here, "..", "src", "WebExpress.WebUI", "Assets", "js"); +const webappAssetsJs = path.resolve(here, "..", "..", "WebExpress.WebApp", "src", "WebExpress.WebApp", "Assets", "js"); + +/** + * Resolves the absolute path of a WebExpress.WebApp asset by file name, so that + * application modules can be loaded into the engine for testing. + * @param {string} name - The asset file name, for example "webexpress.webapp.list.model.js". + * @returns {string} The absolute path. + */ +export function webappAsset(name) { + return path.join(webappAssetsJs, name); +} + +// load order mirrors the Asset attribute order in IncludeJavaScript +// the engine lives in WebExpress.WebApp (WebUI carries only static controls) +const ENGINE_FILES = [ + "webexpress.webapp.store.js", + "webexpress.webapp.service.js", + "webexpress.webapp.renderer.js", + "webexpress.webapp.intent.js", + "webexpress.webapp.data.js", + "service/default.js", + "intent/default.js" +]; + +// a minimal Ctrl base, defined inside the context, that mirrors the parts of +// webexpress.webui.Ctrl the Component relies on, without the DOM heavy runtime +const BOOTSTRAP = ` + var webexpress = { webui: {}, webapp: {} }; + webexpress.webui.Ctrl = class { + constructor(element) { this._element = element; } + render() { } + update() { this.render(); } + destroy() { } + _dispatch(type, detail) { + if (this._element && typeof this._element.dispatchEvent === "function") { + this._element.dispatchEvent({ type: type, detail: detail }); + } + } + _i18n(key, fallback) { return fallback; } + _isVisible() { return true; } + _iconTheme() { return "dark"; } + _iconClass(faClass, lightClass) { return faClass || lightClass || ""; } + }; + // a minimal Controller registry so that application control files, which + // register their class at the end, can be loaded into the harness alongside + // the models. The engine itself does not depend on it. + webexpress.webui.Controller = { + registerClass() { }, + getInstance() { return null; }, + getInstanceByElement() { return null; }, + getClosestInstance() { return null; } + }; + // event name constants live in the full webexpress.webui.js, which the engine + // harness does not load; an empty map lets controls dispatch without throwing + // (the dispatched type is simply undefined, which the stub element ignores). + webexpress.webui.Event = {}; + webexpress.webapp.Event = {}; +`; + +/** + * Loads a fresh, isolated engine. + * @param {object} [options] - Optional overrides such as a fetch mock. + * @returns {object} An object with the engine namespace, the document and helpers. + */ +export function loadEngine(options = {}) { + const document = createDocument(); + + const sandbox = { + console, + queueMicrotask, + setTimeout, + clearTimeout, + URL, + URLSearchParams, + AbortController, + document, + fetch: options.fetch || (async () => { throw new Error("fetch is not stubbed for this test"); }) + }; + + vm.createContext(sandbox); + vm.runInContext(BOOTSTRAP, sandbox, { filename: "bootstrap" }); + + for (const file of ENGINE_FILES) { + const full = path.join(webappAssetsJs, file); + const code = fs.readFileSync(full, "utf8"); + vm.runInContext(code, sandbox, { filename: full }); + } + + // optional additional modules (for example application level helpers), + // loaded after the engine so they can build on it + for (const full of options.extraFiles || []) { + const code = fs.readFileSync(full, "utf8"); + vm.runInContext(code, sandbox, { filename: full }); + } + + return { + wx: sandbox.webexpress.webui, + wxapp: sandbox.webexpress.webapp, + document, + sandbox, + setFetch(fn) { sandbox.fetch = fn; }, + createElement(tag) { return document.createElement(tag); } + }; +} + +/** + * Awaits a turn of the microtask queue, so that batched store notifications run. + * @returns {Promise} A promise that resolves after the microtask queue drains. + */ +export async function tick() { + await Promise.resolve(); + await Promise.resolve(); +} diff --git a/src/WebExpress.WebApp.Test/JsTest/input.selection.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/input.selection.model.test.mjs new file mode 100644 index 0000000..2b406d3 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/input.selection.model.test.mjs @@ -0,0 +1,82 @@ +/** + * Headless unit tests for the REST input selection model helpers (View, State + * and Service). + * + * These cover the pure logic extracted from webexpress.webapp.input.selection.js: + * the request url and init shaping and the response item mapping with its data + * and aria attribute tuples, plus an end to end path that searches through the + * shared request and maps the result. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.input.selection.model.js")] }, + options + )); +} + +test("build url appends the query and page for get and is unchanged otherwise", () => { + const { wxapp } = load(); + const cfg = { apiEndpoint: "/api/s", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 0 }; + assert.equal(wxapp.inputSelectionModel.buildUrl(cfg, "ab"), "/api/s?q=ab&p=0"); + assert.equal(wxapp.inputSelectionModel.buildUrl({ apiEndpoint: "/api/s", httpMethod: "POST" }, "x"), "/api/s"); +}); + +test("build request init carries a json body for post and a signal for get", () => { + const { wxapp } = load(); + const post = wxapp.inputSelectionModel.buildRequestInit({ httpMethod: "POST", queryParam: "q", pageParam: "p", page: 1 }, "term", "SIG"); + assert.equal(post.method, "POST"); + assert.deepEqual(JSON.parse(post.body), { q: "term", p: 1 }); + assert.equal(post.signal, "SIG"); + + const get = wxapp.inputSelectionModel.buildRequestInit({ httpMethod: "GET" }, "term", "SIG"); + assert.equal(get.method, "GET"); + assert.equal("body" in get, false); +}); + +test("map api item projects aliases and builds data and aria tuples", () => { + const { wxapp } = load(); + const item = wxapp.inputSelectionModel.mapApiItem({ + id: "1", + name: "N", + data: { foo: "bar", "data-baz": "1" }, + aria: { label: "L" } + }); + + assert.equal(item.id, "1"); + assert.equal(item.value, "1"); + assert.equal(item.label, "N"); + assert.equal(item.content, "N"); + assert.equal(item.uri, "javascript:void(0);"); + assert.deepEqual(item.data, [["data-foo", "bar"], ["data-baz", "1"]]); + assert.deepEqual(item.aria, [["aria-label", "L"]]); + + const withUri = wxapp.inputSelectionModel.mapApiItem({ id: "2", url: "/u" }); + assert.equal(withUri.uri, "/u"); +}); + +test("model searches and maps the result through the shared request end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: url, method: (init && init.method) || "GET" }); + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ items: [{ id: "1", content: "One" }] }) }; + }); + + const cfg = { apiEndpoint: "/api/s", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 0 }; + const url = wxapp.inputSelectionModel.buildUrl(cfg, "on"); + const init = wxapp.inputSelectionModel.buildRequestInit(cfg, "on", null); + const res = await wxapp.ServiceRegistry.request(url, init); + + assert.equal(calls[0].url, "/api/s?q=on&p=0"); + const mapped = (res.data.items || []).map((x) => wxapp.inputSelectionModel.mapApiItem(x)); + assert.equal(mapped[0].label, "One"); + assert.equal(mapped[0].value, "1"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/input.unique.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/input.unique.model.test.mjs new file mode 100644 index 0000000..679fc0f --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/input.unique.model.test.mjs @@ -0,0 +1,83 @@ +/** + * Headless unit tests for the unique input model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.input.unique.js: + * the header parsing, the request body shaping and the availability extraction + * with its configured field and the status and code heuristics, plus an end to + * end path that checks uniqueness through the shared request and interprets the + * result through the model. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.input.unique.model.js")] }, + options + )); +} + +test("parse headers reads string pairs and tolerates invalid input", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders('{"X-A":"1","X-B":"2"}'), { "X-A": "1", "X-B": "2" }); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders(""), {}); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders("not json"), {}); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders('["a"]'), {}); + assert.deepEqual(wxapp.inputUniqueModel.parseHeaders('{"X-A":1,"X-B":"ok"}'), { "X-B": "ok" }); +}); + +test("request body carries the configured parameter", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.inputUniqueModel.requestBody("v", "name"), { v: "name" }); + assert.deepEqual(wxapp.inputUniqueModel.requestBody("login", "ann"), { login: "ann" }); +}); + +test("extract availability reads the configured field as boolean, string or number", () => { + const { wxapp } = load(); + const m = wxapp.inputUniqueModel; + assert.equal(m.extractAvailability({ available: true }, "available"), true); + assert.equal(m.extractAvailability({ available: false }, "available"), false); + assert.equal(m.extractAvailability({ free: "true" }, "free"), true); + assert.equal(m.extractAvailability({ free: "FALSE" }, "free"), false); + assert.equal(m.extractAvailability({ ok: 1 }, "ok"), true); + assert.equal(m.extractAvailability({ ok: 0 }, "ok"), false); +}); + +test("extract availability falls back to the status and code heuristics", () => { + const { wxapp } = load(); + const m = wxapp.inputUniqueModel; + assert.equal(m.extractAvailability({ status: "free" }, "available"), true); + assert.equal(m.extractAvailability({ status: "available" }, "available"), true); + assert.equal(m.extractAvailability({ status: "taken" }, "available"), false); + assert.equal(m.extractAvailability({ status: "in_use" }, "available"), false); + assert.equal(m.extractAvailability({ code: "available" }, "available"), true); + assert.equal(m.extractAvailability({ code: "unavailable" }, "available"), false); +}); + +test("extract availability returns null when undecidable", () => { + const { wxapp } = load(); + const m = wxapp.inputUniqueModel; + assert.equal(m.extractAvailability({ foo: "bar" }, "available"), null); + assert.equal(m.extractAvailability({ available: "maybe" }, "available"), null); + assert.equal(m.extractAvailability(null, "available"), null); +}); + +test("model checks uniqueness through the shared request end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ available: false }) }; + }); + + const res = await wxapp.ServiceRegistry.request("/api/unique?v=taken", { method: "GET" }); + assert.equal(calls[0].method, "GET"); + assert.equal(wxapp.inputUniqueModel.extractAvailability(res.data, "available"), false); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/kanban.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/kanban.model.test.mjs new file mode 100644 index 0000000..637df0f --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/kanban.model.test.mjs @@ -0,0 +1,88 @@ +/** + * Headless unit tests for the REST kanban model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.kanban.js, namely + * the board normalisation, plus an end to end path that loads the board with a + * query and persists a card move with an update through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.kanban.model.js")] }, + options + )); +} + +test("legacy descriptor loads with get and persists with put", () => { + const { wxapp } = load(); + const descriptor = wxapp.kanbanModel.legacyDescriptor("/api/board"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/board"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); +}); + +test("normalize board maps columns, swimlanes and cards with defaults", () => { + const { wxapp } = load(); + const board = wxapp.kanbanModel.normalizeBoard({ + columns: [{ id: "c1", label: "To Do" }, { id: "c2", label: "Done", size: "2fr" }], + swimlanes: [{ id: "s1", label: "Lane", expanded: false }], + items: [{ id: "i1", columnId: "c1", swimlaneId: "s1", label: "Card" }] + }); + + assert.equal(board.columns.length, 2); + assert.equal(board.columns[0].size, "1fr"); + assert.equal(board.columns[1].size, "2fr"); + assert.equal(board.swimlanes[0].expanded, false); + assert.equal(board.cards[0].id, "i1"); + assert.equal(board.cards[0].label, "Card"); + assert.deepEqual(board.cards[0].primaryAction, {}); +}); + +test("normalize board returns only the present parts and tolerates empties", () => { + const { wxapp } = load(); + + const partial = wxapp.kanbanModel.normalizeBoard({ items: [{ id: "x" }] }); + assert.equal("columns" in partial, false); + assert.equal("swimlanes" in partial, false); + assert.equal(partial.cards.length, 1); + + const lanes = wxapp.kanbanModel.normalizeBoard({ swimlanes: [{ id: "s", label: "L" }] }); + assert.equal(lanes.swimlanes[0].expanded, true); + + assert.deepEqual(wxapp.kanbanModel.normalizeBoard(null), {}); +}); + +test("model loads the board and persists a move through a service", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ columns: [{ id: "c1", label: "To Do" }], items: [{ id: "i1", columnId: "c1" }] }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.kanbanModel.legacyDescriptor("/api/board")); + + const loaded = await service.query({}); + assert.equal(calls[0].method, "GET"); + const board = wxapp.kanbanModel.normalizeBoard(loaded.data); + assert.equal(board.columns[0].id, "c1"); + assert.equal(board.cards[0].id, "i1"); + + const moved = await service.update({ cardId: "i1", columnId: "c2", swimlaneId: null }); + assert.equal(calls[1].method, "PUT"); + assert.deepEqual(JSON.parse(calls[1].body), { cardId: "i1", columnId: "c2", swimlaneId: null }); + assert.equal(moved.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/list.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/list.model.test.mjs new file mode 100644 index 0000000..313929c --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/list.model.test.mjs @@ -0,0 +1,125 @@ +/** + * Headless unit tests for the REST list model helpers (phase one). + * + * These cover the pure logic extracted from webexpress.webapp.list.js, and an + * end to end path that feeds the model output through a RestService to confirm + * the legacy query parameter names survive the migration. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.list.model.js")] }, + options + )); +} + +test("legacy descriptor maps logical names to the historical wire names", () => { + const { wxapp } = load(); + const descriptor = wxapp.listModel.legacyDescriptor("/api/orders"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/orders"); + assert.equal(descriptor.method, "GET"); + assert.deepEqual(descriptor.query, { + search: "q", wql: "wql", filter: "f", page: "p", pageSize: "l", orderBy: "o", orderDir: "d" + }); + assert.deepEqual(descriptor.response, { items: "items", total: "total" }); +}); + +test("query params always include the base fields and omit order when unset", () => { + const { wxapp } = load(); + const params = wxapp.listModel.queryParams({ search: "abc", page: 3, pageSize: 25 }); + + assert.equal(params.search, "abc"); + assert.equal(params.wql, ""); + assert.equal(params.filter, ""); + assert.equal(params.page, 3); + assert.equal(params.pageSize, 25); + assert.equal("orderBy" in params, false); + assert.equal("orderDir" in params, false); +}); + +test("query params include order when an order field is set", () => { + const { wxapp } = load(); + const params = wxapp.listModel.queryParams({ orderBy: "name", orderDir: "asc" }); + + assert.equal(params.orderBy, "name"); + assert.equal(params.orderDir, "asc"); +}); + +test("reduce response extracts paging and clears loading and error", () => { + const { wxapp } = load(); + const patch = wxapp.listModel.reduceResponse({ page: 0, pageSize: 50 }, { total: 42, page: 1, pageSize: 25 }); + + assert.equal(patch.total, 42); + assert.equal(patch.page, 1); + assert.equal(patch.pageSize, 25); + assert.equal(patch.loading, false); + assert.equal(patch.error, null); +}); + +test("reduce response falls back to the current state and tolerates alternates", () => { + const { wxapp } = load(); + const patch = wxapp.listModel.reduceResponse({ page: 2, pageSize: 50 }, { totalCount: 7 }); + + assert.equal(patch.total, 7); + assert.equal(patch.page, 2); + assert.equal(patch.pageSize, 50); +}); + +test("map items projects strings and objects and tolerates a missing array", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.listModel.mapItems(null), []); + assert.deepEqual(wxapp.listModel.mapItems({}), []); + + const items = wxapp.listModel.mapItems({ + items: ["plain", { id: 7, text: "labelled", editable: true, options: [{ a: 1 }] }] + }); + + assert.equal(items.length, 2); + assert.equal(items[0].id, null); + assert.deepEqual(items[0].content, { content: "plain" }); + assert.equal(items[1].id, 7); + assert.equal(items[1].content, "labelled"); + assert.equal(items[1].editable, true); + assert.deepEqual(items[1].options, [{ a: 1 }]); +}); + +test("model feeds a rest service with the legacy parameter names end to end", async () => { + const { wxapp, setFetch } = load(); + let capturedUrl = null; + setFetch(async (url) => { + capturedUrl = url; + return { ok: true, status: 200, json: async () => ({ items: [{ id: 1, text: "a" }], total: 1 }) }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.listModel.legacyDescriptor("/api/orders")); + const state = { search: "abc", wql: "", filter: "x", page: 2, pageSize: 25, orderBy: "name", orderDir: "asc" }; + + const result = await service.query(wxapp.listModel.queryParams(state)); + + assert.equal(result.ok, true); + assert.match(capturedUrl, /\/api\/orders\?/); + assert.match(capturedUrl, /q=abc/); + assert.match(capturedUrl, /f=x/); + assert.match(capturedUrl, /p=2/); + assert.match(capturedUrl, /l=25/); + assert.match(capturedUrl, /o=name/); + assert.match(capturedUrl, /d=asc/); + + const patch = wxapp.listModel.reduceResponse(state, result.data); + assert.equal(patch.total, 1); + assert.equal(patch.loading, false); + + const items = wxapp.listModel.mapItems(result.data); + assert.equal(items.length, 1); + assert.equal(items[0].content, "a"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/restform.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/restform.model.test.mjs new file mode 100644 index 0000000..7bb92d1 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/restform.model.test.mjs @@ -0,0 +1,158 @@ +/** + * Headless unit tests for the REST form model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.restform.js, + * namely the request shaping, the response classification and the server error + * normalisation, plus an end to end path through a service request. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.restform.model.js")] }, + options + )); +} + +test("build load url carries the id and the mode", () => { + const { wxapp } = load(); + const url = wxapp.restFormModel.buildLoadUrl("/api/form", 42, "edit", "http://localhost"); + + assert.match(url, /\/api\/form\?/); + assert.match(url, /id=42/); + assert.match(url, /mode=edit/); +}); + +test("build request shapes a json body and appends the id for post", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", + { method: "POST", json: true, headers: { "Content-Type": "application/json; charset=utf-8" }, id: 5 }, + { name: "a" }, + "http://localhost" + ); + + assert.equal(built.init.method, "POST"); + assert.equal(JSON.parse(built.init.body).name, "a"); + assert.equal(built.init.headers["Content-Type"], "application/json; charset=utf-8"); + assert.match(built.url, /id=5/); +}); + +test("build request adds the json content type when missing", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "POST", json: true, headers: {} }, { x: 1 }, "http://localhost"); + + assert.equal(built.init.headers["Content-Type"], "application/json; charset=utf-8"); +}); + +test("build request shapes a form encoded body when json is off", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "POST", json: false, headers: {} }, { a: "1", b: "2" }, "http://localhost"); + + assert.equal(built.init.headers["Content-Type"], "application/x-www-form-urlencoded; charset=utf-8"); + assert.match(built.init.body, /a=1/); + assert.match(built.init.body, /b=2/); +}); + +test("build request for delete carries only the id and drops the content type", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "DELETE", id: 9, headers: { "Content-Type": "application/json" } }, {}, "http://localhost"); + + assert.equal(built.init.method, "DELETE"); + assert.equal(built.init.body, undefined); + assert.match(built.url, /id=9/); + assert.equal("Content-Type" in built.init.headers, false); +}); + +test("build request for get appends the payload as query parameters", () => { + const { wxapp } = load(); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "GET", headers: {} }, { q: "abc", f: "x" }, "http://localhost"); + + assert.match(built.url, /q=abc/); + assert.match(built.url, /f=x/); + assert.equal(built.init.body, undefined); +}); + +test("classify response handles success, close, confirm, validation and error", () => { + const { wxapp } = load(); + + let c = wxapp.restFormModel.classifyResponse(true, 200, { ok: true }); + assert.equal(c.kind, "success"); + assert.equal(c.closeModal, true); + + c = wxapp.restFormModel.classifyResponse(true, 200, { message: "saved" }); + assert.equal(c.kind, "success"); + assert.equal(c.closeModal, false); + assert.equal(c.message, "saved"); + + c = wxapp.restFormModel.classifyResponse(true, 200, { message: "m", data: { confirmHtml: "ok" } }); + assert.equal(c.confirmHtml, "ok"); + + c = wxapp.restFormModel.classifyResponse(false, 400, [{ field: "name", message: "required" }]); + assert.equal(c.kind, "validation"); + assert.equal(c.errors[0].field, "name"); + assert.equal(c.errors[0].message, "required"); + + c = wxapp.restFormModel.classifyResponse(false, 400, { errors: { email: "invalid" } }); + assert.equal(c.kind, "validation"); + assert.equal(c.errors[0].field, "email"); + assert.equal(c.errors[0].message, "invalid"); + + c = wxapp.restFormModel.classifyResponse(false, 400, { message: "bad" }); + assert.equal(c.kind, "validation"); + assert.deepEqual(c.errors, []); + assert.equal(c.message, "bad"); + + c = wxapp.restFormModel.classifyResponse(false, 500, {}); + assert.equal(c.kind, "error"); + assert.equal(c.status, 500); +}); + +test("normalize errors reads the several casings the server may use", () => { + const { wxapp } = load(); + + assert.deepEqual( + wxapp.restFormModel.normalizeFieldErrors({ a: "x", b: "y" }), + [{ field: "a", message: "x" }, { field: "b", message: "y" }] + ); + assert.deepEqual(wxapp.restFormModel.normalizeFieldErrors(null), []); + + assert.deepEqual( + wxapp.restFormModel.normalizeArrayErrors([{ field: "f", message: "m" }, { Message: "M2" }]), + [{ field: "f", message: "m" }, { field: null, message: "M2" }] + ); +}); + +test("model feeds a service request and classifies the result end to end", async () => { + const { wxapp, setFetch } = load(); + let captured = null; + setFetch(async (url, init) => { + captured = { url: url, init: init }; + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ message: "saved" }) }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.restFormModel.legacyDescriptor("/api/form")); + const built = wxapp.restFormModel.buildRequest( + "/api/form", { method: "POST", json: true, headers: {}, id: 7 }, { name: "a" }, "http://localhost"); + + const result = await service.request(built.url, built.init); + + assert.equal(result.ok, true); + assert.equal(result.data.message, "saved"); + assert.match(captured.url, /id=7/); + assert.equal(JSON.parse(captured.init.body).name, "a"); + + const classification = wxapp.restFormModel.classifyResponse(result.ok, result.status, result.data); + assert.equal(classification.kind, "success"); + assert.equal(classification.message, "saved"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/restform.test.mjs b/src/WebExpress.WebApp.Test/JsTest/restform.test.mjs new file mode 100644 index 0000000..5961f61 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/restform.test.mjs @@ -0,0 +1,67 @@ +/** + * Headless tests for the REST form control after it was lifted onto the Data + * base (View, State and Service). The form already owned a store, store-backed + * ui-state accessors and an island-or-legacy service map; the lift delegates the + * store and the service map to the base (super(element, { state, services })) and + * gains the base teardown that aborts the service. The form keeps its imperative + * render and submit flow. + * + * The tests omit the data-uri so the constructor performs no load (load() returns + * early without an api), which keeps the lift assertions free of async I/O. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.restform.model.js"), + webappAsset("webexpress.webapp.restform.js") + ] + }, + options + )); +} + +test("restform extends the data base and resolves its service", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("form"); + + const ctrl = new wxapp.RestFormCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); + assert.ok(ctrl.useService("data")); + assert.equal(ctrl.mode, "new"); +}); + +test("restform seeds its ui state from the data-wx-state island", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("form"); + element.setAttribute("data-wx-state", JSON.stringify({ submitting: true })); + + const ctrl = new wxapp.RestFormCtrl(element); + + assert.equal(ctrl.state.submitting, true); +}); + +test("restform destroy tears down without throwing", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({}) })); + + const element = createElement("form"); + + const ctrl = new wxapp.RestFormCtrl(element); + + assert.doesNotThrow(() => ctrl.destroy()); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/restwizard.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/restwizard.model.test.mjs new file mode 100644 index 0000000..b56b1c0 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/restwizard.model.test.mjs @@ -0,0 +1,85 @@ +/** + * Headless unit tests for the REST wizard model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.restwizard.js, + * namely the step request shaping, the cache decision and the last step + * detection, plus an end to end path through a service request that returns an + * html step and one that returns a 204 skip. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.restwizard.model.js")] }, + options + )); +} + +test("build step request init posts the payload and accepts html", () => { + const { wxapp } = load(); + const init = wxapp.restWizardModel.buildStepRequestInit('{"a":1}'); + + assert.equal(init.method, "POST"); + assert.equal(init.headers["Content-Type"], "application/json; charset=utf-8"); + assert.equal(init.headers["Accept"], "text/html"); + assert.equal(init.body, '{"a":1}'); +}); + +test("should use cache only when loaded, unchanged and without error", () => { + const { wxapp } = load(); + const page = { isLoaded: true, payloadHash: "abc", hasError: false }; + + assert.equal(wxapp.restWizardModel.shouldUseCache(page, "abc"), true); + assert.equal(wxapp.restWizardModel.shouldUseCache(page, "xyz"), false); + assert.equal(wxapp.restWizardModel.shouldUseCache({ isLoaded: false, payloadHash: "abc" }, "abc"), false); + assert.equal(wxapp.restWizardModel.shouldUseCache({ isLoaded: true, payloadHash: "abc", hasError: true }, "abc"), false); + assert.equal(wxapp.restWizardModel.shouldUseCache(null, "abc"), false); +}); + +test("is last page ignores skipped pages and an empty list", () => { + const { wxapp } = load(); + const pages = [{ skipped: false }, { skipped: false }, { skipped: false }]; + + assert.equal(wxapp.restWizardModel.isLastPage(pages, 2), true); + assert.equal(wxapp.restWizardModel.isLastPage(pages, 1), false); + + const trailing = [{ skipped: false }, { skipped: false }, { skipped: true }]; + assert.equal(wxapp.restWizardModel.isLastPage(trailing, 1), true); + + assert.equal(wxapp.restWizardModel.isLastPage([], 0), true); +}); + +test("model feeds a service request that returns an html step", async () => { + const { wxapp, setFetch } = load(); + let captured = null; + setFetch(async (url, init) => { + captured = { url: url, init: init }; + return { ok: true, status: 200, headers: { get: () => "text/html" }, text: async () => "

step

" }; + }); + + const service = wxapp.ServiceRegistry.create({ kind: "rest", baseUri: "" }); + const result = await service.request("/step/2", wxapp.restWizardModel.buildStepRequestInit('{"x":1}')); + + assert.equal(result.ok, true); + assert.equal(result.status, 200); + assert.equal(result.data.text, "

step

"); + assert.equal(captured.init.method, "POST"); + assert.equal(captured.init.body, '{"x":1}'); +}); + +test("model feeds a service request that returns a 204 skip", async () => { + const { wxapp, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 204, headers: { get: () => null } })); + + const service = wxapp.ServiceRegistry.create({ kind: "rest", baseUri: "" }); + const result = await service.request("/step/3", wxapp.restWizardModel.buildStepRequestInit("{}")); + + assert.equal(result.status, 204); + assert.equal(result.data, null); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/run.ps1 b/src/WebExpress.WebApp.Test/JsTest/run.ps1 new file mode 100644 index 0000000..803afb5 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/run.ps1 @@ -0,0 +1,37 @@ +# Runs the headless engine tests with Node. +# +# It uses node from the PATH when available, and otherwise falls back to the +# Node that ships with a Visual Studio installation (the "Node.js development" +# component), which is not added to the system PATH. +# +# Usage from this folder: +# ./run.ps1 + +$ErrorActionPreference = "Stop" + +function Resolve-Node { + $onPath = Get-Command node -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + $roots = @("$env:ProgramFiles\Microsoft Visual Studio", "${env:ProgramFiles(x86)}\Microsoft Visual Studio") + foreach ($root in $roots) { + if (Test-Path $root) { + $candidate = Get-ChildItem -Path $root -Filter "node.exe" -Recurse -ErrorAction SilentlyContinue -File | + Select-Object -First 1 + if ($candidate) { return $candidate.FullName } + } + } + + return $null +} + +$node = Resolve-Node +if (-not $node) { + Write-Error "node was not found on the PATH or in a Visual Studio installation. Install Node 18 or newer." + exit 1 +} + +Write-Host "using node: $node" +Set-Location $PSScriptRoot +& $node --test +exit $LASTEXITCODE diff --git a/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.model.test.mjs new file mode 100644 index 0000000..732d2f1 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.model.test.mjs @@ -0,0 +1,159 @@ +/** + * Headless unit tests for the scrum backlog model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.scrum.backlog.js: + * the legacy descriptor, the board and sprint normalisation, the sprint and item + * paths, the request bodies, the sprint group filter and sort, the rank rewrite + * and the active sprint crossing classification, plus an end to end path that + * loads the board with a query, persists a sprint and an item rank with an + * update on a path and deletes a sprint with a remove through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.scrum.backlog.model.js")] }, + options + )); +} + +test("legacy descriptor loads with get and uses put for the update", () => { + const { wxapp } = load(); + const descriptor = wxapp.scrumBacklogModel.legacyDescriptor("/api/backlog"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/backlog"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); +}); + +test("normalize data returns sprint and item arrays and tolerates absence", () => { + const { wxapp } = load(); + const norm = wxapp.scrumBacklogModel.normalizeData({ sprints: [{ id: "s1" }], items: [{ id: "i1" }] }); + assert.deepEqual(norm.sprints.map(s => s.id), ["s1"]); + assert.deepEqual(norm.items.map(i => i.id), ["i1"]); + + const empty = wxapp.scrumBacklogModel.normalizeData(null); + assert.deepEqual(empty.sprints, []); + assert.deepEqual(empty.items, []); +}); + +test("normalize sprint applies defaults and keeps caller supplied fields", () => { + const { wxapp } = load(); + const s = wxapp.scrumBacklogModel.normalizeSprint({ id: "s1", name: "Sprint 1", capacity: 5, extra: "x" }); + assert.equal(s.id, "s1"); + assert.equal(s.name, "Sprint 1"); + assert.equal(s.status, "planned"); + assert.equal(s.goal, ""); + assert.equal(s.start, null); + assert.equal(s.capacity, 5); + assert.equal(s.extra, "x"); + + const d = wxapp.scrumBacklogModel.normalizeSprint({ id: "s2" }); + assert.equal(d.status, "planned"); + assert.equal(d.capacity, 0); + + const active = wxapp.scrumBacklogModel.normalizeSprint({ id: "s3", status: "active" }); + assert.equal(active.status, "active"); +}); + +test("paths build the sprint, item rank and batch endpoints", () => { + const { wxapp } = load(); + assert.equal(wxapp.scrumBacklogModel.sprintPath("s 1"), "/sprints/s%201"); + assert.equal(wxapp.scrumBacklogModel.itemRankPath("i 1"), "/items/i%201/rank"); + assert.equal(wxapp.scrumBacklogModel.rankBatchPath(), "/items/rank-batch"); +}); + +test("request bodies carry the rank payloads", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.scrumBacklogModel.itemRankBody({ id: "i1", sprintId: "s1", rank: 3 }), { sprintId: "s1", rank: 3 }); + assert.deepEqual(wxapp.scrumBacklogModel.itemRankBody({ id: "i2", rank: 1 }), { sprintId: null, rank: 1 }); + + const batch = wxapp.scrumBacklogModel.rankBatchBody([{ id: "i1", sprintId: "s1", rank: 1 }, { id: "i2", rank: 2 }]); + assert.deepEqual(batch, { ranks: [{ id: "i1", sprintId: "s1", rank: 1 }, { id: "i2", sprintId: null, rank: 2 }] }); + assert.deepEqual(wxapp.scrumBacklogModel.rankBatchBody(null), { ranks: [] }); +}); + +test("items for sprint sorted filters by group and sorts by rank then key", () => { + const { wxapp } = load(); + const items = [ + { id: "a", sprintId: "s1", rank: 2, key: "A" }, + { id: "b", sprintId: "s1", rank: 1, key: "B" }, + { id: "c", sprintId: null, status: "backlog", key: "C" }, + { id: "d", status: "backlog", key: "D" } + ]; + + assert.deepEqual(wxapp.scrumBacklogModel.itemsForSprintSorted(items, "s1").map(i => i.id), ["b", "a"]); + assert.deepEqual(wxapp.scrumBacklogModel.itemsForSprintSorted(items, null).map(i => i.id), ["c", "d"]); + assert.deepEqual(wxapp.scrumBacklogModel.itemsForSprintSorted(null, "s1"), []); +}); + +test("rewrite ranks assigns a one based rank and the sprint id", () => { + const { wxapp } = load(); + const items = [{ id: "x" }, { id: "y" }, { id: "z" }]; + wxapp.scrumBacklogModel.rewriteRanks("s1", items); + assert.deepEqual(items.map(i => i.rank), [1, 2, 3]); + assert.deepEqual(items.map(i => i.sprintId), ["s1", "s1", "s1"]); + + const backlog = [{ id: "x", sprintId: "s1" }]; + wxapp.scrumBacklogModel.rewriteRanks(null, backlog); + assert.equal(backlog[0].sprintId, null); + assert.equal(backlog[0].rank, 1); +}); + +test("crosses active sprint detects entering or leaving the active sprint", () => { + const { wxapp } = load(); + const m = wxapp.scrumBacklogModel; + + assert.equal(m.crossesActiveSprint([{ sprintId: "s1" }], "s2", null), false); + assert.equal(m.crossesActiveSprint([{ sprintId: null }], "act", "act"), true); + assert.equal(m.crossesActiveSprint([{ sprintId: "act" }], null, "act"), true); + assert.equal(m.crossesActiveSprint([{ sprintId: "act" }], "act", "act"), false); + assert.equal(m.crossesActiveSprint([{ sprintId: "s1" }], "s2", "act"), false); +}); + +test("model loads, persists and deletes through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ sprints: [{ id: "s1" }], items: [{ id: "i1", sprintId: "s1" }] }) }; + } + if (method === "DELETE") { + return { ok: true, status: 204 }; + } + return { ok: true, status: 200, json: async () => ({ ok: true }) }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.scrumBacklogModel.legacyDescriptor("/api/backlog")); + + const loaded = await service.query({}); + const norm = wxapp.scrumBacklogModel.normalizeData(loaded.data); + assert.equal(norm.sprints[0].id, "s1"); + assert.equal(norm.items[0].id, "i1"); + + await service.update({ id: "s1", status: "active" }, { path: wxapp.scrumBacklogModel.sprintPath("s1") }); + assert.equal(calls[1].method, "PUT"); + assert.equal(calls[1].url.endsWith("/sprints/s1"), true); + + await service.update( + wxapp.scrumBacklogModel.itemRankBody({ id: "i1", sprintId: "s1", rank: 3 }), + { path: wxapp.scrumBacklogModel.itemRankPath("i1") } + ); + assert.equal(calls[2].url.endsWith("/items/i1/rank"), true); + assert.deepEqual(JSON.parse(calls[2].body), { sprintId: "s1", rank: 3 }); + + const removed = await service.remove({ path: wxapp.scrumBacklogModel.sprintPath("s1") }); + assert.equal(calls[3].method, "DELETE"); + assert.equal(calls[3].url.endsWith("/sprints/s1"), true); + assert.equal(removed.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.test.mjs b/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.test.mjs new file mode 100644 index 0000000..045f4c8 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/scrum.backlog.test.mjs @@ -0,0 +1,91 @@ +/** + * Headless tests for the scrum backlog control after it was lifted onto the + * Component base (View, State and Service). The backlog is large and mutates its + * items in place throughout, so it is a light lift: it extends Component for the + * store, the service map, the seed and the lifecycle, but keeps its own manual + * render flow. The tests assert that it extends Component, seeds its sprints and + * items from the data-wx-state island and skips the load in that case, and + * otherwise loads from the service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.scrum.backlog.model.js"), + webappAsset("webexpress.webapp.scrum.backlog.js") + ] + }, + options + )); +} + +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("scrum backlog extends the component base", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, json: async () => ({ sprints: [], items: [] }) })); + + const element = createElement("div"); + element.setAttribute("data-wx-service", JSON.stringify({ name: "data", kind: "rest", baseUri: "/api/backlog", method: "GET", updateMethod: "PUT" })); + + const ctrl = new wxapp.ScrumBacklogCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); +}); + +test("scrum backlog seeds from the data-wx-state island and skips the load", async () => { + const { wxapp, createElement, setFetch } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, json: async () => ({ sprints: [], items: [] }) }; }); + + const element = createElement("div"); + element.setAttribute("data-wx-service", JSON.stringify({ name: "data", kind: "rest", baseUri: "/api/backlog", method: "GET", updateMethod: "PUT" })); + element.setAttribute("data-wx-state", JSON.stringify({ + sprints: [{ id: "s1", name: "Sprint 1", status: "active" }], + items: [{ id: "i1", sprintId: "s1", title: "Task", key: "T-1", points: 3 }] + })); + + const ctrl = new wxapp.ScrumBacklogCtrl(element); + + assert.equal(ctrl.sprints.length, 1); + assert.equal(ctrl.sprints[0].id, "s1"); + assert.equal(ctrl.items.length, 1); + assert.equal(ctrl.items[0].id, "i1"); + // the backlog is rendered from the seeded state, not deferred to a load + assert.ok(element.childNodes.length > 0); + + await settle(); + assert.equal(fetchCount, 0); +}); + +test("scrum backlog loads from the service when no state island is present", async () => { + const { wxapp, createElement, setFetch } = load(); + let fetchCount = 0; + setFetch(async () => { + fetchCount++; + return { ok: true, status: 200, json: async () => ({ sprints: [{ id: "s9" }], items: [] }) }; + }); + + const element = createElement("div"); + element.setAttribute("data-wx-service", JSON.stringify({ name: "data", kind: "rest", baseUri: "/api/backlog", method: "GET", updateMethod: "PUT" })); + + const ctrl = new wxapp.ScrumBacklogCtrl(element); + assert.equal(ctrl.sprints.length, 0); + + await settle(); + + assert.equal(fetchCount, 1); + assert.equal(ctrl.sprints.length, 1); + assert.equal(ctrl.sprints[0].id, "s9"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/scrum.sprint.test.mjs b/src/WebExpress.WebApp.Test/JsTest/scrum.sprint.test.mjs new file mode 100644 index 0000000..61baeba --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/scrum.sprint.test.mjs @@ -0,0 +1,86 @@ +/** + * Headless tests for the scrum sprint control after it was lifted onto the + * Component base (View, State and Service). The control keeps its own imperative + * render method, which Component._apply calls on every state change. The tests + * assert that it extends Component, seeds its sprint from the data-wx-state + * island and skips the network load in that case, and otherwise loads from the + * shared request. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.scrum.sprint.js")] }, + options + )); +} + +/** + * Awaits the asynchronous load and the batched store notification. + * @returns {Promise} Resolves after the macrotask and microtask queues drain. + */ +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("scrum sprint extends the component base", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, json: async () => ({}) })); + + const element = createElement("div"); + element.dataset.restUri = "/api/sprint"; + + const ctrl = new wxapp.ScrumSprintCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); +}); + +test("scrum sprint seeds from the data-wx-state island and skips the load", async () => { + const { wxapp, createElement, setFetch } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, json: async () => ({}) }; }); + + const element = createElement("div"); + element.dataset.restUri = "/api/sprint"; + element.setAttribute("data-wx-state", JSON.stringify({ + sprint: { name: "Sprint 24", goal: "MVP", committedPoints: 47, completedPoints: 18, capacity: 60, daysTotal: 14, daysElapsed: 7 } + })); + + const ctrl = new wxapp.ScrumSprintCtrl(element); + + assert.ok(ctrl.sprint); + assert.equal(ctrl.sprint.name, "Sprint 24"); + // the sprint card is rendered from the seeded state on mount, not the empty placeholder + assert.ok(element.childNodes.length > 0); + + await settle(); + assert.equal(fetchCount, 0); +}); + +test("scrum sprint loads from the service when no state island is present", async () => { + const { wxapp, createElement, setFetch } = load(); + let fetchCount = 0; + setFetch(async () => { + fetchCount++; + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ name: "Sprint 9" }) }; + }); + + const element = createElement("div"); + element.dataset.restUri = "/api/sprint"; + + const ctrl = new wxapp.ScrumSprintCtrl(element); + assert.equal(ctrl.sprint, null); + + await settle(); + + assert.equal(fetchCount, 1); + assert.ok(ctrl.sprint); + assert.equal(ctrl.sprint.name, "Sprint 9"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/selection.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/selection.model.test.mjs new file mode 100644 index 0000000..ec8a922 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/selection.model.test.mjs @@ -0,0 +1,79 @@ +/** + * Headless unit tests for the REST selection model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.selection.js: the + * request url and init shaping and the response item mapping, plus an end to end + * path that searches through the shared request and maps the result. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.selection.model.js")] }, + options + )); +} + +test("build url appends the query and page for get and is unchanged otherwise", () => { + const { wxapp } = load(); + const cfg = { apiEndpoint: "/api/s", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 0 }; + assert.equal(wxapp.selectionModel.buildUrl(cfg, "ab"), "/api/s?q=ab&p=0"); + + const cfgQ = { apiEndpoint: "/api/s?x=1", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 2 }; + assert.equal(wxapp.selectionModel.buildUrl(cfgQ, "a b"), "/api/s?x=1&q=a%20b&p=2"); + + assert.equal(wxapp.selectionModel.buildUrl({ apiEndpoint: "/api/s", httpMethod: "POST" }, "x"), "/api/s"); +}); + +test("build request init carries a json body for post and a signal for get", () => { + const { wxapp } = load(); + const post = wxapp.selectionModel.buildRequestInit({ httpMethod: "POST", queryParam: "q", pageParam: "p", page: 1 }, "term", "SIG"); + assert.equal(post.method, "POST"); + assert.equal(post.headers["Content-Type"], "application/json"); + assert.deepEqual(JSON.parse(post.body), { q: "term", p: 1 }); + assert.equal(post.signal, "SIG"); + + const get = wxapp.selectionModel.buildRequestInit({ httpMethod: "GET" }, "term", "SIG"); + assert.equal(get.method, "GET"); + assert.equal(get.signal, "SIG"); + assert.equal("body" in get, false); +}); + +test("map api item chooses field aliases defensively", () => { + const { wxapp } = load(); + const item = wxapp.selectionModel.mapApiItem({ id: "1", content: "C", url: "/u", disabled: true }); + assert.equal(item.id, "1"); + assert.equal(item.label, "C"); + assert.equal(item.primaryUri, "/u"); + assert.equal(item.disabled, true); + + const empty = wxapp.selectionModel.mapApiItem({}); + assert.equal(empty.id, null); + assert.equal(empty.label, ""); + assert.equal(empty.disabled, false); +}); + +test("model searches and maps the result through the shared request end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + calls.push({ url: url, method: (init && init.method) || "GET" }); + return { ok: true, status: 200, headers: { get: () => "application/json" }, json: async () => ({ items: [{ id: "1", label: "One" }] }) }; + }); + + const cfg = { apiEndpoint: "/api/s", httpMethod: "GET", queryParam: "q", pageParam: "p", page: 0 }; + const url = wxapp.selectionModel.buildUrl(cfg, "on"); + const init = wxapp.selectionModel.buildRequestInit(cfg, "on", null); + const res = await wxapp.ServiceRegistry.request(url, init); + + assert.equal(calls[0].url, "/api/s?q=on&p=0"); + const mapped = (res.data.items || []).map((x) => wxapp.selectionModel.mapApiItem(x)); + assert.equal(mapped[0].label, "One"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/tab.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/tab.model.test.mjs new file mode 100644 index 0000000..8cbd2fe --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/tab.model.test.mjs @@ -0,0 +1,117 @@ +/** + * Headless unit tests for the REST tab model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.tab.js, and an + * end to end path that drives the four operations (list, create, reorder, + * close) through a service to confirm the methods and bodies survive the + * migration. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.tab.model.js")] }, + options + )); +} + +test("legacy descriptor lists with get, updates with put and maps the id", () => { + const { wxapp } = load(); + const descriptor = wxapp.tabModel.legacyDescriptor("/api/tabs"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/tabs"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); + assert.deepEqual(descriptor.query, { id: "id" }); +}); + +test("map tabs reads the items array and tolerates a missing one", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.tabModel.mapTabs({ items: [{ id: 1 }, { id: 2 }] }), [{ id: 1 }, { id: 2 }]); + assert.deepEqual(wxapp.tabModel.mapTabs({}), []); + assert.deepEqual(wxapp.tabModel.mapTabs(null), []); +}); + +test("create and reorder bodies carry the action and payload", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.tabModel.createBody("t"), { action: "create", templateId: "t" }); + assert.deepEqual(wxapp.tabModel.reorderBody(["a", "b"]), { action: "reorder", order: ["a", "b"] }); +}); + +test("extract new tab applies the requested template id and tolerates absence", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.tabModel.extractNewTab({ newTab: { id: 1 } }, "t"), { id: 1, templateId: "t" }); + assert.deepEqual(wxapp.tabModel.extractNewTab({ newTab: { id: 1, templateId: "x" } }, "t"), { id: 1, templateId: "x" }); + assert.equal(wxapp.tabModel.extractNewTab({}, "t"), null); + assert.equal(wxapp.tabModel.extractNewTab(null, "t"), null); +}); + +test("parse multiplicity yields a non negative integer or null", () => { + const { wxapp } = load(); + + assert.equal(wxapp.tabModel.parseMultiplicity("3"), 3); + assert.equal(wxapp.tabModel.parseMultiplicity("0"), 0); + assert.equal(wxapp.tabModel.parseMultiplicity(""), null); + assert.equal(wxapp.tabModel.parseMultiplicity(undefined), null); + assert.equal(wxapp.tabModel.parseMultiplicity("-1"), null); + assert.equal(wxapp.tabModel.parseMultiplicity("abc"), null); +}); + +test("is template available respects the multiplicity limit", () => { + const { wxapp } = load(); + + assert.equal(wxapp.tabModel.isTemplateAvailable(null, 5), true); + assert.equal(wxapp.tabModel.isTemplateAvailable({ multiplicity: null }, 5), true); + assert.equal(wxapp.tabModel.isTemplateAvailable({ multiplicity: 3 }, 2), true); + assert.equal(wxapp.tabModel.isTemplateAvailable({ multiplicity: 3 }, 3), false); +}); + +test("model drives the four operations through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ items: [{ id: 1 }] }) }; + } + if (method === "POST") { + return { ok: true, status: 200, json: async () => ({ newTab: { id: 9 } }) }; + } + if (method === "PUT") { + return { ok: true, status: 200, json: async () => ({}) }; + } + return { ok: true, status: 204 }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.tabModel.legacyDescriptor("/api/tabs")); + + const list = await service.query({}); + assert.equal(calls[0].method, "GET"); + assert.deepEqual(wxapp.tabModel.mapTabs(list.data), [{ id: 1 }]); + + const created = await service.create(wxapp.tabModel.createBody("t")); + assert.equal(calls[1].method, "POST"); + assert.deepEqual(JSON.parse(calls[1].body), { action: "create", templateId: "t" }); + assert.equal(wxapp.tabModel.extractNewTab(created.data, "t").id, 9); + + const reordered = await service.update(wxapp.tabModel.reorderBody(["a", "b"])); + assert.equal(calls[2].method, "PUT"); + assert.deepEqual(JSON.parse(calls[2].body), { action: "reorder", order: ["a", "b"] }); + assert.equal(reordered.ok, true); + + const removed = await service.remove({ params: { id: "x" } }); + assert.equal(calls[3].method, "DELETE"); + assert.match(calls[3].url, /id=x/); + assert.equal(removed.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/table.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/table.model.test.mjs new file mode 100644 index 0000000..c6f2006 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/table.model.test.mjs @@ -0,0 +1,141 @@ +/** + * Headless unit tests for the REST table model helpers (phase two). + * + * These cover the pure logic extracted from webexpress.webapp.table.js, and an + * end to end path that feeds the model output through a RestService to confirm + * the legacy query parameter names and the PUT update survive the migration. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.table.model.js")] }, + options + )); +} + +test("legacy descriptor maps logical names and uses PUT for the update", () => { + const { wxapp } = load(); + const descriptor = wxapp.tableModel.legacyDescriptor("/api/table"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/table"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); + assert.deepEqual(descriptor.query, { + search: "q", wql: "wql", filter: "f", page: "p", pageSize: "l", orderBy: "o", orderDir: "d" + }); +}); + +test("query params include order only when an order field is set", () => { + const { wxapp } = load(); + + const without = wxapp.tableModel.queryParams({ search: "x", page: 1, pageSize: 20 }); + assert.equal(without.search, "x"); + assert.equal(without.page, 1); + assert.equal(without.pageSize, 20); + assert.equal("orderBy" in without, false); + + const withOrder = wxapp.tableModel.queryParams({ orderBy: "name", orderDir: "desc" }); + assert.equal(withOrder.orderBy, "name"); + assert.equal(withOrder.orderDir, "desc"); +}); + +test("reduce response uses the response total and clamps the page", () => { + const { wxapp } = load(); + const patch = wxapp.tableModel.reduceResponse({ page: 5, pageSize: 10 }, { total: 12, rows: [] }); + + assert.equal(patch.total, 12); + assert.equal(patch.page, 1); + assert.equal(patch.error, null); +}); + +test("reduce response infers the total from page, size and received rows", () => { + const { wxapp } = load(); + const patch = wxapp.tableModel.reduceResponse({ page: 2, pageSize: 10 }, { rows: [{}, {}, {}] }); + + assert.equal(patch.total, 23); + assert.equal(patch.page, 2); +}); + +test("slice rows caps to the page size and tolerates non arrays", () => { + const { wxapp } = load(); + + assert.deepEqual(wxapp.tableModel.sliceRows([1, 2, 3, 4], 2), [1, 2]); + assert.deepEqual(wxapp.tableModel.sliceRows([1, 2], 5), [1, 2]); + assert.deepEqual(wxapp.tableModel.sliceRows(null, 5), []); +}); + +test("normalize columns projects fields and applies the sort", () => { + const { wxapp } = load(); + const columns = wxapp.tableModel.normalizeColumns({ + columns: [ + { id: "a", label: "A" }, + { id: "b", template: { type: "date", options: { fmt: 1 }, editable: true } } + ] + }, "a", "desc"); + + assert.equal(columns.length, 2); + assert.equal(columns[0].id, "a"); + assert.equal(columns[0].label, "A"); + assert.equal(columns[0].sort, "desc"); + assert.equal(columns[1].rendererType, "date"); + assert.equal(columns[1].rendererOptions.fmt, 1); + assert.equal(columns[1].rendererOptions.editable, true); + assert.equal(columns[1].sort, null); + + assert.deepEqual(wxapp.tableModel.normalizeColumns({}, null, null), []); +}); + +test("normalize rows recurses into children and slices to the page size", () => { + const { wxapp } = load(); + const rows = wxapp.tableModel.normalizeRows({ + rows: [ + { id: 1, cells: [{ content: "x" }], children: [{ id: 2 }] }, + { id: 3 }, + { id: 4 } + ] + }, 2); + + assert.equal(rows.length, 2); + assert.equal(rows[0].id, 1); + assert.equal(rows[0].expanded, true); + assert.equal(rows[0].children.length, 1); + assert.equal(rows[0].children[0].id, 2); + assert.equal(rows[0].children[0].parent, rows[0]); +}); + +test("model feeds a rest service for both the query and the put update", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ rows: [{ id: 1, cells: [] }], total: 1 }) }; + } + return { ok: true, status: 204 }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.tableModel.legacyDescriptor("/api/table")); + const state = { search: "x", wql: "", filter: "", page: 0, pageSize: 50, orderBy: "name", orderDir: "asc" }; + + const queryResult = await service.query(wxapp.tableModel.queryParams(state)); + assert.equal(queryResult.ok, true); + assert.match(calls[0].url, /\/api\/table\?/); + assert.match(calls[0].url, /q=x/); + assert.match(calls[0].url, /o=name/); + assert.match(calls[0].url, /d=asc/); + + const updateResult = await service.update({ c: [{ id: "a", visible: true, width: 100 }] }); + assert.equal(updateResult.ok, true); + assert.equal(calls[1].method, "PUT"); + const sentBody = JSON.parse(calls[1].body); + assert.deepEqual(sentBody.c[0], { id: "a", visible: true, width: 100 }); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs new file mode 100644 index 0000000..cf19620 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs @@ -0,0 +1,99 @@ +/** + * Headless unit tests for the REST tile model helpers (View, State and Service). + * + * These cover the pure logic extracted from webexpress.webapp.tile.js: the + * legacy descriptor, the page slice, the total reduction and the item to tile + * mapping, plus an end to end path that loads tiles with a query and persists + * the state with an update through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.tile.model.js")] }, + options + )); +} + +test("legacy descriptor loads with get and uses put for the update", () => { + const { wxapp } = load(); + const descriptor = wxapp.tileModel.legacyDescriptor("/api/tiles"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/tiles"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); +}); + +test("slice items caps to the page size and tolerates non arrays", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.tileModel.sliceItems([1, 2, 3], 2), [1, 2]); + assert.deepEqual(wxapp.tileModel.sliceItems([1, 2], 5), [1, 2]); + assert.deepEqual(wxapp.tileModel.sliceItems(null, 5), []); +}); + +test("reduce total uses the response total and otherwise infers it", () => { + const { wxapp } = load(); + assert.equal(wxapp.tileModel.reduceTotal({ total: 42 }, 10, 0, 50), 42); + assert.equal(wxapp.tileModel.reduceTotal({}, 10, 2, 50), 110); + assert.equal(wxapp.tileModel.reduceTotal({ total: "x" }, 3, 0, 50), 0); + assert.equal(wxapp.tileModel.reduceTotal(null, 4, 1, 50), 54); +}); + +test("map tiles projects field aliases and defaults the visibility", () => { + const { wxapp } = load(); + const tiles = wxapp.tileModel.mapTiles({ + items: [ + { id: "t1", title: "T", color: "red", visible: false, options: [1, 2] }, + { name: "N", content: "" } + ] + }); + + assert.equal(tiles.length, 2); + assert.equal(tiles[0].id, "t1"); + assert.equal(tiles[0].label, "T"); + assert.equal(tiles[0].colorCss, "red"); + assert.equal(tiles[0].visible, false); + assert.deepEqual(tiles[0].options, [1, 2]); + + assert.equal(tiles[1].id, null); + assert.equal(tiles[1].label, "N"); + assert.equal(tiles[1].html, ""); + assert.equal(tiles[1].visible, true); + assert.equal(tiles[1].options, null); + + assert.deepEqual(wxapp.tileModel.mapTiles(null), []); + assert.deepEqual(wxapp.tileModel.mapTiles({}), []); +}); + +test("model loads tiles and persists the state through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ items: [{ id: "t1", title: "Tile" }], total: 1 }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.tileModel.legacyDescriptor("/api/tiles")); + + const loaded = await service.query({}); + assert.equal(calls[0].method, "GET"); + const tiles = wxapp.tileModel.mapTiles(loaded.data); + assert.equal(tiles[0].id, "t1"); + assert.equal(wxapp.tileModel.reduceTotal(loaded.data, tiles.length, 0, 50), 1); + + const saved = await service.update({ layout: "x" }); + assert.equal(calls[1].method, "PUT"); + assert.deepEqual(JSON.parse(calls[1].body), { layout: "x" }); + assert.equal(saved.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/watcher.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/watcher.model.test.mjs new file mode 100644 index 0000000..d0ba017 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/watcher.model.test.mjs @@ -0,0 +1,99 @@ +/** + * Headless unit tests for the watcher model helpers (View, State and Service). + * + * These cover the pure logic extracted from webexpress.webapp.watcher.js, namely + * the legacy descriptor, the list normalisation, the user search url, the + * candidate filtering and the removal helpers, plus an end to end path that + * loads watchers with a query, adds one with a create and deletes one with a + * remove through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.watcher.model.js")] }, + options + )); +} + +test("legacy descriptor loads with get and uses put for the update", () => { + const { wxapp } = load(); + const descriptor = wxapp.watcherModel.legacyDescriptor("/api/watchers/INC-1"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/watchers/INC-1"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); +}); + +test("normalize list passes an array through and defaults to empty", () => { + const { wxapp } = load(); + assert.deepEqual(wxapp.watcherModel.normalizeList([{ id: "a" }]), [{ id: "a" }]); + assert.deepEqual(wxapp.watcherModel.normalizeList(null), []); + assert.deepEqual(wxapp.watcherModel.normalizeList({ id: "a" }), []); +}); + +test("search url appends the query with the correct separator and encoding", () => { + const { wxapp } = load(); + assert.equal(wxapp.watcherModel.searchUrl("/api/users", "a b"), "/api/users?q=a%20b"); + assert.equal(wxapp.watcherModel.searchUrl("/api/users?team=x", "y"), "/api/users?team=x&q=y"); + assert.equal(wxapp.watcherModel.searchUrl("/api/users", null), "/api/users?q="); +}); + +test("candidates excludes existing watchers and tolerates non arrays", () => { + const { wxapp } = load(); + const watchers = [{ id: "u1" }, { id: "u2" }]; + const users = [{ id: "u1" }, { id: "u3" }, { id: "u2" }, { id: "u4" }]; + + assert.deepEqual(wxapp.watcherModel.candidates(watchers, users).map(u => u.id), ["u3", "u4"]); + assert.deepEqual(wxapp.watcherModel.candidates(null, users).map(u => u.id), ["u1", "u3", "u2", "u4"]); + assert.deepEqual(wxapp.watcherModel.candidates(watchers, null), []); +}); + +test("remove path and remove by id drop the matching watcher", () => { + const { wxapp } = load(); + assert.equal(wxapp.watcherModel.removePath("a b"), "/a%20b"); + + const list = [{ id: "u1" }, { id: "u2" }, { id: "u3" }]; + assert.deepEqual(wxapp.watcherModel.removeById(list, "u2").map(u => u.id), ["u1", "u3"]); + assert.deepEqual(wxapp.watcherModel.removeById(null, "u2"), []); +}); + +test("model loads, adds and removes a watcher through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => [{ id: "u1", name: "Ann" }] }; + } + if (method === "POST") { + return { ok: true, status: 200, json: async () => ({ id: "u2", name: "Bob" }) }; + } + return { ok: true, status: 204 }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.watcherModel.legacyDescriptor("/api/watchers")); + + const loaded = await service.query({}); + assert.equal(calls[0].method, "GET"); + assert.deepEqual(wxapp.watcherModel.normalizeList(loaded.data).map(u => u.id), ["u1"]); + + const created = await service.create({ userId: "u2" }); + assert.equal(calls[1].method, "POST"); + assert.deepEqual(JSON.parse(calls[1].body), { userId: "u2" }); + assert.equal(created.data.id, "u2"); + + const removed = await service.remove({ path: wxapp.watcherModel.removePath("u1") }); + assert.equal(calls[2].method, "DELETE"); + assert.equal(calls[2].url.endsWith("/u1"), true); + assert.equal(removed.ok, true); + assert.equal(removed.data, null); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/watcher.test.mjs b/src/WebExpress.WebApp.Test/JsTest/watcher.test.mjs new file mode 100644 index 0000000..e2a168e --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/watcher.test.mjs @@ -0,0 +1,88 @@ +/** + * Headless tests for the watcher control after it was lifted onto the Component + * base (View, State and Service). They instantiate the real control file in the + * harness (alongside its model) and assert that it extends Component, seeds its + * watchers from the data-wx-state island and skips the network load in that + * case, and otherwise loads from the service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { + extraFiles: [ + webappAsset("webexpress.webapp.watcher.model.js"), + webappAsset("webexpress.webapp.watcher.js") + ] + }, + options + )); +} + +/** + * Awaits the asynchronous load and the batched store notification. + * @returns {Promise} Resolves after the macrotask and microtask queues drain. + */ +function settle() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("watcher extends the component base", () => { + const { wxapp, createElement, setFetch } = load(); + setFetch(async () => ({ ok: true, status: 200, json: async () => [] })); + + const element = createElement("div"); + element.dataset.uri = "/api/watchers"; + + const ctrl = new wxapp.WatcherCtrl(element); + + assert.ok(ctrl instanceof wxapp.Data); + assert.equal(typeof ctrl.store, "object"); +}); + +test("watcher seeds its watchers from the data-wx-state island and skips the load", async () => { + const { wxapp, createElement, setFetch } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, json: async () => [] }; }); + + const element = createElement("div"); + element.dataset.uri = "/api/watchers"; + element.setAttribute("data-wx-state", JSON.stringify({ watchers: [{ id: "u1", name: "Ann", initials: "AN" }] })); + + const ctrl = new wxapp.WatcherCtrl(element); + + // the store is seeded synchronously, so the value is available at once + assert.equal(ctrl.value.length, 1); + assert.equal(ctrl.value[0].id, "u1"); + + // the avatar row is rendered from the seeded state on mount + assert.equal(ctrl._row.childNodes.length, 1); + + // the seed avoids the round trip + await settle(); + assert.equal(fetchCount, 0); +}); + +test("watcher loads from the service when no state island is present", async () => { + const { wxapp, createElement, setFetch } = load(); + let fetchCount = 0; + setFetch(async () => { fetchCount++; return { ok: true, status: 200, json: async () => [{ id: "u9", name: "Bob" }] }; }); + + const element = createElement("div"); + element.dataset.uri = "/api/watchers"; + + const ctrl = new wxapp.WatcherCtrl(element); + assert.equal(ctrl.value.length, 0); + + await settle(); + + assert.equal(fetchCount, 1); + assert.equal(ctrl.value.length, 1); + assert.equal(ctrl.value[0].id, "u9"); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/workflow.editor.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/workflow.editor.model.test.mjs new file mode 100644 index 0000000..cbc5853 --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/workflow.editor.model.test.mjs @@ -0,0 +1,118 @@ +/** + * Headless unit tests for the workflow editor model helpers (View, State and + * Service). + * + * These cover the pure logic extracted from webexpress.webapp.workflow.editor.js: + * the legacy descriptor, the meta and catalog normalisation, the wire format + * read (nodes/states and edges/transitions aliases with source/target mapping) + * and the wire payload build, plus an end to end path that loads the workflow + * with a query and persists it with an update through a service. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +function load(options) { + return loadEngine(Object.assign( + { extraFiles: [webappAsset("webexpress.webapp.workflow.editor.model.js")] }, + options + )); +} + +test("legacy descriptor loads with get and uses put for the update", () => { + const { wxapp } = load(); + const descriptor = wxapp.workflowEditorModel.legacyDescriptor("/api/wf"); + + assert.equal(descriptor.kind, "rest"); + assert.equal(descriptor.baseUri, "/api/wf"); + assert.equal(descriptor.method, "GET"); + assert.equal(descriptor.updateMethod, "PUT"); +}); + +test("normalize meta and catalog read fields and default them", () => { + const { wxapp } = load(); + const meta = wxapp.workflowEditorModel.normalizeMeta({ id: "w1", name: "W" }); + assert.equal(meta.id, "w1"); + assert.equal(meta.name, "W"); + assert.equal(meta.state, ""); + assert.equal(meta.version, ""); + + const cat = wxapp.workflowEditorModel.normalizeCatalog({ guards: [{ id: "g" }] }); + assert.deepEqual(cat.guards, [{ id: "g" }]); + assert.deepEqual(cat.validations, []); + assert.deepEqual(cat.postfunctions, []); + assert.deepEqual(wxapp.workflowEditorModel.normalizeCatalog(null).guards, []); +}); + +test("from wire format accepts aliases and maps source and target", () => { + const { wxapp } = load(); + const resp = { states: [{ id: "n1" }], transitions: [{ id: "e1", source: "n1", target: "n2" }] }; + const graph = wxapp.workflowEditorModel.fromWireFormat(resp); + + assert.equal(graph.nodes[0].id, "n1"); + assert.equal(graph.edges[0].from, "n1"); + assert.equal(graph.edges[0].to, "n2"); + assert.notEqual(graph.nodes[0], resp.states[0]); + + const resp2 = { nodes: [{ id: "x" }], states: [{ id: "y" }], edges: [{ id: "e", from: "a", to: "b" }] }; + const g2 = wxapp.workflowEditorModel.fromWireFormat(resp2); + assert.equal(g2.nodes[0].id, "x"); + assert.equal(g2.edges[0].from, "a"); + + assert.deepEqual(wxapp.workflowEditorModel.fromWireFormat({}), { nodes: [], edges: [] }); + assert.deepEqual(wxapp.workflowEditorModel.fromWireFormat(null), { nodes: [], edges: [] }); +}); + +test("to wire payload mirrors nodes and edges under states and transitions", () => { + const { wxapp } = load(); + const nodes = [{ id: "n1" }]; + const edges = [{ id: "e1" }]; + const p = wxapp.workflowEditorModel.toWirePayload( + { id: "w1", name: "W", state: "draft", version: "1", description: "d" }, + { nodes: nodes, edges: edges } + ); + + assert.equal(p.id, "w1"); + assert.equal(p.name, "W"); + assert.equal(p.description, "d"); + assert.equal(p.nodes, nodes); + assert.equal(p.states, nodes); + assert.equal(p.edges, edges); + assert.equal(p.transitions, edges); + + const p2 = wxapp.workflowEditorModel.toWirePayload(null, null); + assert.deepEqual(p2.nodes, []); + assert.deepEqual(p2.states, []); +}); + +test("model loads and persists the workflow through a service end to end", async () => { + const { wxapp, setFetch } = load(); + const calls = []; + setFetch(async (url, init) => { + const method = (init && init.method) || "GET"; + calls.push({ url: url, method: method, body: init && init.body }); + if (method === "GET") { + return { ok: true, status: 200, json: async () => ({ id: "w1", states: [{ id: "n1" }], transitions: [{ id: "e1", source: "n1", target: "n2" }] }) }; + } + return { ok: true, status: 200, json: async () => ({}) }; + }); + + const service = wxapp.ServiceRegistry.create(wxapp.workflowEditorModel.legacyDescriptor("/api/wf")); + + const loaded = await service.query({}); + const meta = wxapp.workflowEditorModel.normalizeMeta(loaded.data); + const graph = wxapp.workflowEditorModel.fromWireFormat(loaded.data); + assert.equal(meta.id, "w1"); + assert.equal(graph.edges[0].from, "n1"); + + const saved = await service.update(wxapp.workflowEditorModel.toWirePayload(meta, graph)); + assert.equal(calls[1].method, "PUT"); + const sentBody = JSON.parse(calls[1].body); + assert.equal(sentBody.states[0].id, "n1"); + assert.equal(sentBody.transitions[0].from, "n1"); + assert.equal(saved.ok, true); +}); diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestDashboard.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataDashboard.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestDashboard.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataDashboard.cs index d3274d2..6d08fb7 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestDashboard.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataDashboard.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestDashboard : FragmentControlRestDashboard + public sealed class TestFragmentControlDataDashboard : FragmentControlDataDashboard { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestDashboard(IFragmentContext fragmentContext) + public TestFragmentControlDataDashboard(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestWorkflow.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataDropdown.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestWorkflow.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataDropdown.cs index e1a599c..c286570 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestWorkflow.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataDropdown.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestWorkflow : FragmentControlRestWorkflow + public sealed class TestFragmentControlDataDropdown : FragmentControlDataDropdown { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestWorkflow(IFragmentContext fragmentContext) + public TestFragmentControlDataDropdown(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormDelete.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormDelete.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestFormDelete.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataFormDelete.cs index e1e6b9f..d20c688 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormDelete.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormDelete.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestFormDelete : FragmentControlRestFormDelete + public sealed class TestFragmentControlDataFormDelete : FragmentControlDataFormDelete { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestFormDelete(IFragmentContext fragmentContext) + public TestFragmentControlDataFormDelete(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestDropdown.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormEdit.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestDropdown.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataFormEdit.cs index 8a2a82d..8dbf28c 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestDropdown.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormEdit.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestDropdown : FragmentControlRestDropdown + public sealed class TestFragmentControlDataFormEdit : FragmentControlDataFormEdit { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestDropdown(IFragmentContext fragmentContext) + public TestFragmentControlDataFormEdit(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormNew.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormNew.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestFormNew.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataFormNew.cs index 20f1180..c8e2ef0 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormNew.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataFormNew.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestFormNew : FragmentControlRestFormAdd + public sealed class TestFragmentControlDataFormNew : FragmentControlDataFormAdd { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestFormNew(IFragmentContext fragmentContext) + public TestFragmentControlDataFormNew(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestQuickfilter.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataQuickfilter.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestQuickfilter.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataQuickfilter.cs index 1ce5cae..e0c58e0 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestQuickfilter.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataQuickfilter.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestQuickfilter : FragmentControlRestQuickfilter + public sealed class TestFragmentControlDataQuickfilter : FragmentControlDataQuickfilter { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestQuickfilter(IFragmentContext fragmentContext) + public TestFragmentControlDataQuickfilter(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestTabTemplate.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataTabTemplate.cs similarity index 76% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestTabTemplate.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataTabTemplate.cs index b4c3f5e..3e3f743 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestTabTemplate.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataTabTemplate.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section] [Scope] - public sealed class TestFragmentControlRestTabTemplate : FragmentControlRestTabTemplate + public sealed class TestFragmentControlDataTabTemplate : FragmentControlDataTabTemplate { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestTabTemplate(IFragmentContext fragmentContext) + public TestFragmentControlDataTabTemplate(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestTable.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataTable.cs similarity index 78% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestTable.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataTable.cs index 42d5dd1..4406a57 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestTable.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataTable.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestTable : FragmentControlRestTable + public sealed class TestFragmentControlDataTable : FragmentControlDataTable { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestTable(IFragmentContext fragmentContext) + public TestFragmentControlDataTable(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestWizard.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataWizard.cs similarity index 78% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestWizard.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataWizard.cs index c94b88c..04db11b 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestWizard.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataWizard.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestWizard : FragmentControlRestWizard + public sealed class TestFragmentControlDataWizard : FragmentControlDataWizard { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestWizard(IFragmentContext fragmentContext) + public TestFragmentControlDataWizard(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormEdit.cs b/src/WebExpress.WebApp.Test/TestFragmentControlDataWorkflow.cs similarity index 77% rename from src/WebExpress.WebApp.Test/TestFragmentControlRestFormEdit.cs rename to src/WebExpress.WebApp.Test/TestFragmentControlDataWorkflow.cs index 9e63d5f..e372317 100644 --- a/src/WebExpress.WebApp.Test/TestFragmentControlRestFormEdit.cs +++ b/src/WebExpress.WebApp.Test/TestFragmentControlDataWorkflow.cs @@ -10,12 +10,12 @@ namespace WebExpress.WebApp.Test /// [Section()] [Scope] - public sealed class TestFragmentControlRestFormEdit : FragmentControlRestFormEdit + public sealed class TestFragmentControlDataWorkflow : FragmentControlDataWorkflow { /// /// Initializes a new instance of the class. /// - public TestFragmentControlRestFormEdit(IFragmentContext fragmentContext) + public TestFragmentControlDataWorkflow(IFragmentContext fragmentContext) : base(fragmentContext) { } diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAvatarDropdown.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAvatarDropdown.cs index 503313c..fa92dec 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAvatarDropdown.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlAvatarDropdown.cs @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestAvatarDropdown(id) + var control = new ControlDataAvatarDropdown(id) { }; @@ -48,7 +48,7 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestAvatarDropdown() + var control = new ControlDataAvatarDropdown() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlComment.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlComment.cs index d7823ad..1d08ee3 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlComment.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlComment.cs @@ -26,7 +26,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment(id) + var control = new ControlDataComment(id) { }; @@ -37,29 +37,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the comment control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/comments/INC-00123", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var context = UnitTestControlFixture.CreateRenderContextMock(); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } /// /// Tests the UsersUri property of the comment control. @@ -73,7 +50,7 @@ public void UsersUri(string uriString, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { UsersUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -99,7 +76,7 @@ public void CurrentUser(string user, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { CurrentUser = _ => user }; @@ -124,7 +101,7 @@ public void Readonly(bool readOnly, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { Readonly = _ => readOnly }; @@ -147,7 +124,7 @@ public void ImageUploadUri() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { ImageUploadUri = _ => new UriEndpoint("https://example.com/api/upload") }; @@ -170,7 +147,7 @@ public void Categories() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment("c") + var control = new ControlDataComment("c") { Categories = _ => "{\"general\":{\"id\":\"general\"}}" }; @@ -192,9 +169,8 @@ public void AllAttributes() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment("c1") + var control = new ControlDataComment("c1") { - RestUri = _ => new UriEndpoint("https://example.com/api/comments/INC-1"), UsersUri = _ => new UriEndpoint("https://example.com/api/users"), CurrentUser = _ => "u-alice", ImageUploadUri = _ => new UriEndpoint("https://example.com/api/upload"), @@ -205,7 +181,7 @@ public void AllAttributes() var html = control.Render(context, visualTree); // validation - AssertExtensions.EqualWithPlaceholders(@"
", html); + AssertExtensions.EqualWithPlaceholders(@"
", html); } /// @@ -219,7 +195,7 @@ public void Enable_False_RendersNothing() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestComment() + var control = new ControlDataComment() { Enable = _ => false }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDashboard.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDashboard.cs similarity index 68% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDashboard.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDashboard.cs index 2041b15..97aaa0c 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDashboard.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDashboard.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api dashboard control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestDashboard + public class UnitTestControlDataDashboard { /// /// Tests the id property of the api dashboard control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDashboard(id) + var control = new ControlDataDashboard(id) { }; @@ -35,30 +35,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the api dashboard control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDashboard() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } /// /// Tests the column capability flags emit their data attributes only when true. @@ -76,7 +52,7 @@ public void ColumnFlags(bool editable, bool movable, bool deletable, string expe var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDashboard() + var control = new ControlDataDashboard() { EditableColumn = _ => editable, MovableColumn = _ => movable, diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDropdown.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDropdown.cs similarity index 95% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDropdown.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDropdown.cs index 96655b8..2eeee96 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestDropdown.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataDropdown.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST dropdown control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestDropdown + public class UnitTestControlDataDropdown { /// /// Tests the id property of the REST dropdown control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDropdown(id) + var control = new ControlDataDropdown(id) { }; @@ -48,7 +48,7 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDropdown() + var control = new ControlDataDropdown() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -74,7 +74,7 @@ public void MaxItems(int maxItems, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDropdown() + var control = new ControlDataDropdown() { MaxItems = _ => maxItems }; @@ -100,7 +100,7 @@ public void SearchPlaceholder(string searchPlaceholder, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestDropdown() + var control = new ControlDataDropdown() { SearchPlaceholder = _ => searchPlaceholder }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestForm.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataForm.cs similarity index 95% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestForm.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataForm.cs index 9b3bf3c..525261d 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestForm.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataForm.cs @@ -11,7 +11,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the rest form control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestForm + public class UnitTestControlDataForm { /// /// Tests the id property of the rest form control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(id) + var control = new ControlDataForm(id) { }; @@ -54,7 +54,7 @@ public void BackgroundColor(TypeColorBackground color, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { BackgroundColor = _ => new PropertyColorBackground(color) }; @@ -78,7 +78,7 @@ public void Name(string name, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { Name = _ => name }; @@ -103,7 +103,7 @@ public void Uri(string uri, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { Uri = _ => uri is not null ? new UriEndpoint(uri) : null }; @@ -131,7 +131,7 @@ public void Method(RequestMethod method, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(null) + var control = new ControlDataForm(null) { Method = _ => method }; @@ -157,7 +157,7 @@ public void Mode(TypeRestFormMode mode, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(null) + var control = new ControlDataForm(null) { Mode = _ => mode.ToMode() }; @@ -181,7 +181,7 @@ public void FormLayout(TypeLayoutForm formLayout, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { FormLayout = _ => formLayout }; @@ -206,7 +206,7 @@ public void ItemLayout(TypeLayoutFormItem itemLayout, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { ItemLayout = _ => itemLayout }; @@ -233,7 +233,7 @@ public void Justify(TypeJustifiedFlex justify, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm() + var control = new ControlDataForm() { Justify = _ => justify, }; @@ -255,7 +255,7 @@ public void EmptyForm() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(); + var control = new ControlDataForm(); // act var html = control.Render(context, visualTree)?.ToString().Trim(); @@ -274,7 +274,7 @@ public void EmptyFormChangeSubmitText() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestForm(); + var control = new ControlDataForm(); control.AddPrimaryButton(new ControlFormItemButtonSubmit("") { Text = _ => "sendbutton" @@ -300,7 +300,7 @@ public void Value(string value, string expected) var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); var control = new ControlFormItemInputText(null); - var form = new ControlRestForm().Add(control).Initialize(renderContext => + var form = new ControlDataForm().Add(control).Initialize(renderContext => { renderContext.SetValue(control, new ControlFormInputValueString(value)); }); diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormEditor.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormEditor.cs similarity index 92% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormEditor.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormEditor.cs index 4e457ad..9f9ff74 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormEditor.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormEditor.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the form editor control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestFormEditor + public class UnitTestControlDataFormEditor { /// /// Tests the id property of the form editor control. @@ -23,7 +23,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor(id); + var control = new ControlDataFormEditor(id); // act var html = control.Render(context, visualTree); @@ -42,7 +42,7 @@ public void RestUri() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor() + var control = new ControlDataFormEditor() { RestUri = _ => new UriEndpoint("/api/1/FormStructure") }; @@ -68,7 +68,7 @@ public void Preview(bool preview, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor() + var control = new ControlDataFormEditor() { Preview = _ => preview }; @@ -95,7 +95,7 @@ public void Indent(int indent, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor() + var control = new ControlDataFormEditor() { Indent = _ => indent }; @@ -119,7 +119,7 @@ public void Readonly(bool readOnly, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormEditor() + var control = new ControlDataFormEditor() { Readonly = _ => readOnly }; @@ -138,10 +138,10 @@ public void Readonly(bool readOnly, string expected) public void Defaults() { // arrange - var control = new ControlRestFormEditor(); + var control = new ControlDataFormEditor(); // validation - Assert.Equal(ControlRestFormEditor._defaultIndent, control.Indent?.Invoke(null)); + Assert.Equal(ControlDataFormEditor._defaultIndent, control.Indent?.Invoke(null)); Assert.True(control.Preview?.Invoke(null)); Assert.False(control.Readonly?.Invoke(null) ?? false); Assert.Null(control.RestUri?.Invoke(null)); diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputCheck.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputCheck.cs similarity index 94% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputCheck.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputCheck.cs index cbdb14e..c1a2229 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputCheck.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputCheck.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST-backed check control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestFormItemInputCheck + public class UnitTestControlDataFormItemInputCheck { /// /// Tests the id property of the REST check control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(id) + var control = new ControlDataFormItemInputCheck(id) { }; @@ -48,7 +48,7 @@ public void AutoId(string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck() + var control = new ControlDataFormItemInputCheck() { }; @@ -72,7 +72,7 @@ public void Name(string name, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { Name = _ => name }; @@ -99,7 +99,7 @@ public void Description(string description, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { Description = _ => description }; @@ -124,7 +124,7 @@ public void Layout(TypeLayoutCheck layout, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { Layout = _ => layout }; @@ -149,7 +149,7 @@ public void Inline(bool inline, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { Inline = _ => inline }; @@ -177,7 +177,7 @@ public void RestUri(string uriString, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck(null) + var control = new ControlDataFormItemInputCheck(null) { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -201,7 +201,7 @@ public void NoInitialValueOmitsDataValue() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck("chk") + var control = new ControlDataFormItemInputCheck("chk") { RestUri = _ => new UriEndpoint("https://example.com/api/check") }; @@ -217,7 +217,7 @@ public void NoInitialValueOmitsDataValue() } /// - /// Tests that + /// Tests that /// causes the control to emit the data-value attribute so the /// client can skip the initial GET request and use the supplied value /// directly. @@ -231,7 +231,7 @@ public void InitialCheckedEmitsDataValue(bool initial, string expectedValue, str var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputCheck("chk") + var control = new ControlDataFormItemInputCheck("chk") { RestUri = _ => new UriEndpoint("https://example.com/api/check"), InitialChecked = _ => initial diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputPassword.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputPassword.cs similarity index 94% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputPassword.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputPassword.cs index b683398..d4caeff 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputPassword.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputPassword.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST password input control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestFormItemInputPassword + public class UnitTestControlDataFormItemInputPassword { /// /// Tests the id property of the REST password control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(id) + var control = new ControlDataFormItemInputPassword(id) { }; @@ -48,7 +48,7 @@ public void AutoId(string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword() + var control = new ControlDataFormItemInputPassword() { }; @@ -72,7 +72,7 @@ public void Name(string name, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { Name = _ => name }; @@ -98,7 +98,7 @@ public void Placeholder(string placeholder, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { Placeholder = _ => placeholder }; @@ -124,7 +124,7 @@ public void MinLength(uint? minLength, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { MinLength = _ => minLength }; @@ -150,7 +150,7 @@ public void MaxLength(uint? maxLength, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { MaxLength = _ => maxLength }; @@ -175,7 +175,7 @@ public void Required(bool required, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) { Required = _ => required }; @@ -200,7 +200,7 @@ public void ValueForm(string value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null); + var control = new ControlDataFormItemInputPassword(null); var form = new ControlForm().Add(control) .Initialize(renderContext => { @@ -229,7 +229,7 @@ public void ValueItem(string value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword(null) + var control = new ControlDataFormItemInputPassword(null) .Initialize(arg => { arg.Value.Text = value; @@ -263,7 +263,7 @@ public void ValidateForm(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword("password-box").Initialize(args => + var control = new ControlDataFormItemInputPassword("password-box").Initialize(args => { args.Value.Text = value; }); @@ -310,7 +310,7 @@ public void ProcessForm(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputPassword("password-box") + var control = new ControlDataFormItemInputPassword("password-box") .Initialize(args => { args.Value.Text = value; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputSelection.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputSelection.cs similarity index 93% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputSelection.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputSelection.cs index 748820b..bdc5921 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputSelection.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputSelection.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST selection control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestFormItemInputSelection + public class UnitTestControlDataFormItemInputSelection { /// /// Tests the id property of the form REST selection control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(id) + var control = new ControlDataFormItemInputSelection(id) { }; @@ -48,7 +48,7 @@ public void AutoId(string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection() + var control = new ControlDataFormItemInputSelection() { }; @@ -72,7 +72,7 @@ public void Name(string name, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { Name = _ => name }; @@ -98,7 +98,7 @@ public void Placeholder(string placeholder, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { Placeholder = _ => placeholder }; @@ -123,7 +123,7 @@ public void MultiSelect(bool multiSelect, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { MultiSelect = _ => multiSelect }; @@ -148,7 +148,7 @@ public void RestUri(string uriString, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -174,7 +174,7 @@ public void MaxItems(int maxItems, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputSelection(null) + var control = new ControlDataFormItemInputSelection(null) { MaxItems = _ => maxItems }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputUnique.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputUnique.cs similarity index 94% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputUnique.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputUnique.cs index d5cf3ea..ae49141 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestFormItemInputUnique.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataFormItemInputUnique.cs @@ -11,7 +11,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST unique control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestFormItemInputUnique + public class UnitTestControlDataFormItemInputUnique { /// /// Tests the id property of the REST unique control. @@ -26,7 +26,7 @@ public void Id(string id, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(id) + var control = new ControlDataFormItemInputUnique(id) { }; @@ -49,7 +49,7 @@ public void AutoId(string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique() + var control = new ControlDataFormItemInputUnique() { }; @@ -73,7 +73,7 @@ public void Name(string name, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Name = _ => name }; @@ -98,7 +98,7 @@ public void Description(string description, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Description = _ => description }; @@ -124,7 +124,7 @@ public void Placeholder(string placeholder, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Placeholder = _ => placeholder }; @@ -150,7 +150,7 @@ public void MinLength(uint? minLength, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { MinLength = _ => minLength }; @@ -176,7 +176,7 @@ public void MaxLength(uint? maxLength, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { MaxLength = _ => maxLength }; @@ -201,7 +201,7 @@ public void Required(bool required, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Required = _ => required }; @@ -226,7 +226,7 @@ public void Pattern(string pattern, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { Pattern = _ => pattern }; @@ -251,7 +251,7 @@ public void RestUri(string uriString, string expected) var form = new ControlForm(); var context = new RenderControlFormContext(UnitTestControlFixture.CreateRenderContextMock(), form); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -276,7 +276,7 @@ public void ValueForm(string value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null); + var control = new ControlDataFormItemInputUnique(null); var form = new ControlForm().Add(control) .Initialize(renderContext => { @@ -305,7 +305,7 @@ public void ValueItem(string value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique(null) + var control = new ControlDataFormItemInputUnique(null) .Initialize(arg => { arg.Value.Text = value; @@ -339,7 +339,7 @@ public void ValidateForm(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique("text-box").Initialize(args => + var control = new ControlDataFormItemInputUnique("text-box").Initialize(args => { args.Value.Text = value; }); @@ -386,7 +386,7 @@ public void ValidateItem(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique("text-box") + var control = new ControlDataFormItemInputUnique("text-box") .Validate ( x => @@ -430,7 +430,7 @@ public void ProcessForm(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique("text-box") + var control = new ControlDataFormItemInputUnique("text-box") .Initialize(args => { args.Value.Text = value; @@ -474,7 +474,7 @@ public void ProcessItem(string value, string expected) new Parameter("form", "", ParameterScope.Parameter) ); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestFormItemInputUnique("text-box") + var control = new ControlDataFormItemInputUnique("text-box") .Initialize(x => x.Value.Text = value) .Process(x => processed = true); var form = new ControlForm() { Name = _ => "form" } diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestKanban.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataKanban.cs similarity index 68% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestKanban.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataKanban.cs index f9d3ad9..e15d2e4 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestKanban.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataKanban.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api kanban control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestKanban + public class UnitTestControlDataKanban { /// /// Tests the id property of the api kanban control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestKanban(id) + var control = new ControlDataKanban(id) { }; @@ -35,30 +35,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the api kanban control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestKanban() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } /// /// Tests the column capability flags emit their data attributes only when true. @@ -76,7 +52,7 @@ public void ColumnFlags(bool editable, bool movable, bool deletable, string expe var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestKanban() + var control = new ControlDataKanban() { EditableColumn = _ => editable, MovableColumn = _ => movable, diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestList.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataList.cs similarity index 76% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestList.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataList.cs index 17f187f..40a17e6 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestList.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataList.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api list control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestList + public class UnitTestControlDataList { /// /// Tests the id property of the api list control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList(id) + var control = new ControlDataList(id) { }; @@ -36,31 +36,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the api list control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - /// /// Tests the layout property of the list control. /// @@ -76,7 +51,7 @@ public void Layout(TypeLayoutList layout, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList() + var control = new ControlDataList() { Layout = _ => layout }; @@ -102,7 +77,7 @@ public void Title(string title, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList(null) { Title = _ => title }; + var control = new ControlDataList(null) { Title = _ => title }; // act var html = control.Render(context, visualTree); @@ -123,7 +98,7 @@ public void Sortable(bool sortable, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestList(null) { Sortable = _ => sortable }; + var control = new ControlDataList(null) { Sortable = _ => sortable }; // act var html = control.Render(context, visualTree); diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListData.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListData.cs new file mode 100644 index 0000000..80fb22b --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListData.cs @@ -0,0 +1,114 @@ +using System.Net; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; +using WebExpress.WebApp.WebControl; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests that the REST list control emits the C# authored data-wx-state + /// island through the IDataIsland interface, alongside the data-wx-service + /// island. The state island is consumed by the engine through + /// webexpress.webapp.Data.readState. These tests assert both the new + /// emission and the non breaking default (an absent or empty state emits no + /// island). + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataListData + { + /// + /// Tests that a declared state emits the data-wx-state island. + /// + [Fact] + public void StateEmitsTheStateIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + StateFactory = _ => DataState.Create().Set("page", 0).Set("pageSize", 50) + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that a declared state and service emit both islands, with the + /// state island first. + /// + [Fact] + public void StateAndServiceEmitBothIslands() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + StateFactory = _ => DataState.Create().Set("page", 0), + ServiceFactory = _ => DataServiceDescriptor.ListData("/api/orders") + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that an empty state emits no island, so the default stays non + /// breaking. + /// + [Fact] + public void EmptyStateEmitsNoStateIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + StateFactory = _ => DataState.Create() + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that the state island is HTML attribute encoded so its json + /// quotes do not break the markup, and that the full encoded island is + /// present. + /// + [Fact] + public void StateIslandIsHtmlEncoded() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + StateFactory = _ => DataState.Create().Set("page", 0).Set("pageSize", 50) + }; + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains(""page":0", html); + Assert.Contains(WebUtility.HtmlEncode(DataState.Create().Set("page", 0).Set("pageSize", 50).ToIsland()), html); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListService.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListService.cs new file mode 100644 index 0000000..5b499a6 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataListService.cs @@ -0,0 +1,88 @@ +using System.Linq; +using System.Net; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; +using WebExpress.WebApp.WebControl; +using WebExpress.WebCore.WebUri; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests that the REST list control emits the C# authored data-wx-service + /// island. This is the pilot of the C# side of the View, State and Service + /// architecture: the JavaScript already consumes the island through + /// ServiceRegistry.fromElement and falls back to its legacy descriptor when + /// the island is absent, so these tests assert both the non breaking default + /// and the new emission. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataListService + { + /// + /// Tests that a control without a declared data service emits no island, + /// so the existing markup and the legacy client fallback are preserved. + /// + [Fact] + public void NoServiceEmitsNoIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList(); + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that a declared data service emits the data-wx-service island. + /// + [Fact] + public void DataServiceEmitsTheIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + ServiceFactory = _ => DataServiceDescriptor.ListData("/api/orders") + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that the island is HTML attribute encoded so its json quotes do + /// not break the markup, and that it decodes back to the exact island. + /// + [Fact] + public void IslandIsHtmlEncoded() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList() + { + ServiceFactory = _ => DataServiceDescriptor.ListData("/api/orders") + }; + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation: encoded json quotes, and the full encoded island is present + Assert.Contains(""name":"data"", html); + Assert.Contains(WebUtility.HtmlEncode(DataServiceDescriptor.ListData("/api/orders").ToIsland()), html); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestLoginForm.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataLoginForm.cs similarity index 94% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestLoginForm.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataLoginForm.cs index 4a4a12e..4ecccf1 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestLoginForm.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataLoginForm.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the REST login form control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestLoginForm + public class UnitTestControlDataLoginForm { /// /// Tests the id property of the login form control. @@ -23,7 +23,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestLogin(id); + var control = new ControlDataLogin(id); // act var html = control.Render(context, visualTree); @@ -44,7 +44,7 @@ public void RestUri(string uriString, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestLogin() + var control = new ControlDataLogin() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -68,7 +68,7 @@ public void RedirectUri(string uriString, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestLogin() + var control = new ControlDataLogin() { RedirectUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -92,7 +92,7 @@ public void Title(string title, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestLogin() + var control = new ControlDataLogin() { Title = _ => title }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestQuickfilter.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataQuickfilter.cs similarity index 93% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestQuickfilter.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataQuickfilter.cs index 71cf71a..6c9e67a 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestQuickfilter.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataQuickfilter.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api quickfilter control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestQuickfilter + public class UnitTestControlDataQuickfilter { /// /// Tests the id property of the api quickfilter control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestQuickfilter(id) + var control = new ControlDataQuickfilter(id) { }; @@ -48,7 +48,7 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestQuickfilter() + var control = new ControlDataQuickfilter() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumBacklog.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumBacklog.cs similarity index 81% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumBacklog.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumBacklog.cs index 20cecaf..919adc4 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumBacklog.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumBacklog.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the scrum backlog control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestScrumBacklog + public class UnitTestControlDataScrumBacklog { /// /// Tests the id property of the scrum backlog control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumBacklog(id) + var control = new ControlDataScrumBacklog(id) { }; @@ -46,9 +46,8 @@ public void RenderAttributes() var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumBacklog("scrum") + var control = new ControlDataScrumBacklog("scrum") { - RestUri = _ => new UriEndpoint("https://example.com/api/scrum/backlog"), Title = _ => "Backlog", Selectable = _ => false, IconActive = _ => "active-icon", @@ -66,7 +65,7 @@ public void RenderAttributes() var html = control.Render(context, visualTree); // validation - AssertExtensions.EqualWithPlaceholders(@"
", html); + AssertExtensions.EqualWithPlaceholders(@"
", html); } /// @@ -82,7 +81,7 @@ public void Readonly(bool readOnly, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumBacklog() + var control = new ControlDataScrumBacklog() { Readonly = _ => readOnly }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumSprint.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumSprint.cs similarity index 93% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumSprint.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumSprint.cs index a484743..3b58a8e 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestScrumSprint.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataScrumSprint.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the scrum sprint control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestScrumSprint + public class UnitTestControlDataScrumSprint { /// /// Tests the id property of the scrum sprint control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumSprint(id) + var control = new ControlDataScrumSprint(id) { }; @@ -48,7 +48,7 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestScrumSprint() + var control = new ControlDataScrumSprint() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestSelectionTheme.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataSelectionTheme.cs similarity index 89% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestSelectionTheme.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataSelectionTheme.cs index e31fd03..37298fb 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestSelectionTheme.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataSelectionTheme.cs @@ -6,14 +6,14 @@ namespace WebExpress.WebApp.Test.WebControl { /// - /// Tests : the dropdown shell + /// Tests : the dropdown shell /// should be promoted to the theme-specific JS class /// (wx-webapp-dropdown-theme) and the REST URI should arrive on /// the data-uri attribute so the JS layer can fetch the theme /// list and PUT the user's selection. No surrounding form is required. /// [Collection("NonParallelTests")] - public class UnitTestControlRestSelectionTheme + public class UnitTestControlDataSelectionTheme { /// /// Without an explicit id the control auto-generates one and still @@ -26,7 +26,7 @@ public void AutoId_RendersThemeClass() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestSelectionTheme(); + var control = new ControlDataSelectionTheme(); // act var html = control.Render(context, visualTree); @@ -47,7 +47,7 @@ public void ExplicitId_RendersOnHost() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestSelectionTheme("themePicker"); + var control = new ControlDataSelectionTheme("themePicker"); // act var html = control.Render(context, visualTree); @@ -59,7 +59,7 @@ public void ExplicitId_RendersOnHost() } /// - /// The REST URI carried by + /// The REST URI carried by /// is emitted on data-uri. /// [Theory] @@ -71,7 +71,7 @@ public void RestUri_RendersAsDataUri(string uri, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestSelectionTheme("themePicker") + var control = new ControlDataSelectionTheme("themePicker") { RestUri = _ => uri is not null ? new UriEndpoint(uri) : null }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataServiceIslandRollout.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataServiceIslandRollout.cs new file mode 100644 index 0000000..fd7e1b8 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataServiceIslandRollout.cs @@ -0,0 +1,110 @@ +using System; +using System.Linq; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; +using WebExpress.WebApp.WebControl; +using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebUri; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests the C# rollout of the data-wx-service island across the control + /// families that already have a tested JavaScript descriptor: kanban, + /// dashboard, tile, comment, scrum backlog, workflow and tab. Each family + /// asserts that a declared data service emits the island and that it + /// coexists with the legacy uri attribute. The non breaking default (no + /// service emits no island) is covered by the existing per control render + /// tests, which would fail if the emission were unconditional. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataServiceIslandRollout + { + /// + /// Renders a control through the standard mock render context and visual + /// tree, so each test stays a single expressive line. + /// + /// The render invocation. + /// The rendered html node. + private static IHtmlNode Render(Func render) + { + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); + var context = UnitTestControlFixture.CreateRenderContextMock(application); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + + return render(context, visualTree); + } + + // kanban + + [Fact] + public void KanbanEmitsTheIsland() + { + var html = Render((ctx, vt) => new ControlDataKanban() { ServiceFactory = _ => DataServiceDescriptor.Data("/api/board") }.Render(ctx, vt)); + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + + // dashboard + + [Fact] + public void DashboardEmitsTheIsland() + { + var html = Render((ctx, vt) => new ControlDataDashboard() { ServiceFactory = _ => DataServiceDescriptor.Data("/api/dash") }.Render(ctx, vt)); + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + + // tile + + [Fact] + public void TileEmitsTheIsland() + { + var html = Render((ctx, vt) => new ControlDataTile() { ServiceFactory = _ => DataServiceDescriptor.Data("/api/tiles") }.Render(ctx, vt)); + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + + // comment (renders no id attribute when the id is null) + + [Fact] + public void CommentEmitsTheIsland() + { + var html = Render((ctx, vt) => new ControlDataComment() { ServiceFactory = _ => DataServiceDescriptor.Data("/api/comments") }.Render(ctx, vt)); + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + + // scrum backlog (uses the data-rest-uri attribute) + + [Fact] + public void ScrumBacklogEmitsTheIsland() + { + var html = Render((ctx, vt) => new ControlDataScrumBacklog() { ServiceFactory = _ => DataServiceDescriptor.Data("/api/backlog") }.Render(ctx, vt)); + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + + // workflow + + [Fact] + public void WorkflowEmitsTheIsland() + { + var html = Render((ctx, vt) => new ControlDataWorkflow() { ServiceFactory = _ => DataServiceDescriptor.Data("/api/wf") }.Render(ctx, vt)); + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + + // tab (uses the tab data descriptor with the id query and items response) + + [Fact] + public void TabEmitsTheIsland() + { + var html = Render((ctx, vt) => new ControlDataTab() { ServiceFactory = _ => DataServiceDescriptor.TabData("/api/tabs") }.Render(ctx, vt)); + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + } +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTab.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTab.cs similarity index 71% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTab.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTab.cs index 01e0113..42f0fe1 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTab.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTab.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api tab control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestTab + public class UnitTestControlDataTab { /// /// Tests the id property of the api tab control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTab(id) + var control = new ControlDataTab(id) { }; @@ -35,30 +35,6 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } - /// - /// Tests the RestUri property of the api tab control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTab() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } /// /// Tests the readonly property emits a data-readonly attribute only when true. @@ -73,7 +49,7 @@ public void Readonly(bool readOnly, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTab() + var control = new ControlDataTab() { Readonly = _ => readOnly }; @@ -98,7 +74,7 @@ public void MovableTab(bool movable, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTab() + var control = new ControlDataTab() { MovableTab = _ => movable }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTabTemplate.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTabTemplate.cs similarity index 93% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTabTemplate.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTabTemplate.cs index 9822a40..554c154 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTabTemplate.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTabTemplate.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api tab template control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestTabTemplate + public class UnitTestControlDataTabTemplate { /// /// Tests the id property of the api tab control. @@ -25,7 +25,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate(id) + var control = new ControlDataTabTemplate(id) { }; @@ -47,7 +47,7 @@ public void Metadata() var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate("template") + var control = new ControlDataTabTemplate("template") { Icon = _ => new IconUser(), Name = _ => "User Template", @@ -74,7 +74,7 @@ public void Multiplicity(int multiplicity, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate("template") + var control = new ControlDataTabTemplate("template") { Multiplicity = _ => multiplicity }; @@ -97,7 +97,7 @@ public void MultiplicityUnlimited() var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate("template") + var control = new ControlDataTabTemplate("template") { Multiplicity = _ => null }; @@ -116,7 +116,7 @@ public void MultiplicityUnlimited() public void InterfaceId() { // arrange - IControlRestTabTemplate template = new ControlRestTabTemplate("template-id"); + IControlDataTabTemplate template = new ControlDataTabTemplate("template-id"); // validation Assert.Equal("template-id", template.Id); @@ -133,7 +133,7 @@ public void Bind() var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTabTemplate("template") + var control = new ControlDataTabTemplate("template") { Bind = _ => new Binding().Add(new BindTemplate() .Add("uri", TypeBindMode.Attr, ".wx-webapp-dashboard", "data-uri") diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTile.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTable.cs similarity index 68% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTile.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTable.cs index 95a39b5..65aa29d 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTile.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTable.cs @@ -6,17 +6,17 @@ namespace WebExpress.WebApp.Test.WebControl { /// - /// Tests the api tile control. + /// Tests the api table control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestTile + public class UnitTestControlDataTable { /// - /// Tests the id property of the api tile control. + /// Tests the id property of the api table control. /// [Theory] - [InlineData(null, @"
")] - [InlineData("id", @"
")] + [InlineData(null, @"
")] + [InlineData("id", @"
")] public void Id(string id, string expected) { // arrange @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTile(id) + var control = new ControlDataTable(id) { }; @@ -35,22 +35,23 @@ public void Id(string id, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } + /// - /// Tests the RestUri property of the api tile control. + /// Tests the page size property of the API table control. /// [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) + [InlineData(0, @"
")] + [InlineData(10, @"
")] + public void PageSize(uint pageSize, string expected) { // arrange var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTile() + var control = new ControlDataTable() { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null + PageSize = _ => pageSize }; // act @@ -60,4 +61,4 @@ public void RestUri(string uriString, string expected) AssertExtensions.EqualWithPlaceholders(expected, html); } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTableService.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTableService.cs new file mode 100644 index 0000000..ebfc639 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTableService.cs @@ -0,0 +1,93 @@ +using System.Linq; +using System.Net; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebData; +using WebExpress.WebApp.WebControl; +using WebExpress.WebCore.WebUri; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests that the REST table control emits the C# authored data-wx-service + /// island. This is the second control family of the C# rollout of the View, + /// State and Service architecture, after the list. The JavaScript already + /// consumes the island through ServiceRegistry.fromElement and falls back to + /// its legacy descriptor when the island is absent, so these tests assert + /// both the non breaking default and the new emission. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataTableService + { + /// + /// Tests that a control without a declared data service emits no island, + /// so the existing markup and the legacy client fallback are preserved. + /// + [Fact] + public void NoServiceEmitsNoIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataTable(); + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that a declared data service emits the data-wx-service island. + /// + [Fact] + public void DataServiceEmitsTheIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataTable() + { + ServiceFactory = _ => DataServiceDescriptor.TableData("/api/orders") + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that the island is emitted next to the legacy data-uri attribute, + /// so the migration is additive and the legacy fallback stays available. + /// + + /// + /// Tests that the island is HTML attribute encoded so its json quotes do + /// not break the markup, and that the full encoded island is present. + /// + [Fact] + public void IslandIsHtmlEncoded() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataTable() + { + ServiceFactory = _ => DataServiceDescriptor.TableData("/api/orders") + }; + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation: encoded json quotes, and the full encoded island is present + Assert.Contains(""updateMethod":"PUT"", html); + Assert.Contains(WebUtility.HtmlEncode(DataServiceDescriptor.TableData("/api/orders").ToIsland()), html); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTag.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTag.cs similarity index 95% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTag.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTag.cs index 175f641..bd7f26b 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTag.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTag.cs @@ -12,7 +12,7 @@ namespace WebExpress.WebApp.Test.WebControl /// webexpress.webapp.TagCtrl. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestTag + public class UnitTestControlDataTag { /// /// Tests the id property of the tag control. @@ -26,7 +26,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag(id) + var control = new ControlDataTag(id) { }; @@ -49,7 +49,7 @@ public void RestUri(string uriString, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -74,7 +74,7 @@ public void Readonly(bool readOnly, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Readonly = _ => readOnly }; @@ -99,7 +99,7 @@ public void Placeholder(string placeholder, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Placeholder = _ => placeholder }; @@ -126,7 +126,7 @@ public void SystemColor(TypeColorTag color, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Color = _ => new PropertyColorTag(color) }; @@ -152,7 +152,7 @@ public void UserColor(string color, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Color = _ => new PropertyColorTag(color) }; @@ -178,7 +178,7 @@ public void Value(string[] value, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Value = _ => value }; @@ -200,7 +200,7 @@ public void AllAttributes() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag("t1") + var control = new ControlDataTag("t1") { RestUri = _ => new UriEndpoint("https://example.com/api/tags/INC-1"), Placeholder = _ => "add tag", @@ -227,7 +227,7 @@ public void Enable_False_RendersNothing() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTag() + var control = new ControlDataTag() { Enable = _ => false }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTile.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTile.cs new file mode 100644 index 0000000..577497f --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataTile.cs @@ -0,0 +1,39 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebControl; +using WebExpress.WebCore.WebUri; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests the api tile control. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataTile + { + /// + /// Tests the id property of the api tile control. + /// + [Theory] + [InlineData(null, @"
")] + [InlineData("id", @"
")] + public void Id(string id, string expected) + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); + var context = UnitTestControlFixture.CreateRenderContextMock(application); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataTile(id) + { + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(expected, html); + } + + } +} \ No newline at end of file diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizard.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizard.cs similarity index 94% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizard.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizard.cs index 26a023a..251671b 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizard.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizard.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api wizard control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestWizard + public class UnitTestControlDataWizard { /// /// Tests the id property of the api wizard control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWizard(id) + var control = new ControlDataWizard(id) { }; @@ -48,7 +48,7 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWizard() + var control = new ControlDataWizard() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizardPage.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizardPage.cs similarity index 92% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizardPage.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizardPage.cs index ff7c580..e4d79a0 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWizardPage.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWizardPage.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api wizard page control. /// [Collection("NonParallelTests")] - public class UnitTestControlRestWizardPage + public class UnitTestControlDataWizardPage { /// /// Tests the id property of the api wizard page control. @@ -23,7 +23,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWizardPage(id) + var control = new ControlDataWizardPage(id) { }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWorkflow.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWorkflow.cs new file mode 100644 index 0000000..db8d706 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWorkflow.cs @@ -0,0 +1,39 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebControl; +using WebExpress.WebCore.WebUri; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebControl +{ + /// + /// Tests the api workflow control. + /// + [Collection("NonParallelTests")] + public class UnitTestControlDataWorkflow + { + /// + /// Tests the id property of the api workflow control. + /// + [Theory] + [InlineData(null, @"
")] + [InlineData("id", @"
")] + public void Id(string id, string expected) + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); + var context = UnitTestControlFixture.CreateRenderContextMock(application); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataWorkflow(id) + { + }; + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(expected, html); + } + + } +} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWqlPrompt.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWqlPrompt.cs similarity index 94% rename from src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWqlPrompt.cs rename to src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWqlPrompt.cs index ffad2c4..66ab6a6 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWqlPrompt.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlDataWqlPrompt.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.Test.WebControl /// Tests the api list control. ///
[Collection("NonParallelTests")] - public class UnitTestControlRestWqlPrompt + public class UnitTestControlDataWqlPrompt { /// /// Tests the id property of the api list control. @@ -24,7 +24,7 @@ public void Id(string id, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWqlPrompt(id) + var control = new ControlDataWqlPrompt(id) { }; @@ -48,7 +48,7 @@ public void RestUri(string uriString, string expected) var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); var context = UnitTestControlFixture.CreateRenderContextMock(application); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWqlPrompt() + var control = new ControlDataWqlPrompt() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTable.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTable.cs deleted file mode 100644 index 0c86ec7..0000000 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestTable.cs +++ /dev/null @@ -1,88 +0,0 @@ -using WebExpress.WebApp.Test.Fixture; -using WebExpress.WebApp.WebControl; -using WebExpress.WebCore.WebUri; -using WebExpress.WebUI.WebPage; - -namespace WebExpress.WebApp.Test.WebControl -{ - /// - /// Tests the api table control. - /// - [Collection("NonParallelTests")] - public class UnitTestControlRestTable - { - /// - /// Tests the id property of the api table control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("id", @"
")] - public void Id(string id, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTable(id) - { - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - - /// - /// Tests the RestUri property of the API table control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTable() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - - /// - /// Tests the page size property of the API table control. - /// - [Theory] - [InlineData(0, @"
")] - [InlineData(10, @"
")] - public void PageSize(uint pageSize, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestTable() - { - PageSize = _ => pageSize - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - } -} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWorkflow.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWorkflow.cs deleted file mode 100644 index 1b6ce27..0000000 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlRestWorkflow.cs +++ /dev/null @@ -1,63 +0,0 @@ -using WebExpress.WebApp.Test.Fixture; -using WebExpress.WebApp.WebControl; -using WebExpress.WebCore.WebUri; -using WebExpress.WebUI.WebPage; - -namespace WebExpress.WebApp.Test.WebControl -{ - /// - /// Tests the api workflow control. - /// - [Collection("NonParallelTests")] - public class UnitTestControlRestWorkflow - { - /// - /// Tests the id property of the api workflow control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("id", @"
")] - public void Id(string id, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWorkflow(id) - { - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - - /// - /// Tests the RestUri property of the api workflow control. - /// - [Theory] - [InlineData(null, @"
")] - [InlineData("https://example.com/api/data", @"
")] - public void RestUri(string uriString, string expected) - { - // arrange - var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplication)).FirstOrDefault(); - var context = UnitTestControlFixture.CreateRenderContextMock(application); - var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWorkflow() - { - RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null - }; - - // act - var html = control.Render(context, visualTree); - - // validation - AssertExtensions.EqualWithPlaceholders(expected, html); - } - } -} diff --git a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlWatcher.cs b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlWatcher.cs index b6391b2..f69c3d7 100644 --- a/src/WebExpress.WebApp.Test/WebControl/UnitTestControlWatcher.cs +++ b/src/WebExpress.WebApp.Test/WebControl/UnitTestControlWatcher.cs @@ -26,7 +26,7 @@ public void Id(string id, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher(id); + var control = new ControlDataWatcher(id); // act var html = control.Render(context, visualTree); @@ -47,7 +47,7 @@ public void RestUri(string uriString, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { RestUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -71,7 +71,7 @@ public void UsersUri(string uriString, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { UsersUri = _ => uriString is not null ? new UriEndpoint(uriString) : null }; @@ -96,7 +96,7 @@ public void MaxVisible(int? maxVisible, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { MaxVisible = _ => maxVisible }; @@ -121,7 +121,7 @@ public void Readonly(bool readOnly, string expected) var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { Readonly = _ => readOnly }; @@ -143,7 +143,7 @@ public void AllAttributes() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher("o1") + var control = new ControlDataWatcher("o1") { RestUri = _ => new UriEndpoint("https://example.com/api/watchers/INC-1"), UsersUri = _ => new UriEndpoint("https://example.com/api/users"), @@ -169,7 +169,7 @@ public void Enable_False_RendersNothing() var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); var context = UnitTestControlFixture.CreateRenderContextMock(); var visualTree = new VisualTreeControl(componentHub, context.PageContext); - var control = new ControlRestWatcher() + var control = new ControlDataWatcher() { Enable = _ => false }; diff --git a/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs b/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs new file mode 100644 index 0000000..40ce135 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs @@ -0,0 +1,89 @@ +using System.Net.Http; +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebData; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebData +{ + /// + /// Tests the fluent C# authoring surface of the data layer: the + /// that produces a service descriptor and the + /// State and Service extensions that let a control declare its state and + /// service by chaining, matching the View, State and Service concept. The + /// endpoint resolution through the sitemap is exercised by the tutorial pages; + /// these tests cover the builder shape and that the chain emits the islands. + /// + [Collection("NonParallelTests")] + public class UnitTestDataAuthoring + { + /// + /// Tests that the builder produces a descriptor that carries the declared + /// method, update method, query mapping and response mapping. + /// + [Fact] + public void BuilderProducesDescriptorShape() + { + // act + var island = new DataServiceBuilder("data") + .Method(HttpMethod.Get) + .UpdateMethod(HttpMethod.Put) + .Query(q => q.Map("search", "q").Map("page", "p")) + .Response(r => r.Items("items").Total("total")) + .Build(null) + .ToIsland(); + + // validation + Assert.Contains("\"name\":\"data\"", island); + Assert.Contains("\"method\":\"GET\"", island); + Assert.Contains("\"updateMethod\":\"PUT\"", island); + Assert.Contains("\"search\":\"q\"", island); + Assert.Contains("\"items\":\"items\"", island); + Assert.Contains("\"total\":\"total\"", island); + } + + /// + /// Tests that the fluent State extension makes the control emit the + /// data-wx-state island. + /// + [Fact] + public void FluentStateEmitsTheStateIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList("myList") + .State(s => s.Set("page", 0).Set("pageSize", 25)); + + // act + var html = control.Render(context, visualTree); + + // validation + AssertExtensions.EqualWithPlaceholders(@"
", html); + } + + /// + /// Tests that the fluent Service extension makes the control emit the + /// data-wx-service island with the declared, HTML-encoded shape. + /// + [Fact] + public void FluentServiceEmitsTheServiceIsland() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList("myList") + .Service("data", svc => svc.Method(HttpMethod.Get).Response(r => r.Items("items"))); + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains("data-wx-service=", html); + Assert.Contains(""name":"data"", html); + Assert.Contains(""method":"GET"", html); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs b/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs new file mode 100644 index 0000000..86e1c7d --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs @@ -0,0 +1,120 @@ +using WebExpress.WebApp.WebData; + +namespace WebExpress.WebApp.Test.WebData +{ + /// + /// Tests the C# service descriptor that serializes into the data-wx-service + /// island. This is the first C# artifact of the View, State and Service + /// architecture, so the test pins the island shape that the JavaScript + /// ServiceRegistry consumes, in particular that it matches the JavaScript + /// legacyDescriptor fallback of the list control. + /// + public class UnitTestDataServiceDescriptor + { + /// + /// Tests that the list data descriptor serializes into exactly the island + /// that mirrors webexpress.webapp.listModel.legacyDescriptor. + /// + [Fact] + public void ListDataIslandMatchesTheLegacyDescriptor() + { + var json = DataServiceDescriptor.ListData("/api/orders").ToIsland(); + + Assert.Equal( + "{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"/api/orders\",\"method\":\"GET\"," + + "\"query\":{\"search\":\"q\",\"wql\":\"wql\",\"filter\":\"f\",\"page\":\"p\",\"pageSize\":\"l\",\"orderBy\":\"o\",\"orderDir\":\"d\"}," + + "\"response\":{\"items\":\"items\",\"total\":\"total\"}}", + json); + } + + /// + /// Tests that the table data descriptor serializes into exactly the + /// island that mirrors webexpress.webapp.tableModel.legacyDescriptor, + /// including the put update method and the rows response key. + /// + [Fact] + public void TableDataIslandMatchesTheLegacyDescriptor() + { + var json = DataServiceDescriptor.TableData("/api/orders").ToIsland(); + + Assert.Equal( + "{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"/api/orders\",\"method\":\"GET\",\"updateMethod\":\"PUT\"," + + "\"query\":{\"search\":\"q\",\"wql\":\"wql\",\"filter\":\"f\",\"page\":\"p\",\"pageSize\":\"l\",\"orderBy\":\"o\",\"orderDir\":\"d\"}," + + "\"response\":{\"rows\":\"rows\",\"total\":\"total\"}}", + json); + } + + /// + /// Tests that the common data descriptor (load with GET, persist with + /// PUT, no query or response mapping) serializes into the shape the + /// kanban, tile, dashboard, comment, scrum backlog and workflow controls + /// share. + /// + [Fact] + public void DataIslandIsTheCommonGetPutShape() + { + var json = DataServiceDescriptor.Data("/api/x").ToIsland(); + + Assert.Equal("{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"/api/x\",\"method\":\"GET\",\"updateMethod\":\"PUT\"}", json); + } + + /// + /// Tests that the tab data descriptor serializes into exactly the island + /// that mirrors webexpress.webapp.tabModel.legacyDescriptor, with the id + /// query mapping and the items response mapping. + /// + [Fact] + public void TabDataIslandMatchesTheLegacyDescriptor() + { + var json = DataServiceDescriptor.TabData("/api/tabs").ToIsland(); + + Assert.Equal( + "{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"/api/tabs\",\"method\":\"GET\",\"updateMethod\":\"PUT\"," + + "\"query\":{\"id\":\"id\"},\"response\":{\"items\":\"items\"}}", + json); + } + + /// + /// Tests that a minimal rest descriptor omits the empty query, response + /// and update method parts so the island stays compact. + /// + [Fact] + public void RestOmitsEmptyParts() + { + var json = DataServiceDescriptor.Rest("data") + .WithBaseUri("/api/x") + .WithMethod("GET") + .ToIsland(); + + Assert.Equal("{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"/api/x\",\"method\":\"GET\"}", json); + } + + /// + /// Tests that the update method is emitted when it is set, which the tab, + /// kanban and table services use for their put updates. + /// + [Fact] + public void UpdateMethodIsEmittedWhenSet() + { + var json = DataServiceDescriptor.Rest("data") + .WithBaseUri("/api/x") + .WithMethod("GET") + .WithUpdateMethod("PUT") + .ToIsland(); + + Assert.Equal("{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"/api/x\",\"method\":\"GET\",\"updateMethod\":\"PUT\"}", json); + } + + /// + /// Tests that a missing base uri serializes as an empty string rather than + /// a null, so the JavaScript RestService always has a usable base. + /// + [Fact] + public void MissingBaseUriBecomesEmptyString() + { + var json = DataServiceDescriptor.Rest("data").WithMethod("GET").ToIsland(); + + Assert.Equal("{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"\",\"method\":\"GET\"}", json); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebData/UnitTestDataState.cs b/src/WebExpress.WebApp.Test/WebData/UnitTestDataState.cs new file mode 100644 index 0000000..1fea3c5 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebData/UnitTestDataState.cs @@ -0,0 +1,63 @@ +using WebExpress.WebApp.WebData; + +namespace WebExpress.WebApp.Test.WebData +{ + /// + /// Tests the C# control state that serializes into the data-wx-state island. + /// The island is consumed by the engine through + /// webexpress.webapp.Data.readState, so the test pins the shape that the + /// component seeds its store from. + /// + public class UnitTestDataState + { + /// + /// Tests that keys and numeric values serialize in insertion order. + /// + [Fact] + public void SetSerializesValuesByType() + { + var json = DataState.Create().Set("page", 0).Set("pageSize", 50).ToIsland(); + + Assert.Equal("{\"page\":0,\"pageSize\":50}", json); + } + + /// + /// Tests that strings, booleans and arrays are supported as state values. + /// + [Fact] + public void SupportsStringsBooleansAndArrays() + { + var json = DataState.Create() + .Set("search", "") + .Set("loading", false) + .Set("items", new[] { "a", "b" }) + .ToIsland(); + + Assert.Equal("{\"search\":\"\",\"loading\":false,\"items\":[\"a\",\"b\"]}", json); + } + + /// + /// Tests that an empty state reports empty and serializes to an empty + /// object, so the control can omit the island. + /// + [Fact] + public void EmptyStateIsEmptyAndSerializesToAnEmptyObject() + { + var state = DataState.Create(); + + Assert.True(state.IsEmpty); + Assert.Equal("{}", state.ToIsland()); + } + + /// + /// Tests that a later set for the same key replaces the earlier value. + /// + [Fact] + public void LaterSetReplacesEarlierValue() + { + var json = DataState.Create().Set("page", 0).Set("page", 3).ToIsland(); + + Assert.Equal("{\"page\":3}", json); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebFragment/UnitTestFragmentManager.cs b/src/WebExpress.WebApp.Test/WebFragment/UnitTestFragmentManager.cs index ab5c00c..5570fae 100644 --- a/src/WebExpress.WebApp.Test/WebFragment/UnitTestFragmentManager.cs +++ b/src/WebExpress.WebApp.Test/WebFragment/UnitTestFragmentManager.cs @@ -187,35 +187,35 @@ private void AssertGetFragments(Type applicationType, Type fragmentType, Type se /// Test helper for GetFragments assertions. ///
[Fact] - public void GetFragments_RestTable_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlRestTable), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); + public void GetFragments_RestTable_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlDataTable), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); /// /// Test the get fragments function of the fragment manager. /// Test helper for GetFragments assertions. /// [Fact] - public void GetFragments_RestDropdown_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlRestDropdown), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); + public void GetFragments_RestDropdown_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlDataDropdown), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); /// /// Test the get fragments function of the fragment manager. /// Test helper for GetFragments assertions. /// [Fact] - public void GetFragments_RestFormNew_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(TestFragmentControlRestFormNew), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); + public void GetFragments_RestFormNew_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(TestFragmentControlDataFormNew), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); /// /// Test the get fragments function of the fragment manager. /// Test helper for GetFragments assertions. /// [Fact] - public void GetFragments_RestFormEdit_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(TestFragmentControlRestFormEdit), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); + public void GetFragments_RestFormEdit_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(TestFragmentControlDataFormEdit), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); /// /// Test the get fragments function of the fragment manager. /// Test helper for GetFragments assertions. /// [Fact] - public void GetFragments_RestFormDelete_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(TestFragmentControlRestFormDelete), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"

Are you sure you want to delete this item?

"); + public void GetFragments_RestFormDelete_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(TestFragmentControlDataFormDelete), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"

Are you sure you want to delete this item?

"); /// /// Test the get fragments function of the fragment manager. @@ -236,28 +236,28 @@ private void AssertGetFragments(Type applicationType, Type fragmentType, Type se /// Test helper for GetFragments assertions. /// [Fact] - public void GetFragments_RestQuickfilter_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlRestQuickfilter), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); + public void GetFragments_RestQuickfilter_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlDataQuickfilter), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); /// /// Test the get fragments function of the fragment manager. /// Test helper for GetFragments assertions. /// [Fact] - public void GetFragments_RestDashboard_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlRestDashboard), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); + public void GetFragments_RestDashboard_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlDataDashboard), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); /// /// Test the get fragments function of the fragment manager. /// Test helper for GetFragments assertions. /// [Fact] - public void GetFragments_RestWizard_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlRestWizard), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); + public void GetFragments_RestWizard_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlDataWizard), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); /// /// Test the get fragments function of the fragment manager. /// Test helper for GetFragments assertions. /// [Fact] - public void GetFragments_RestWorkflow_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlRestWorkflow), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); + public void GetFragments_RestWorkflow_ContentSecondary_TestPageA() => AssertGetFragments(typeof(TestApplication), typeof(FragmentControlDataWorkflow), typeof(SectionContentSecondary), typeof(TestPageA), 1, @"
"); /// /// Test the render function of the fragment manager. @@ -283,7 +283,7 @@ public void Render(Type applicationType, Type sectionType, Type scopeType, strin } /// - /// Verifies FragmentControlRestTabTemplate retrieval in isolation. + /// Verifies FragmentControlDataTabTemplate retrieval in isolation. /// [Fact] public void GetFragments_RestTabTemplate() @@ -312,18 +312,18 @@ public void GetFragments_RestTabTemplate() }; // act - var fragments = (IEnumerable)getFragmentsMethod.MakeGenericMethod(typeof(FragmentControlRestTabTemplate), typeof(SectionContentSecondary)) + var fragments = (IEnumerable)getFragmentsMethod.MakeGenericMethod(typeof(FragmentControlDataTabTemplate), typeof(SectionContentSecondary)) .Invoke(componentHub.FragmentManager, parameters); - var controls = Enumerable.Cast(fragments).ToList(); + var controls = Enumerable.Cast(fragments).ToList(); var html = controls.Select(x => x.Render(renderContext, visualTree)).ToList(); // validation Assert.Single(html); - AssertExtensions.EqualWithPlaceholders(@"
", html.FirstOrDefault()?.ToString()?.Trim()); + AssertExtensions.EqualWithPlaceholders(@"
", html.FirstOrDefault()?.ToString()?.Trim()); } /// - /// Verifies IFragmentControlRestTabTemplate retrieval in isolation. + /// Verifies IFragmentControlDataTabTemplate retrieval in isolation. /// [Fact] public void GetFragments_IRestTabTemplate() @@ -352,14 +352,14 @@ public void GetFragments_IRestTabTemplate() }; // act - var fragments = (IEnumerable)getFragmentsMethod.MakeGenericMethod(typeof(IFragmentControlRestTabTemplate), typeof(SectionContentSecondary)) + var fragments = (IEnumerable)getFragmentsMethod.MakeGenericMethod(typeof(IFragmentControlDataTabTemplate), typeof(SectionContentSecondary)) .Invoke(componentHub.FragmentManager, parameters); - var controls = Enumerable.Cast(fragments).ToList(); + var controls = Enumerable.Cast(fragments).ToList(); var html = controls.Select(x => x.Render(renderContext, visualTree)).ToList(); // validation Assert.Single(html); - AssertExtensions.EqualWithPlaceholders(@"
", html.FirstOrDefault()?.ToString()?.Trim()); + AssertExtensions.EqualWithPlaceholders(@"
", html.FirstOrDefault()?.ToString()?.Trim()); } /// @@ -391,9 +391,9 @@ public void GetFragments_RestTabTemplate_WrongScope() }; // act - var fragments = (IEnumerable)getFragmentsMethod.MakeGenericMethod(typeof(FragmentControlRestTabTemplate), typeof(SectionContentSecondary)) + var fragments = (IEnumerable)getFragmentsMethod.MakeGenericMethod(typeof(FragmentControlDataTabTemplate), typeof(SectionContentSecondary)) .Invoke(componentHub.FragmentManager, parameters); - var controls = Enumerable.Cast(fragments).ToList(); + var controls = Enumerable.Cast(fragments).ToList(); // validation Assert.Empty(controls); diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestCommentComposerModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestCommentComposerModelAsset.cs new file mode 100644 index 0000000..c36e01b --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestCommentComposerModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the comment composer model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.comment.composer.model.js + /// must be embedded as a resource and registered through an Asset attribute + /// on IncludeJavaScript before the comment composer control that consumes + /// them. This guards the build pipeline part of the comment composer + /// migration without executing any JavaScript. + /// + public class UnitTestCommentComposerModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.comment.composer.model.js"; + private const string Composer = "/assets/js/webexpress.webapp.comment.composer.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the comment composer model module is registered through an + /// Asset attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the comment composer model module loads before the comment + /// composer control, so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheComposerControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int composer = order.IndexOf(Composer); + + Assert.True(model >= 0, "the comment composer model must be registered"); + Assert.True(composer >= 0, "the comment composer control must be registered"); + Assert.True(model < composer, "the comment composer model must load before the comment composer control"); + } + + /// + /// Tests that the comment composer model module is embedded as a resource + /// in the WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestCommentModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestCommentModelAsset.cs new file mode 100644 index 0000000..13b8dbe --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestCommentModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the phase two REST comment model module is shipped and + /// registered correctly. The pure helpers in + /// webexpress.webapp.comment.model.js must be embedded as a resource and + /// registered through an Asset attribute on IncludeJavaScript before the + /// comment control that consumes them. This guards the build pipeline part + /// of the comment migration without executing any JavaScript. + /// + public class UnitTestCommentModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.comment.model.js"; + private const string Comment = "/assets/js/webexpress.webapp.comment.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the comment model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the comment model module loads before the comment control, + /// so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheCommentControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int comment = order.IndexOf(Comment); + + Assert.True(model >= 0, "the comment model must be registered"); + Assert.True(comment >= 0, "the comment control must be registered"); + Assert.True(model < comment, "the comment model must load before the comment control"); + } + + /// + /// Tests that the comment model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestDashboardModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestDashboardModelAsset.cs new file mode 100644 index 0000000..3eabc9d --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestDashboardModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the REST dashboard model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.dashboard.model.js must + /// be embedded as a resource and registered through an Asset attribute on + /// IncludeJavaScript before the dashboard control that consumes them. This + /// guards the build pipeline part of the dashboard migration without + /// executing any JavaScript. + /// + public class UnitTestDashboardModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.dashboard.model.js"; + private const string Dashboard = "/assets/js/webexpress.webapp.dashboard.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the dashboard model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the dashboard model module loads before the dashboard + /// control, so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheDashboardControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int dashboard = order.IndexOf(Dashboard); + + Assert.True(model >= 0, "the dashboard model must be registered"); + Assert.True(dashboard >= 0, "the dashboard control must be registered"); + Assert.True(model < dashboard, "the dashboard model must load before the dashboard control"); + } + + /// + /// Tests that the dashboard model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestDropdownThemeModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestDropdownThemeModelAsset.cs new file mode 100644 index 0000000..2421f50 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestDropdownThemeModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the theme dropdown model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.dropdown.theme.model.js + /// must be embedded as a resource and registered through an Asset attribute + /// on IncludeJavaScript before the theme dropdown control that consumes them. + /// This guards the build pipeline part of the theme dropdown migration + /// without executing any JavaScript. + /// + public class UnitTestDropdownThemeModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.dropdown.theme.model.js"; + private const string Dropdown = "/assets/js/webexpress.webapp.dropdown.theme.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the theme dropdown model module is registered through an + /// Asset attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the theme dropdown model module loads before the theme + /// dropdown control, so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheDropdownControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int dropdown = order.IndexOf(Dropdown); + + Assert.True(model >= 0, "the theme dropdown model must be registered"); + Assert.True(dropdown >= 0, "the theme dropdown control must be registered"); + Assert.True(model < dropdown, "the theme dropdown model must load before the theme dropdown control"); + } + + /// + /// Tests that the theme dropdown model module is embedded as a resource in + /// the WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestEngineAssets.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestEngineAssets.cs new file mode 100644 index 0000000..9c7dd56 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestEngineAssets.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the View, State and Service engine is shipped and registered + /// in WebExpress.WebApp. The engine (store, service, renderer, intent and the + /// data base) plus the service and intent default registries must be embedded + /// as resources and registered through Asset attributes on IncludeJavaScript + /// in the correct load order, after the WebApp core and before the controls. + /// The engine moved out of WebExpress.WebUI, which carries only static + /// controls and has nothing to do with the dynamic data concept. + /// + public class UnitTestEngineAssets + { + private const string Core = "/assets/js/webexpress.webapp.js"; + private const string Store = "/assets/js/webexpress.webapp.store.js"; + private const string Service = "/assets/js/webexpress.webapp.service.js"; + private const string Renderer = "/assets/js/webexpress.webapp.renderer.js"; + private const string Intent = "/assets/js/webexpress.webapp.intent.js"; + private const string Data = "/assets/js/webexpress.webapp.data.js"; + private const string ServiceDefault = "/assets/js/service/default.js"; + private const string IntentDefault = "/assets/js/intent/default.js"; + private const string FirstControl = "/assets/js/webexpress.webapp.avatar.dropdown.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Normalizes a manifest resource name into a dot separated form. + /// + /// The manifest resource name. + /// The normalized name. + private static string Normalize(string resourceName) + { + return resourceName.Replace('\\', '.').Replace('/', '.'); + } + + /// + /// Tests that every engine module is registered through an Asset attribute. + /// + /// The expected asset path. + [Theory] + [InlineData(Store)] + [InlineData(Service)] + [InlineData(Renderer)] + [InlineData(Intent)] + [InlineData(Data)] + [InlineData(ServiceDefault)] + [InlineData(IntentDefault)] + public void Registered(string assetPath) + { + Assert.Contains(assetPath, GetAssetOrder()); + } + + /// + /// Tests that every engine module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + /// The expected asset path. + [Theory] + [InlineData(Store)] + [InlineData(Service)] + [InlineData(Renderer)] + [InlineData(Intent)] + [InlineData(Data)] + [InlineData(ServiceDefault)] + [InlineData(IntentDefault)] + public void Embedded(string assetPath) + { + var suffix = assetPath.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => Normalize(x).EndsWith(suffix, StringComparison.Ordinal)); + } + + /// + /// Tests that the engine loads after the WebApp core and in its declared + /// order, and before the controls that depend on it. + /// + [Fact] + public void EngineLoadsAfterCoreAndBeforeControls() + { + var order = GetAssetOrder(); + + int core = order.IndexOf(Core); + int store = order.IndexOf(Store); + int service = order.IndexOf(Service); + int renderer = order.IndexOf(Renderer); + int intent = order.IndexOf(Intent); + int data = order.IndexOf(Data); + int firstControl = order.IndexOf(FirstControl); + + Assert.True(core >= 0, "the webapp core must be registered"); + Assert.True(core < store, "the store must load after the webapp core"); + + Assert.True(store < service, "service must load after store"); + Assert.True(service < renderer, "renderer must load after service"); + Assert.True(renderer < intent, "intent must load after renderer"); + Assert.True(intent < data, "the data base must load after intent"); + + Assert.True(firstControl >= 0, "a control must be registered"); + Assert.True(data < firstControl, "the engine must load before the controls"); + } + + /// + /// Tests that the service and intent default registries load after the + /// engine modules that define the registries they populate. + /// + [Fact] + public void DefaultsLoadAfterEngine() + { + var order = GetAssetOrder(); + + int service = order.IndexOf(Service); + int intent = order.IndexOf(Intent); + int serviceDefault = order.IndexOf(ServiceDefault); + int intentDefault = order.IndexOf(IntentDefault); + + Assert.True(serviceDefault > service, "service default must load after the service engine"); + Assert.True(intentDefault > intent, "intent default must load after the intent engine"); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestInputSelectionModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestInputSelectionModelAsset.cs new file mode 100644 index 0000000..db7e080 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestInputSelectionModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the REST input selection model module is shipped and + /// registered correctly. The pure helpers in + /// webexpress.webapp.input.selection.model.js must be embedded as a resource + /// and registered through an Asset attribute on IncludeJavaScript before the + /// input selection control that consumes them. This guards the build pipeline + /// part of the input selection migration without executing any JavaScript. + /// + public class UnitTestInputSelectionModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.input.selection.model.js"; + private const string Input = "/assets/js/webexpress.webapp.input.selection.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the input selection model module is registered through an + /// Asset attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the input selection model module loads before the input + /// selection control, so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheInputSelectionControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int input = order.IndexOf(Input); + + Assert.True(model >= 0, "the input selection model must be registered"); + Assert.True(input >= 0, "the input selection control must be registered"); + Assert.True(model < input, "the input selection model must load before the input selection control"); + } + + /// + /// Tests that the input selection model module is embedded as a resource + /// in the WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestInputUniqueModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestInputUniqueModelAsset.cs new file mode 100644 index 0000000..16b6359 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestInputUniqueModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the unique input model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.input.unique.model.js + /// must be embedded as a resource and registered through an Asset attribute + /// on IncludeJavaScript before the unique input control that consumes them. + /// This guards the build pipeline part of the unique input migration without + /// executing any JavaScript. + /// + public class UnitTestInputUniqueModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.input.unique.model.js"; + private const string Input = "/assets/js/webexpress.webapp.input.unique.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the unique input model module is registered through an + /// Asset attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the unique input model module loads before the unique input + /// control, so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheInputControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int input = order.IndexOf(Input); + + Assert.True(model >= 0, "the unique input model must be registered"); + Assert.True(input >= 0, "the unique input control must be registered"); + Assert.True(model < input, "the unique input model must load before the unique input control"); + } + + /// + /// Tests that the unique input model module is embedded as a resource in + /// the WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestKanbanModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestKanbanModelAsset.cs new file mode 100644 index 0000000..e0eca60 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestKanbanModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the phase two REST kanban model module is shipped and + /// registered correctly. The pure helpers in + /// webexpress.webapp.kanban.model.js must be embedded as a resource and + /// registered through an Asset attribute on IncludeJavaScript before the + /// kanban control that consumes them. This guards the build pipeline part of + /// the kanban migration without executing any JavaScript. + /// + public class UnitTestKanbanModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.kanban.model.js"; + private const string Kanban = "/assets/js/webexpress.webapp.kanban.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the kanban model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the kanban model module loads before the kanban control, + /// so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheKanbanControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int kanban = order.IndexOf(Kanban); + + Assert.True(model >= 0, "the kanban model must be registered"); + Assert.True(kanban >= 0, "the kanban control must be registered"); + Assert.True(model < kanban, "the kanban model must load before the kanban control"); + } + + /// + /// Tests that the kanban model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestListModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestListModelAsset.cs new file mode 100644 index 0000000..87e5465 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestListModelAsset.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the phase one list model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.list.model.js must be + /// embedded as a resource and registered through an Asset attribute on + /// IncludeJavaScript before the list control that consumes them. This guards + /// the build pipeline part of the list migration without executing any + /// JavaScript. + /// + public class UnitTestListModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.list.model.js"; + private const string List = "/assets/js/webexpress.webapp.list.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// The metadata order corresponds to the declaration order in source, + /// which is the load order the framework applies. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the list model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the list model module loads before the list control, so + /// that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheListControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int list = order.IndexOf(List); + + Assert.True(model >= 0, "the list model must be registered"); + Assert.True(list >= 0, "the list control must be registered"); + Assert.True(model < list, "the list model must load before the list control"); + } + + /// + /// Tests that the list model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestRestFormModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestRestFormModelAsset.cs new file mode 100644 index 0000000..c13908d --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestRestFormModelAsset.cs @@ -0,0 +1,72 @@ +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the phase two REST form model module is shipped and + /// registered correctly. The pure helpers in + /// webexpress.webapp.restform.model.js must be embedded as a resource and + /// registered through an Asset attribute on IncludeJavaScript before the + /// form control that consumes them. This guards the build pipeline part of + /// the form migration without executing any JavaScript. + /// + public class UnitTestRestFormModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.restform.model.js"; + private const string Form = "/assets/js/webexpress.webapp.restform.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the form model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the form model module loads before the form control, so + /// that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheFormControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int form = order.IndexOf(Form); + + Assert.True(model >= 0, "the form model must be registered"); + Assert.True(form >= 0, "the form control must be registered"); + Assert.True(model < form, "the form model must load before the form control"); + } + + /// + /// Tests that the form model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model["/assets/".Length..].Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestRestWizardModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestRestWizardModelAsset.cs new file mode 100644 index 0000000..8468c97 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestRestWizardModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the phase two REST wizard model module is shipped and + /// registered correctly. The pure helpers in + /// webexpress.webapp.restwizard.model.js must be embedded as a resource and + /// registered through an Asset attribute on IncludeJavaScript before the + /// wizard control that consumes them. This guards the build pipeline part of + /// the wizard migration without executing any JavaScript. + /// + public class UnitTestRestWizardModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.restwizard.model.js"; + private const string Wizard = "/assets/js/webexpress.webapp.restwizard.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the wizard model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the wizard model module loads before the wizard control, + /// so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheWizardControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int wizard = order.IndexOf(Wizard); + + Assert.True(model >= 0, "the wizard model must be registered"); + Assert.True(wizard >= 0, "the wizard control must be registered"); + Assert.True(model < wizard, "the wizard model must load before the wizard control"); + } + + /// + /// Tests that the wizard model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestScrumBacklogModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestScrumBacklogModelAsset.cs new file mode 100644 index 0000000..3f02a9d --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestScrumBacklogModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the scrum backlog model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.scrum.backlog.model.js + /// must be embedded as a resource and registered through an Asset attribute + /// on IncludeJavaScript before the backlog control that consumes them. This + /// guards the build pipeline part of the backlog migration without executing + /// any JavaScript. + /// + public class UnitTestScrumBacklogModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.scrum.backlog.model.js"; + private const string Backlog = "/assets/js/webexpress.webapp.scrum.backlog.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the scrum backlog model module is registered through an + /// Asset attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the scrum backlog model module loads before the backlog + /// control, so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheBacklogControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int backlog = order.IndexOf(Backlog); + + Assert.True(model >= 0, "the scrum backlog model must be registered"); + Assert.True(backlog >= 0, "the scrum backlog control must be registered"); + Assert.True(model < backlog, "the scrum backlog model must load before the backlog control"); + } + + /// + /// Tests that the scrum backlog model module is embedded as a resource in + /// the WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestSelectionModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestSelectionModelAsset.cs new file mode 100644 index 0000000..ab67180 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestSelectionModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the REST selection model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.selection.model.js must + /// be embedded as a resource and registered through an Asset attribute on + /// IncludeJavaScript before the selection control that consumes them. This + /// guards the build pipeline part of the selection migration without + /// executing any JavaScript. + /// + public class UnitTestSelectionModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.selection.model.js"; + private const string Selection = "/assets/js/webexpress.webapp.selection.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the selection model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the selection model module loads before the selection + /// control, so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheSelectionControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int selection = order.IndexOf(Selection); + + Assert.True(model >= 0, "the selection model must be registered"); + Assert.True(selection >= 0, "the selection control must be registered"); + Assert.True(model < selection, "the selection model must load before the selection control"); + } + + /// + /// Tests that the selection model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestTabModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestTabModelAsset.cs new file mode 100644 index 0000000..0b2a271 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestTabModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the phase two REST tab model module is shipped and + /// registered correctly. The pure helpers in webexpress.webapp.tab.model.js + /// must be embedded as a resource and registered through an Asset attribute + /// on IncludeJavaScript before the tab control that consumes them. This + /// guards the build pipeline part of the tab migration without executing any + /// JavaScript. + /// + public class UnitTestTabModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.tab.model.js"; + private const string Tab = "/assets/js/webexpress.webapp.tab.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the tab model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the tab model module loads before the tab control, so that + /// the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheTabControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int tab = order.IndexOf(Tab); + + Assert.True(model >= 0, "the tab model must be registered"); + Assert.True(tab >= 0, "the tab control must be registered"); + Assert.True(model < tab, "the tab model must load before the tab control"); + } + + /// + /// Tests that the tab model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestTableModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestTableModelAsset.cs new file mode 100644 index 0000000..b824d88 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestTableModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the phase two table model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.table.model.js must be + /// embedded as a resource and registered through an Asset attribute on + /// IncludeJavaScript before the table control that consumes them. This guards + /// the build pipeline part of the table migration without executing any + /// JavaScript. + /// + public class UnitTestTableModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.table.model.js"; + private const string Table = "/assets/js/webexpress.webapp.table.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the table model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the table model module loads before the table control, so + /// that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheTableControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int table = order.IndexOf(Table); + + Assert.True(model >= 0, "the table model must be registered"); + Assert.True(table >= 0, "the table control must be registered"); + Assert.True(model < table, "the table model must load before the table control"); + } + + /// + /// Tests that the table model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestTileModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestTileModelAsset.cs new file mode 100644 index 0000000..bbae1f6 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestTileModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the REST tile model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.tile.model.js must be + /// embedded as a resource and registered through an Asset attribute on + /// IncludeJavaScript before the tile control that consumes them. This guards + /// the build pipeline part of the tile migration without executing any + /// JavaScript. + /// + public class UnitTestTileModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.tile.model.js"; + private const string Tile = "/assets/js/webexpress.webapp.tile.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the tile model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the tile model module loads before the tile control, so + /// that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheTileControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int tile = order.IndexOf(Tile); + + Assert.True(model >= 0, "the tile model must be registered"); + Assert.True(tile >= 0, "the tile control must be registered"); + Assert.True(model < tile, "the tile model must load before the tile control"); + } + + /// + /// Tests that the tile model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestWatcherModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestWatcherModelAsset.cs new file mode 100644 index 0000000..4362b53 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestWatcherModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the watcher model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.watcher.model.js must be + /// embedded as a resource and registered through an Asset attribute on + /// IncludeJavaScript before the watcher control that consumes them. This + /// guards the build pipeline part of the watcher migration without executing + /// any JavaScript. + /// + public class UnitTestWatcherModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.watcher.model.js"; + private const string Watcher = "/assets/js/webexpress.webapp.watcher.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the watcher model module is registered through an Asset + /// attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the watcher model module loads before the watcher control, + /// so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheWatcherControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int watcher = order.IndexOf(Watcher); + + Assert.True(model >= 0, "the watcher model must be registered"); + Assert.True(watcher >= 0, "the watcher control must be registered"); + Assert.True(model < watcher, "the watcher model must load before the watcher control"); + } + + /// + /// Tests that the watcher model module is embedded as a resource in the + /// WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestWorkflowEditorModelAsset.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestWorkflowEditorModelAsset.cs new file mode 100644 index 0000000..7d77737 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestWorkflowEditorModelAsset.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebApp.WebInclude; + +namespace WebExpress.WebApp.Test.WebInclude +{ + /// + /// Verifies that the workflow editor model module is shipped and registered + /// correctly. The pure helpers in webexpress.webapp.workflow.editor.model.js + /// must be embedded as a resource and registered through an Asset attribute + /// on IncludeJavaScript before the workflow editor control that consumes + /// them. This guards the build pipeline part of the workflow editor migration + /// without executing any JavaScript. + /// + public class UnitTestWorkflowEditorModelAsset + { + private const string Model = "/assets/js/webexpress.webapp.workflow.editor.model.js"; + private const string Editor = "/assets/js/webexpress.webapp.workflow.editor.js"; + + /// + /// Reads the ordered list of Asset paths declared on IncludeJavaScript. + /// + /// The ordered list of asset paths. + private static List GetAssetOrder() + { + return typeof(IncludeJavaScript) + .GetCustomAttributesData() + .Where(x => x.AttributeType.Name == "AssetAttribute") + .Select(x => x.ConstructorArguments.FirstOrDefault().Value as string) + .Where(x => x is not null) + .ToList(); + } + + /// + /// Tests that the workflow editor model module is registered through an + /// Asset attribute on IncludeJavaScript. + /// + [Fact] + public void Registered() + { + Assert.Contains(Model, GetAssetOrder()); + } + + /// + /// Tests that the workflow editor model module loads before the workflow + /// editor control, so that the control can use it at instantiation time. + /// + [Fact] + public void LoadsBeforeTheWorkflowEditorControl() + { + var order = GetAssetOrder(); + + int model = order.IndexOf(Model); + int editor = order.IndexOf(Editor); + + Assert.True(model >= 0, "the workflow editor model must be registered"); + Assert.True(editor >= 0, "the workflow editor control must be registered"); + Assert.True(model < editor, "the workflow editor model must load before the workflow editor control"); + } + + /// + /// Tests that the workflow editor model module is embedded as a resource + /// in the WebExpress.WebApp assembly, so that it actually ships. + /// + [Fact] + public void Embedded() + { + var suffix = Model.Substring("/assets/".Length).Replace('/', '.'); + var resources = typeof(IncludeJavaScript).Assembly.GetManifestResourceNames(); + + Assert.Contains(resources, x => x.Replace('\\', '.').Replace('/', '.').EndsWith(suffix, StringComparison.Ordinal)); + } + } +} diff --git a/src/WebExpress.WebApp/Assets/js/action/default.js b/src/WebExpress.WebApp/Assets/js/action/default.js index ab8ce21..66f7c53 100644 --- a/src/WebExpress.WebApp/Assets/js/action/default.js +++ b/src/WebExpress.WebApp/Assets/js/action/default.js @@ -16,7 +16,7 @@ webexpress.webui.Actions.register("logout", { target = "/"; } - fetch(uri, { + webexpress.webapp.ServiceRegistry.request(uri, { method: "DELETE", headers: { "Content-Type": "application/json; charset=utf-8" @@ -49,12 +49,19 @@ webexpress.webui.Actions.register("plugin-package", { } var handleResponse = function (response) { + // response is the normalised service result, not a raw Response; the + // body is already parsed into data and a non-json body is wrapped as + // { text }. On failure prefer the parsed text, then the error message. if (!response.ok) { - return response.text().then(function (text) { - throw new Error(text || ("Request failed with status " + response.status + " for " + method + " " + uri)); - }); + var detail = ""; + if (response.data && typeof response.data === "object" && typeof response.data.text === "string") { + detail = response.data.text; + } else if (response.error && response.error.message) { + detail = response.error.message; + } + throw new Error(detail || ("Request failed with status " + response.status + " for " + method + " " + uri)); } - return response.json().catch(function () { return {}; }); + return (response.data && typeof response.data === "object") ? response.data : {}; }; var handleResult = function (payload) { @@ -87,7 +94,7 @@ webexpress.webui.Actions.register("plugin-package", { var formData = new FormData(); formData.append("file", input.files[0], input.files[0].name); - fetch(uri, { + webexpress.webapp.ServiceRegistry.request(uri, { method: method, body: formData }).then(handleResponse).then(handleResult).catch(handleError).finally(cleanup); @@ -97,7 +104,7 @@ webexpress.webui.Actions.register("plugin-package", { return; } - fetch(uri, { + webexpress.webapp.ServiceRegistry.request(uri, { method: method }).then(handleResponse).then(handleResult).catch(handleError); } diff --git a/src/WebExpress.WebApp/Assets/js/intent/default.js b/src/WebExpress.WebApp/Assets/js/intent/default.js new file mode 100644 index 0000000..089e636 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/intent/default.js @@ -0,0 +1,30 @@ +/** + * Default intent definitions for the WebExpress.WebUI intent registry. + * Registers the generic, reusable intents that controls and binds compose with. + * Domain specific intents, for example list search or tab add, are registered + * by the controls that own them during their migration. + * + * An intent definition has an optional reduce, which is a pure state transition + * that returns a patch, and an optional effect, which performs input or output. + */ + +// wx/patch - applies the payload as a shallow patch to the store. This is the +// generic state setter used by the model bind and by simple actions. +webexpress.webapp.Intents.register("wx/patch", { + reduce(state, payload) { + return (payload && typeof payload === "object") ? payload : null; + } +}); + +// wx/set - sets a single key to a value. The payload is { key, value }. This is +// a convenience for declarative bindings that carry a single field. +webexpress.webapp.Intents.register("wx/set", { + reduce(state, payload) { + if (!payload || typeof payload.key !== "string") { + return null; + } + const patch = {}; + patch[payload.key] = payload.value; + return patch; + } +}); diff --git a/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.guard.js b/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.guard.js index d145859..fbc7782 100644 --- a/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.guard.js +++ b/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.guard.js @@ -84,9 +84,9 @@ webexpress.webui.DialogPanels.register("workflow-guard-management", { renderList(); if (ctx.fetchUri !== "") { - fetch(ctx.fetchUri) + webexpress.webapp.ServiceRegistry.request(ctx.fetchUri) .then((res) => { - return res.json(); + return res.data; }) .then((data) => { modal.availableTemplates = Array.isArray(data.items) ? data.items : data; diff --git a/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.postfunction.js b/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.postfunction.js index 7349a03..52ea2ac 100644 --- a/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.postfunction.js +++ b/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.postfunction.js @@ -72,9 +72,9 @@ webexpress.webui.DialogPanels.register("workflow-postfunction-management", { renderList(); if (ctx.fetchUri !== "") { - fetch(ctx.fetchUri) + webexpress.webapp.ServiceRegistry.request(ctx.fetchUri) .then((res) => { - return res.json(); + return res.data; }) .then((data) => { modal.availableTemplates = Array.isArray(data.items) ? data.items : data; diff --git a/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.validator.js b/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.validator.js index e050c4c..d26b94a 100644 --- a/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.validator.js +++ b/src/WebExpress.WebApp/Assets/js/panels/webexpress.webapp.panel.workflow.validator.js @@ -84,9 +84,9 @@ webexpress.webui.DialogPanels.register("workflow-validator-management", { renderList(); if (ctx.fetchUri !== "") { - fetch(ctx.fetchUri) + webexpress.webapp.ServiceRegistry.request(ctx.fetchUri) .then((res) => { - return res.json(); + return res.data; }) .then((data) => { modal.availableTemplates = Array.isArray(data.items) ? data.items : data; diff --git a/src/WebExpress.WebApp/Assets/js/service/default.js b/src/WebExpress.WebApp/Assets/js/service/default.js new file mode 100644 index 0000000..a025a02 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/service/default.js @@ -0,0 +1,14 @@ +/** + * Default service definitions for the WebExpress.WebUI service registry. + * Registers the built in service kinds. The rest kind is the default and is + * used when a service descriptor does not name a kind. + * + * A service factory receives a descriptor and returns a configured service + * instance. Additional kinds, for example a websocket service or a static + * data service, are registered the same way by plugins. + */ + +// rest service - the default network service backed by fetch +webexpress.webapp.ServiceRegistry.register("rest", (descriptor) => { + return new webexpress.webapp.RestService(descriptor); +}); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.avatar.dropdown.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.avatar.dropdown.js index 9e670e7..29b4d0c 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.avatar.dropdown.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.avatar.dropdown.js @@ -24,6 +24,11 @@ webexpress.webapp.AvatarDropdownCtrl = class extends webexpress.webui.AvatarDrop this._httpMethod = (element.dataset.method || "GET").toUpperCase(); this._maxItems = Number.isFinite(parseInt(element.dataset.maxitems, 10)) ? parseInt(element.dataset.maxitems, 10) : 25; + // data service used to fetch the dropdown items through the service layer + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data || + webexpress.webapp.ServiceRegistry.create({ kind: "rest", baseUri: this._apiEndpoint || "" }); + // dynamic items storage this._allItems = []; @@ -318,13 +323,13 @@ webexpress.webapp.AvatarDropdownCtrl = class extends webexpress.webui.AvatarDrop } } - const res = await fetch(url, init); + const res = await this._service.request(url, init); if (!res.ok) { throw new Error("http error " + res.status); } - const json = await res.json(); + const json = res.data; const username = json.username || null; const image = json.image || null; const rawItems = json.items; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.composer.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.composer.js index 68a5d57..ad65eb7 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.composer.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.composer.js @@ -23,16 +23,30 @@ * webexpress.webapp.Event.COMMENT_ADDED_EVENT * detail: { comment, uri } */ -webexpress.webapp.CommentComposerCtrl = class extends webexpress.webui.Ctrl { +webexpress.webapp.CommentComposerCtrl = class extends webexpress.webapp.Data { /** * Construct a new CommentComposerCtrl. * @param {HTMLElement} element - host element. */ constructor(element) { - super(element); + const uri = element.dataset.uri || null; - this._uri = element.dataset.uri || null; + // categories are sourced from the REST API ({uri}/categories) unless + // a static override is supplied via the data-categories attribute. + // the categories load uses the shared request against the categories + // url; the new comment is posted through this rest service. a configured + // island service is preferred over the legacy descriptor, and the Data + // base aborts the service on teardown. + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + const services = { + data: islandServices.data || + webexpress.webapp.ServiceRegistry.create(webexpress.webapp.commentComposerModel.legacyDescriptor(uri)) + }; + + super(element, { services }); + + this._uri = uri; this._usersUri = element.dataset.usersUri || null; this._currentUser = element.dataset.currentUser || null; this._imageUploadUri = element.dataset.imageUploadUri || null; @@ -40,8 +54,8 @@ webexpress.webapp.CommentComposerCtrl = class extends webexpress.webui.Ctrl { this._placeholder = element.dataset.placeholder || this._i18n("webexpress.webapp:comment.composer.trigger", "Write a comment…"); - // categories are sourced from the REST API ({uri}/categories) unless - // a static override is supplied via the data-categories attribute. + this._service = this.useService("data"); + this._categoriesPreset = false; this._categories = {}; if (element.dataset.categories) { @@ -85,11 +99,10 @@ webexpress.webapp.CommentComposerCtrl = class extends webexpress.webui.Ctrl { return; } try { - const sep = this._uri.endsWith("/") ? "" : "/"; - const url = this._uri + sep + "categories"; - const res = await fetch(url, { headers: { "Accept": "application/json" } }); - if (!res.ok) throw new Error(res.statusText); - this._categories = this._normalizeCategories(await res.json()); + const url = webexpress.webapp.commentComposerModel.categoriesUrl(this._uri); + const res = await webexpress.webapp.ServiceRegistry.request(url, { headers: { "Accept": "application/json" } }); + if (!res.ok) throw new Error(res.error ? res.error.message : String(res.status)); + this._categories = this._normalizeCategories(res.data); this._rebuildCategoryOptions(); } catch (e) { console.warn("CommentComposerCtrl: categories load failed", e); @@ -103,19 +116,7 @@ webexpress.webapp.CommentComposerCtrl = class extends webexpress.webui.Ctrl { * @returns {Object} */ _normalizeCategories(input) { - if (!input) { - return {}; - } - if (Array.isArray(input)) { - const obj = {}; - for (const c of input) { - if (c && c.id) { - obj[c.id] = c; - } - } - return obj; - } - return input; + return webexpress.webapp.commentComposerModel.normalizeCategories(input); } /** @@ -338,17 +339,13 @@ webexpress.webapp.CommentComposerCtrl = class extends webexpress.webui.Ctrl { } const body = this._editorRef ? this._editorRef.value : this._editorHost.innerHTML; const category = this._catSelect.value; - const labels = this._labelsInput.value.split(",").map(s => s.trim()).filter(Boolean); + const labels = webexpress.webapp.commentComposerModel.parseLabels(this._labelsInput.value); this._submitBtn.disabled = true; try { - const res = await fetch(this._uri, { - method: "POST", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify({ body, category, labels }) - }); - if (!res.ok) throw new Error(res.statusText); - const created = await res.json(); + const res = await this._service.create({ body, category, labels }); + if (!res.ok) throw new Error(res.error ? res.error.message : String(res.status)); + const created = res.data; this._dispatch(webexpress.webapp.Event.COMMENT_ADDED_EVENT, { comment: created, uri: this._uri }); this._collapse(); } catch (e) { diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.composer.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.composer.model.js new file mode 100644 index 0000000..964e140 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.composer.model.js @@ -0,0 +1,68 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the comment composer control (View, State and Service + * migration). These functions carry no DOM or network dependency, so they can + * be unit tested in isolation. The control composes them with a RestService: + * the categories are loaded through the shared request from the categories url, + * the categories payload is normalised through the model, the labels are parsed + * through the model and the new comment is posted with the service create. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.commentComposerModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. The comment is posted with POST against + * the base uri. + * @param {string} uri - The REST endpoint backing the comments. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(uri) { + return { name: "data", kind: "rest", baseUri: uri || "", method: "GET", updateMethod: "PUT" }; + }, + + /** + * Builds the categories url, appending the categories segment with a single + * separating slash regardless of a trailing slash on the base uri. + * @param {string} uri - The base comments uri. + * @returns {string} The categories url. + */ + categoriesUrl(uri) { + const base = uri || ""; + const sep = base.endsWith("/") ? "" : "/"; + return base + sep + "categories"; + }, + + /** + * Accepts either an array or an object keyed by category id and returns the + * canonical object form keyed by id, dropping array entries without an id. + * @param {Array|Object} input - The raw categories payload. + * @returns {Object} The categories keyed by id. + */ + normalizeCategories(input) { + if (!input) { + return {}; + } + if (Array.isArray(input)) { + const obj = {}; + for (const c of input) { + if (c && c.id) { + obj[c.id] = c; + } + } + return obj; + } + return input; + }, + + /** + * Parses a comma separated label string into a trimmed, non empty list. + * @param {string} raw - The raw label input value. + * @returns {Array} The parsed labels. + */ + parseLabels(raw) { + return String(raw == null ? "" : raw).split(",").map((s) => s.trim()).filter(Boolean); + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.js index 5458dce..ea81673 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.js @@ -39,7 +39,7 @@ * - When detail.uri matches this control's REST URI (or is missing), * the new comment is appended to the list and re-rendered. */ -webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { +webexpress.webapp.CommentCtrl = class extends webexpress.webapp.Data { /** * Default reaction emoji palette. @@ -94,9 +94,25 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * @param {HTMLElement} element - host element. */ constructor(element) { - super(element); + // the toolbar ui state is seeded from the persisted sort cookie and the + // optional data-wx-state island; the data service is a configured island + // or a legacy descriptor. both are resolved before super so the component + // owns the store and the service map. The data-wx-state island may also + // carry the comments themselves, in which case the first paint needs no + // round trip. + const cookieMatch = document.cookie.match(new RegExp("(^| )wx_comment_sort_dir=([^;]*)")); + const persistedSortDir = cookieMatch ? decodeURIComponent(cookieMatch[2]) : null; + const initialState = Object.assign({ + sortBy: "date", // "date" | "likes" + sortDir: (persistedSortDir === "asc" || persistedSortDir === "desc") ? persistedSortDir : "desc", + filterCat: "all", + editingId: null // id of comment currently in edit-mode + }, webexpress.webapp.Data.readState(element)); + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + const services = islandServices; + + super(element, { state: initialState, services: services }); - this._uri = element.dataset.uri || null; this._usersUri = element.dataset.usersUri || null; this._currentUser = element.dataset.currentUser || null; this._imageUploadUri = element.dataset.imageUploadUri || null; @@ -116,15 +132,13 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { } } - // state + // the data service backs the categories, comments, users, edit, delete, + // like, pin, reaction and reply requests + this._service = this.useService("data"); + this._uri = this._service ? this._service.baseUri : null; + + // data and caches (view state, not part of the store) this._comments = []; - this._sortBy = "date"; // "date" | "likes" - const persistedSortDir = this._getCookie("wx_comment_sort_dir"); - this._sortDir = persistedSortDir === "asc" || persistedSortDir === "desc" - ? persistedSortDir - : "desc"; // "asc" | "desc" - this._filterCat = "all"; - this._editingId = null; // id of comment currently in edit-mode this._editorEditRef = null; // EditorCtrl instance while editing this._userCache = {}; // userId -> user record @@ -143,6 +157,21 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { void this._init(); } + // toolbar and edit state accessors backed by the store, so the single + // source of truth is the store + + get _sortBy() { return this._store.getState().sortBy; } + set _sortBy(value) { this._store.setState({ sortBy: value }); } + + get _sortDir() { return this._store.getState().sortDir; } + set _sortDir(value) { this._store.setState({ sortDir: value }); } + + get _filterCat() { return this._store.getState().filterCat; } + set _filterCat(value) { this._store.setState({ filterCat: value }); } + + get _editingId() { return this._store.getState().editingId; } + set _editingId(value) { this._store.setState({ editingId: value }); } + /** * Bootstraps the control: loads categories from the REST API (unless * provided declaratively) and then performs the initial comment load. @@ -152,7 +181,18 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { await this._loadCategories(); } this._rebuildFilterOptions(); - await this._load(); + + // when the server seeded the comments through the data-wx-state island, + // render them without a round trip; otherwise load from the endpoint + const seeded = this.state.comments; + if (Array.isArray(seeded) && seeded.length > 0) { + this._comments = seeded.slice(); + await this._preloadUsers(); + this._rebuildFilterOptions(); + this._renderList(); + } else { + await this._load(); + } } /** @@ -160,18 +200,17 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * empty set; the filter will then show only "All categories". */ async _loadCategories() { - if (!this._uri) { + if (!this._uri || !this._service) { this._categories = {}; return; } - try { - const sep = this._uri.endsWith("/") ? "" : "/"; - const url = this._uri + sep + "categories"; - const res = await fetch(url, { headers: { "Accept": "application/json" } }); - if (!res.ok) throw new Error(res.statusText); - this._categories = this._normalizeCategories(await res.json()); - } catch (e) { - console.warn("CommentCtrl: categories load failed", e); + const result = await this._service.request( + webexpress.webapp.commentModel.categoriesUrl(this._uri), + { headers: { "Accept": "application/json" } }); + if (result.ok) { + this._categories = this._normalizeCategories(result.data); + } else { + console.warn("CommentCtrl: categories load failed", result.error.message); this._categories = {}; } } @@ -183,19 +222,7 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * @returns {Object} */ _normalizeCategories(input) { - if (!input) { - return {}; - } - if (Array.isArray(input)) { - const obj = {}; - for (const c of input) { - if (c && c.id) { - obj[c.id] = c; - } - } - return obj; - } - return input; + return webexpress.webapp.commentModel.normalizeCategories(input); } /** @@ -367,17 +394,16 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * Loads the comments from the configured URI and renders them. */ async _load() { - if (!this._uri) { + if (!this._uri || !this._service) { this._comments = []; this._renderList(); return; } - try { - const res = await fetch(this._uri, { headers: { "Accept": "application/json" } }); - if (!res.ok) throw new Error(res.statusText); - this._comments = await res.json(); - } catch (e) { - console.warn("CommentCtrl: load failed", e); + const result = await this._service.request(this._uri, { headers: { "Accept": "application/json" } }); + if (result.ok) { + this._comments = result.data; + } else { + console.warn("CommentCtrl: load failed", result.error.message); this._comments = []; } // pre-warm user cache for everyone referenced @@ -391,7 +417,7 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * resolve names + colors synchronously. */ async _preloadUsers() { - if (!this._usersUri) { + if (!this._usersUri || !this._service) { return; } const ids = new Set(); @@ -410,16 +436,15 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { if (missing.length === 0) { return; } - try { - const url = this._usersUri + (this._usersUri.includes("?") ? "&" : "?") + "ids=" + missing.map(encodeURIComponent).join(","); - const res = await fetch(url, { headers: { "Accept": "application/json" } }); - if (!res.ok) throw new Error(res.statusText); - const users = await res.json(); - for (const u of users) { + const result = await this._service.request( + webexpress.webapp.commentModel.buildUsersUrl(this._usersUri, missing), + { headers: { "Accept": "application/json" } }); + if (result.ok) { + for (const u of result.data) { this._userCache[u.id] = u; } - } catch (e) { - console.warn("CommentCtrl: user preload failed", e); + } else { + console.warn("CommentCtrl: user preload failed", result.error.message); } } @@ -916,22 +941,22 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * @param {Object} patch */ async _saveEdit(comment, patch) { - try { - const res = await fetch(this._uri + "/" + encodeURIComponent(comment.id), { - method: "PUT", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify(patch) - }); - if (!res.ok) throw new Error(res.statusText); - const updated = await res.json(); + if (!this._service) { + return; + } + const result = await this._service.update(patch, { + path: webexpress.webapp.commentModel.commentPath(comment.id) + }); + if (result.ok) { + const updated = result.data; this._comments = this._comments.map(c => c.id === updated.id ? updated : c); this._editingId = null; this._editorEditRef = null; this._rebuildFilterOptions(); this._renderList(); this._dispatch(webexpress.webapp.Event.COMMENT_UPDATED_EVENT, { comment: updated }); - } catch (e) { - console.warn("CommentCtrl: edit failed", e); + } else { + console.warn("CommentCtrl: edit failed", result.error.message); } } @@ -940,15 +965,19 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * @param {Object} comment */ async _delete(comment) { - try { - const res = await fetch(this._uri + "/" + encodeURIComponent(comment.id), { method: "DELETE" }); - if (!res.ok && res.status !== 204) throw new Error(res.statusText); + if (!this._service) { + return; + } + const result = await this._service.remove({ + path: webexpress.webapp.commentModel.commentPath(comment.id) + }); + if (result.ok) { this._comments = this._comments.filter(c => c.id !== comment.id); this._rebuildFilterOptions(); this._renderList(); this._dispatch(webexpress.webapp.Event.COMMENT_DELETED_EVENT, { id: comment.id }); - } catch (e) { - console.warn("CommentCtrl: delete failed", e); + } else { + console.warn("CommentCtrl: delete failed", result.error.message); } } @@ -957,19 +986,18 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * @param {Object} comment */ async _toggleLike(comment) { - try { - const res = await fetch(this._uri + "/" + encodeURIComponent(comment.id) + "/likes", { - method: "POST", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify({ userId: this._currentUser }) - }); - if (!res.ok) throw new Error(res.statusText); - const updated = await res.json(); - comment.likes = updated.likes; + if (!this._service) { + return; + } + const result = await this._service.create({ userId: this._currentUser }, { + path: webexpress.webapp.commentModel.commentSubPath(comment.id, "likes") + }); + if (result.ok) { + comment.likes = result.data.likes; this._renderList(); this._dispatch(webexpress.webapp.Event.COMMENT_UPDATED_EVENT, { comment }); - } catch (e) { - console.warn("CommentCtrl: like failed", e); + } else { + console.warn("CommentCtrl: like failed", result.error.message); } } @@ -978,18 +1006,18 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * @param {Object} comment */ async _togglePin(comment) { - try { - const res = await fetch(this._uri + "/" + encodeURIComponent(comment.id) + "/pin", { - method: "POST", - headers: { "Accept": "application/json" } - }); - if (!res.ok) throw new Error(res.statusText); - const updated = await res.json(); - comment.pinned = updated.pinned; + if (!this._service) { + return; + } + const result = await this._service.create(undefined, { + path: webexpress.webapp.commentModel.commentSubPath(comment.id, "pin") + }); + if (result.ok) { + comment.pinned = result.data.pinned; this._renderList(); this._dispatch(webexpress.webapp.Event.COMMENT_UPDATED_EVENT, { comment }); - } catch (e) { - console.warn("CommentCtrl: pin failed", e); + } else { + console.warn("CommentCtrl: pin failed", result.error.message); } } @@ -999,19 +1027,18 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * @param {string} emoji */ async _toggleReaction(comment, emoji) { - try { - const res = await fetch(this._uri + "/" + encodeURIComponent(comment.id) + "/reactions", { - method: "POST", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify({ emoji, userId: this._currentUser }) - }); - if (!res.ok) throw new Error(res.statusText); - const updated = await res.json(); - comment.reactions = updated.reactions; + if (!this._service) { + return; + } + const result = await this._service.create({ emoji, userId: this._currentUser }, { + path: webexpress.webapp.commentModel.commentSubPath(comment.id, "reactions") + }); + if (result.ok) { + comment.reactions = result.data.reactions; this._renderList(); this._dispatch(webexpress.webapp.Event.COMMENT_REACTION_EVENT, { commentId: comment.id, emoji, reactions: comment.reactions }); - } catch (e) { - console.warn("CommentCtrl: reaction failed", e); + } else { + console.warn("CommentCtrl: reaction failed", result.error.message); } } @@ -1021,20 +1048,20 @@ webexpress.webapp.CommentCtrl = class extends webexpress.webui.Ctrl { * @param {string} body */ async _postReply(comment, body) { - try { - const res = await fetch(this._uri + "/" + encodeURIComponent(comment.id) + "/replies", { - method: "POST", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify({ body }) - }); - if (!res.ok) throw new Error(res.statusText); - const reply = await res.json(); + if (!this._service) { + return; + } + const result = await this._service.create({ body }, { + path: webexpress.webapp.commentModel.commentSubPath(comment.id, "replies") + }); + if (result.ok) { + const reply = result.data; comment.replies = comment.replies || []; comment.replies.push(reply); this._renderList(); this._dispatch(webexpress.webapp.Event.COMMENT_REPLY_EVENT, { commentId: comment.id, reply }); - } catch (e) { - console.warn("CommentCtrl: reply failed", e); + } else { + console.warn("CommentCtrl: reply failed", result.error.message); } } diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.model.js new file mode 100644 index 0000000..ea8cb0e --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.comment.model.js @@ -0,0 +1,92 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST comment control (phase two of the View, State + * and Service migration). These functions carry no DOM dependency, so they can + * be unit tested in isolation. They cover the endpoint url and path building + * and the category normalisation. The control composes them with a Store and a + * RestService whose request, create, update and remove operations replace the + * nine inline fetch calls (categories, comments, users, edit, delete, like, + * pin, reaction, reply). + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.commentModel = { + /** + * Builds the service descriptor for the comment endpoint. The id is carried + * in the path, not the query, so only the base uri is configured. PUT is + * used for the edit operation. + * @param {string} uri - The REST endpoint backing the comments. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(uri) { + return { name: "data", kind: "rest", baseUri: uri || "", method: "GET", updateMethod: "PUT" }; + }, + + /** + * Accepts either an array of category descriptors or an object keyed by + * category id and returns the canonical object form keyed by id. + * @param {Array|object} input - The category set. + * @returns {object} The categories keyed by id. + */ + normalizeCategories(input) { + if (!input) { + return {}; + } + if (Array.isArray(input)) { + const obj = {}; + for (const c of input) { + if (c && c.id) { + obj[c.id] = c; + } + } + return obj; + } + return input; + }, + + /** + * Builds the categories url, joining the base uri and the categories + * segment with a single slash, matching the historical behaviour. + * @param {string} uri - The comment endpoint. + * @returns {string} The categories url. + */ + categoriesUrl(uri) { + const sep = uri.endsWith("/") ? "" : "/"; + return uri + sep + "categories"; + }, + + /** + * Builds the users preload url, appending the comma separated, encoded ids + * to the users endpoint and respecting an existing query string. + * @param {string} usersUri - The users endpoint. + * @param {Array} ids - The user ids to load. + * @returns {string} The users url. + */ + buildUsersUrl(usersUri, ids) { + const sep = usersUri.includes("?") ? "&" : "?"; + return usersUri + sep + "ids=" + ids.map(encodeURIComponent).join(","); + }, + + /** + * Builds the path of a single comment relative to the base uri, encoding the + * id, matching the historical behaviour. + * @param {string} id - The comment id. + * @returns {string} The comment path. + */ + commentPath(id) { + return "/" + encodeURIComponent(id); + }, + + /** + * Builds the path of a comment sub resource (likes, pin, reactions or + * replies) relative to the base uri, encoding the id. + * @param {string} id - The comment id. + * @param {string} sub - The sub resource segment. + * @returns {string} The sub resource path. + */ + commentSubPath(id, sub) { + return "/" + encodeURIComponent(id) + "/" + sub; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dashboard.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dashboard.js index 1c0a95c..f118c31 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dashboard.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dashboard.js @@ -15,9 +15,14 @@ webexpress.webapp.DashboardCtrl = class extends webexpress.webui.DashboardCtrl { constructor(element) { super(element); - this._restUri = element.dataset.uri || ""; element.removeAttribute("data-uri"); + // the load keeps its own abort and loading state through the shared + // request; the layout state save flows through this rest service + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data; + this._restUri = this._service ? this._service.baseUri : ""; + this._initRestPersistence(element); if (this._restUri) { @@ -51,12 +56,17 @@ webexpress.webapp.DashboardCtrl = class extends webexpress.webui.DashboardCtrl { const fetchUrl = this._restUri.startsWith("http") ? urlObj.href : (urlObj.pathname + urlObj.search); - fetch(fetchUrl, { signal: this._abortController.signal }) + webexpress.webapp.ServiceRegistry.request(fetchUrl, { signal: this._abortController.signal }) .then((res) => { + if (res.error && res.error.kind === "abort") { + const abort = new Error("aborted"); + abort.name = "AbortError"; + throw abort; + } if (!res.ok) { throw new Error("request failed"); } - return res.json(); + return res.data; }) .then((response) => { this.updateData(response); @@ -78,28 +88,9 @@ webexpress.webapp.DashboardCtrl = class extends webexpress.webui.DashboardCtrl { * @param {Object} data - The json payload containing columns and layout. */ updateData(data) { - if (data.columns) { - this._columns = data.columns.map((col) => { - return { - id: col.id, - label: col.label || "", - size: col.size || "1fr", - widgets: (col.widgets || []).map((w, i) => { - return { - instanceId: "wx_inst_" + col.id + "_" + i + "_" + Date.now(), - id: w.id, - label: w.label || null, - icon: w.icon || null, - image: w.image || null, - color: w.color || null, - removable: w.removable !== false, - movable: w.movable !== false, - html: w.html || "", - params: w.params || {} - }; - }) - }; - }); + const columns = webexpress.webapp.dashboardModel.normalizeColumns(data); + if (columns) { + this._columns = columns; } this.render(); } @@ -134,12 +125,10 @@ webexpress.webapp.DashboardCtrl = class extends webexpress.webui.DashboardCtrl { return; } - fetch(this._restUri, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }).catch((err) => { - console.error("dashboard update state failed", err); + this._service.update(payload).then((r) => { + if (!r.ok) { + console.error("dashboard update state failed", r.error); + } }); } diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dashboard.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dashboard.model.js new file mode 100644 index 0000000..7844574 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dashboard.model.js @@ -0,0 +1,57 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST dashboard control (View, State and Service + * migration). These functions carry no DOM or network dependency, so they can + * be unit tested in isolation. The control composes them with a RestService: + * the load is fetched through the shared request (it keeps its own abort and + * loading state), the columns are normalised through the model and the layout + * state is persisted with the service update. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.dashboardModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. The dashboard is loaded with GET and the + * layout state is persisted with PUT. + * @param {string} uri - The REST endpoint backing the dashboard. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(uri) { + return { name: "data", kind: "rest", baseUri: uri || "", method: "GET", updateMethod: "PUT" }; + }, + + /** + * Normalises a dashboard response into its columns and widgets, assigning a + * fresh instance id to each widget. Returns null when the response carries no + * columns, so the caller can leave the current layout untouched, which + * matches the historical behaviour. + * @param {object} data - The raw dashboard response. + * @returns {Array|null} The normalised columns, or null. + */ + normalizeColumns(data) { + if (!data || !data.columns) { + return null; + } + + return data.columns.map((col) => ({ + id: col.id, + label: col.label || "", + size: col.size || "1fr", + widgets: (col.widgets || []).map((w, i) => ({ + instanceId: "wx_inst_" + col.id + "_" + i + "_" + Date.now(), + id: w.id, + label: w.label || null, + icon: w.icon || null, + image: w.image || null, + color: w.color || null, + removable: w.removable !== false, + movable: w.movable !== false, + html: w.html || "", + params: w.params || {} + })) + })); + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.data.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.data.js new file mode 100644 index 0000000..718556a --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.data.js @@ -0,0 +1,195 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Component base, part of the View, State and Service architecture. + * + * A Component extends the existing Ctrl base and ties together a Store, a set + * of services, a render function and the lifecycle. It seeds its store from the + * data-wx-state island, resolves its services from the data-wx-service island, + * exposes a dispatch method for intents and runs the onMount, onUpdate and + * onUnmount hooks. Existing controls migrate to extend Component, while Ctrl + * stays available for trivial controls that hold no state and perform no + * network access. + * + * A subclass implements render(state) to return a virtual node tree, which the + * renderer patches into the render root. A subclass that prefers imperative + * updates may instead implement onUpdate(state) and omit render, which is the + * first level of adoption described in the design. + */ +webexpress.webapp.Data = class extends webexpress.webui.Ctrl { + /** + * Creates a component for a host element. + * @param {HTMLElement} element - The host element. + * @param {object} [options={}] - Optional overrides: state, store, services, shared, renderRoot. + */ + constructor(element, options = {}) { + super(element); + + this._mounted = false; + this._unsubscribe = null; + this._sharedStoreId = null; + this._renderRoot = options.renderRoot || element; + + const initialState = options.state || webexpress.webapp.Data.readState(element); + + if (options.store) { + this._store = options.store; + } else if (options.shared && element && element.id) { + this._sharedStoreId = element.id; + this._store = webexpress.webapp.StoreRegistry.acquire(element.id, initialState); + } else { + this._store = new webexpress.webapp.Store(initialState); + } + + this._services = options.services || webexpress.webapp.ServiceRegistry.fromElement(element); + } + + /** + * Returns the current state. + * @returns {object} The state. + */ + get state() { + return this._store.getState(); + } + + /** + * Returns the store. + * @returns {webexpress.webapp.Store} The store. + */ + get store() { + return this._store; + } + + /** + * Applies a shallow patch to the state. + * @param {object|Function} patch - The patch. + * @returns {object} The resulting state. + */ + setState(patch) { + return this._store.setState(patch); + } + + /** + * Returns a service by name. + * @param {string} name - The service name. + * @returns {webexpress.webapp.Service|null} The service or null. + */ + useService(name) { + return (this._services && this._services[name]) || null; + } + + /** + * Dispatches an intent against this component's store and services. + * @param {string} name - The intent name. + * @param {*} payload - The intent payload. + * @returns {*} The return value of the intent effect, when present. + */ + dispatch(name, payload) { + return webexpress.webapp.Intents.dispatch(name, { + store: this._store, + payload: payload, + services: this._services, + component: this, + element: this._element + }); + } + + /** + * Subscribes to the store, performs the first render and runs onMount. A + * subclass calls this at the end of its constructor once it has finished + * its own setup. + * @returns {this} The component for chaining. + */ + mount() { + if (this._mounted) { + return this; + } + + this._unsubscribe = this._store.subscribe((state) => this._apply(state)); + this._apply(this._store.getState()); + this._mounted = true; + + if (typeof this.onMount === "function") { + this.onMount(this._store.getState()); + } + + return this; + } + + /** + * Renders the current state into the render root and runs onUpdate after + * the first render. The first render is driven by mount and runs onMount + * instead of onUpdate. + * @param {object} state - The current state. + */ + _apply(state) { + if (typeof this.render === "function" && webexpress.webapp.Renderer) { + const tree = this.render(state); + if (tree !== undefined && tree !== null) { + webexpress.webapp.Renderer.patch(this._renderRoot || this._element, tree); + } + } + + if (this._mounted && typeof this.onUpdate === "function") { + this.onUpdate(state); + } + } + + /** + * Tears the component down. It unsubscribes from the store, aborts in + * flight services, releases a shared store and runs onUnmount. + */ + destroy() { + if (this._unsubscribe) { + this._unsubscribe(); + this._unsubscribe = null; + } + + if (this._services) { + for (const service of Object.values(this._services)) { + if (service && typeof service.abort === "function") { + service.abort(); + } + } + } + + if (typeof this.onUnmount === "function") { + this.onUnmount(); + } + + if (this._sharedStoreId) { + webexpress.webapp.StoreRegistry.release(this._sharedStoreId); + this._sharedStoreId = null; + } + + this._mounted = false; + + super.destroy(); + } + + /** + * Reads and parses the data-wx-state island of a host element. + * @param {HTMLElement} element - The host element. + * @returns {object} The parsed initial state, or an empty object. + */ + static readState(element) { + if (!element || typeof element.getAttribute !== "function") { + return {}; + } + + const raw = element.getAttribute("data-wx-state"); + + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw); + return (parsed && typeof parsed === "object") ? parsed : {}; + } catch (error) { + console.warn("invalid data-wx-state island", error); + return {}; + } + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.js index 6bf580e..877de8a 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.js @@ -28,6 +28,11 @@ webexpress.webapp.DropdownCtrl = class extends webexpress.webui.DropdownCtrl { this._searchPlaceholder = element.dataset.searchplaceholder || this._i18n( "webexpress.webapp:dropdown.search.placeholder", ""); + // data service used to fetch the dropdown items through the service layer + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data || + webexpress.webapp.ServiceRegistry.create({ kind: "rest", baseUri: this._apiEndpoint || "" }); + // dynamic items storage this._allItems = []; this._searchTerm = ""; @@ -332,12 +337,12 @@ webexpress.webapp.DropdownCtrl = class extends webexpress.webui.DropdownCtrl { init.body = JSON.stringify(body); } - const res = await fetch(url, init); + const res = await this._service.request(url, init); if (!res.ok) { throw new Error("http error " + res.status); } - const json = await res.json(); + const json = res.data; const rawItems = json.items; this._allItems = rawItems.map((x) => { return this._mapApiItem(x); }); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.theme.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.theme.js index b6bf09b..ef274df 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.theme.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.theme.js @@ -60,7 +60,7 @@ webexpress.webapp.DropdownTheme = class extends webexpress.webui.DropdownCtrl { * @returns {Promise} */ async _fetchThemes() { - const res = await fetch(this._apiEndpoint, { + const res = await webexpress.webapp.ServiceRegistry.request(this._apiEndpoint, { method: "GET", headers: { "Accept": "application/json" }, credentials: "same-origin" @@ -69,20 +69,14 @@ webexpress.webapp.DropdownTheme = class extends webexpress.webui.DropdownCtrl { throw new Error("http error " + res.status); } - const json = await res.json(); - const rawItems = Array.isArray(json && json.items) ? json.items : []; - const selected = (json && typeof json.selected === "string" && json.selected.length > 0) - ? json.selected - : null; - - // map each raw item to the structure the base DropdownCtrl renderer - // expects (see _createMenuItem in webexpress.webui.dropdown.js). - const items = rawItems.map((it) => this._mapItem(it)); + const json = res.data; + const themes = webexpress.webapp.dropdownThemeModel.normalizeThemes(json); + const items = themes.items; // pick the active theme: cookie selection wins, otherwise the first // item is chosen so the dropdown is never blank. const fallback = items.length > 0 ? items[0].id : null; - this._activeId = selected || fallback; + this._activeId = themes.selected || fallback; // re-render with the live items and the active theme as the label. const activeItem = items.find((it) => it.id === this._activeId) || null; @@ -100,17 +94,7 @@ webexpress.webapp.DropdownTheme = class extends webexpress.webui.DropdownCtrl { * @returns {Object} normalised menu item. */ _mapItem(apiItem) { - const id = apiItem && apiItem.id ? String(apiItem.id) : null; - const text = (apiItem && (apiItem.content || apiItem.name || apiItem.label || apiItem.title)) || id || ""; - return { - id: id, - uri: "javascript:void(0);", - text: text, - icon: apiItem && apiItem.icon ? apiItem.icon : null, - image: apiItem && apiItem.image ? apiItem.image : null, - data: [], - aria: [] - }; + return webexpress.webapp.dropdownThemeModel.mapItem(apiItem); } /** @@ -133,7 +117,7 @@ webexpress.webapp.DropdownTheme = class extends webexpress.webui.DropdownCtrl { const body = new URLSearchParams(); body.set("v", themeId); - fetch(this._apiEndpoint, { + webexpress.webapp.ServiceRegistry.request(this._apiEndpoint, { method: "PUT", headers: { "Accept": "application/json", diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.theme.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.theme.model.js new file mode 100644 index 0000000..ff0dec0 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.dropdown.theme.model.js @@ -0,0 +1,51 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the theme dropdown control (View, State and Service + * migration). The control loads the themes through the shared request and + * persists the selection with a form encoded PUT, so the network access stays + * in the control. The model owns the theme item mapping and the theme list + * normalisation, which carry no DOM or network dependency and can be unit + * tested in isolation. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.dropdownThemeModel = { + /** + * Maps a raw API theme item to the structure the base DropdownCtrl renderer + * expects. Inactive themes use a javascript:void(0) uri to suppress default + * navigation; the reload is triggered after the PUT succeeds. + * @param {object} apiItem - The raw API item. + * @returns {object} The normalised menu item. + */ + mapItem(apiItem) { + const id = apiItem && apiItem.id ? String(apiItem.id) : null; + const text = (apiItem && (apiItem.content || apiItem.name || apiItem.label || apiItem.title)) || id || ""; + return { + id: id, + uri: "javascript:void(0);", + text: text, + icon: apiItem && apiItem.icon ? apiItem.icon : null, + image: apiItem && apiItem.image ? apiItem.image : null, + data: [], + aria: [] + }; + }, + + /** + * Normalises a themes response into the mapped items and the selected id. + * The items array is the mapped raw items; the selected id is the non empty + * string carried in the response, or null. + * @param {object} json - The raw themes response. + * @returns {{items: Array, selected: (string|null)}} The themes. + */ + normalizeThemes(json) { + const rawItems = Array.isArray(json && json.items) ? json.items : []; + const items = rawItems.map((it) => this.mapItem(it)); + const selected = (json && typeof json.selected === "string" && json.selected.length > 0) + ? json.selected + : null; + return { items: items, selected: selected }; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.cascading.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.cascading.js index f8e75cd..d66a466 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.cascading.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.cascading.js @@ -53,12 +53,12 @@ webexpress.webapp.InputCascadingCtrl = class extends webexpress.webui.InputCasca } // perform fetch and normalize response - return fetch(url, { method: "GET", credentials: "same-origin" }) + return webexpress.webapp.ServiceRegistry.request(url, { method: "GET", credentials: "same-origin" }) .then(function (resp) { if (!resp.ok) { throw new Error("Network response was not ok"); } - return resp.json(); + return resp.data; }) .then(function (data) { // expect data to be an array of nodes; normalize shape diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.selection.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.selection.js index da64b90..a9abc3b 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.selection.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.selection.js @@ -171,13 +171,19 @@ webexpress.webapp.InputSelectionCtrl = class extends webexpress.webui.InputSelec const init = this._buildRequestInit(term, this._abortCtrl.signal); // perform request using fetch api - fetch(url, init) + webexpress.webapp.ServiceRegistry.request(url, init) .then((res) => { + // ignore superseded requests that a newer keystroke aborted + if (res.error && res.error.kind === "abort") { + const abort = new Error("aborted"); + abort.name = "AbortError"; + throw abort; + } // check http status if (!res.ok) { throw new Error(`http ${res.status}`); } - return res.json(); + return res.data; }) .then((response) => { const rawData = response.items; @@ -240,57 +246,7 @@ webexpress.webapp.InputSelectionCtrl = class extends webexpress.webui.InputSelec * @returns {Object} A normalized item compatible with InputSelectionCtrl.options. */ _mapApiItem(apiItem) { - // choose field aliases defensively - const id = apiItem.id || null; - const uri = apiItem.uri || apiItem.url || "javascript:void(0);"; - const content = apiItem.content || apiItem.name || apiItem.text || apiItem.title || ""; - const icon = apiItem.icon || null; - const image = apiItem.image || apiItem.img || null; - const color = apiItem.color || null; - const disabled = Boolean(apiItem.disabled); - const role = apiItem.role || null; - - // transform data/aria objects into attribute tuples - const dataTuples = []; - if (apiItem.data && typeof apiItem.data === "object") { - Object.keys(apiItem.data).forEach((k) => { - // ensure attribute key has data- prefix - const key = k.startsWith("data-") ? k : "data-" + k; - dataTuples.push([key, String(apiItem.data[k])]); - }); - } - - const ariaTuples = []; - if (apiItem.aria && typeof apiItem.aria === "object") { - Object.keys(apiItem.aria).forEach((k) => { - // ensure attribute key has aria- prefix - const key = k.startsWith("aria-") ? k : "aria-" + k; - ariaTuples.push([key, String(apiItem.aria[k])]); - }); - } - - return { - id: id, - value: id, - label: content, - content: content, - uri: uri, - image: image, - icon: icon, - color: color, - disabled: disabled, - data: dataTuples, - aria: ariaTuples, - role: role, - - // Action attributes mapping - primaryAction: apiItem.primaryAction || null, - primaryTarget: apiItem.primaryTarget || null, - primaryUri: apiItem.primaryUri || null, - secondaryAction: apiItem.secondaryAction || null, - secondaryTarget: apiItem.secondaryTarget || null, - secondaryUri: apiItem.secondaryUri || null - }; + return webexpress.webapp.inputSelectionModel.mapApiItem(apiItem); } /** @@ -299,14 +255,13 @@ webexpress.webapp.InputSelectionCtrl = class extends webexpress.webui.InputSelec * @returns {string} the composed request url. */ _buildUrl(term) { - if (this._httpMethod !== "GET") { - return this._apiEndpoint; - } - const hasQuery = this._apiEndpoint.includes("?"); - const sep = hasQuery ? "&" : "?"; - const qp = `${encodeURIComponent(this._queryParam)}=${encodeURIComponent(term)}`; - const pp = `${encodeURIComponent(this._pageParam)}=${encodeURIComponent(this._page)}`; - return `${this._apiEndpoint}${sep}${qp}&${pp}`; + return webexpress.webapp.inputSelectionModel.buildUrl({ + apiEndpoint: this._apiEndpoint, + httpMethod: this._httpMethod, + queryParam: this._queryParam, + pageParam: this._pageParam, + page: this._page + }, term); } /** @@ -316,25 +271,12 @@ webexpress.webapp.InputSelectionCtrl = class extends webexpress.webui.InputSelec * @returns {RequestInit} the fetch init object. */ _buildRequestInit(term, signal) { - const headers = { "Accept": "application/json" }; - if (this._httpMethod === "POST") { - headers["Content-Type"] = "application/json"; - return { - method: "POST", - headers: headers, - body: JSON.stringify({ - [this._queryParam]: term, - [this._pageParam]: this._page - }), - signal: signal - }; - } else { - return { - method: "GET", - headers: headers, - signal: signal - }; - } + return webexpress.webapp.inputSelectionModel.buildRequestInit({ + httpMethod: this._httpMethod, + queryParam: this._queryParam, + pageParam: this._pageParam, + page: this._page + }, term, signal); } }; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.selection.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.selection.model.js new file mode 100644 index 0000000..fb614bd --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.selection.model.js @@ -0,0 +1,119 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST input selection control (View, State and + * Service migration). The control performs a debounced, abortable search + * through the shared request, so it keeps its own abort handling. The model + * owns the request shaping (url and init) and the response mapping, which carry + * no DOM or network dependency and can be unit tested in isolation. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.inputSelectionModel = { + /** + * Builds the request url. For GET it appends the query and page parameters + * with the correct separator and encoding; for other methods the endpoint is + * returned unchanged (the term travels in the body). + * @param {object} config - The endpoint configuration. + * @param {string} term - The search term. + * @returns {string} The request url. + */ + buildUrl(config, term) { + if (config.httpMethod !== "GET") { + return config.apiEndpoint; + } + const hasQuery = config.apiEndpoint.includes("?"); + const sep = hasQuery ? "&" : "?"; + const qp = `${encodeURIComponent(config.queryParam)}=${encodeURIComponent(term)}`; + const pp = `${encodeURIComponent(config.pageParam)}=${encodeURIComponent(config.page)}`; + return `${config.apiEndpoint}${sep}${qp}&${pp}`; + }, + + /** + * Builds the fetch init. A POST carries the term and page in a json body, a + * GET only carries the abort signal and the accept header. + * @param {object} config - The endpoint configuration. + * @param {string} term - The search term. + * @param {AbortSignal} signal - The abort signal. + * @returns {object} The fetch init. + */ + buildRequestInit(config, term, signal) { + const headers = { "Accept": "application/json" }; + if (config.httpMethod === "POST") { + headers["Content-Type"] = "application/json"; + return { + method: "POST", + headers: headers, + body: JSON.stringify({ + [config.queryParam]: term, + [config.pageParam]: config.page + }), + signal: signal + }; + } + return { + method: "GET", + headers: headers, + signal: signal + }; + }, + + /** + * Maps a raw API item to the internal input selection item format, choosing + * field aliases defensively and turning the data and aria objects into + * prefixed attribute tuples. + * @param {object} apiItem - The raw item from the API. + * @returns {object} A normalized input selection item. + */ + mapApiItem(apiItem) { + const id = apiItem.id || null; + const uri = apiItem.uri || apiItem.url || "javascript:void(0);"; + const content = apiItem.content || apiItem.name || apiItem.text || apiItem.title || ""; + const icon = apiItem.icon || null; + const image = apiItem.image || apiItem.img || null; + const color = apiItem.color || null; + const disabled = Boolean(apiItem.disabled); + const role = apiItem.role || null; + + // transform data/aria objects into prefixed attribute tuples + const dataTuples = []; + if (apiItem.data && typeof apiItem.data === "object") { + Object.keys(apiItem.data).forEach((k) => { + const key = k.startsWith("data-") ? k : "data-" + k; + dataTuples.push([key, String(apiItem.data[k])]); + }); + } + + const ariaTuples = []; + if (apiItem.aria && typeof apiItem.aria === "object") { + Object.keys(apiItem.aria).forEach((k) => { + const key = k.startsWith("aria-") ? k : "aria-" + k; + ariaTuples.push([key, String(apiItem.aria[k])]); + }); + } + + return { + id: id, + value: id, + label: content, + content: content, + uri: uri, + image: image, + icon: icon, + color: color, + disabled: disabled, + data: dataTuples, + aria: ariaTuples, + role: role, + + // action attributes mapping + primaryAction: apiItem.primaryAction || null, + primaryTarget: apiItem.primaryTarget || null, + primaryUri: apiItem.primaryUri || null, + secondaryAction: apiItem.secondaryAction || null, + secondaryTarget: apiItem.secondaryTarget || null, + secondaryUri: apiItem.secondaryUri || null + }; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.tile.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.tile.js index 85d83b2..5a331db 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.tile.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.tile.js @@ -174,12 +174,12 @@ webexpress.webapp.InputTileCtrl = class extends webexpress.webui.InputTileCtrl { url += separator + "q=" + encodeURIComponent(filter); } - fetch(url) + webexpress.webapp.ServiceRegistry.request(url) .then((res) => { if (!res.ok) { throw new Error("Request failed"); } - return res.json(); + return res.data; }) .then((response) => { let tiles = []; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.unique.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.unique.js index 66f8875..e0309e0 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.unique.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.unique.js @@ -170,27 +170,7 @@ webexpress.webapp.InputUniqueCtrl = class extends webexpress.webui.Ctrl { * @returns {Record} A map of header names to values. */ _parseHeaders(headersJson) { - // return empty set if not provided - if (!headersJson) { - return {}; - } - // attempt to parse a plain object of string-to-string pairs - try { - const obj = JSON.parse(headersJson); - if (obj && typeof obj === "object" && !Array.isArray(obj)) { - const out = {}; - for (const [k, v] of Object.entries(obj)) { - // only accept string keys with string values - if (typeof k === "string" && typeof v === "string") { - out[k] = v; - } - } - return out; - } - } catch (e) { - // ignore invalid json - } - return {}; + return webexpress.webapp.inputUniqueModel.parseHeaders(headersJson); } /** @@ -284,11 +264,16 @@ webexpress.webapp.InputUniqueCtrl = class extends webexpress.webui.Ctrl { if (!headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } - opts.body = JSON.stringify({ [this._param]: value }); + opts.body = JSON.stringify(webexpress.webapp.inputUniqueModel.requestBody(this._param, value)); } - // perform request - const response = await fetch(reqUrl, opts); + // perform request through the shared service + const response = await webexpress.webapp.ServiceRegistry.request(reqUrl, opts); + + // ignore superseded requests that a newer keystroke aborted + if (response.error && response.error.kind === "abort") { + return; + } // handle non-2xx responses as errors if (!response.ok) { @@ -301,11 +286,9 @@ webexpress.webapp.InputUniqueCtrl = class extends webexpress.webui.Ctrl { return; } - // parse json response - let data = null; - try { - data = await response.json(); - } catch (_parseErr) { + // read the parsed json response; a null payload indicates a parse failure + const data = response.data; + if (data === null || data === undefined) { if (value === this._currentValue) { this._setState("error", this._messageError); this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { @@ -365,54 +348,7 @@ webexpress.webapp.InputUniqueCtrl = class extends webexpress.webui.Ctrl { * @returns {boolean|null} True if available, false if not, null if undecidable. */ _extractAvailability(data) { - // prefer configured field if present - if (data && Object.prototype.hasOwnProperty.call(data, this._responseField)) { - const raw = data[this._responseField]; - if (typeof raw === "boolean") { - return raw; - } - if (typeof raw === "string") { - const s = raw.trim().toLowerCase(); - if (s === "true") { - return true; - } - if (s === "false") { - return false; - } - } - if (typeof raw === "number") { - if (raw === 1) { - return true; - } - if (raw === 0) { - return false; - } - } - } - - // heuristics for common shapes - if (data && typeof data === "object") { - if (typeof data.status === "string") { - const st = data.status.toLowerCase(); - if (st === "free" || st === "available") { - return true; - } - if (st === "taken" || st === "unavailable" || st === "exists" || st === "in_use") { - return false; - } - } - if (typeof data.code === "string") { - const cd = data.code.toLowerCase(); - if (cd === "available") { - return true; - } - if (cd === "unavailable") { - return false; - } - } - } - - return null; + return webexpress.webapp.inputUniqueModel.extractAvailability(data, this._responseField); } /** diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.unique.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.unique.model.js new file mode 100644 index 0000000..c624a08 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.input.unique.model.js @@ -0,0 +1,111 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the unique input control (View, State and Service + * migration). The control performs a single bespoke uniqueness check (GET with + * query parameters or POST with a json body) through the shared request, so it + * keeps its own abort handling rather than a configured service. The model owns + * the response interpretation and the request shaping that carry no DOM or + * network dependency, so they can be unit tested in isolation. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.inputUniqueModel = { + /** + * Parses the data-headers attribute into a plain object of string to string + * pairs, tolerating absent or invalid json and dropping non string values. + * @param {string} headersJson - The raw data-headers attribute. + * @returns {Object} The parsed headers. + */ + parseHeaders(headersJson) { + if (!headersJson) { + return {}; + } + try { + const obj = JSON.parse(headersJson); + if (obj && typeof obj === "object" && !Array.isArray(obj)) { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + if (typeof k === "string" && typeof v === "string") { + out[k] = v; + } + } + return out; + } + } catch (e) { + // ignore invalid json + } + return {}; + }, + + /** + * Builds the request body for a non GET uniqueness check. + * @param {string} param - The configured value parameter name. + * @param {string} value - The value being checked. + * @returns {Object} The request body. + */ + requestBody(param, value) { + return { [param]: value }; + }, + + /** + * Extracts the availability flag from the API response, preferring the + * configured response field and otherwise applying heuristics for common + * status and code shapes. Returns true when available, false when taken and + * null when the response is undecidable. + * @param {*} data - The parsed API response. + * @param {string} responseField - The configured availability field name. + * @returns {boolean|null} The availability, or null when undecidable. + */ + extractAvailability(data, responseField) { + // prefer configured field if present + if (data && Object.prototype.hasOwnProperty.call(data, responseField)) { + const raw = data[responseField]; + if (typeof raw === "boolean") { + return raw; + } + if (typeof raw === "string") { + const s = raw.trim().toLowerCase(); + if (s === "true") { + return true; + } + if (s === "false") { + return false; + } + } + if (typeof raw === "number") { + if (raw === 1) { + return true; + } + if (raw === 0) { + return false; + } + } + } + + // heuristics for common shapes + if (data && typeof data === "object") { + if (typeof data.status === "string") { + const st = data.status.toLowerCase(); + if (st === "free" || st === "available") { + return true; + } + if (st === "taken" || st === "unavailable" || st === "exists" || st === "in_use") { + return false; + } + } + if (typeof data.code === "string") { + const cd = data.code.toLowerCase(); + if (cd === "available") { + return true; + } + if (cd === "unavailable") { + return false; + } + } + } + + return null; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.intent.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.intent.js new file mode 100644 index 0000000..b1518e8 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.intent.js @@ -0,0 +1,121 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Intent registry, part of the View, State and Service architecture. + * + * An intent is the single bridge from a user gesture, expressed through the + * Actions and Binds registries, to the Service and State layers. An intent + * definition has an optional reducer and an optional effect. The reducer is a + * pure state transition that returns a patch. The effect is an asynchronous + * routine that may call a service and then dispatch a follow up intent with the + * result. Side effects live only in effects, never in reducers and never in the + * view. + * + * The registry follows the same register, get and unregister shape as the + * Actions and Binds registries, so the surface stays uniform. + */ +webexpress.webapp.Intents = new class { + /** + * Creates the registry. + */ + constructor() { + this._intents = new Map(); + } + + /** + * Registers an intent definition. + * @param {string} name - The intent name, for example "list search". + * @param {object} definition - An object with an optional reduce and an optional effect. + * @returns {this} The registry for chaining. + */ + register(name, definition) { + if (typeof name !== "string" || name.trim() === "") { + return this; + } + if (!definition || typeof definition !== "object") { + console.error(`Intent "${name}" must be defined as an object.`); + return this; + } + this._intents.set(name, definition); + return this; + } + + /** + * Returns an intent definition by name. + * @param {string} name - The intent name. + * @returns {object|null} The definition or null. + */ + get(name) { + return this._intents.get(name) || null; + } + + /** + * Returns whether an intent is registered. + * @param {string} name - The intent name. + * @returns {boolean} True when registered. + */ + has(name) { + return this._intents.has(name); + } + + /** + * Removes an intent by name. + * @param {string} name - The intent name. + */ + unregister(name) { + this._intents.delete(name); + } + + /** + * Removes all intents. Useful for tests. + */ + clear() { + this._intents.clear(); + } + + /** + * Dispatches an intent. The reducer, when present, produces a patch that is + * applied to the store. The effect, when present, runs afterwards and may + * perform input or output. The context carries the store, the payload, the + * services and a reference to the dispatching component. + * @param {string} name - The intent name. + * @param {object} context - { store, payload, services, component, element, dispatch }. + * @returns {*} The return value of the effect, when present. + */ + dispatch(name, context) { + const definition = this.get(name); + + if (!definition) { + console.warn(`Intent "${name}" is not registered.`); + return undefined; + } + + context = context || {}; + + if (typeof context.dispatch !== "function") { + context.dispatch = (nextName, payload) => this.dispatch(nextName, Object.assign({}, context, { payload: payload })); + } + + if (typeof definition.reduce === "function" && context.store) { + try { + const patch = definition.reduce(context.store.getState(), context.payload, context); + if (patch) { + context.store.setState(patch); + } + } catch (error) { + console.error(`Intent "${name}" reducer failed`, error); + } + } + + if (typeof definition.effect === "function") { + try { + return definition.effect(context); + } catch (error) { + console.error(`Intent "${name}" effect failed`, error); + } + } + + return undefined; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.kanban.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.kanban.js index b3e1028..cc64f3d 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.kanban.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.kanban.js @@ -7,7 +7,6 @@ webexpress.webapp.KanbanCtrl = class extends webexpress.webui.KanbanCtrl { // configuration _restUri = ""; - _abortController = null; /** * Initializes the REST Kanban control. @@ -16,9 +15,20 @@ webexpress.webapp.KanbanCtrl = class extends webexpress.webui.KanbanCtrl { constructor(element) { super(element); - this._restUri = element.dataset.uri || ""; + // canonical ui state: a single source of truth for the loading flag, + // seeded from the optional data-wx-state island + this._store = new webexpress.webapp.Store(Object.assign({ + loading: false + }, webexpress.webapp.Data.readState(element))); + element.removeAttribute("data-uri"); + // data service: a configured island when present, otherwise a legacy + // descriptor. its query loads the board, its update persists changes. + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data; + this._restUri = this._service ? this._service.baseUri : ""; + this._initRestPersistence(element); if (this._restUri) { @@ -26,54 +36,41 @@ webexpress.webapp.KanbanCtrl = class extends webexpress.webui.KanbanCtrl { } } + // loading flag accessor backed by the store, so the single source of truth + // is the store + + get _loading() { return this._store.getState().loading; } + set _loading(value) { this._store.setState({ loading: value }); } + /** * Fetches the board data including columns, swimlanes, and cards. */ - _receiveData() { - if (!this._restUri) { + async _receiveData() { + if (!this._restUri || !this._service) { return; } - if (this._abortController) { - this._abortController.abort("search replaced"); - } - - this._abortController = new AbortController(); + this._loading = true; this._element.classList.add("placeholder-glow"); - const base = window.location.origin; - let urlObj; - - try { - urlObj = new URL(this._restUri, base); - } catch (e) { - urlObj = new URL(this._restUri, document.baseURI); + const result = await this._service.query({}); + + if (!result.ok) { + // a superseded query arrives as an abort result and is ignored + if (result.error.kind === "abort") { + return; + } + // log error and reset state + console.error("kanban load failed:", result.error.message); + this._element.classList.remove("placeholder-glow"); + this._loading = false; + return; } - const fetchUrl = this._restUri.startsWith("http") ? urlObj.href : (urlObj.pathname + urlObj.search); - - fetch(fetchUrl, { signal: this._abortController.signal }) - .then((res) => { - if (!res.ok) { - throw new Error("request failed"); - } - return res.json(); - }) - .then((response) => { - this.updateData(response); - - this._element.classList.remove("placeholder-glow"); - this._abortController = null; - }) - .catch((error) => { - if (error.name === "AbortError") { - return; - } - // log error and reset state - console.error("kanban load failed:", error); - this._element.classList.remove("placeholder-glow"); - this._abortController = null; - }); + this.updateData(result.data); + + this._element.classList.remove("placeholder-glow"); + this._loading = false; } /** @@ -81,44 +78,16 @@ webexpress.webapp.KanbanCtrl = class extends webexpress.webui.KanbanCtrl { * @param {Object} data - The json payload containing columns, swimlanes, and items. */ updateData(data) { - // parse column configuration - if (data.columns) { - this._columns = data.columns.map((col) => { - return { - id: col.id, - label: col.label, - size: col.size || "1fr" - }; - }); - } + const board = webexpress.webapp.kanbanModel.normalizeBoard(data); - // parse swimlane configuration including the expanded state - if (data.swimlanes) { - this._swimlanes = data.swimlanes.map((lane) => { - return { - id: lane.id, - label: lane.label, - expanded: lane.expanded !== false - }; - }); + if (board.columns) { + this._columns = board.columns; } - - // parse card items - if (data.items) { - this._cards = data.items.map((item) => { - return { - id: item.id, - columnId: item.columnId, - swimlaneId: item.swimlaneId, - label: item.label || "", - html: item.html || "", - colorCss: item.colorCss || "", - icon: item.icon || null, - image: item.image || null, - primaryAction: item.primaryAction || {}, - secondaryAction: item.secondaryAction || {} - }; - }); + if (board.swimlanes) { + this._swimlanes = board.swimlanes; + } + if (board.cards) { + this._cards = board.cards; } // redraw the control with new data @@ -161,17 +130,15 @@ webexpress.webapp.KanbanCtrl = class extends webexpress.webui.KanbanCtrl { * @param {Object} payload The data payload containing card position info. */ _sendStateToServer(payload) { - if (!this._restUri) { + if (!this._restUri || !this._service) { return; } - fetch(this._restUri, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }).catch((err) => { - // log failed update request - console.error("kanban update state failed", err); + this._service.update(payload).then((result) => { + if (!result.ok && result.error.kind !== "abort") { + // log failed update request + console.error("kanban update state failed", result.error.message); + } }); } diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.kanban.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.kanban.model.js new file mode 100644 index 0000000..88dfcb4 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.kanban.model.js @@ -0,0 +1,70 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST kanban control (phase two of the View, State + * and Service migration). These functions carry no DOM or network dependency, + * so they can be unit tested in isolation. The control composes them with a + * Store and a RestService whose query loads the board and whose update persists + * card moves and column changes. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.kanbanModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. The board is loaded with GET and state + * changes are persisted with PUT. + * @param {string} restUri - The REST endpoint backing the board. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(restUri) { + return { name: "data", kind: "rest", baseUri: restUri || "", method: "GET", updateMethod: "PUT" }; + }, + + /** + * Normalises a board response into the internal columns, swimlanes and + * cards. Only the parts present in the response are returned, so that a + * partial update leaves the other parts of the board untouched, which + * matches the historical behaviour. + * @param {object} data - The raw board response. + * @returns {object} An object with the present columns, swimlanes and cards. + */ + normalizeBoard(data) { + data = data || {}; + const out = {}; + + if (data.columns) { + out.columns = data.columns.map((col) => ({ + id: col.id, + label: col.label, + size: col.size || "1fr" + })); + } + + if (data.swimlanes) { + out.swimlanes = data.swimlanes.map((lane) => ({ + id: lane.id, + label: lane.label, + expanded: lane.expanded !== false + })); + } + + if (data.items) { + out.cards = data.items.map((item) => ({ + id: item.id, + columnId: item.columnId, + swimlaneId: item.swimlaneId, + label: item.label || "", + html: item.html || "", + colorCss: item.colorCss || "", + icon: item.icon || null, + image: item.image || null, + primaryAction: item.primaryAction || {}, + secondaryAction: item.secondaryAction || {} + })); + } + + return out; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.js index 8d8bff1..16162e6 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.js @@ -9,19 +9,9 @@ * - webexpress.webui.Event.DATA_ARRIVED_EVENT */ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { - _search = ""; - _wql = ""; - _filter = ""; - _page = 0; - _pageSize = 50; - _items = {}; - - _orderBy = null; - _orderDir = null; - _restUri = ""; _progressDiv = this._createProgressDiv(); - + /** * Constructor for the REST ListCtrl. * @param {HTMLElement} element The host element. @@ -29,10 +19,30 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { constructor(element) { super(element); + // canonical state for the list: a single source of truth that the + // accessors below read from and write to. seeded from the optional + // data-wx-state island. + this._store = new webexpress.webapp.Store(Object.assign({ + search: "", + wql: "", + filter: "", + page: 0, + pageSize: 50, + orderBy: null, + orderDir: null, + total: 0, + loading: false, + error: null + }, webexpress.webapp.Data.readState(element))); + // read rest uri and clean attribute - this._restUri = element.dataset.uri || ""; element.removeAttribute("data-uri"); - + + // data service: the configured island authored in C# through .Service(). + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data; + this._restUri = this._service ? this._service.baseUri : ""; + element.className = "wx-list"; // insert progress at top @@ -56,103 +66,107 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { }); this._initPager(element); - + // initial data load - this._receiveData(); + this._load(); } + // state accessors backed by the store, so the single source of truth is the + // store while the inherited pager and selection logic keeps reading fields + + get _search() { return this._store.getState().search; } + set _search(value) { this._store.setState({ search: value }); } + + get _wql() { return this._store.getState().wql; } + set _wql(value) { this._store.setState({ wql: value }); } + + get _filter() { return this._store.getState().filter; } + set _filter(value) { this._store.setState({ filter: value }); } + + get _page() { return this._store.getState().page; } + set _page(value) { this._store.setState({ page: value }); } + + get _pageSize() { return this._store.getState().pageSize; } + set _pageSize(value) { this._store.setState({ pageSize: value }); } + + get _orderBy() { return this._store.getState().orderBy; } + set _orderBy(value) { this._store.setState({ orderBy: value }); } + + get _orderDir() { return this._store.getState().orderDir; } + set _orderDir(value) { this._store.setState({ orderDir: value }); } + + get _totalRecords() { return this._store.getState().total; } + set _totalRecords(value) { this._store.setState({ total: value }); } + /** - * Retrieves data from the REST endpoint and updates the list. + * Retrieves data from the REST endpoint through the data service and updates + * the list. A superseded query is cancelled by the service, so a stale + * response arrives as an abort result and is ignored here. + * @returns {Promise} Resolves when the load completes. */ - _receiveData() { + async _load() { this._progressDiv.style.display = "none"; - // abort previous request if present - if (this._abortController) { - this._abortController.abort("search replaced"); + if (!this._service) { + return; } - this._abortController = new AbortController(); - - // safely construct url using document base uri - const urlObj = new URL(this._restUri, document.baseURI); - - // set query parameters - urlObj.searchParams.set("q", this._search || ""); - urlObj.searchParams.set("wql", this._wql || ""); - urlObj.searchParams.set("f", this._filter || ""); - urlObj.searchParams.set("p", String(this._page)); - urlObj.searchParams.set("l", String(this._pageSize)); - - if (this._orderBy) { - urlObj.searchParams.set("o", this._orderBy); - if (this._orderDir) { - urlObj.searchParams.set("d", this._orderDir); + + this._store.setState({ loading: true, error: null }); + + const params = webexpress.webapp.listModel.queryParams(this._store.getState()); + const result = await this._service.query(params); + + if (!result.ok) { + // ignore aborts (a newer query replaced this one); report the rest + if (result.error.kind !== "abort") { + console.error("the request could not be completed successfully:", result.error.message); + this._store.setState({ loading: false, error: result.error }); } + this._progressDiv.style.visibility = "hidden"; + return; } - const fetchUrl = this._restUri.startsWith("http") ? urlObj.href : (urlObj.pathname + urlObj.search); + const response = result.data; - fetch(fetchUrl, { signal: this._abortController.signal }) - .then(res => { - if (!res.ok) { - throw new Error("request failed"); - } - return res.json(); - }) - .then(response => { - // extract paging information from server response - this._totalRecords = Number(response.total ?? response.totalCount ?? response.count ?? 0) || 0; - this._page = Number(response.page ?? this._page ?? 0) || 0; - this._pageSize = Number(response.pageSize ?? this._pageSize ?? 50) || 50; - - // emit data arrived event - const evt = new CustomEvent(webexpress.webui.Event.DATA_ARRIVED_EVENT, { - detail: { response: response } - }); - this._element.dispatchEvent(evt); - - // remove placeholder state - const listUl = this._element.querySelector("ul.wx-list"); - if (listUl) { - listUl.classList.remove("placeholder-glow"); - } + // reduce paging information into the store (single source of truth) + this._store.setState(webexpress.webapp.listModel.reduceResponse(this._store.getState(), response)); - // map response into list items - const newItems = this._mapResponseToItems(response); + // emit data arrived event (kept identical for existing listeners) + const evt = new CustomEvent(webexpress.webui.Event.DATA_ARRIVED_EVENT, { + detail: { response: response } + }); + this._element.dispatchEvent(evt); - // update list via base class - this.setItems(newItems); + // remove placeholder state + const listUl = this._element.querySelector("ul.wx-list"); + if (listUl) { + listUl.classList.remove("placeholder-glow"); + } - if (this._selectable) { - let selected = this._items.find((i) => i.id === this._selectedItem?.id) || null; - if (!selected && this._items.length > 0) { - selected = this._items[0]; - this._handleSelectionChange(selected, null, true); - this._triggerPrimaryAction(selected); - } - } + // map response into list items and update the view + const newItems = webexpress.webapp.listModel.mapItems(response); + this.setItems(newItems); - // update paging display - this._syncPagerAndInfo(); - - // notify listeners that data arrived - this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { - response: response, - page: this._page - }); - - // hide progress - this._progressDiv.style.visibility = "hidden"; - this._abortController = null; - }) - .catch(error => { - // ignore abort errors, log others - if (error.name !== "AbortError") { - console.error("the request could not be completed successfully:", error); - } - this._progressDiv.style.visibility = "hidden"; - this._abortController = null; - }); + if (this._selectable) { + let selected = this._items.find((i) => i.id === this._selectedItem?.id) || null; + if (!selected && this._items.length > 0) { + selected = this._items[0]; + this._handleSelectionChange(selected, null, true); + this._triggerPrimaryAction(selected); + } + } + + // update paging display + this._syncPagerAndInfo(); + + // notify listeners that data arrived + this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { + response: response, + page: this._page + }); + + // hide progress + this._progressDiv.style.visibility = "hidden"; } /** @@ -161,50 +175,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { * @returns {Array} Normalized items for ListCtrl. */ _mapResponseToItems(response) { - const result = []; - - // handle response.items array - if (Array.isArray(response?.items)) { - for (const it of response.items) { - if (typeof it === "string") { - result.push({ - id: null, - content: { content: it } - }); - } else if (it !== null && typeof it === "object") { - // detect optional html template - let htmlEl = null; - if (it.html instanceof Element) { - htmlEl = it.html.cloneNode(true); - } else if (typeof it.html === "string") { - const tmp = document.createElement("span"); - tmp.innerHTML = it.html; - htmlEl = tmp.firstElementChild ? tmp : null; - } - - result.push({ - id: it.id || null, - class: it.class || null, - style: it.style || null, - color: it.color || null, - image: it.image || null, - icon: it.icon || null, - uri: it.uri || null, - target: it.target || null, - editable: !!it.editable, - rendererType: it.rendererType || it.type || null, - rendererOptions: it.rendererOptions || {}, - content: it.text ?? it.label ?? it.name ?? "", - primaryAction: it.primaryAction || null, - secondaryAction: it.secondaryAction || null, - bind: it.bind || null, - options: Array.isArray(it.options) ? it.options : null - }); - } - } - } - - return result; + return webexpress.webapp.listModel.mapItems(response); } /** @@ -214,7 +185,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { */ update() { if (this._restUri && this._isVisible()) { - this._receiveData(); + this._load(); } } @@ -228,7 +199,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { this._wql = searchType === "wql" ? pattern : null; this._page = 0; if (this._restUri && this._isVisible()) { - this._receiveData(); + this._load(); } } @@ -241,10 +212,10 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { this._page = 0; if (this._restUri && this._isVisible()) { - this._receiveData(); + this._load(); } } - + /** * Sets and loads the page. * @param {number} page The current page index. @@ -253,7 +224,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { this._page = page; if (this._restUri && this._isVisible()) { - this._receiveData(); + this._load(); } } @@ -290,7 +261,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { div.appendChild(bar); return div; } - + /** * Initializes or binds a pagination control and an information area. * @param {HTMLElement} host The host element to search or attach the pager to. @@ -298,7 +269,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { _initPager(host) { // find existing pager element based on dataset const paginationId = host.dataset.wxSourcePaging || null; - + const init = () => { if (paginationId) { this._pagerElement = document.querySelector(paginationId); @@ -316,13 +287,13 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { } else { init(); } - + // create info div to show totals and current page details this._infoDiv = document.createElement("div"); this._infoDiv.className = "text-muted small"; this._infoDiv.style.marginTop = "0.25rem"; this._infoDiv.textContent = ""; - + host.appendChild(this._infoDiv); } @@ -333,7 +304,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { _syncPagerAndInfo() { const total = Number(this._totalRecords) || 0; let totalPages = 1; - + if (this._pageSize > 0) { totalPages = Math.max(1, Math.ceil(total / this._pageSize)); } @@ -381,7 +352,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { this._infoDiv.textContent = `Page ${currentPage + 1} of ${totalPages} / ${itemsOnPage} of ${total} items`; } } - + /** * Handles page changes coming from external or internal pagination controls. * @param {number} targetPage Zero-based page index. @@ -393,7 +364,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { if (page < 0) { page = 0; } - + if (page >= totalPages) { page = totalPages - 1; } @@ -404,9 +375,9 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { this._infoDiv.textContent = `Page ${this._page + 1} of ${totalPages} - loading…`; } - this._receiveData(); + this._load(); } }; // register the class in the controller -webexpress.webui.Controller.registerClass("wx-webapp-list", webexpress.webapp.ListCtrl); \ No newline at end of file +webexpress.webui.Controller.registerClass("wx-webapp-list", webexpress.webapp.ListCtrl); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.model.js new file mode 100644 index 0000000..ed5d40d --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.model.js @@ -0,0 +1,128 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST list control (phase one of the View, State + * and Service migration). These functions carry no DOM or network dependency, + * so they can be unit tested in isolation. The control composes them with a + * Store and a RestService. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.listModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. It reproduces the historical query + * parameter names of the list control so that the migration is behaviour + * preserving until the descriptor is authored in C# in a later phase. + * @param {string} restUri - The REST endpoint backing the list. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(restUri) { + return { + name: "data", + kind: "rest", + baseUri: restUri || "", + method: "GET", + query: { + search: "q", + wql: "wql", + filter: "f", + page: "p", + pageSize: "l", + orderBy: "o", + orderDir: "d" + }, + response: { items: "items", total: "total" } + }; + }, + + /** + * Builds the logical query parameters from the current state. The order + * parameters are only included when an order field is set, which matches + * the historical behaviour. + * @param {object} state - The list state. + * @returns {object} The logical query parameters. + */ + queryParams(state) { + state = state || {}; + + const params = { + search: state.search || "", + wql: state.wql || "", + filter: state.filter || "", + page: state.page || 0, + pageSize: state.pageSize || 50 + }; + + if (state.orderBy) { + params.orderBy = state.orderBy; + if (state.orderDir) { + params.orderDir = state.orderDir; + } + } + + return params; + }, + + /** + * Reduces a server response into a state patch carrying the paging + * information and clearing the loading and error flags. The current state + * provides the fallback values, which matches the historical behaviour. + * @param {object} state - The current list state. + * @param {object} response - The raw server response. + * @returns {object} A state patch. + */ + reduceResponse(state, response) { + state = state || {}; + response = response || {}; + + const total = Number(response.total ?? response.totalCount ?? response.count ?? 0) || 0; + const page = Number(response.page ?? state.page ?? 0) || 0; + const pageSize = Number(response.pageSize ?? state.pageSize ?? 50) || 50; + + return { total: total, page: page, pageSize: pageSize, loading: false, error: null }; + }, + + /** + * Maps a raw server response into the normalised list item structures that + * the base list control consumes. String items become simple content + * items, object items are projected field by field. + * @param {object} response - The raw server response. + * @returns {Array} The normalised list items. + */ + mapItems(response) { + const result = []; + + if (!response || !Array.isArray(response.items)) { + return result; + } + + for (const item of response.items) { + if (typeof item === "string") { + result.push({ id: null, content: { content: item } }); + } else if (item !== null && typeof item === "object") { + result.push({ + id: item.id || null, + class: item.class || null, + style: item.style || null, + color: item.color || null, + image: item.image || null, + icon: item.icon || null, + uri: item.uri || null, + target: item.target || null, + editable: !!item.editable, + rendererType: item.rendererType || item.type || null, + rendererOptions: item.rendererOptions || {}, + content: item.text ?? item.label ?? item.name ?? "", + primaryAction: item.primaryAction || null, + secondaryAction: item.secondaryAction || null, + bind: item.bind || null, + options: Array.isArray(item.options) ? item.options : null + }); + } + } + + return result; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.login.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.login.js index bd175da..6320f2b 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.login.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.login.js @@ -31,6 +31,12 @@ webexpress.webapp.LoginCtrl = class extends webexpress.webui.LoginCtrl { this._retryCountdown = 0; this._failedAttempts = 0; + // data service used to post the credentials through the service layer; + // a configured island when present, otherwise a service for the endpoint + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data || + webexpress.webapp.ServiceRegistry.create({ kind: "rest", baseUri: this._apiEndpoint || "" }); + // add error container before the form this._errorContainer = document.createElement("div"); this._errorContainer.className = "alert alert-danger"; @@ -47,7 +53,7 @@ webexpress.webapp.LoginCtrl = class extends webexpress.webui.LoginCtrl { * instead of the basic auth approach in the base class. */ _attachEventHandlers() { - this._form.addEventListener("submit", (e) => { + this._form.addEventListener("submit", async (e) => { e.preventDefault(); // block submission if currently processing, locked out or in countdown @@ -81,73 +87,78 @@ webexpress.webapp.LoginCtrl = class extends webexpress.webui.LoginCtrl { username: username }); - fetch(this._apiEndpoint, { + const result = await this._service.request(this._apiEndpoint, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify({ username, password }) - }).then((response) => { - return response.json().then((data) => { - this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, data); - - if (data.success) { - // reset failed attempts on successful login - this._failedAttempts = 0; - - if (data.sessionId) { - document.cookie = "session=" + encodeURIComponent(data.sessionId) + "; path=/"; - } - - if (this._redirectUri) { - window.location.href = this._redirectUri; - } else { - window.location.reload(); - } - - return; - } - - // increase failed attempts counter - this._failedAttempts++; - - // permanently lock form after 5 failed attempts - if (this._failedAttempts >= 5) { - this._showError(this._i18n("webexpress.webapp:login.error.locked", "Account is locked due to too many failed attempts.")); - this._submitting = false; - this._loginBtn.disabled = true; - this._loginBtn.textContent = this._i18n("webexpress.webapp:login.locked", "Locked"); - return; - } - - // apply exponential penalty starting from the 3rd attempt - if (this._failedAttempts >= 3) { - // base penalty is 30 seconds, doubles with each subsequent fail - const basePenalty = 30; - const multiplier = Math.pow(2, this._failedAttempts - 3); - const penaltySeconds = basePenalty * multiplier; - - this._startRetryCountdown( - penaltySeconds, - this._i18n("webexpress.webapp:login.error.ratelimit", "Too many failed attempts. Please wait."), - submitLabel - ); - - return; - } - - // normal authentication failed response - this._showError(data.message || this._i18n("webexpress.webapp:login.error.invalid", "Invalid username or password.")); - this._submitting = false; - this._loginBtn.disabled = false; - this._loginBtn.textContent = submitLabel; - }); - }).catch(() => { + }); + + const data = result.data; + + // a network error or an unparseable response is reported generically, + // matching the historical catch behaviour + if (data === null || data === undefined) { this._showError(this._i18n("webexpress.webapp:error.generic", "An error occurred.")); this._submitting = false; this._loginBtn.disabled = false; this._loginBtn.textContent = submitLabel; - }); + return; + } + + this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, data); + + if (data.success) { + // reset failed attempts on successful login + this._failedAttempts = 0; + + if (data.sessionId) { + document.cookie = "session=" + encodeURIComponent(data.sessionId) + "; path=/"; + } + + if (this._redirectUri) { + window.location.href = this._redirectUri; + } else { + window.location.reload(); + } + + return; + } + + // increase failed attempts counter + this._failedAttempts++; + + // permanently lock form after 5 failed attempts + if (this._failedAttempts >= 5) { + this._showError(this._i18n("webexpress.webapp:login.error.locked", "Account is locked due to too many failed attempts.")); + this._submitting = false; + this._loginBtn.disabled = true; + this._loginBtn.textContent = this._i18n("webexpress.webapp:login.locked", "Locked"); + return; + } + + // apply exponential penalty starting from the 3rd attempt + if (this._failedAttempts >= 3) { + // base penalty is 30 seconds, doubles with each subsequent fail + const basePenalty = 30; + const multiplier = Math.pow(2, this._failedAttempts - 3); + const penaltySeconds = basePenalty * multiplier; + + this._startRetryCountdown( + penaltySeconds, + this._i18n("webexpress.webapp:login.error.ratelimit", "Too many failed attempts. Please wait."), + submitLabel + ); + + return; + } + + // normal authentication failed response + this._showError(data.message || this._i18n("webexpress.webapp:login.error.invalid", "Invalid username or password.")); + this._submitting = false; + this._loginBtn.disabled = false; + this._loginBtn.textContent = submitLabel; }); } diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.quickfilter.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.quickfilter.js index 47c3366..e629cf3 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.quickfilter.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.quickfilter.js @@ -54,12 +54,17 @@ webexpress.webapp.QuickFilterCtrl = class extends webexpress.webui.QuickFilterCt ? urlObj.href : (urlObj.pathname + urlObj.search); - fetch(fetchUrl, { signal: this._abortController.signal }) + webexpress.webapp.ServiceRegistry.request(fetchUrl, { signal: this._abortController.signal }) .then((res) => { + if (res.error && res.error.kind === "abort") { + const abort = new Error("aborted"); + abort.name = "AbortError"; + throw abort; + } if (!res.ok) { throw new Error("REST quick filter request failed"); } - return res.json(); + return res.data; }) .then((response) => { // register new filters to the global filter registry if available diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.renderer.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.renderer.js new file mode 100644 index 0000000..886a7c1 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.renderer.js @@ -0,0 +1,397 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * View renderer, part of the View, State and Service architecture. + * + * The renderer turns a lightweight virtual node tree into DOM through a keyed + * reconciler. It applies the minimal set of mutations, preserves focus by + * moving existing nodes rather than recreating them, and preserves nested + * controls through a keep flag that tells the reconciler not to touch the + * children of a node. The view layer is a pure function of state, so a render + * never performs input or output. + * + * A virtual node is either an element node produced by h, of the shape + * { tag, props, key, keep, children }, or a text node of the shape { text }. + * Props support class, style as a string or an object, dataset as an object, + * on as a map of event name to handler, value and checked for inputs, html as + * an inner html escape hatch, ref as a callback, key for reconciliation and + * keep to protect a subtree. + */ +;(function () { + "use strict"; + + const RESERVED = { key: true, keep: true, ref: true, children: true }; + + /** + * Determines whether a virtual node is a text node. + * @param {object} vnode - The virtual node. + * @returns {boolean} True for a text node. + */ + function isText(vnode) { + return vnode && vnode.text !== undefined && vnode.tag === undefined; + } + + /** + * Flattens a children argument into a list of virtual nodes, dropping null, + * undefined and boolean values and turning primitives into text nodes. + * @param {*} children - A child or an array of children. + * @returns {Array} The normalised list of virtual nodes. + */ + function normalize(children) { + const out = []; + + const walk = (child) => { + if (child === null || child === undefined || child === false || child === true) { + return; + } + if (Array.isArray(child)) { + child.forEach(walk); + return; + } + if (typeof child === "object" && (child.tag !== undefined || child.text !== undefined)) { + out.push(child); + return; + } + out.push({ text: String(child) }); + }; + + walk(children); + + return out; + } + + /** + * Determines whether an existing DOM node has the same type as a virtual + * node, which is the condition for reusing it instead of recreating it. + * @param {Node} dom - The existing DOM node. + * @param {object} vnode - The virtual node. + * @returns {boolean} True when the types match. + */ + function sameType(dom, vnode) { + if (isText(vnode)) { + return dom.nodeType === 3; + } + return dom.nodeType === 1 && dom.tagName && dom.tagName.toLowerCase() === String(vnode.tag).toLowerCase(); + } + + /** + * Adds, updates and removes event listeners so that the attached set + * matches the new handler map. Stable handlers are left in place. + * @param {Element} el - The element. + * @param {object} newHandlers - A map of event name to handler. + */ + function updateEvents(el, newHandlers) { + const attached = el._wxlisteners || (el._wxlisteners = {}); + newHandlers = newHandlers || {}; + + for (const type of Object.keys(attached)) { + if (attached[type] !== newHandlers[type]) { + el.removeEventListener(type, attached[type]); + delete attached[type]; + } + } + + for (const type of Object.keys(newHandlers)) { + if (typeof newHandlers[type] === "function" && attached[type] !== newHandlers[type]) { + el.addEventListener(type, newHandlers[type]); + attached[type] = newHandlers[type]; + } + } + } + + /** + * Applies a single property to an element. + * @param {Element} el - The element. + * @param {string} key - The property name. + * @param {*} value - The new value. + * @param {*} oldValue - The previous value, used to diff style and dataset. + */ + function setProp(el, key, value, oldValue) { + if (key === "class" || key === "className") { + el.className = Array.isArray(value) ? value.filter(Boolean).join(" ") : (value || ""); + } else if (key === "style") { + if (typeof value === "string") { + el.style.cssText = value; + } else if (value && typeof value === "object") { + if (oldValue && typeof oldValue === "object") { + for (const k of Object.keys(oldValue)) { + if (!(k in value)) { + el.style[k] = ""; + } + } + } + for (const k of Object.keys(value)) { + el.style[k] = value[k]; + } + } else { + el.style.cssText = ""; + } + } else if (key === "dataset") { + const next = value || {}; + const previous = (oldValue && typeof oldValue === "object") ? oldValue : {}; + for (const k of Object.keys(previous)) { + if (!(k in next)) { + delete el.dataset[k]; + } + } + for (const k of Object.keys(next)) { + el.dataset[k] = next[k]; + } + } else if (key === "on") { + updateEvents(el, value || {}); + } else if (key === "value") { + el.value = value == null ? "" : value; + } else if (key === "checked") { + el.checked = !!value; + } else if (key === "html" || key === "innerHTML") { + const html = value == null ? "" : String(value); + if (el.innerHTML !== html) { + el.innerHTML = html; + } + } else { + if (value === false || value === null || value === undefined) { + el.removeAttribute(key); + } else { + el.setAttribute(key, value === true ? "" : String(value)); + } + } + } + + /** + * Removes a property that is present in the old props but absent in the new + * props. + * @param {Element} el - The element. + * @param {string} key - The property name. + * @param {*} oldValue - The previous value. + */ + function removeProp(el, key, oldValue) { + if (key === "class" || key === "className") { + el.className = ""; + } else if (key === "style") { + el.style.cssText = ""; + } else if (key === "dataset") { + const previous = (oldValue && typeof oldValue === "object") ? oldValue : {}; + for (const k of Object.keys(previous)) { + delete el.dataset[k]; + } + } else if (key === "on") { + updateEvents(el, {}); + } else if (key === "value") { + el.value = ""; + } else if (key === "checked") { + el.checked = false; + } else if (key === "html" || key === "innerHTML") { + el.innerHTML = ""; + } else { + el.removeAttribute(key); + } + } + + /** + * Diffs and applies the props of an element. + * @param {Element} el - The element. + * @param {object} oldProps - The previous props. + * @param {object} newProps - The new props. + */ + function applyProps(el, oldProps, newProps) { + oldProps = oldProps || {}; + newProps = newProps || {}; + + for (const key of Object.keys(oldProps)) { + if (RESERVED[key] || key in newProps) { + continue; + } + removeProp(el, key, oldProps[key]); + } + + for (const key of Object.keys(newProps)) { + if (RESERVED[key]) { + continue; + } + setProp(el, key, newProps[key], oldProps[key]); + } + } + + /** + * Creates a DOM node from a virtual node, recursing into children unless the + * node is marked to keep its subtree. + * @param {object} vnode - The virtual node. + * @returns {Node} The created DOM node. + */ + function createDom(vnode) { + if (isText(vnode)) { + return document.createTextNode(String(vnode.text)); + } + + const el = document.createElement(vnode.tag); + el._wxkey = vnode.key; + el._wxkeep = !!vnode.keep; + applyProps(el, {}, vnode.props || {}); + el._wxprops = vnode.props || {}; + + if (!vnode.keep) { + for (const child of normalize(vnode.children)) { + el.appendChild(createDom(child)); + } + } + + if (vnode.props && typeof vnode.props.ref === "function") { + vnode.props.ref(el); + } + + return el; + } + + /** + * Patches an existing DOM node to match a virtual node, replacing it when + * the types differ. Returns the resulting node, which may be a replacement. + * @param {Node} dom - The existing DOM node. + * @param {object} vnode - The virtual node. + * @returns {Node} The resulting DOM node. + */ + function patchNode(dom, vnode) { + if (isText(vnode)) { + if (dom.nodeType === 3) { + const text = String(vnode.text); + if (dom.textContent !== text) { + dom.textContent = text; + } + return dom; + } + const replacement = document.createTextNode(String(vnode.text)); + if (dom.parentNode) { + dom.parentNode.replaceChild(replacement, dom); + } + return replacement; + } + + if (!sameType(dom, vnode)) { + const replacement = createDom(vnode); + if (dom.parentNode) { + dom.parentNode.replaceChild(replacement, dom); + } + return replacement; + } + + applyProps(dom, dom._wxprops || {}, vnode.props || {}); + dom._wxprops = vnode.props || {}; + dom._wxkey = vnode.key; + dom._wxkeep = !!vnode.keep; + + if (!vnode.keep) { + reconcile(dom, vnode.children); + } + + if (vnode.props && typeof vnode.props.ref === "function") { + vnode.props.ref(dom); + } + + return dom; + } + + /** + * Reconciles the children of a parent element with a list of virtual nodes + * using keys where present and position otherwise. Existing nodes are + * reused and moved rather than recreated, which preserves focus and nested + * controls. + * @param {Element} parent - The parent element. + * @param {*} nextChildren - The new children. + */ + function reconcile(parent, nextChildren) { + const next = normalize(nextChildren); + const existing = Array.prototype.slice.call(parent.childNodes); + const keyedOld = new Map(); + + for (const node of existing) { + if (node._wxkey !== null && node._wxkey !== undefined) { + keyedOld.set(node._wxkey, node); + } + } + + const used = new Set(); + const result = []; + + for (let i = 0; i < next.length; i++) { + const vnode = next[i]; + let dom = null; + + if (!isText(vnode) && vnode.key !== null && vnode.key !== undefined && keyedOld.has(vnode.key)) { + const match = keyedOld.get(vnode.key); + if (!used.has(match) && sameType(match, vnode)) { + dom = patchNode(match, vnode); + used.add(match); + } + } + + if (dom === null) { + const candidate = existing[i]; + const candidateReusable = candidate && + (candidate._wxkey === null || candidate._wxkey === undefined) && + !used.has(candidate) && + sameType(candidate, vnode) && + (isText(vnode) || vnode.key === null || vnode.key === undefined); + + if (candidateReusable) { + dom = patchNode(candidate, vnode); + used.add(candidate); + } else { + dom = createDom(vnode); + } + } + + result.push(dom); + } + + const resultSet = new Set(result); + + for (const node of existing) { + if (!resultSet.has(node) && node.parentNode === parent) { + parent.removeChild(node); + } + } + + for (let i = 0; i < result.length; i++) { + const dom = result[i]; + const current = parent.childNodes[i] || null; + if (current !== dom) { + parent.insertBefore(dom, current); + } + } + } + + /** + * Creates an element virtual node. + * @param {string} tag - The element tag name. + * @param {object} [props] - The element props. + * @param {...*} children - The child nodes. + * @returns {object} The element virtual node. + */ + function h(tag, props, ...children) { + props = props || {}; + return { + tag: tag, + props: props, + key: props.key !== undefined && props.key !== null ? props.key : null, + keep: !!props.keep, + children: children + }; + } + + /** + * Patches a container so that its children match the given virtual node or + * list of virtual nodes. This is the public entry point for the view layer. + * @param {Element} container - The container element. + * @param {object|Array} next - The virtual node tree. + */ + function patch(container, next) { + reconcile(container, Array.isArray(next) ? next : [next]); + } + + webexpress.webapp.h = h; + webexpress.webapp.Renderer = { + h: h, + patch: patch, + normalize: normalize + }; +})(); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.editor.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.editor.js index 4afc44a..805658e 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.editor.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.editor.js @@ -332,14 +332,14 @@ webexpress.webapp.RestFormEditorCtrl = class extends webexpress.webui.Ctrl { return; } try { - const res = await fetch(this._restUrl, { + const res = await webexpress.webapp.ServiceRegistry.request(this._restUrl, { method: "GET", headers: { "Accept": "application/json" } }); if (!res.ok) { return; } - const json = await res.json(); + const json = res.data; this._structure = json.structure || json.data || json; this._fieldCatalog = json.catalog; } catch (e) { @@ -1694,7 +1694,7 @@ webexpress.webapp.RestFormEditorCtrl = class extends webexpress.webui.Ctrl { return; } try { - const res = await fetch(this._restUrl, { + const res = await webexpress.webapp.ServiceRegistry.request(this._restUrl, { method: "PUT", headers: { "Accept": "application/json", @@ -1703,12 +1703,11 @@ webexpress.webapp.RestFormEditorCtrl = class extends webexpress.webui.Ctrl { body: JSON.stringify(this._structure) }); if (!res.ok) { - let body = null; - try { body = await res.json(); } catch { body = null; } + const body = res.data; this._dispatch(webexpress.webapp.Event.FORM_EDITOR_VALIDATION_FAILED_EVENT, body || { status: res.status }); return; } - const body = await res.json(); + const body = res.data; if (body && body.data && typeof body.data.version === "number") { this._structure.version = body.data.version; this._renderHeader(); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.js index f8ceb79..3d2e9da 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.js @@ -11,7 +11,7 @@ * - webexpress.webui.Event.UPLOAD_SUCCESS_EVENT * - webexpress.webui.Event.UPLOAD_ERROR_EVENT */ -webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { +webexpress.webapp.RestFormCtrl = class extends webexpress.webapp.Data { /** * Create a new RestFormCtrl instance. * Configuration is read strictly from data-attributes on the form element. @@ -19,16 +19,39 @@ webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { * @param {HTMLFormElement} element The form element to enhance. */ constructor(element) { - super(element); + const ds = element.dataset; + const api = ds.uri || ds.url || ds.api || null; + + // data service used for the load and the submit; the form shapes its own + // requests (see restFormModel) and routes them through the service. a + // configured island when present, otherwise a legacy descriptor that + // carries the form endpoint. the Data base aborts the service on teardown. + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + const services = { + data: islandServices.data || + webexpress.webapp.ServiceRegistry.create(webexpress.webapp.restFormModel.legacyDescriptor(api)) + }; + + // canonical ui state: a single source of truth for the transient flags, + // exposed through the accessors below. seeded from the optional + // data-wx-state island. + const initialState = Object.assign({ + loading: false, + submitting: false, + mode: "new" + }, webexpress.webapp.Data.readState(element)); + + super(element, { state: initialState, services }); + + this._service = this.useService("data"); - const ds = this._element.dataset; const parseBool = (val, defaultVal) => { return val === "true" ? true : (val === "false" ? false : defaultVal); }; this.options = { id: ds.id || null, - api: ds.uri || ds.url || ds.api || null, + api: api, method: (ds.method || this._element.method || "POST").toUpperCase(), headers: {}, json: parseBool(ds.json, true), @@ -60,8 +83,6 @@ webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { this._element.classList.add("wx-restform"); - this._submitting = false; - this._loading = false; this._fieldErrorMap = new Map(); this._confirmHtml = null; @@ -81,6 +102,18 @@ webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { this._init(); } + // transient ui state accessors backed by the store, so the single source of + // truth is the store while the existing logic keeps reading fields + + get _loading() { return this._store.getState().loading; } + set _loading(value) { this._store.setState({ loading: value }); } + + get _submitting() { return this._store.getState().submitting; } + set _submitting(value) { this._store.setState({ submitting: value }); } + + get mode() { return this._store.getState().mode; } + set mode(value) { this._store.setState({ mode: value }); } + /** * Initialize control: attach event listeners and trigger data loading if configured. * Ensures the correct DOM order of structural elements. @@ -250,23 +283,20 @@ webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { this._dispatch(webexpress.webui.Event.TASK_START_EVENT, { name: "loading" }); try { - let url = new URL(this.options.api, window.location.origin); - if (this.options.id) { - url.searchParams.append("id", String(this.options.id || "")); - } - url.searchParams.append("mode", this.mode); + const url = webexpress.webapp.restFormModel.buildLoadUrl( + this.options.api, this.options.id, this.mode, window.location.origin); - const resp = await fetch(url.toString(), { + const result = await this._service.request(url, { method: "GET", headers: { "Accept": "application/json" }, credentials: this.options.credentials || "same-origin" }); - if (!resp.ok) { - throw new Error(this._i18n("webexpress.webapp:error.load_failed", { status: resp.status })); + if (!result.ok) { + throw new Error(this._i18n("webexpress.webapp:error.load_failed", { status: result.status })); } - const json = await resp.json(); + const json = result.data; const dataObj = (json && typeof json === "object") ? json : {}; const formData = dataObj.data || (Object.keys(dataObj).length ? dataObj : null); @@ -282,10 +312,10 @@ webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { } if (typeof this.options.onLoadSuccess === "function") { - this.options.onLoadSuccess(json, resp); + this.options.onLoadSuccess(json, result.response); } - this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { data: json, status: resp.status }); + this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { data: json, status: result.status }); this._dispatch(webexpress.webui.Event.CHANGE_VALUE_EVENT, { source: "load", data: json }); } catch (error) { @@ -537,15 +567,16 @@ webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { } } - const { url, init } = this._prepareRequest(endpoint, payload); + const { url, init } = webexpress.webapp.restFormModel.buildRequest( + endpoint, this.options, payload, window.location.origin); this._setSubmitting(true); this._dispatch(webexpress.webui.Event.TASK_START_EVENT, { name: "submitting" }); this._dispatch(webexpress.webui.Event.DATA_REQUESTED_EVENT, { type: "submit", url: url }); try { - const resp = await fetch(url, init); - await this._handleResponse(resp); + const result = await this._service.request(url, init); + this._handleResult(result); } catch (error) { if (typeof this.options.onError === "function") { this.options.onError(error); @@ -564,146 +595,71 @@ webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { * @returns {{url: string, init: Object}} Request configuration. */ _prepareRequest(endpoint, payload) { - const method = this.options.method; - const init = { - method: method, - headers: Object.assign({}, this.options.headers), - credentials: this.options.credentials || "same-origin" - }; - - const urlObj = new URL(endpoint, window.location.origin); - let requestUrl = endpoint; - - const appendParams = (target, data) => { - for (const [k, v] of Object.entries(data)) { - const values = Array.isArray(v) ? v : [v]; - values.forEach((val) => { - target.searchParams.append(k, val == null ? "" : String(val)); - }); - } - }; - - if (["GET", "HEAD", "DELETE"].includes(method)) { - // remove content-type for these methods - Object.keys(init.headers).forEach((h) => { - if (h.toLowerCase() === "content-type") { - delete init.headers[h]; - } - }); - - if (method === "DELETE") { - const idParam = this.options.id || payload.id || payload.Id; - if (idParam) { - urlObj.searchParams.append("id", String(idParam)); - } - } else { - appendParams(urlObj, payload); - } - requestUrl = urlObj.toString(); - } else { - // post/put/patch - if (this.options.json) { - init.body = JSON.stringify(payload); - if (!Object.keys(init.headers).some((k) => { - return k.toLowerCase() === "content-type"; - })) { - init.headers["Content-Type"] = "application/json; charset=utf-8"; - } - } else { - const params = new URLSearchParams(); - for (const [k, v] of Object.entries(payload)) { - const values = Array.isArray(v) ? v : [v]; - values.forEach((val) => { - params.append(k, val == null ? "" : String(val)); - }); - } - init.body = params.toString(); - if (!Object.keys(init.headers).some((k) => { - return k.toLowerCase() === "content-type"; - })) { - init.headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"; - } - } - - if (this.options.id) { - if (!urlObj.searchParams.has("id")) { - urlObj.searchParams.append("id", String(this.options.id)); - requestUrl = urlObj.toString(); - } - } - } - - return { url: requestUrl, init: init }; + return webexpress.webapp.restFormModel.buildRequest(endpoint, this.options, payload, window.location.origin); } /** - * Handles the fetch response parsing and UI updates. - * @param {Response} resp The fetch response object. + * Handles a normalised service result by classifying it into a success, a + * validation or a system error outcome and updating the UI accordingly. The + * classification and the server error normalisation are pure and live in + * restFormModel; the DOM updates stay here. A system error is thrown so the + * submit caller reports it through its catch. + * @param {object} result - The normalised service result. */ - async _handleResponse(resp) { - let json = null; - const contentType = resp.headers.get("content-type") || ""; + _handleResult(result) { + const json = result.data; + const classification = webexpress.webapp.restFormModel.classifyResponse(result.ok, result.status, json); - if (contentType.includes("application/json")) { - try { - json = await resp.json(); - } catch (e) { - // ignore json parse error on empty body - } - } else { - try { - json = { text: await resp.text() }; - } catch (e) { - // ignore - } - } - - if (resp.ok) { + if (classification.kind === "success") { this.clearErrors(); if (typeof this.options.onSuccess === "function") { - this.options.onSuccess(json, resp); + this.options.onSuccess(json, result.response); } - this._dispatch(webexpress.webui.Event.UPLOAD_SUCCESS_EVENT, { response: json, status: resp.status, form: this._element }); + this._dispatch(webexpress.webui.Event.UPLOAD_SUCCESS_EVENT, { response: json, status: result.status, form: this._element }); this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { type: "submit", data: json }); - const dataBlock = (json && json.data) ? json.data : json; - const confirmHtml = (dataBlock && dataBlock.confirmHtml) || (json && json.confirmHtml); - const message = (dataBlock && dataBlock.message) || (json && (json.confirmMessage || json.message)); - - if (json && (!json.message || json.hideForm === true)) { + if (classification.closeModal) { this._closeEnclosingModal(); - } else { - if (confirmHtml) { - this._showConfirm(String(confirmHtml)); - } else if (message) { - this._showConfirm(String(message)); - } else if (this._confirmHtml) { - this._showConfirm(null); + } else if (classification.confirmHtml) { + this._showConfirm(String(classification.confirmHtml)); + } else if (classification.message) { + this._showConfirm(String(classification.message)); + } else if (this._confirmHtml) { + this._showConfirm(null); + } + } else if (classification.kind === "validation") { + this.clearErrors(); + + const messages = []; + for (const e of (classification.errors || [])) { + if (e.field) { + const field = this._findFieldByName(e.field); + if (field) { + this._showFieldError(field, e.message); + } } + messages.push(e.message); } - } else if (resp.status === 400) { - // handle validation errors - if (Array.isArray(json)) { - this._applyServerArrayErrors(json); - } else if (json && json.errors) { - this._applyServerFieldErrors(json.errors); - } else { - const msg = (json && (json.message || json.error)) || this._i18n("webexpress.webapp:validation.failed"); - this._displayAggregatedErrors([typeof msg === "object" ? JSON.stringify(msg) : msg]); + + if (messages.length === 0) { + const fallback = classification.message || this._i18n("webexpress.webapp:validation.failed"); + messages.push(typeof fallback === "object" ? JSON.stringify(fallback) : fallback); } + this._displayAggregatedErrors(messages); + if (typeof this.options.onError === "function") { - this.options.onError(json, resp); + this.options.onError(json, result.response); } - this._dispatch(webexpress.webui.Event.UPLOAD_ERROR_EVENT, { type: "validation", response: json, status: resp.status, form: this._element }); + this._dispatch(webexpress.webui.Event.UPLOAD_ERROR_EVENT, { type: "validation", response: json, status: result.status, form: this._element }); } else { // handle system errors const message = this._i18n("webexpress.webapp:error.request_failed") - .replace("{status}", resp.status); + .replace("{status}", result.status); const err = new Error(message); - err.status = resp.status; - err.response = resp; + err.status = result.status; + err.response = result.response; err.payload = json; throw err; } @@ -740,56 +696,6 @@ webexpress.webapp.RestFormCtrl = class extends webexpress.webui.Ctrl { } } - /** - * Applies server-side field errors provided as a key-value map. - * @param {Object} errors A map of fieldName → message returned by the server. - */ - _applyServerFieldErrors(errors) { - this.clearErrors(); - if (!errors || typeof errors !== "object") { - return; - } - - const messages = []; - for (const [name, msg] of Object.entries(errors)) { - const field = this._findFieldByName(name); - if (field) { - this._showFieldError(field, msg); - } - messages.push(msg); - } - this._displayAggregatedErrors(messages); - } - - /** - * Applies server-side validation errors provided as an array. - * @param {Array} errorsArray The array of validation error objects returned by the server. - */ - _applyServerArrayErrors(errorsArray) { - this.clearErrors(); - if (!Array.isArray(errorsArray)) { - return; - } - - const messages = []; - for (const err of errorsArray) { - if (!err) { - continue; - } - const msg = err.message || err.msg || err.Message || JSON.stringify(err); - const fieldName = err.field || err.Field; - - if (fieldName) { - const field = this._findFieldByName(fieldName); - if (field) { - this._showFieldError(field, msg); - } - } - messages.push(msg); - } - this._displayAggregatedErrors(messages); - } - /** * Attempts to locate a form field by name or id. * @param {string} raw The raw field name provided by the server. diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.model.js new file mode 100644 index 0000000..2eac73f --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restform.model.js @@ -0,0 +1,190 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST form control (phase two of the View, State + * and Service migration). These functions carry no DOM dependency, so they can + * be unit tested in isolation. They cover the request shaping, the response + * classification and the server error normalisation. The control composes them + * with a Store and a RestService. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.restFormModel = { + /** + * Builds the minimal service descriptor for the form endpoint. The form + * shapes its own requests, so only the base uri is needed. + * @param {string} api - The REST endpoint backing the form. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(api) { + return { name: "data", kind: "rest", baseUri: api || "" }; + }, + + /** + * Builds the load url, which carries the optional id and the mode as query + * parameters, matching the historical behaviour. + * @param {string} api - The form endpoint. + * @param {string|number|null} id - The optional record id. + * @param {string} mode - The form mode (new, edit or delete). + * @param {string} origin - The base origin used to resolve a relative endpoint. + * @returns {string} The absolute load url. + */ + buildLoadUrl(api, id, mode, origin) { + const url = new URL(api, origin); + if (id) { + url.searchParams.append("id", String(id || "")); + } + url.searchParams.append("mode", mode); + return url.toString(); + }, + + /** + * Builds the submit url and the fetch init from the options and the payload. + * For GET, HEAD and DELETE the payload becomes query parameters (DELETE only + * carries the id); for POST, PUT and PATCH the payload becomes a json or a + * form encoded body, and the id is appended as a query parameter. This + * reproduces the historical request shaping. + * @param {string} endpoint - The submit endpoint. + * @param {object} options - The form options (method, headers, credentials, json, id). + * @param {object} payload - The form payload. + * @param {string} origin - The base origin used to resolve a relative endpoint. + * @returns {{url: string, init: object}} The request configuration. + */ + buildRequest(endpoint, options, payload, origin) { + options = options || {}; + payload = payload || {}; + + const method = options.method; + const init = { + method: method, + headers: Object.assign({}, options.headers || {}), + credentials: options.credentials || "same-origin" + }; + + const urlObj = new URL(endpoint, origin); + let requestUrl = endpoint; + + const appendParams = (target, data) => { + for (const [k, v] of Object.entries(data)) { + const values = Array.isArray(v) ? v : [v]; + values.forEach((val) => { + target.searchParams.append(k, val == null ? "" : String(val)); + }); + } + }; + + if (["GET", "HEAD", "DELETE"].includes(method)) { + // remove content-type for these methods + Object.keys(init.headers).forEach((h) => { + if (h.toLowerCase() === "content-type") { + delete init.headers[h]; + } + }); + + if (method === "DELETE") { + const idParam = options.id || payload.id || payload.Id; + if (idParam) { + urlObj.searchParams.append("id", String(idParam)); + } + } else { + appendParams(urlObj, payload); + } + requestUrl = urlObj.toString(); + } else { + // post/put/patch + if (options.json) { + init.body = JSON.stringify(payload); + if (!Object.keys(init.headers).some((k) => k.toLowerCase() === "content-type")) { + init.headers["Content-Type"] = "application/json; charset=utf-8"; + } + } else { + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(payload)) { + const values = Array.isArray(v) ? v : [v]; + values.forEach((val) => { + params.append(k, val == null ? "" : String(val)); + }); + } + init.body = params.toString(); + if (!Object.keys(init.headers).some((k) => k.toLowerCase() === "content-type")) { + init.headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"; + } + } + + if (options.id && !urlObj.searchParams.has("id")) { + urlObj.searchParams.append("id", String(options.id)); + requestUrl = urlObj.toString(); + } + } + + return { url: requestUrl, init: init }; + }, + + /** + * Classifies a normalised service result into a success, a validation or a + * system error outcome, and extracts the confirmation and closing hints on + * success. This reproduces the historical response handling without touching + * the DOM or performing input or output. + * @param {boolean} ok - Whether the request succeeded. + * @param {number} status - The http status. + * @param {object} data - The parsed response body. + * @returns {object} The classification. + */ + classifyResponse(ok, status, data) { + if (ok) { + const dataBlock = (data && data.data) ? data.data : data; + const confirmHtml = (dataBlock && dataBlock.confirmHtml) || (data && data.confirmHtml) || null; + const message = (dataBlock && dataBlock.message) || (data && (data.confirmMessage || data.message)) || null; + const closeModal = !!(data && (!data.message || data.hideForm === true)); + return { kind: "success", confirmHtml: confirmHtml, message: message, closeModal: closeModal }; + } + + if (status === 400) { + if (Array.isArray(data)) { + return { kind: "validation", errors: this.normalizeArrayErrors(data), message: null }; + } + if (data && data.errors) { + return { kind: "validation", errors: this.normalizeFieldErrors(data.errors), message: null }; + } + return { kind: "validation", errors: [], message: (data && (data.message || data.error)) || null }; + } + + return { kind: "error", status: status }; + }, + + /** + * Normalises a field error map into a list of field and message pairs. + * @param {object} errors - A map of field name to message. + * @returns {Array<{field: string, message: string}>} The normalised errors. + */ + normalizeFieldErrors(errors) { + if (!errors || typeof errors !== "object") { + return []; + } + return Object.entries(errors).map(([name, msg]) => ({ field: name, message: msg })); + }, + + /** + * Normalises an error array into a list of field and message pairs, reading + * the message and the field from the several casings the server may use. + * @param {Array} errorsArray - The error array. + * @returns {Array<{field: (string|null), message: string}>} The normalised errors. + */ + normalizeArrayErrors(errorsArray) { + if (!Array.isArray(errorsArray)) { + return []; + } + + const out = []; + for (const err of errorsArray) { + if (!err) { + continue; + } + const msg = err.message || err.msg || err.Message || JSON.stringify(err); + const field = err.field || err.Field || null; + out.push({ field: field, message: msg }); + } + return out; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restwizard.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restwizard.js index 5481820..b85dc87 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restwizard.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restwizard.js @@ -14,6 +14,15 @@ webexpress.webapp.RestWizardCtrl = class extends webexpress.webapp.RestFormCtrl super(element); } + // wizard step state accessors backed by the store inherited from the form + // control, so the single source of truth is the store + + get _currentIndex() { return this._store.getState().currentIndex || 0; } + set _currentIndex(value) { this._store.setState({ currentIndex: value }); } + + get _wizardLoading() { return this._store.getState().wizardLoading || false; } + set _wizardLoading(value) { this._store.setState({ wizardLoading: value }); } + /** * Initialize the wizard, parse pages and build the layout. * Overrides the base _init method. @@ -115,7 +124,7 @@ webexpress.webapp.RestWizardCtrl = class extends webexpress.webapp.RestFormCtrl this._btnPrev = document.createElement("button"); this._btnPrev.type = "button"; this._btnPrev.className = "btn btn-outline-secondary"; - this._btnPrev.textContent = this._i18n("webexpress.webapp:wizard.previous") || "Zurück"; + this._btnPrev.textContent = this._i18n("webexpress.webapp:wizard.previous") || "Previous"; this._btnPrev.addEventListener("click", () => { this._navigate(-1); }); @@ -123,7 +132,7 @@ webexpress.webapp.RestWizardCtrl = class extends webexpress.webapp.RestFormCtrl this._btnNext = document.createElement("button"); this._btnNext.type = "button"; this._btnNext.className = "btn btn-primary"; - this._btnNext.textContent = this._i18n("webexpress.webapp:wizard.next") || "Weiter"; + this._btnNext.textContent = this._i18n("webexpress.webapp:wizard.next") || "Next"; this._btnNext.addEventListener("click", () => { this._navigate(1); }); @@ -131,7 +140,7 @@ webexpress.webapp.RestWizardCtrl = class extends webexpress.webapp.RestFormCtrl this._btnFinish = document.createElement("button"); this._btnFinish.type = "submit"; this._btnFinish.className = "btn btn-success"; - this._btnFinish.textContent = this._i18n("webexpress.webapp:wizard.finish") || "Abschließen"; + this._btnFinish.textContent = this._i18n("webexpress.webapp:wizard.finish") || "Finish"; if (modalFooter) { // hide original submit button @@ -198,7 +207,7 @@ webexpress.webapp.RestWizardCtrl = class extends webexpress.webapp.RestFormCtrl const payloadStr = JSON.stringify(this._buildPayload()); // check cache: if already loaded successfully and payload did not change - if (page.isLoaded && page.payloadHash === payloadStr && !page.hasError) { + if (webexpress.webapp.restWizardModel.shouldUseCache(page, payloadStr)) { targetFound = true; break; } @@ -264,38 +273,33 @@ webexpress.webapp.RestWizardCtrl = class extends webexpress.webapp.RestFormCtrl } page.element.style.display = "block"; - try { - const response = await fetch(page.uri, { - method: "POST", - headers: { - "Content-Type": "application/json; charset=utf-8", - "Accept": "text/html" - }, - body: payloadStr - }); - - if (response.status === 204) { - this._setWizardLoading(false); - return 204; - } - - if (!response.ok) { - throw new Error(this._i18n("webexpress.webapp:error.load_failed") || `Fehler beim Laden des Schritts (HTTP ${response.status})`); - } + const result = await this._service.request( + page.uri, webexpress.webapp.restWizardModel.buildStepRequestInit(payloadStr)); - const html = await response.text(); - this._injectHtml(page.element, html); - page.isLoaded = true; + // a 204 No Content signals that the step is skipped + if (result.status === 204) { this._setWizardLoading(false); - - return 200; + return 204; + } - } catch (error) { + // any failure (http or network) renders the step error and stops here + if (!result.ok) { + const message = this._i18n("webexpress.webapp:error.load_failed") || + (result.error && result.error.message) || + `Failed to load the step (HTTP ${result.status})`; page.hasError = true; - page.element.innerHTML = `
${error.message}
`; + page.element.innerHTML = `
${message}
`; this._setWizardLoading(false); return 500; } + + // the step content is delivered as html text (parsed by the service) + const html = (result.data && typeof result.data.text === "string") ? result.data.text : ""; + this._injectHtml(page.element, html); + page.isLoaded = true; + this._setWizardLoading(false); + + return 200; } /** @@ -380,16 +384,10 @@ webexpress.webapp.RestWizardCtrl = class extends webexpress.webapp.RestFormCtrl } // determine if current page is the last non-skipped page - let isLastPage = true; - for (let j = this._currentIndex + 1; j < this._pages.length; j++) { - if (!this._pages[j].skipped) { - isLastPage = false; - break; - } - } + const isLast = webexpress.webapp.restWizardModel.isLastPage(this._pages, this._currentIndex); if (this._btnNext && this._btnFinish) { - if (isLastPage) { + if (isLast) { this._btnNext.style.display = "none"; this._btnFinish.style.display = ""; } else { diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restwizard.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restwizard.model.js new file mode 100644 index 0000000..bbb32aa --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.restwizard.model.js @@ -0,0 +1,59 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST wizard control (phase two of the View, State + * and Service migration). These functions carry no DOM or network dependency, + * so they can be unit tested in isolation. The control composes them with the + * RestService inherited from the form control and a Store for the step state. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.restWizardModel = { + /** + * Builds the fetch init for loading a dynamic step. The step is posted as a + * json payload and an html fragment is requested, matching the historical + * behaviour. A 204 No Content response signals that the step is skipped. + * @param {string} payloadStr - The serialised form payload. + * @returns {object} The fetch init. + */ + buildStepRequestInit(payloadStr) { + return { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + "Accept": "text/html" + }, + body: payloadStr + }; + }, + + /** + * Determines whether a dynamic step can be served from its cache, which is + * the case when it was already loaded successfully and the payload has not + * changed since. + * @param {object} page - The page object. + * @param {string} payloadStr - The current serialised payload. + * @returns {boolean} True when the cached step is still valid. + */ + shouldUseCache(page, payloadStr) { + return !!(page && page.isLoaded && page.payloadHash === payloadStr && !page.hasError); + }, + + /** + * Determines whether the page at the current index is the last active step, + * which is the case when every following page is skipped or none follow. + * @param {Array} pages - The wizard pages. + * @param {number} currentIndex - The current page index. + * @returns {boolean} True when no active page follows. + */ + isLastPage(pages, currentIndex) { + pages = pages || []; + for (let j = currentIndex + 1; j < pages.length; j++) { + if (!pages[j].skipped) { + return false; + } + } + return true; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.backlog.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.backlog.js index 51a6721..07cedc2 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.backlog.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.backlog.js @@ -4,7 +4,7 @@ * keyboard accessibility, sprint completion/start logic, smart duration selection, * configurable icons, bootstrap-based modals and item selection (single & multi). */ -webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { +webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webapp.Data { _restUri = null; _title = null; @@ -28,15 +28,25 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { * @param {HTMLElement} element - The host element. */ constructor(element) { - super(element); + // resolve the data service (a configured island or a legacy descriptor) + // and seed the sprints and items from the optional data-wx-state island + // before super, so the component owns the store and the service map; the + // model builds the descriptor, the sprint and item paths and the bodies + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + const services = islandServices; + const initialState = Object.assign({ sprints: [], items: [] }, webexpress.webapp.Data.readState(element)); + + super(element, { state: initialState, services: services }); - this._restUri = element.dataset.restUri || element.getAttribute("data-rest-uri") || null; this._title = element.dataset.title || element.getAttribute("data-title") || this._i18n("webexpress.webapp:scrum.backlog", "Backlog"); const selAttr = element.dataset.selectable || element.getAttribute("data-selectable"); this._selectable = selAttr !== "false"; this._readonly = element.dataset.readonly === "true"; + this._service = this.useService("data"); + this._restUri = this._service ? this._service.baseUri : null; + // read configurable icons or use font awesome defaults // item type icons are not configured here - they are delivered per item via item.icon from the rest api this._icons = { @@ -67,7 +77,14 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { // global keyboard shortcuts (Ctrl+A, Escape) element.addEventListener("keydown", this._onRootKeyDown); - if (this._restUri) { + // when the server seeded the backlog through the data-wx-state island, + // render it without a round trip; otherwise load from the endpoint or + // parse the inline static configuration + const seeded = this.state; + if ((Array.isArray(seeded.sprints) && seeded.sprints.length > 0) + || (Array.isArray(seeded.items) && seeded.items.length > 0)) { + this.data = { sprints: seeded.sprints || [], items: seeded.items || [] }; + } else if (this._restUri) { this._load(); } else { this._parseStaticConfig(); @@ -86,8 +103,9 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { } try { const parsed = JSON.parse(cfgEl.textContent); - this._sprints = Array.isArray(parsed.sprints) ? parsed.sprints : []; - this._items = Array.isArray(parsed.items) ? parsed.items : []; + const norm = webexpress.webapp.scrumBacklogModel.normalizeData(parsed); + this._sprints = norm.sprints; + this._items = norm.items; this._rebuildIndexes(); this._ensureRanking(); } catch (e) { @@ -118,8 +136,9 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { * @param {Object} data - { sprints: [], items: [] } */ set data(data) { - this._sprints = Array.isArray(data?.sprints) ? data.sprints : []; - this._items = Array.isArray(data?.items) ? data.items : []; + const norm = webexpress.webapp.scrumBacklogModel.normalizeData(data); + this._sprints = norm.sprints; + this._items = norm.items; this._rebuildIndexes(); this._ensureRanking(); this._pruneSelection(); @@ -164,11 +183,14 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { this._dispatch(webexpress.webui.Event.DATA_REQUESTED_EVENT, { uri: this._restUri }); - fetch(this._restUri, { headers: { "Accept": "application/json" } }) - .then((r) => r.json()) - .then((data) => { - this._sprints = Array.isArray(data?.sprints) ? data.sprints : []; - this._items = Array.isArray(data?.items) ? data.items : []; + this._service.query({}) + .then((r) => { + if (!r.ok) { + throw new Error(r.error ? r.error.message : ("HTTP " + r.status)); + } + const norm = webexpress.webapp.scrumBacklogModel.normalizeData(r.data); + this._sprints = norm.sprints; + this._items = norm.items; this._rebuildIndexes(); this._ensureRanking(); this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { uri: this._restUri }); @@ -189,12 +211,13 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { if (!this._restUri) { return Promise.resolve(sprint); } - return fetch(this._restUri, { - method: "POST", - headers: { "Accept": "application/json", "Content-Type": "application/json" }, - body: JSON.stringify(sprint) - }) - .then((r) => r.json()) + return this._service.create(sprint) + .then((r) => { + if (!r.ok) { + throw new Error(r.error ? r.error.message : ("HTTP " + r.status)); + } + return r.data; + }) .catch((err) => { console.error("ScrumBacklogCtrl: failed to create sprint", err); return sprint; @@ -210,12 +233,13 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { if (!this._restUri) { return Promise.resolve(sprint); } - return fetch(this._restUri + "/sprints/" + encodeURIComponent(sprint.id), { - method: "PUT", - headers: { "Accept": "application/json", "Content-Type": "application/json" }, - body: JSON.stringify(sprint) - }) - .then((r) => r.json()) + return this._service.update(sprint, { path: webexpress.webapp.scrumBacklogModel.sprintPath(sprint.id) }) + .then((r) => { + if (!r.ok) { + throw new Error(r.error ? r.error.message : ("HTTP " + r.status)); + } + return r.data; + }) .catch((err) => { console.error("ScrumBacklogCtrl: failed to update sprint", err); return sprint; @@ -231,11 +255,13 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { if (!this._restUri) { return Promise.resolve(); } - return fetch(this._restUri + "/sprints/" + encodeURIComponent(sprintId), { - method: "DELETE", - headers: { "Accept": "application/json" } - }) - .then(() => undefined) + return this._service.remove({ path: webexpress.webapp.scrumBacklogModel.sprintPath(sprintId) }) + .then((r) => { + if (!r.ok && r.status !== 204) { + throw new Error(r.error ? r.error.message : ("HTTP " + r.status)); + } + return undefined; + }) .catch((err) => { console.error("ScrumBacklogCtrl: failed to delete sprint", err); return undefined; @@ -251,12 +277,16 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { if (!this._restUri) { return Promise.resolve(item); } - return fetch(this._restUri + "/items/" + encodeURIComponent(item.id) + "/rank", { - method: "PUT", - headers: { "Accept": "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ sprintId: item.sprintId || null, rank: item.rank }) - }) - .then((r) => r.json()) + return this._service.update( + webexpress.webapp.scrumBacklogModel.itemRankBody(item), + { path: webexpress.webapp.scrumBacklogModel.itemRankPath(item.id) } + ) + .then((r) => { + if (!r.ok) { + throw new Error(r.error ? r.error.message : ("HTTP " + r.status)); + } + return r.data; + }) .catch((err) => { console.error("ScrumBacklogCtrl: failed to persist rank", err); return item; @@ -282,15 +312,12 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { return; } // attempt a batch endpoint; fall back transparently on 404 - fetch(this._restUri + "/items/rank-batch", { - method: "PUT", - headers: { "Accept": "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ - ranks: items.map((i) => ({ id: i.id, sprintId: i.sprintId || null, rank: i.rank })) - }) - }) + this._service.update( + webexpress.webapp.scrumBacklogModel.rankBatchBody(items), + { path: webexpress.webapp.scrumBacklogModel.rankBatchPath() } + ) .then((r) => { - if (r.status === 404 || r.status === 405) { + if (r.status === 404 || r.status === 405 || (r.error && r.error.kind === "network")) { for (const it of items) { this._persistItemRank(it); } @@ -316,15 +343,7 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { if (!sprint) { return; } - const normalized = Object.assign({ - id: sprint.id || ("sp_" + Date.now()), - name: sprint.name || "", - goal: sprint.goal || "", - status: sprint.status || "planned", - start: sprint.start || null, - end: sprint.end || null, - capacity: typeof sprint.capacity === "number" ? sprint.capacity : 0 - }, sprint); + const normalized = webexpress.webapp.scrumBacklogModel.normalizeSprint(sprint); this._sprints.push(normalized); this._sprintIndex.set(normalized.id, normalized); this.render(); @@ -865,11 +884,7 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { * @returns {void} */ _rewriteRanks(sprintId, orderedItems) { - let rank = 1; - for (const it of orderedItems) { - it.sprintId = sprintId || null; - it.rank = rank++; - } + webexpress.webapp.scrumBacklogModel.rewriteRanks(sprintId, orderedItems); } /** @@ -878,23 +893,7 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { * @returns {Array} */ _itemsForSprintSorted(sprintId) { - const sid = sprintId || null; - const out = this._items.filter((i) => { - return (i.sprintId || null) === sid || (sid === null && (!i.sprintId || i.status === "backlog")); - }); - - out.sort((a, b) => { - const ra = typeof a.rank === "number" ? a.rank : Number.MAX_SAFE_INTEGER; - const rb = typeof b.rank === "number" ? b.rank : Number.MAX_SAFE_INTEGER; - if (ra !== rb) { - return ra - rb; - } - const ka = String(a.key || a.title || ""); - const kb = String(b.key || b.title || ""); - return ka.localeCompare(kb); - }); - - return out; + return webexpress.webapp.scrumBacklogModel.itemsForSprintSorted(this._items, sprintId); } /** @@ -1519,18 +1518,7 @@ webexpress.webapp.ScrumBacklogCtrl = class extends webexpress.webui.Ctrl { * @returns {boolean} */ _crossesActiveSprint(items, targetSprintId) { - const activeId = this._activeSprintId(); - if (!activeId) { - return false; - } - const targetIsActive = targetSprintId === activeId; - for (const it of items) { - const sourceIsActive = (it.sprintId || null) === activeId; - if (sourceIsActive !== targetIsActive) { - return true; - } - } - return false; + return webexpress.webapp.scrumBacklogModel.crossesActiveSprint(items, targetSprintId, this._activeSprintId()); } /** diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.backlog.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.backlog.model.js new file mode 100644 index 0000000..ac4a517 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.backlog.model.js @@ -0,0 +1,175 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the scrum backlog control (View, State and Service + * migration). These functions carry no DOM or network dependency, so they can + * be unit tested in isolation. The control composes them with a RestService: + * the query loads sprints and items, the create adds a sprint, the update + * persists a sprint or an item rank on a path and the remove deletes a sprint. + * The remaining helpers are the pure ranking, sorting and move classification + * logic that the control delegates to. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.scrumBacklogModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. The board is loaded with GET and changes + * are persisted with POST (create), PUT (update) and DELETE on a path. + * @param {string} uri - The REST endpoint backing the backlog. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(uri) { + return { name: "data", kind: "rest", baseUri: uri || "", method: "GET", updateMethod: "PUT" }; + }, + + /** + * Normalises a board response into its sprints and items, tolerating a + * missing or malformed payload so the renderer always receives arrays. + * @param {object} data - The raw board response. + * @returns {{sprints: Array, items: Array}} The arrays. + */ + normalizeData(data) { + return { + sprints: Array.isArray(data && data.sprints) ? data.sprints : [], + items: Array.isArray(data && data.items) ? data.items : [] + }; + }, + + /** + * Applies the default fields to a sprint without losing any caller supplied + * field, matching the historical addSprint behaviour. + * @param {object} sprint - The sprint to normalise. + * @returns {object} The normalised sprint. + */ + normalizeSprint(sprint) { + sprint = sprint || {}; + return Object.assign({ + id: sprint.id || ("sp_" + Date.now()), + name: sprint.name || "", + goal: sprint.goal || "", + status: sprint.status || "planned", + start: sprint.start || null, + end: sprint.end || null, + capacity: typeof sprint.capacity === "number" ? sprint.capacity : 0 + }, sprint); + }, + + /** + * Builds the path segment used to update or delete a sprint by id. + * @param {string} id - The sprint id. + * @returns {string} The path appended to the base uri. + */ + sprintPath(id) { + return "/sprints/" + encodeURIComponent(id); + }, + + /** + * Builds the path segment used to persist a single item rank. + * @param {string} id - The item id. + * @returns {string} The path appended to the base uri. + */ + itemRankPath(id) { + return "/items/" + encodeURIComponent(id) + "/rank"; + }, + + /** + * Returns the path segment for the optional batch rank endpoint. + * @returns {string} The path appended to the base uri. + */ + rankBatchPath() { + return "/items/rank-batch"; + }, + + /** + * Builds the request body for a single item rank update. + * @param {object} item - The item carrying sprintId and rank. + * @returns {{sprintId: (string|null), rank: *}} The body. + */ + itemRankBody(item) { + return { sprintId: (item && item.sprintId) || null, rank: item ? item.rank : undefined }; + }, + + /** + * Builds the request body for a batched item rank update. + * @param {Array} items - The items to persist. + * @returns {{ranks: Array}} The body. + */ + rankBatchBody(items) { + return { + ranks: (Array.isArray(items) ? items : []).map((i) => ({ + id: i.id, + sprintId: i.sprintId || null, + rank: i.rank + })) + }; + }, + + /** + * Returns the items belonging to a sprint group sorted by rank, then by a + * stable key/title fallback. The backlog group (sprintId null) also collects + * items without a sprint or marked as backlog. + * @param {Array} items - All items. + * @param {string|null} sprintId - The sprint id, or null for the backlog. + * @returns {Array} The filtered and sorted items. + */ + itemsForSprintSorted(items, sprintId) { + const sid = sprintId || null; + const out = (Array.isArray(items) ? items : []).filter((i) => { + return (i.sprintId || null) === sid || (sid === null && (!i.sprintId || i.status === "backlog")); + }); + + out.sort((a, b) => { + const ra = typeof a.rank === "number" ? a.rank : Number.MAX_SAFE_INTEGER; + const rb = typeof b.rank === "number" ? b.rank : Number.MAX_SAFE_INTEGER; + if (ra !== rb) { + return ra - rb; + } + const ka = String(a.key || a.title || ""); + const kb = String(b.key || b.title || ""); + return ka.localeCompare(kb); + }); + + return out; + }, + + /** + * Rewrites ranks sequentially for a sprint group, assigning the sprintId and + * a one based rank to each item in order. Mutates the passed items. + * @param {string|null} sprintId - The sprint id, or null for the backlog. + * @param {Array} orderedItems - The items in their target order. + * @returns {Array} The same items, for chaining. + */ + rewriteRanks(sprintId, orderedItems) { + let rank = 1; + for (const it of (Array.isArray(orderedItems) ? orderedItems : [])) { + it.sprintId = sprintId || null; + it.rank = rank++; + } + return orderedItems; + }, + + /** + * Determines whether moving the given items to a target sprint would enter + * or leave the active sprint. A pure reorder within (or completely outside) + * the active sprint returns false. + * @param {Array} items - The items being moved. + * @param {string|null} targetSprintId - The destination sprint, or null. + * @param {string|null} activeId - The id of the active sprint, or null. + * @returns {boolean} True when the move crosses the active sprint boundary. + */ + crossesActiveSprint(items, targetSprintId, activeId) { + if (!activeId) { + return false; + } + const targetIsActive = targetSprintId === activeId; + for (const it of (Array.isArray(items) ? items : [])) { + const sourceIsActive = (it.sprintId || null) === activeId; + if (sourceIsActive !== targetIsActive) { + return true; + } + } + return false; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.sprint.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.sprint.js index 3b1900f..e5db229 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.sprint.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.scrum.sprint.js @@ -30,7 +30,7 @@ * - webexpress.webui.Event.SELECT_EVENT (or "wx:select-sprint" fallback) * when the sprint card is clicked */ -webexpress.webapp.ScrumSprintCtrl = class extends webexpress.webui.Ctrl { +webexpress.webapp.ScrumSprintCtrl = class extends webexpress.webapp.Data { static SVG_NS = "http://www.w3.org/2000/svg"; static CHART_W = 160; @@ -38,32 +38,59 @@ webexpress.webapp.ScrumSprintCtrl = class extends webexpress.webui.Ctrl { static OVERBOOK_THRESHOLD = 0.10; // 10% delta vs ideal counts as ahead/behind _restUri = null; - _sprint = null; - _state = "idle"; // "idle" | "loading" | "error" - _error = null; /** * Initializes the sprint overview control. * @param {HTMLElement} element - The host element. */ constructor(element) { - super(element); + // seed the sprint state from the optional data-wx-state island before + // super, so the component store owns the sprint, the load status and the + // error. The single sprint load uses the shared request, so no service + // map is needed. + const initialState = Object.assign( + { sprint: null, status: "idle", error: null }, + webexpress.webapp.Data.readState(element) + ); + + super(element, { state: initialState }); this._restUri = element.dataset.restUri || element.getAttribute("data-rest-uri") || null; element.removeAttribute("data-rest-uri"); + element.removeAttribute("data-wx-state"); element.classList.add("wx-scrum-sprint"); // dispatch a select event when the card is clicked (ignoring inner controls) element.addEventListener("click", (e) => this._onCardClick(e)); - if (this._restUri) { - this._load(); - } else { + // without an endpoint and without a seed, fall back to the inline config + if (!this._restUri && !this._sprint) { this._parseStaticConfig(); - this.render(); + } + + // subscribe and perform the first render from the seeded, parsed or empty + // state; Component._apply calls the existing imperative render method + this.mount(); + + // load from the endpoint only when the server did not seed the sprint + if (this._restUri && !this._sprint) { + this._load(); } } + // the sprint, the load status and the error are backed by the component store, + // so the store is the single source of truth and a change re-renders through + // the subscription that mount established + + get _sprint() { return this.state.sprint; } + set _sprint(value) { this.setState({ sprint: value || null }); } + + get _state() { return this.state.status; } + set _state(value) { this.setState({ status: value }); } + + get _error() { return this.state.error; } + set _error(value) { this.setState({ error: value }); } + /** * Returns the currently displayed sprint. * @returns {Object|null} @@ -77,10 +104,7 @@ webexpress.webapp.ScrumSprintCtrl = class extends webexpress.webui.Ctrl { * @param {Object} sprint - The sprint payload. */ set sprint(sprint) { - this._sprint = sprint || null; - this._state = "idle"; - this._error = null; - this.render(); + this.setState({ sprint: sprint || null, status: "idle", error: null }); } /** @@ -119,26 +143,23 @@ webexpress.webapp.ScrumSprintCtrl = class extends webexpress.webui.Ctrl { this._state = "loading"; this._error = null; this._dispatch(webexpress.webui.Event.DATA_REQUESTED_EVENT, { uri: this._restUri }); - this.render(); - fetch(this._restUri, { headers: { "Accept": "application/json" } }) + webexpress.webapp.ServiceRegistry.request(this._restUri, { headers: { "Accept": "application/json" } }) .then((r) => { if (!r.ok) { throw new Error("HTTP " + r.status); } - return r.json(); + return r.data; }) .then((data) => { this._sprint = data || null; this._state = "idle"; this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { uri: this._restUri }); - this.render(); }) .catch((err) => { console.error("ScrumSprintCtrl: failed to load data", err); this._state = "error"; this._error = err; - this.render(); }); } diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.selection.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.selection.js index 5ca3708..2d1cfb9 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.selection.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.selection.js @@ -100,13 +100,19 @@ webexpress.webapp.SelectionCtrl = class extends webexpress.webui.SelectionCtrl { const init = this._buildRequestInit(term, this._abortCtrl.signal); // perform request using fetch api - fetch(url, init) + webexpress.webapp.ServiceRegistry.request(url, init) .then((res) => { + // ignore superseded requests that a newer keystroke aborted + if (res.error && res.error.kind === "abort") { + const abort = new Error("aborted"); + abort.name = "AbortError"; + throw abort; + } // check http status if (!res.ok) { throw new Error(`http ${res.status}`); } - return res.json(); + return res.data; }) .then((response) => { const rawData = response.items || []; @@ -134,30 +140,7 @@ webexpress.webapp.SelectionCtrl = class extends webexpress.webui.SelectionCtrl { * @returns {Object} A normalized item compatible with the selection list. */ _mapApiItem(apiItem) { - // choose field aliases defensively - const id = apiItem.id || null; - const label = apiItem.label || apiItem.content || apiItem.name || apiItem.title || ""; - const icon = apiItem.icon || null; - const image = apiItem.image || apiItem.img || null; - const color = apiItem.color || apiItem.color || null; - const disabled = Boolean(apiItem.disabled); - - return { - id: id, - label: label, - color: color, - icon: icon, - image: image, - disabled: disabled, - - // action attributes mapping - primaryAction: apiItem.primaryAction || null, - primaryTarget: apiItem.primaryTarget || null, - primaryUri: apiItem.primaryUri || apiItem.uri || apiItem.url || null, - secondaryAction: apiItem.secondaryAction || null, - secondaryTarget: apiItem.secondaryTarget || null, - secondaryUri: apiItem.secondaryUri || null - }; + return webexpress.webapp.selectionModel.mapApiItem(apiItem); } /** @@ -166,14 +149,13 @@ webexpress.webapp.SelectionCtrl = class extends webexpress.webui.SelectionCtrl { * @returns {string} The composed request URL. */ _buildUrl(term) { - if (this._httpMethod !== "GET") { - return this._apiEndpoint; - } - const hasQuery = this._apiEndpoint.includes("?"); - const sep = hasQuery ? "&" : "?"; - const qp = `${encodeURIComponent(this._queryParam)}=${encodeURIComponent(term)}`; - const pp = `${encodeURIComponent(this._pageParam)}=${encodeURIComponent(this._page)}`; - return `${this._apiEndpoint}${sep}${qp}&${pp}`; + return webexpress.webapp.selectionModel.buildUrl({ + apiEndpoint: this._apiEndpoint, + httpMethod: this._httpMethod, + queryParam: this._queryParam, + pageParam: this._pageParam, + page: this._page + }, term); } /** @@ -183,25 +165,12 @@ webexpress.webapp.SelectionCtrl = class extends webexpress.webui.SelectionCtrl { * @returns {RequestInit} The fetch init object. */ _buildRequestInit(term, signal) { - const headers = { "Accept": "application/json" }; - if (this._httpMethod === "POST") { - headers["Content-Type"] = "application/json"; - return { - method: "POST", - headers: headers, - body: JSON.stringify({ - [this._queryParam]: term, - [this._pageParam]: this._page - }), - signal: signal - }; - } else { - return { - method: "GET", - headers: headers, - signal: signal - }; - } + return webexpress.webapp.selectionModel.buildRequestInit({ + httpMethod: this._httpMethod, + queryParam: this._queryParam, + pageParam: this._pageParam, + page: this._page + }, term, signal); } }; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.selection.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.selection.model.js new file mode 100644 index 0000000..ce94209 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.selection.model.js @@ -0,0 +1,93 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST selection control (View, State and Service + * migration). The control performs a debounced, abortable search through the + * shared request, so it keeps its own abort handling. The model owns the + * request shaping (url and init) and the response mapping, which carry no DOM + * or network dependency and can be unit tested in isolation. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.selectionModel = { + /** + * Builds the request url. For GET it appends the query and page parameters + * with the correct separator and encoding; for other methods the endpoint is + * returned unchanged (the term travels in the body). + * @param {object} config - The endpoint configuration. + * @param {string} term - The search term. + * @returns {string} The request url. + */ + buildUrl(config, term) { + if (config.httpMethod !== "GET") { + return config.apiEndpoint; + } + const hasQuery = config.apiEndpoint.includes("?"); + const sep = hasQuery ? "&" : "?"; + const qp = `${encodeURIComponent(config.queryParam)}=${encodeURIComponent(term)}`; + const pp = `${encodeURIComponent(config.pageParam)}=${encodeURIComponent(config.page)}`; + return `${config.apiEndpoint}${sep}${qp}&${pp}`; + }, + + /** + * Builds the fetch init. A POST carries the term and page in a json body, a + * GET only carries the abort signal and the accept header. + * @param {object} config - The endpoint configuration. + * @param {string} term - The search term. + * @param {AbortSignal} signal - The abort signal. + * @returns {object} The fetch init. + */ + buildRequestInit(config, term, signal) { + const headers = { "Accept": "application/json" }; + if (config.httpMethod === "POST") { + headers["Content-Type"] = "application/json"; + return { + method: "POST", + headers: headers, + body: JSON.stringify({ + [config.queryParam]: term, + [config.pageParam]: config.page + }), + signal: signal + }; + } + return { + method: "GET", + headers: headers, + signal: signal + }; + }, + + /** + * Maps a raw API item to the internal selection item format, choosing field + * aliases defensively. + * @param {object} apiItem - The raw item from the API. + * @returns {object} A normalized selection item. + */ + mapApiItem(apiItem) { + const id = apiItem.id || null; + const label = apiItem.label || apiItem.content || apiItem.name || apiItem.title || ""; + const icon = apiItem.icon || null; + const image = apiItem.image || apiItem.img || null; + const color = apiItem.color || null; + const disabled = Boolean(apiItem.disabled); + + return { + id: id, + label: label, + color: color, + icon: icon, + image: image, + disabled: disabled, + + // action attributes mapping + primaryAction: apiItem.primaryAction || null, + primaryTarget: apiItem.primaryTarget || null, + primaryUri: apiItem.primaryUri || apiItem.uri || apiItem.url || null, + secondaryAction: apiItem.secondaryAction || null, + secondaryTarget: apiItem.secondaryTarget || null, + secondaryUri: apiItem.secondaryUri || null + }; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.service.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.service.js new file mode 100644 index 0000000..d8b12a3 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.service.js @@ -0,0 +1,467 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Helpers that build the normalised result shape used by every service + * operation. A result is { ok, data, error, status }, where error is null on + * success and { kind, status, message, retriable } on failure. + */ +webexpress.webapp.ServiceResult = { + /** + * Builds a successful result. + * @param {*} data - The payload. + * @param {number} [status=200] - The http status. + * @returns {object} The normalised success result. + */ + ok(data, status = 200) { + return { ok: true, data: data, error: null, status: status }; + }, + + /** + * Builds a failed result. + * @param {string} kind - One of "network", "http", "parse", "abort", "validation". + * @param {number} [status=0] - The http status when applicable. + * @param {string} [message=""] - A human readable message. + * @param {boolean} [retriable=false] - Whether retrying may succeed. + * @returns {object} The normalised failure result. + */ + fail(kind, status = 0, message = "", retriable = false) { + return { + ok: false, + data: null, + error: { kind: kind, status: status, message: message, retriable: retriable }, + status: status + }; + } +}; + +/** + * Base class for services. It holds the descriptor configuration and provides + * a no operation abort. Concrete services implement the operations they + * support and call into the network. + */ +webexpress.webapp.Service = class { + /** + * Creates a service from a descriptor. + * @param {object} [descriptor={}] - The service descriptor. + */ + constructor(descriptor = {}) { + this._descriptor = descriptor || {}; + this._name = this._descriptor.name || null; + } + + /** + * Returns the service name. + * @returns {string|null} The name. + */ + get name() { + return this._name; + } + + /** + * Returns the base address the service calls, from its descriptor. Controls + * derive their data uri from this instead of the legacy data-uri attribute. + * @returns {string} The base address, or an empty string. + */ + get baseUri() { + return this._descriptor.baseUri || ""; + } + + /** + * Aborts any request that is in flight. The base implementation does + * nothing. + */ + abort() { + } +}; + +/** + * The default REST service. It maps logical parameters to wire parameters, + * builds a url against the document base, performs the request with fetch, + * cancels a superseded query through an AbortController and normalises both + * success and failure. + * + * Descriptor shape: + * { + * name: "data", + * kind: "rest", + * baseUri: "/api/orders", + * method: "GET", + * updateMethod: "PUT", + * query: { search: "q", page: "p", pageSize: "l" }, + * response: { items: "items", total: "total" }, + * headers: { ... }, + * errors: { "404": "webexpress.webapp:error.notfound" } + * } + */ +webexpress.webapp.RestService = class extends webexpress.webapp.Service { + /** + * Creates a REST service from a descriptor. + * @param {object} descriptor - The service descriptor. + */ + constructor(descriptor) { + super(descriptor); + this._abort = null; + } + + /** + * Aborts the query that is currently in flight, if any. + */ + abort() { + if (this._abort) { + this._abort.abort("aborted"); + this._abort = null; + } + } + + /** + * Loads data with a GET request. Alias of query with a load semantic. + * @param {object} [params={}] - Logical query parameters. + * @param {object} [options={}] - Request options such as path. + * @returns {Promise} A normalised result. + */ + load(params = {}, options = {}) { + return this.query(params, options); + } + + /** + * Queries data with a GET request. A new query aborts the previous one. + * @param {object} [params={}] - Logical query parameters. + * @param {object} [options={}] - Request options such as path. + * @returns {Promise} A normalised result. + */ + query(params = {}, options = {}) { + return this._send(this._descriptor.method || "GET", { + params: params, + path: options.path, + abortable: true + }); + } + + /** + * Creates a resource with a POST request. + * @param {*} body - The request body, serialised as JSON. + * @param {object} [options={}] - Request options such as path and params. + * @returns {Promise} A normalised result. + */ + create(body, options = {}) { + return this._send("POST", { + params: options.params, + path: options.path, + body: body, + abortable: false + }); + } + + /** + * Updates a resource with a PUT or PATCH request. + * @param {*} body - The request body, serialised as JSON. + * @param {object} [options={}] - Request options such as path, params and method. + * @returns {Promise} A normalised result. + */ + update(body, options = {}) { + return this._send(options.method || this._descriptor.updateMethod || "PUT", { + params: options.params, + path: options.path, + body: body, + abortable: false + }); + } + + /** + * Removes a resource with a DELETE request. + * @param {object} [options={}] - Request options such as path and params. + * @returns {Promise} A normalised result. + */ + remove(options = {}) { + return this._send("DELETE", { + params: options.params, + path: options.path, + abortable: false + }); + } + + /** + * Performs an arbitrary request with a caller supplied url and fetch init, + * and normalises the outcome. The response body is parsed by content type, + * as json when the content type is application/json and as { text } + * otherwise. The parsed body is returned on both success and failure, so a + * control can inspect a validation response. This lets controls with a + * bespoke request shape, for example forms, route their network access + * through the service layer. The result also carries the raw response and + * the content type. + * @param {string} url - The request url. + * @param {object} [init={}] - The fetch init, used as provided. + * @returns {Promise} A normalised result with response and contentType. + */ + async request(url, init = {}) { + try { + const response = await fetch(url, init); + const contentType = (response.headers && typeof response.headers.get === "function" + ? response.headers.get("content-type") + : "") || ""; + + let data = null; + if (response.status !== 204) { + if (contentType.includes("application/json")) { + try { data = await response.json(); } catch (parseError) { data = null; } + } else { + try { data = { text: await response.text() }; } catch (parseError) { data = null; } + } + } + + if (!response.ok) { + const mapped = this._descriptor.errors && this._descriptor.errors[String(response.status)]; + const message = mapped || ("request failed with status " + response.status); + return { + ok: false, + data: data, + error: { kind: "http", status: response.status, message: message, retriable: response.status >= 500 }, + status: response.status, + response: response, + contentType: contentType + }; + } + + return { ok: true, data: data, error: null, status: response.status, response: response, contentType: contentType }; + } catch (networkError) { + const kind = (networkError && networkError.name === "AbortError") ? "abort" : "network"; + const result = webexpress.webapp.ServiceResult.fail(kind, 0, networkError ? networkError.message : "network error", kind === "network"); + result.response = null; + result.contentType = ""; + return result; + } + } + + /** + * Projects a raw response into the configured shape, returning the items + * and the total when a response mapping is present in the descriptor. + * @param {object} data - The raw response payload. + * @returns {object} An object with items and total. + */ + project(data) { + const map = this._descriptor.response || {}; + const itemsKey = map.items || "items"; + const totalKey = map.total || "total"; + const items = data && Array.isArray(data[itemsKey]) ? data[itemsKey] : []; + const total = data && data[totalKey] != null ? Number(data[totalKey]) : items.length; + + return { items: items, total: total }; + } + + /** + * Builds the request url from the base uri and the mapped query parameters. + * @param {object} params - Logical query parameters. + * @param {string} [path] - An optional path segment appended to the base. + * @returns {string} The request url, absolute or root relative. + */ + _buildUrl(params, path) { + const base = (this._descriptor.baseUri || "") + (path ? path : ""); + const url = new URL(base, document.baseURI); + const map = this._descriptor.query || {}; + + for (const logical of Object.keys(params || {})) { + const value = params[logical]; + if (value === undefined || value === null) { + continue; + } + const wire = map[logical] || logical; + url.searchParams.set(wire, String(value)); + } + + return base.startsWith("http") ? url.href : url.pathname + url.search; + } + + /** + * Performs a request and normalises the outcome. A superseded abortable + * request is cancelled, and the abort channel is only cleared when the + * request that owns it completes, so that a newer request is not affected. + * @param {string} method - The http method. + * @param {object} request - The request descriptor. + * @returns {Promise} A normalised result. + */ + async _send(method, request) { + let abort = null; + + if (request.abortable) { + if (this._abort) { + this._abort.abort("replaced"); + } + abort = new AbortController(); + this._abort = abort; + } + + const url = this._buildUrl(request.params, request.path); + const headers = Object.assign({ "Accept": "application/json" }, this._descriptor.headers || {}); + const init = { method: method, headers: headers }; + + if (request.body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(request.body); + } + + if (abort) { + init.signal = abort.signal; + } + + try { + const response = await fetch(url, init); + + if (!response.ok) { + const mapped = this._descriptor.errors && this._descriptor.errors[String(response.status)]; + const message = mapped || ("request failed with status " + response.status); + return webexpress.webapp.ServiceResult.fail("http", response.status, message, response.status >= 500); + } + + if (response.status === 204 || method === "DELETE") { + return webexpress.webapp.ServiceResult.ok(null, response.status); + } + + try { + const data = await response.json(); + return webexpress.webapp.ServiceResult.ok(data, response.status); + } catch (parseError) { + return webexpress.webapp.ServiceResult.fail("parse", response.status, "response was not valid json", false); + } + } catch (networkError) { + if (networkError && networkError.name === "AbortError") { + return webexpress.webapp.ServiceResult.fail("abort", 0, "request was aborted", false); + } + return webexpress.webapp.ServiceResult.fail("network", 0, networkError ? networkError.message : "network error", true); + } finally { + if (abort && this._abort === abort) { + this._abort = null; + } + } + } +}; + +/** + * Registry of service factories keyed by descriptor kind. The default kind is + * "rest". A descriptor is turned into a configured service through create, and + * a host element's data-wx-service island is turned into a map of named + * services through fromElement. + */ +webexpress.webapp.ServiceRegistry = new class { + /** + * Creates the registry. + */ + constructor() { + this._factories = new Map(); + this._shared = null; + } + + /** + * Registers a factory for a descriptor kind. + * @param {string} kind - The descriptor kind, for example "rest". + * @param {Function} factory - Receives a descriptor and returns a service. + * @returns {this} The registry for chaining. + */ + register(kind, factory) { + if (typeof kind === "string" && typeof factory === "function") { + this._factories.set(kind, factory); + } + return this; + } + + /** + * Returns whether a factory exists for the given kind. + * @param {string} kind - The descriptor kind. + * @returns {boolean} True when registered. + */ + has(kind) { + return this._factories.has(kind); + } + + /** + * Creates a configured service from a descriptor. + * @param {object} descriptor - The service descriptor. + * @returns {webexpress.webapp.Service|null} The service or null. + */ + create(descriptor) { + if (!descriptor || typeof descriptor !== "object") { + return null; + } + + const kind = descriptor.kind || "rest"; + const factory = this._factories.get(kind); + + if (!factory) { + console.warn(`Service kind "${kind}" is not registered.`); + return null; + } + + return factory(descriptor); + } + + /** + * Performs a single arbitrary request through a shared rest service, so that + * one off calls (for example a bespoke autocomplete, theme or validator + * endpoint) route through the service layer without the caller configuring a + * dedicated service. The shared service is created lazily on first use and + * reused afterwards. The url is taken as given, so callers pass an absolute + * or already resolved path. Returns the same normalised result as + * RestService.request. + * @param {string} url - The request url. + * @param {object} [init={}] - The fetch init, for example method and body. + * @returns {Promise} A normalised result. + */ + request(url, init) { + if (!this._shared) { + this._shared = this.create({ kind: "rest", name: "shared", baseUri: "" }); + } + + return this._shared.request(url, init); + } + + /** + * Reads the data-wx-service island of a host element and returns a map of + * named services. The island is a json object or an array of descriptors, + * each of which carries a name. + * @param {HTMLElement} element - The host element. + * @returns {object} A map of service name to service instance. + */ + fromElement(element) { + const services = {}; + + if (!element || typeof element.getAttribute !== "function") { + return services; + } + + const raw = element.getAttribute("data-wx-service"); + + if (!raw) { + return services; + } + + let parsed = null; + + try { + parsed = JSON.parse(raw); + } catch (error) { + console.warn("invalid data-wx-service island", error); + return services; + } + + const descriptors = Array.isArray(parsed) ? parsed : [parsed]; + + for (const descriptor of descriptors) { + if (!descriptor || typeof descriptor !== "object") { + continue; + } + const service = this.create(descriptor); + if (service) { + services[descriptor.name || "default"] = service; + } + } + + return services; + } + + /** + * Removes all factories. Useful for tests. + */ + clear() { + this._factories.clear(); + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.store.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.store.js new file mode 100644 index 0000000..5b433be --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.store.js @@ -0,0 +1,293 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Performs a shallow equality check of two values. Objects are compared by + * their own enumerable keys with Object.is on each value. Everything else is + * compared with Object.is directly. + * @param {*} a - The first value. + * @param {*} b - The second value. + * @returns {boolean} True when the two values are shallowly equal. + */ +webexpress.webapp.shallowEqual = function (a, b) { + if (Object.is(a, b)) { + return true; + } + + if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) { + return false; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) { + return false; + } + + for (const key of keysA) { + if (!Object.prototype.hasOwnProperty.call(b, key) || !Object.is(a[key], b[key])) { + return false; + } + } + + return true; +}; + +/** + * Schedules a callback on the microtask queue, with a promise based fallback + * for environments without queueMicrotask. + * @param {Function} callback - The callback to run. + */ +webexpress.webapp._microtask = function (callback) { + if (typeof queueMicrotask === "function") { + queueMicrotask(callback); + } else { + Promise.resolve().then(callback); + } +}; + +/** + * The observable state container. + */ +webexpress.webapp.Store = class { + /** + * Creates a new store. + * @param {object} [initialState={}] - The initial state object. It is copied, not referenced. + */ + constructor(initialState = {}) { + this._state = Object.assign({}, initialState); + this._listeners = new Set(); + this._notifyScheduled = false; + } + + /** + * Returns the current state object. The returned object must be treated as + * immutable. Use setState to produce a new state. + * @returns {object} The current state. + */ + getState() { + return this._state; + } + + /** + * Convenience accessor for the current state. + * @returns {object} The current state. + */ + get state() { + return this._state; + } + + /** + * Applies a shallow patch to the state. The patch may be an object that is + * merged into the current state, or a function that receives the current + * state and returns such an object. Subscribers are notified once on the + * next microtask, and only when at least one value actually changed. + * @param {object|Function} patch - The patch object or a producer function. + * @returns {object} The resulting state. + */ + setState(patch) { + if (typeof patch === "function") { + patch = patch(this._state); + } + + if (!patch || typeof patch !== "object") { + return this._state; + } + + let changed = false; + const next = Object.assign({}, this._state); + + for (const key of Object.keys(patch)) { + if (!Object.is(next[key], patch[key])) { + next[key] = patch[key]; + changed = true; + } + } + + if (!changed) { + return this._state; + } + + this._state = next; + this._scheduleNotify(); + + return this._state; + } + + /** + * Subscribes a listener to every state change. The listener receives the + * current state. Returns an unsubscribe function. + * @param {Function} listener - Called with the new state after a change. + * @returns {Function} An unsubscribe function. + */ + subscribe(listener) { + if (typeof listener !== "function") { + return () => { }; + } + + this._listeners.add(listener); + + return () => { + this._listeners.delete(listener); + }; + } + + /** + * Computes a derived slice of the current state. + * @param {Function} selector - Receives the state and returns a slice. + * @returns {*} The selected slice. + */ + select(selector) { + return typeof selector === "function" ? selector(this._state) : undefined; + } + + /** + * Subscribes a listener to a derived slice of the state. The listener is + * only invoked when the selected slice changes according to the equality + * function, which defaults to shallow equality. Returns an unsubscribe + * function. + * @param {Function} selector - Receives the state and returns a slice. + * @param {Function} listener - Called with the selected slice and the state. + * @param {Function} [equality] - Compares the previous and next slice. + * @returns {Function} An unsubscribe function. + */ + watch(selector, listener, equality) { + const isEqual = typeof equality === "function" ? equality : webexpress.webapp.shallowEqual; + let previous = this.select(selector); + + return this.subscribe((state) => { + const nextSlice = selector(state); + if (!isEqual(previous, nextSlice)) { + previous = nextSlice; + listener(nextSlice, state); + } + }); + } + + /** + * Forces any pending notification to run synchronously. This is intended + * for tests and for deterministic teardown, not for normal operation. + */ + flush() { + if (this._notifyScheduled) { + this._notifyScheduled = false; + this._notify(); + } + } + + /** + * Removes all listeners. Used during teardown of a shared store. + */ + dispose() { + this._listeners.clear(); + this._notifyScheduled = false; + } + + /** + * Schedules a single batched notification on the microtask queue. + */ + _scheduleNotify() { + if (this._notifyScheduled) { + return; + } + + this._notifyScheduled = true; + + webexpress.webapp._microtask(() => { + if (!this._notifyScheduled) { + return; + } + this._notifyScheduled = false; + this._notify(); + }); + } + + /** + * Notifies all listeners with the current state. + */ + _notify() { + const snapshot = this._state; + for (const listener of Array.from(this._listeners)) { + try { + listener(snapshot); + } catch (error) { + console.error("Store listener failed", error); + } + } + } +}; + +/** + * Registry of named, shared stores. A shared store is created when the first + * consumer acquires it and disposed when the last consumer releases it, which + * gives cross component state a single owner and a deterministic lifetime. + */ +webexpress.webapp.StoreRegistry = new class { + /** + * Creates a new registry. + */ + constructor() { + this._stores = new Map(); + } + + /** + * Acquires a shared store by id, creating it on first use and increasing + * its reference count. + * @param {string} id - The store id. + * @param {object} [initialState={}] - The initial state used on creation. + * @returns {webexpress.webapp.Store} The shared store. + */ + acquire(id, initialState = {}) { + let entry = this._stores.get(id); + + if (!entry) { + entry = { store: new webexpress.webapp.Store(initialState), refs: 0 }; + this._stores.set(id, entry); + } + + entry.refs += 1; + + return entry.store; + } + + /** + * Returns a shared store by id without changing its reference count. + * @param {string} id - The store id. + * @returns {webexpress.webapp.Store|null} The store or null. + */ + get(id) { + const entry = this._stores.get(id); + return entry ? entry.store : null; + } + + /** + * Releases a shared store by id, decreasing its reference count and + * disposing it when no consumer remains. + * @param {string} id - The store id. + */ + release(id) { + const entry = this._stores.get(id); + + if (!entry) { + return; + } + + entry.refs -= 1; + + if (entry.refs <= 0) { + entry.store.dispose(); + this._stores.delete(id); + } + } + + /** + * Removes all shared stores. Useful for tests and full resets. + */ + clear() { + for (const entry of this._stores.values()) { + entry.store.dispose(); + } + this._stores.clear(); + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tab.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tab.js index e754d91..067f312 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tab.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tab.js @@ -19,10 +19,6 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { // drag & drop reorder state _dragTabId = null; - // request state - _isLoading = false; - _abortController = null; - // dom nodes for dynamic elements _addLi = null; _addTabButton = null; @@ -37,7 +33,13 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { // initialize base class structure super(element); - this._restUri = element.dataset.uri || ""; + // canonical ui state: a single source of truth for the loading flag, + // seeded from the optional data-wx-state island + this._store = new webexpress.webapp.Store(Object.assign({ + loading: false, + error: null + }, webexpress.webapp.Data.readState(element))); + this._readonly = element.dataset.readonly === "true"; this._movableTab = element.dataset.movableTab === "true"; @@ -51,6 +53,13 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { element.removeAttribute("data-movable-tab"); } + // data service: a configured island when present, otherwise a legacy + // descriptor. its query, create, update and remove operations back the + // list, create, reorder and close requests. + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data; + this._restUri = this._service ? this._service.baseUri : ""; + // extract and store templates this._extractTemplates(); @@ -69,6 +78,12 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { } } + // loading flag accessor backed by the store, so the single source of truth + // is the store + + get _isLoading() { return this._store.getState().loading; } + set _isLoading(value) { this._store.setState({ loading: value }); } + /** * Extracts template definitions from the element and removes them from the DOM. */ @@ -82,14 +97,7 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { const icon = tpl.dataset.icon || ""; const name = tpl.dataset.name || id; const description = tpl.dataset.description || ""; - const multiplicityRaw = tpl.dataset.multiplicity; - let multiplicity = null; - if (multiplicityRaw !== undefined && multiplicityRaw !== null && multiplicityRaw !== "") { - const parsed = parseInt(multiplicityRaw, 10); - if (!isNaN(parsed) && parsed >= 0) { - multiplicity = parsed; - } - } + const multiplicity = webexpress.webapp.tabModel.parseMultiplicity(tpl.dataset.multiplicity); // store template payload for later instantiation this._templates.set(id, { @@ -275,14 +283,8 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { * @returns {boolean} */ _isTemplateAvailable(templateId) { - const tpl = this._templates.get(templateId); - if (!tpl) { - return true; - } - if (tpl.multiplicity === null || tpl.multiplicity === undefined) { - return true; - } - return this._countTabsByTemplate(templateId) < tpl.multiplicity; + return webexpress.webapp.tabModel.isTemplateAvailable( + this._templates.get(templateId), this._countTabsByTemplate(templateId)); } /** @@ -321,64 +323,45 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { /** * Fetches tab data from the configured REST endpoint via GET. */ - _receiveData() { - if (this._restUri === "") { + async _receiveData() { + if (this._restUri === "" || !this._service) { return; } - if (this._abortController !== null) { - // abort previous running requests - this._abortController.abort("search replaced"); - } - - this._abortController = new AbortController(); this._isLoading = true; this._element.classList.add("placeholder-glow"); - const fetchUrl = this._resolveUrl(this._restUri); + const result = await this._service.query({}); - fetch(fetchUrl, { signal: this._abortController.signal }) - .then((res) => { - if (res.ok === false) { - throw new Error("request failed"); - } - return res.json(); - }) - .then((response) => { - let newTabs = []; - if (Array.isArray(response.items)) { - newTabs = response.items; - } + if (!result.ok) { + // a superseded query arrives as an abort result and is ignored + if (result.error.kind === "abort") { + return; + } - this.updateData(newTabs); + console.error("request failed:", result.error.message); + this._element.classList.remove("placeholder-glow"); + this._isLoading = false; + return; + } - // remove loading indicators - this._element.classList.remove("placeholder-glow"); - this._isLoading = false; - this._abortController = null; - }) - .catch((error) => { - if (error.name === "AbortError") { - return; - } + this.updateData(webexpress.webapp.tabModel.mapTabs(result.data)); - console.error("request failed:", error); - this._element.classList.remove("placeholder-glow"); - this._isLoading = false; - this._abortController = null; - }); + // remove loading indicators + this._element.classList.remove("placeholder-glow"); + this._isLoading = false; } /** * Sends a POST request to the server to create a new tab and appends it to the UI. * @param {string|null} templateId - Optional template id to create the tab from. */ - _createNewTab(templateId = null) { + async _createNewTab(templateId = null) { if (this._readonly) { return; } - if (this._restUri === "") { + if (this._restUri === "" || !this._service) { return; } @@ -399,34 +382,11 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { this._addTabButton.innerHTML = ``; this._addTabButton.disabled = true; - const fetchUrl = this._resolveUrl(this._restUri); - - fetch(fetchUrl, { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - action: "create", - templateId: templateId - }) - }) - .then((res) => { - if (res.ok === false) { - throw new Error("post request failed"); - } - return res.json(); - }) - .then((response) => { - const newTab = response.newTab; - if (!newTab) { - throw new Error("post response did not contain newTab"); - } - - if (!newTab.templateId && templateId) { - newTab.templateId = templateId; - } + const result = await this._service.create(webexpress.webapp.tabModel.createBody(templateId)); + if (result.ok) { + const newTab = webexpress.webapp.tabModel.extractNewTab(result.data, templateId); + if (newTab) { this._renderSingleTab(newTab); this.selectTab(newTab.id); @@ -434,40 +394,18 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { this._dispatch(webexpress.webapp.Event.TAB_ADDED_EVENT, { tabId: newTab.id }); - }) - .catch((error) => { - console.error("failed to create new tab:", error); - }) - .finally(() => { - // restore button state - this._addTabButton.innerHTML = originalHtml; - this._addTabButton.disabled = false; - // re-apply multiplicity-based disabled state - this._updateAddButtonState(); - }); - } - - /** - * Resolves a potentially relative URI to a fully qualified URL string. - * @param {string} uri - The URI to resolve. - * @returns {string} The fully qualified URL. - */ - _resolveUrl(uri) { - const base = window.location.origin; - let urlObj; - - try { - urlObj = new URL(uri, base); - } catch (error) { - // fallback to document base uri if parsing fails - urlObj = new URL(uri, document.baseURI); - } - - if (uri.startsWith("http")) { - return urlObj.href; + } else { + console.error("failed to create new tab:", "post response did not contain newTab"); + } + } else { + console.error("failed to create new tab:", result.error.message); } - return urlObj.pathname + urlObj.search; + // restore button state + this._addTabButton.innerHTML = originalHtml; + this._addTabButton.disabled = false; + // re-apply multiplicity-based disabled state + this._updateAddButtonState(); } /** @@ -974,36 +912,22 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { * Persists the current tab order to the server via PUT. */ _persistOrder() { - if (this._restUri === "") { + if (this._restUri === "" || !this._service) { return; } const order = this._tabs.map((t) => t.id); - const fetchUrl = this._resolveUrl(this._restUri); - - fetch(fetchUrl, { - method: "PUT", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - action: "reorder", - order: order - }) - }) - .then((res) => { - if (res.ok === false) { - throw new Error("reorder request failed: " + res.status); - } + this._service.update(webexpress.webapp.tabModel.reorderBody(order)).then((result) => { + if (result.ok) { // notify external components about the new order this._dispatch(webexpress.webapp.Event.TAB_REORDERED_EVENT, { order: order }); - }) - .catch((err) => { - console.error("failed to persist tab order:", err); - }); + } else if (result.error.kind !== "abort") { + console.error("failed to persist tab order:", result.error.message); + } + }); } /** @@ -1016,18 +940,13 @@ webexpress.webapp.TabCtrl = class extends webexpress.webui.TabCtrl { } // send delete request to the server before removing the tab locally - if (this._restUri && tabId) { - const fetchUrl = this._resolveUrl(this._restUri + "?id=" + encodeURIComponent(tabId)); - fetch(fetchUrl, { method: "DELETE" }) - .then((res) => { - if (!res.ok) { - throw new Error("failed to delete tab: " + res.status); - } - }) - .catch((err) => { + if (this._restUri && tabId && this._service) { + this._service.remove({ params: { id: tabId } }).then((result) => { + if (!result.ok && result.error.kind !== "abort") { // optionally show error, but still remove tab from ui to ensure responsiveness - console.error("delete request failed (still removing tab locally):", err); - }); + console.error("delete request failed (still removing tab locally):", result.error.message); + } + }); } let closedIndex = -1; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tab.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tab.model.js new file mode 100644 index 0000000..8f23372 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tab.model.js @@ -0,0 +1,109 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST tab control (phase two of the View, State and + * Service migration). These functions carry no DOM or network dependency, so + * they can be unit tested in isolation. The control composes them with a Store + * and a RestService whose query, create, update and remove operations replace + * the four inline fetch calls (list, create, reorder, close). + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.tabModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. All four operations target the same + * endpoint; the id query parameter is used by the close (delete) operation. + * @param {string} restUri - The REST endpoint backing the tabs. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(restUri) { + return { + name: "data", + kind: "rest", + baseUri: restUri || "", + method: "GET", + updateMethod: "PUT", + query: { id: "id" }, + response: { items: "items" } + }; + }, + + /** + * Extracts the tab list from the server response. + * @param {object} response - The raw server response. + * @returns {Array} The tab items, or an empty array. + */ + mapTabs(response) { + return (response && Array.isArray(response.items)) ? response.items : []; + }, + + /** + * Builds the request body for creating a new tab. + * @param {string|null} templateId - The optional template id. + * @returns {object} The create body. + */ + createBody(templateId) { + return { action: "create", templateId: templateId }; + }, + + /** + * Builds the request body for persisting a new tab order. + * @param {Array} order - The ordered tab ids. + * @returns {object} The reorder body. + */ + reorderBody(order) { + return { action: "reorder", order: order }; + }, + + /** + * Extracts the created tab from a create response, applying the requested + * template id when the server did not echo it. Returns null when the + * response does not carry a new tab. + * @param {object} response - The create response. + * @param {string|null} templateId - The requested template id. + * @returns {object|null} The new tab, or null. + */ + extractNewTab(response, templateId) { + const newTab = response && response.newTab; + if (!newTab) { + return null; + } + if (!newTab.templateId && templateId) { + newTab.templateId = templateId; + } + return newTab; + }, + + /** + * Parses a raw multiplicity attribute into a non negative integer or null + * when it is unset or invalid (which is treated as unlimited). + * @param {string|null|undefined} raw - The raw multiplicity value. + * @returns {number|null} The parsed multiplicity. + */ + parseMultiplicity(raw) { + if (raw === undefined || raw === null || raw === "") { + return null; + } + const parsed = parseInt(raw, 10); + return (!isNaN(parsed) && parsed >= 0) ? parsed : null; + }, + + /** + * Determines whether another tab may be created from the given template. + * A template without a defined multiplicity is treated as unlimited. + * @param {object|null|undefined} template - The template definition. + * @param {number} count - The number of existing tabs of this template. + * @returns {boolean} True when another tab may be created. + */ + isTemplateAvailable(template, count) { + if (!template) { + return true; + } + if (template.multiplicity === null || template.multiplicity === undefined) { + return true; + } + return count < template.multiplicity; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.js index 6d27ca1..ad28620 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.js @@ -1,29 +1,31 @@ -/** +/** * A REST-enabled table control that extends the reorderable table class and * integrates with a REST API. Supports standard pagination. * * Emits events: * - webexpress.webui.Event.DATA_ARRIVED_EVENT + * + * Phase two of the View, State and Service migration: + * - the query, paging and result state is owned by a webexpress.webapp.Store, + * exposed through accessors so the inherited pager, sorting and persistence + * logic keeps working against a single source of truth + * - the data load and the layout state update go through a + * webexpress.webapp.RestService, configured from a data-wx-service island when + * present and otherwise from a legacy descriptor that reproduces the + * historical query parameter names and the PUT update + * - the pure column and row normalisation lives in webexpress.webapp.tableModel + * and is unit tested in isolation + * The emitted events and the rendered DOM are unchanged. */ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderable { // configuration _restUri = ""; - // state - _orderBy = null; - _orderDir = null; - _search = ""; - _wql = ""; - _filter = ""; - _page = 0; - _pageSize = 50; - _totalRecords = 0; - _isLoading = false; + // view data _rows = {}; // ui helpers _progressDiv = null; - _abortController = null; // pager & info _pagerElement = null; @@ -52,14 +54,32 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl constructor(element) { super(element); - this._restUri = element.dataset.uri || ""; + // canonical state: a single source of truth that the accessors below + // read from and write to. seeded from the optional data-wx-state island. + this._store = new webexpress.webapp.Store(Object.assign({ + search: "", + wql: "", + filter: "", + page: 0, + pageSize: 50, + orderBy: null, + orderDir: null, + total: 0, + loading: false, + error: null + }, webexpress.webapp.Data.readState(element))); + element.removeAttribute("data-uri"); + // data service: a configured island when present, otherwise a legacy + // descriptor that reproduces the historical query parameter names + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data; + this._restUri = this._service ? this._service.baseUri : ""; + if (element.dataset.pageSize) { - this._pageSize = parseInt(element.dataset.pageSize, 10); - if (isNaN(this._pageSize) || this._pageSize <= 0) { - this._pageSize = 50; - } + const parsed = parseInt(element.dataset.pageSize, 10); + this._pageSize = (isNaN(parsed) || parsed <= 0) ? 50 : parsed; } this._initProgressBar(element); @@ -80,12 +100,43 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl this._initPager(element); if (this._restUri) { - this._receiveData(); + this._load(); } else { this._toggleProgress(false); } } + // state accessors backed by the store, so the single source of truth is the + // store while the inherited pager, sorting and persistence logic keeps + // reading fields + + get _search() { return this._store.getState().search; } + set _search(value) { this._store.setState({ search: value }); } + + get _wql() { return this._store.getState().wql; } + set _wql(value) { this._store.setState({ wql: value }); } + + get _filter() { return this._store.getState().filter; } + set _filter(value) { this._store.setState({ filter: value }); } + + get _page() { return this._store.getState().page; } + set _page(value) { this._store.setState({ page: value }); } + + get _pageSize() { return this._store.getState().pageSize; } + set _pageSize(value) { this._store.setState({ pageSize: value }); } + + get _orderBy() { return this._store.getState().orderBy; } + set _orderBy(value) { this._store.setState({ orderBy: value }); } + + get _orderDir() { return this._store.getState().orderDir; } + set _orderDir(value) { this._store.setState({ orderDir: value }); } + + get _totalRecords() { return this._store.getState().total; } + set _totalRecords(value) { this._store.setState({ total: value }); } + + get _isLoading() { return this._store.getState().loading; } + set _isLoading(value) { this._store.setState({ loading: value }); } + /** * Initialize DOM and document-level event listeners required by the control. * Listens for: @@ -102,7 +153,7 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl if (this._element.contains(e.target)) { targetMatches = true; } - + const detail = e.detail || {}; if (detail.id) { if (detail.id === this._element.id) { @@ -117,7 +168,7 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl this._page = 0; if (this._restUri) { - this._receiveData(); + this._load(); } else { this._dispatch(webexpress.webui.Event.TABLE_SORT_EVENT, { orderBy: this._orderBy, orderDir: this._orderDir @@ -153,13 +204,13 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl } else { init(); } - + // create info div to show totals and current page details this._infoDiv = document.createElement("div"); this._infoDiv.className = "text-muted small"; this._infoDiv.style.marginTop = "0.25rem"; this._infoDiv.textContent = ""; - + host.appendChild(this._infoDiv); } @@ -264,136 +315,60 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl } /** - * Request data from the configured REST endpoint. + * Request data from the configured REST endpoint through the data service. + * A superseded query is cancelled by the service, so a stale response + * arrives as an abort result and is ignored here. + * @returns {Promise} Resolves when the load completes. */ - _receiveData() { - // abort if no uri - if (!this._restUri) { + async _load() { + // abort if no uri or service + if (!this._restUri || !this._service) { return; } - // abort previous request if present - if (this._abortController) { - // call abort without reason to trigger a standard AbortError - this._abortController.abort(); - } - this._abortController = new AbortController(); - this._toggleProgress(true); - // build request url with fallback for relative uris - const base = window.location.origin; - let urlObj; - try { - urlObj = new URL(this._restUri, base); - } catch (e) { - urlObj = new URL(this._restUri, document.baseURI); - } - - // set query parameters - if (this._search) { - urlObj.searchParams.set("q", this._search); - } else { - urlObj.searchParams.set("q", ""); - } - - if (this._wql) { - urlObj.searchParams.set("wql", this._wql); - } else { - urlObj.searchParams.set("wql", ""); - } - - if (this._filter) { - urlObj.searchParams.set("f", this._filter); - } else { - urlObj.searchParams.set("f", ""); - } + const params = webexpress.webapp.tableModel.queryParams(this._store.getState()); + const result = await this._service.query(params); - urlObj.searchParams.set("p", this._page); - urlObj.searchParams.set("l", this._pageSize); - - if (this._orderBy) { - urlObj.searchParams.set("o", this._orderBy); - if (this._orderDir) { - urlObj.searchParams.set("d", this._orderDir); + if (!result.ok) { + // handle aborts silently (a newer query replaced this one) + if (result.error.kind === "abort") { + return; } - } - - const fetchUrl = this._restUri.startsWith("http") ? urlObj.href : (urlObj.pathname + urlObj.search); - - fetch(fetchUrl, { signal: this._abortController.signal }) - .then((res) => { - if (!res.ok) { - throw new Error("Request failed: " + res.status); - } - return res.json(); - }) - .then((response) => { - // try multiple possible fields for total - const totalFromResponse = response.total ?? null; - - // determine number of rows actually returned - let receivedRows = 0; - if (Array.isArray(response.rows)) { - receivedRows = response.rows.length; - } - - // set or infer totalrecords - if (totalFromResponse !== null) { - this._totalRecords = Number(totalFromResponse) || 0; - } else { - this._totalRecords = (this._page * this._pageSize) + receivedRows; - } - - // compute total pages and clamp current page - const totalPages = Math.max(1, Math.ceil(this._totalRecords / this._pageSize)); - if (this._page >= totalPages) { - this._page = totalPages - 1; - } - // normalize rows and apply client-side cap - let newRows = response.rows || []; - if (Array.isArray(newRows)) { - if (newRows.length > this._pageSize) { - // slice to configured page size - newRows = newRows.slice(0, this._pageSize); - } - } + console.error("TableCtrl Request failed:", result.error.message); + this._store.setState({ error: result.error }); + this._toggleProgress(false); + this._isLoading = false; + return; + } - // ensure the response passed to updatedata reflects any slicing - const responseForUpdate = Object.assign({}, response, { rows: newRows }); + const response = result.data; - // integrate received data into table structures - this.updateData(responseForUpdate); + // reduce the total and the clamped page into the store + this._store.setState(webexpress.webapp.tableModel.reduceResponse(this._store.getState(), response)); - // notify listeners that data arrived - this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { - id: this._element.id, - response: responseForUpdate, - page: this._page - }); + // slice raw rows to the page size before integrating + const newRows = webexpress.webapp.tableModel.sliceRows(response.rows || [], this._pageSize); + const responseForUpdate = Object.assign({}, response, { rows: newRows }); - // sync pager and info in a microtask - setTimeout(() => { - this._syncPagerAndInfo(); - }, 0); + // integrate received data into table structures + this.updateData(responseForUpdate); - this._toggleProgress(false); - this._abortController = null; - }) - .catch((error) => { - // handle aborts silently - const isAbort = (error && typeof error === "object" && error.name === "AbortError"); - if (isAbort) { - return; - } + // notify listeners that data arrived + this._dispatch(webexpress.webui.Event.DATA_ARRIVED_EVENT, { + id: this._element.id, + response: responseForUpdate, + page: this._page + }); - console.error("TableCtrl Request failed:", error); + // sync pager and info in a microtask + setTimeout(() => { + this._syncPagerAndInfo(); + }, 0); - this._toggleProgress(false); - this._abortController = null; - this._isLoading = false; - }); + this._toggleProgress(false); } /** @@ -406,100 +381,11 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl } if (!this._columns || this._columns === this._previewColumns) { - this._columns = (response.columns || []).map((c, idx) => { - let rType = c.rendererType || null; - let rOpts = c.rendererOptions || {}; - if (c.template) { - if (typeof c.template === "object") { - rType = c.template.type; - rOpts = c.template.options || {}; - if (c.template.editable) { - rOpts.editable = c.template.editable; - } - } - } - - let isVisible = true; - if (typeof c.visible === "boolean") { - isVisible = c.visible; - } - - let isResizable = true; - if (typeof c.resizable === "boolean") { - isResizable = c.resizable; - } - - return { - id: c.id || `col_${idx}`, - label: c.label || c.id, - name: c.name || null, - visible: isVisible, - sort: null, - width: c.width || null, - minWidth: c.minWidth || null, - resizable: isResizable, - icon: c.icon || null, - image: c.image || null, - color: c.color || null, - rendererType: rType, - rendererOptions: rOpts - }; - }); - if (this._orderBy) { - const targetCol = this._columns.find((c) => c.id === this._orderBy); - if (targetCol) { - targetCol.sort = this._orderDir || "asc"; - } - } - } - - /** - * Convert a raw server row object into the internal row representation. - * @param {Object} r - raw row object from the response - * @param {Object|null} parent - parent row, or null for root rows - * @returns {Object} normalized row - */ - const normalizeRow = (r, parent = null) => { - let isExpanded = true; - if (typeof r.expanded === "boolean") { - isExpanded = r.expanded; - } - - const row = { - id: r.id || null, - class: r.class || null, - style: r.style || null, - color: r.color || null, - image: r.image || null, - icon: r.icon || null, - uri: r.uri || r.url || null, - target: r.target || null, - primaryAction: r.primaryAction || null, - secondaryAction: r.secondaryAction || null, - bind: r.bind || null, - cells: r.cells || [], - options: r.options || null, - children: [], - parent: parent, - expanded: isExpanded - }; - if (r.children) { - if (Array.isArray(r.children)) { - row.children = r.children.map((child) => normalizeRow(child, row)); - } - } - return row; - }; - - // normalize incoming rows - let newRows = (response.rows || []).map((r) => normalizeRow(r, null)); - - if (newRows.length > this._pageSize) { - // slice to first pagesize entries - newRows = newRows.slice(0, this._pageSize); + this._columns = webexpress.webapp.tableModel.normalizeColumns(response, this._orderBy, this._orderDir); } - this._rows = newRows; + // normalize incoming rows (recursing into children and slicing) + this._rows = webexpress.webapp.tableModel.normalizeRows(response, this._pageSize); let optionsExist = false; if (this._options) { @@ -507,17 +393,17 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl optionsExist = true; } } - + if (!optionsExist) { if (this._rows.some((r) => r.options && r.options.length > 0)) { optionsExist = true; } } - + this._hasOptions = optionsExist; this.render(); - + // sync pager and info after full render this._syncPagerAndInfo(); } @@ -621,21 +507,21 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl } /** - * Send a state payload to the configured REST endpoint using PUT. - * The payload uses the same shape consumed by - * RestApiTable.Configure: + * Send a state payload to the configured REST endpoint through the data + * service using its update operation (PUT). The payload uses the same shape + * consumed by RestApiTable.Configure: * { "c": [{ "id", "visible", "width" }, ...], "r": ["rowId", ...] }. * @param {Object} stateObj - JSON-serializable object representing the state. */ _sendStateToServer(stateObj) { - if (!this._restUri) { + if (!this._restUri || !this._service) { return; } - fetch(this._restUri, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(stateObj) - }).catch((err) => console.error("Update state failed", err)); + this._service.update(stateObj).then((result) => { + if (!result.ok && result.error.kind !== "abort") { + console.error("Update state failed", result.error.message); + } + }); } /** @@ -644,7 +530,7 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl update() { if (this._restUri) { if (this._isVisible()) { - this._receiveData(); + this._load(); } } } @@ -667,10 +553,10 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl } this._page = 0; - + if (this._restUri) { if (this._isVisible()) { - this._receiveData(); + this._load(); } } } @@ -685,11 +571,11 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl if (this._restUri) { if (this._isVisible()) { - this._receiveData(); + this._load(); } } } - + /** * Sets and loads the page. * @param {string} page - The current page pattern. @@ -699,11 +585,11 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl if (this._restUri) { if (this._isVisible()) { - this._receiveData(); + this._load(); } } } - + /** * Creates bootstrap placeholder markup for preview cells. * @param {string} widthClass Bootstrap width class for the placeholder. @@ -712,7 +598,7 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl _createPlaceholderCellContent(widthClass = "col-12") { return ``; } - + /** * Creates a preview row with bootstrap placeholders. * @param {Array} widths Bootstrap width classes for each cell. @@ -731,4 +617,4 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl }; // register the class in the controller -webexpress.webui.Controller.registerClass("wx-webapp-table", webexpress.webapp.TableCtrl); \ No newline at end of file +webexpress.webui.Controller.registerClass("wx-webapp-table", webexpress.webapp.TableCtrl); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.model.js new file mode 100644 index 0000000..3845a44 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.model.js @@ -0,0 +1,221 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST table control (phase two of the View, State + * and Service migration). These functions carry no DOM or network dependency, + * so they can be unit tested in isolation. The control composes them with a + * Store and a RestService. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.tableModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. It reproduces the historical query + * parameter names and uses PUT for the layout state update, which matches + * the historical behaviour. + * @param {string} restUri - The REST endpoint backing the table. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(restUri) { + return { + name: "data", + kind: "rest", + baseUri: restUri || "", + method: "GET", + updateMethod: "PUT", + query: { + search: "q", + wql: "wql", + filter: "f", + page: "p", + pageSize: "l", + orderBy: "o", + orderDir: "d" + }, + response: { rows: "rows", total: "total" } + }; + }, + + /** + * Builds the logical query parameters from the current state. The order + * parameters are only included when an order field is set. + * @param {object} state - The table state. + * @returns {object} The logical query parameters. + */ + queryParams(state) { + state = state || {}; + + const params = { + search: state.search || "", + wql: state.wql || "", + filter: state.filter || "", + page: state.page || 0, + pageSize: state.pageSize || 50 + }; + + if (state.orderBy) { + params.orderBy = state.orderBy; + if (state.orderDir) { + params.orderDir = state.orderDir; + } + } + + return params; + }, + + /** + * Reduces a server response into a state patch carrying the total record + * count and the clamped page index. When the response omits the total, it + * is inferred from the current page, the page size and the number of rows + * received, which matches the historical behaviour. + * @param {object} state - The current table state. + * @param {object} response - The raw server response. + * @returns {object} A state patch. + */ + reduceResponse(state, response) { + state = state || {}; + response = response || {}; + + const pageSize = state.pageSize || 50; + const receivedRows = Array.isArray(response.rows) ? response.rows.length : 0; + const totalFromResponse = response.total ?? null; + const total = totalFromResponse !== null + ? (Number(totalFromResponse) || 0) + : ((state.page || 0) * pageSize) + receivedRows; + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + let page = state.page || 0; + if (page >= totalPages) { + page = totalPages - 1; + } + + return { total: total, page: page, error: null }; + }, + + /** + * Slices a raw row array to the page size, leaving non array values + * untouched, which matches the historical behaviour. + * @param {Array} rows - The raw rows. + * @param {number} pageSize - The page size. + * @returns {Array} The sliced rows. + */ + sliceRows(rows, pageSize) { + if (!Array.isArray(rows)) { + return rows || []; + } + return rows.length > pageSize ? rows.slice(0, pageSize) : rows; + }, + + /** + * Normalises the response columns into the internal column representation + * and applies the current sort to the matching column. + * @param {object} response - The raw server response. + * @param {string|null} orderBy - The current order column id. + * @param {string|null} orderDir - The current order direction. + * @returns {Array} The normalised columns. + */ + normalizeColumns(response, orderBy, orderDir) { + response = response || {}; + + const columns = (response.columns || []).map((c, idx) => { + let rType = c.rendererType || null; + let rOpts = c.rendererOptions || {}; + + if (c.template && typeof c.template === "object") { + rType = c.template.type; + rOpts = c.template.options || {}; + if (c.template.editable) { + rOpts.editable = c.template.editable; + } + } + + let isVisible = true; + if (typeof c.visible === "boolean") { + isVisible = c.visible; + } + + let isResizable = true; + if (typeof c.resizable === "boolean") { + isResizable = c.resizable; + } + + return { + id: c.id || `col_${idx}`, + label: c.label || c.id, + name: c.name || null, + visible: isVisible, + sort: null, + width: c.width || null, + minWidth: c.minWidth || null, + resizable: isResizable, + icon: c.icon || null, + image: c.image || null, + color: c.color || null, + rendererType: rType, + rendererOptions: rOpts + }; + }); + + if (orderBy) { + const targetCol = columns.find((c) => c.id === orderBy); + if (targetCol) { + targetCol.sort = orderDir || "asc"; + } + } + + return columns; + }, + + /** + * Normalises the response rows into the internal row representation, + * recursing into children and slicing to the page size. + * @param {object} response - The raw server response. + * @param {number} pageSize - The page size. + * @returns {Array} The normalised rows. + */ + normalizeRows(response, pageSize) { + response = response || {}; + + const normalizeRow = (r, parent = null) => { + let isExpanded = true; + if (typeof r.expanded === "boolean") { + isExpanded = r.expanded; + } + + const row = { + id: r.id || null, + class: r.class || null, + style: r.style || null, + color: r.color || null, + image: r.image || null, + icon: r.icon || null, + uri: r.uri || r.url || null, + target: r.target || null, + primaryAction: r.primaryAction || null, + secondaryAction: r.secondaryAction || null, + bind: r.bind || null, + cells: r.cells || [], + options: r.options || null, + children: [], + parent: parent, + expanded: isExpanded + }; + + if (Array.isArray(r.children)) { + row.children = r.children.map((child) => normalizeRow(child, row)); + } + + return row; + }; + + let newRows = (response.rows || []).map((r) => normalizeRow(r, null)); + + if (newRows.length > pageSize) { + newRows = newRows.slice(0, pageSize); + } + + return newRows; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tag.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tag.js index 27b9efc..6a757aa 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tag.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tag.js @@ -94,7 +94,7 @@ webexpress.webapp.TagEditorCtrl = class extends webexpress.webui.InputTagCtrl { if (this._apiEndpoint) { try { - const res = await fetch(this._apiEndpoint, { + const res = await webexpress.webapp.ServiceRegistry.request(this._apiEndpoint, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify({ value: value }) @@ -130,7 +130,7 @@ webexpress.webapp.TagEditorCtrl = class extends webexpress.webui.InputTagCtrl { if (this._apiEndpoint) { try { - const res = await fetch(this._apiEndpoint + "/" + encodeURIComponent(tag), { + const res = await webexpress.webapp.ServiceRegistry.request(this._apiEndpoint + "/" + encodeURIComponent(tag), { method: "DELETE" }); @@ -175,7 +175,7 @@ webexpress.webapp.TagEditorCtrl = class extends webexpress.webui.InputTagCtrl { try { const separator = this._apiEndpoint.includes("?") ? "&" : "?"; const url = this._apiEndpoint + separator + "q=" + encodeURIComponent(term); - const res = await fetch(url, { + const res = await webexpress.webapp.ServiceRegistry.request(url, { method: "GET", headers: { "Accept": "application/json" } }); @@ -184,7 +184,7 @@ webexpress.webapp.TagEditorCtrl = class extends webexpress.webui.InputTagCtrl { throw new Error("http " + res.status); } - const json = await res.json(); + const json = res.data; // drop suggestions that are already selected this._suggestions = webexpress.webapp._toTagValues(json).filter((v) => !this._tags.includes(v)); this._activeIndex = -1; @@ -341,7 +341,7 @@ webexpress.webapp.TagCtrl = class extends webexpress.webui.TagCtrl { */ async _loadTags() { try { - const res = await fetch(this._apiEndpoint, { + const res = await webexpress.webapp.ServiceRegistry.request(this._apiEndpoint, { method: "GET", headers: { "Accept": "application/json" } }); @@ -350,7 +350,7 @@ webexpress.webapp.TagCtrl = class extends webexpress.webui.TagCtrl { throw new Error("http " + res.status); } - const json = await res.json(); + const json = res.data; this.value = webexpress.webapp._toTagValues(json); } catch (err) { console.error("failed to load tags:", err); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tile.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tile.js index 1ad4720..3b3d864 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tile.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tile.js @@ -38,7 +38,12 @@ webexpress.webapp.TileCtrl = class extends webexpress.webui.TileCtrl { constructor(element) { super(element); - this._restUri = element.dataset.uri || ""; + + // the load keeps its own abort and loading state through the shared + // request; the state save flows through this rest service + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data; + this._restUri = this._service ? this._service.baseUri : ""; if (element.dataset.pageSize) { this._pageSize = parseInt(element.dataset.pageSize, 10); @@ -243,32 +248,22 @@ webexpress.webapp.TileCtrl = class extends webexpress.webui.TileCtrl { const fetchUrl = this._restUri.startsWith("http") ? urlObj.href : (urlObj.pathname + urlObj.search); - fetch(fetchUrl, { signal: this._abortController.signal }) + webexpress.webapp.ServiceRegistry.request(fetchUrl, { signal: this._abortController.signal }) .then((res) => { + if (res.error && res.error.kind === "abort") { + const abort = new Error("aborted"); + abort.name = "AbortError"; + throw abort; + } if (!res.ok) { throw new Error("Request failed"); } - return res.json(); + return res.data; }) .then((response) => { - const totalFromResponse = response.total ?? null; - - let newItems = []; - if (Array.isArray(response.items)) { - newItems = response.items; - } - - if (newItems.length > this._pageSize) { - newItems = newItems.slice(0, this._pageSize); - } + const newItems = webexpress.webapp.tileModel.sliceItems(response.items, this._pageSize); - const receivedItems = newItems.length; - - if (totalFromResponse !== null) { - this._totalRecords = Number(totalFromResponse) || 0; - } else { - this._totalRecords = (this._page * this._pageSize) + receivedItems; - } + this._totalRecords = webexpress.webapp.tileModel.reduceTotal(response, newItems.length, this._page, this._pageSize); const responseForUpdate = Object.assign({}, response, { items: newItems }); @@ -314,42 +309,7 @@ webexpress.webapp.TileCtrl = class extends webexpress.webui.TileCtrl { return; } - let items = []; - if (response.items) { - items = response.items; - } - - const mappedTiles = items.map((item) => { - let isVisible = true; - if (typeof item.visible === "boolean") { - isVisible = item.visible; - } - - let opts = null; - if (Array.isArray(item.options)) { - opts = item.options; - } - - return { - id: item.id || null, - label: item.label || item.title || item.name || "", - html: item.text || item.description || item.content || null, - class: item.class || null, - icon: item.icon || null, - image: item.image || null, - colorCss: item.colorCss || item.color || null, - colorStyle: item.colorStyle || item.style || null, - visible: isVisible, - primaryAction: item.primaryAction || null, - secondaryAction: item.secondaryAction || null, - bind: item.bind || null, - options: opts, - _lc_id: null, - _lc_label: null - }; - }); - - this._tiles = mappedTiles; + this._tiles = webexpress.webapp.tileModel.mapTiles(response); if (response.meta) { if (response.meta.sort) { @@ -400,12 +360,10 @@ webexpress.webapp.TileCtrl = class extends webexpress.webui.TileCtrl { return; } - fetch(this._restUri, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(stateObj) - }).catch((err) => { - console.error("TileCtrl update state failed", err); + this._service.update(stateObj).then((r) => { + if (!r.ok) { + console.error("TileCtrl update state failed", r.error); + } }); } diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tile.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tile.model.js new file mode 100644 index 0000000..284f5dd --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.tile.model.js @@ -0,0 +1,97 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the REST tile control (View, State and Service + * migration). These functions carry no DOM or network dependency, so they can + * be unit tested in isolation. The control composes them with a RestService: + * the load is fetched through the shared request (it keeps its own abort and + * loading state), the page is reduced and the items are mapped through the + * model, and the state is persisted with the service update. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.tileModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. The tiles are loaded with GET and the + * state is persisted with PUT. + * @param {string} uri - The REST endpoint backing the tiles. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(uri) { + return { name: "data", kind: "rest", baseUri: uri || "", method: "GET", updateMethod: "PUT" }; + }, + + /** + * Caps the received items to a single page, returning the array unchanged + * when it already fits and tolerating a missing or malformed list. + * @param {Array} items - The received items. + * @param {number} pageSize - The page size. + * @returns {Array} The items capped to the page size. + */ + sliceItems(items, pageSize) { + let list = Array.isArray(items) ? items : []; + if (typeof pageSize === "number" && pageSize >= 0 && list.length > pageSize) { + list = list.slice(0, pageSize); + } + return list; + }, + + /** + * Determines the total record count, preferring an explicit total from the + * response and otherwise inferring it from the page, the page size and the + * number of received rows. + * @param {object} response - The raw response. + * @param {number} receivedItems - The number of rows on this page. + * @param {number} page - The zero based page index. + * @param {number} pageSize - The page size. + * @returns {number} The total record count. + */ + reduceTotal(response, receivedItems, page, pageSize) { + const total = response ? (response.total ?? null) : null; + if (total !== null && total !== undefined) { + return Number(total) || 0; + } + return (page * pageSize) + receivedItems; + }, + + /** + * Maps the response items to the internal tile shape, projecting the many + * accepted field aliases and defaulting the visibility to true. + * @param {object} response - The raw response containing items. + * @returns {Array} The mapped tiles. + */ + mapTiles(response) { + const items = (response && Array.isArray(response.items)) ? response.items : []; + return items.map((item) => { + let isVisible = true; + if (typeof item.visible === "boolean") { + isVisible = item.visible; + } + + let opts = null; + if (Array.isArray(item.options)) { + opts = item.options; + } + + return { + id: item.id || null, + label: item.label || item.title || item.name || "", + html: item.text || item.description || item.content || null, + class: item.class || null, + icon: item.icon || null, + image: item.image || null, + colorCss: item.colorCss || item.color || null, + colorStyle: item.colorStyle || item.style || null, + visible: isVisible, + primaryAction: item.primaryAction || null, + secondaryAction: item.secondaryAction || null, + bind: item.bind || null, + options: opts, + _lc_id: null, + _lc_label: null + }; + }); + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.watcher.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.watcher.js index d43ff53..4778f4d 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.watcher.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.watcher.js @@ -21,21 +21,32 @@ * webexpress.webapp.Event.WATCHER_ADDED_EVENT detail: { user } * webexpress.webapp.Event.WATCHER_REMOVED_EVENT detail: { user } */ -webexpress.webapp.WatcherCtrl = class extends webexpress.webui.Ctrl { +webexpress.webapp.WatcherCtrl = class extends webexpress.webapp.Data { /** * Construct a new WatcherCtrl. * @param {HTMLElement} element - host element. */ constructor(element) { - super(element); + // resolve the data service (a configured island when present, otherwise a + // legacy descriptor) and the initial state before super, so the Component + // seeds its store from the optional data-wx-state island and owns the + // service map. The cross endpoint user search keeps using the shared + // request. + const uri = element.dataset.uri || null; + const island = webexpress.webapp.ServiceRegistry.fromElement(element); + const services = island.data + ? island + : { data: webexpress.webapp.ServiceRegistry.create(webexpress.webapp.watcherModel.legacyDescriptor(uri)) }; + const initialState = Object.assign({ watchers: [] }, webexpress.webapp.Data.readState(element)); - this._uri = element.dataset.uri || null; + super(element, { state: initialState, services: services }); + + this._uri = uri; this._usersUri = element.dataset.usersUri || null; this._maxVisible = parseInt(element.dataset.maxVisible || "6", 10); this._readonly = element.dataset.readonly === "true"; + this._service = this.useService("data"); - // state - this._watchers = []; this._dropdownOpen = false; this._searchTimer = null; @@ -45,11 +56,48 @@ webexpress.webapp.WatcherCtrl = class extends webexpress.webui.Ctrl { element.removeAttribute("data-users-uri"); element.removeAttribute("data-max-visible"); element.removeAttribute("data-readonly"); + element.removeAttribute("data-wx-state"); + element.removeAttribute("data-wx-service"); element.classList.add("wx-watcher"); this._buildDom(); this._attachEventHandlers(); - this._load(); + + // subscribe to the store, perform the first render and run onMount + this.mount(); + + // when the server seeded the watchers through the data-wx-state island the + // first paint needs no round trip; otherwise load them from the endpoint + if (this._watchers.length === 0) { + this._load(); + } + } + + /** + * The watchers, backed by the component store so the store is the single + * source of truth and a change triggers a re-render through the subscription. + * @returns {Array} The current watchers. + */ + get _watchers() { + return this.state.watchers || []; + } + + set _watchers(value) { + this.setState({ watchers: value }); + } + + /** + * Renders the avatar row on the first paint. + */ + onMount() { + this._render(); + } + + /** + * Renders the avatar row whenever the watcher state changes. + */ + onUpdate() { + this._render(); } /** @@ -131,18 +179,16 @@ webexpress.webapp.WatcherCtrl = class extends webexpress.webui.Ctrl { async _load() { if (!this._uri) { this._watchers = []; - this._render(); return; } try { - const res = await fetch(this._uri, { headers: { "Accept": "application/json" } }); - if (!res.ok) throw new Error(res.statusText); - this._watchers = await res.json(); + const res = await this._service.query({}); + if (!res.ok) throw new Error(res.error ? res.error.message : String(res.status)); + this._watchers = webexpress.webapp.watcherModel.normalizeList(res.data); } catch (e) { console.warn("WatcherCtrl: load failed", e); this._watchers = []; } - this._render(); } /** @@ -228,16 +274,15 @@ webexpress.webapp.WatcherCtrl = class extends webexpress.webui.Ctrl { } let users = []; try { - const url = this._usersUri + (this._usersUri.includes("?") ? "&" : "?") + "q=" + encodeURIComponent(q); - const res = await fetch(url, { headers: { "Accept": "application/json" } }); - if (!res.ok) throw new Error(res.statusText); - users = await res.json(); + const url = webexpress.webapp.watcherModel.searchUrl(this._usersUri, q); + const res = await webexpress.webapp.ServiceRegistry.request(url, { headers: { "Accept": "application/json" } }); + if (!res.ok) throw new Error(res.error ? res.error.message : String(res.status)); + users = res.data; } catch (e) { console.warn("WatcherCtrl: search failed", e); } - const known = new Set(this._watchers.map(u => u.id)); - const candidates = users.filter(u => !known.has(u.id)); + const candidates = webexpress.webapp.watcherModel.candidates(this._watchers, users); this._resultsList.replaceChildren(); if (candidates.length === 0) { @@ -273,15 +318,10 @@ webexpress.webapp.WatcherCtrl = class extends webexpress.webui.Ctrl { return; } try { - const res = await fetch(this._uri, { - method: "POST", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - body: JSON.stringify({ userId: user.id }) - }); - if (!res.ok) throw new Error(res.statusText); - const created = await res.json(); - this._watchers.push(created); - this._render(); + const res = await this._service.create({ userId: user.id }); + if (!res.ok) throw new Error(res.error ? res.error.message : String(res.status)); + const created = res.data; + this._watchers = this._watchers.concat([created]); this._dispatch(webexpress.webapp.Event.WATCHER_ADDED_EVENT, { user: created }); } catch (e) { console.warn("WatcherCtrl: add failed", e); @@ -297,10 +337,9 @@ webexpress.webapp.WatcherCtrl = class extends webexpress.webui.Ctrl { return; } try { - const res = await fetch(this._uri + "/" + encodeURIComponent(user.id), { method: "DELETE" }); - if (!res.ok && res.status !== 204) throw new Error(res.statusText); - this._watchers = this._watchers.filter(u => u.id !== user.id); - this._render(); + const res = await this._service.remove({ path: webexpress.webapp.watcherModel.removePath(user.id) }); + if (!res.ok && res.status !== 204) throw new Error(res.error ? res.error.message : String(res.status)); + this._watchers = webexpress.webapp.watcherModel.removeById(this._watchers, user.id); this._dispatch(webexpress.webapp.Event.WATCHER_REMOVED_EVENT, { user }); } catch (e) { console.warn("WatcherCtrl: remove failed", e); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.watcher.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.watcher.model.js new file mode 100644 index 0000000..b6a77f9 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.watcher.model.js @@ -0,0 +1,77 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the watcher control (View, State and Service + * migration). These functions carry no DOM or network dependency, so they can + * be unit tested in isolation. The control composes them with a RestService + * whose query loads the watchers, whose create adds one and whose remove + * deletes one, plus a shared request for the cross endpoint user search. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.watcherModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. The watchers are loaded with GET, an + * watcher is added with POST and removed with DELETE on a path. + * @param {string} uri - The REST endpoint backing the watcher list. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(uri) { + return { name: "data", kind: "rest", baseUri: uri || "", method: "GET", updateMethod: "PUT" }; + }, + + /** + * Normalises a watcher list response into an array, tolerating a missing or + * malformed payload so the renderer always receives a list. + * @param {*} data - The raw response payload. + * @returns {Array} The watcher array. + */ + normalizeList(data) { + return Array.isArray(data) ? data : []; + }, + + /** + * Builds the user search url from the users endpoint and a free text query, + * appending the query parameter with the correct separator and encoding. + * @param {string} usersUri - The users search endpoint. + * @param {string} q - The free text query. + * @returns {string} The request url. + */ + searchUrl(usersUri, q) { + const base = usersUri || ""; + const sep = base.includes("?") ? "&" : "?"; + return base + sep + "q=" + encodeURIComponent(q == null ? "" : q); + }, + + /** + * Returns the search results that are not already watchers, keyed by id. + * @param {Array} watchers - The current watchers. + * @param {Array} users - The raw search results. + * @returns {Array} The users that can still be added. + */ + candidates(watchers, users) { + const known = new Set((Array.isArray(watchers) ? watchers : []).map((u) => u.id)); + return (Array.isArray(users) ? users : []).filter((u) => !known.has(u.id)); + }, + + /** + * Builds the delete path segment for a watcher id. + * @param {string} id - The watcher (user) id. + * @returns {string} The path appended to the base uri. + */ + removePath(id) { + return "/" + encodeURIComponent(id); + }, + + /** + * Returns the list without the watcher carrying the given id. + * @param {Array} list - The current watchers. + * @param {string} id - The id to drop. + * @returns {Array} A new list without the matching watcher. + */ + removeById(list, id) { + return (Array.isArray(list) ? list : []).filter((u) => u.id !== id); + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.workflow.editor.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.workflow.editor.js index 76fa323..647c54e 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.workflow.editor.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.workflow.editor.js @@ -62,7 +62,12 @@ webexpress.webapp.WorkflowEditorCtrl = class extends webexpress.webui.GraphEdito super(element); const ds = element.dataset; - this._restUri = ds.uri || ""; + + // the load keeps its own abort and loading state through the shared + // request; the debounced autosave PUT flows through this rest service + const islandServices = webexpress.webapp.ServiceRegistry.fromElement(element); + this._service = islandServices.data; + this._restUri = this._service ? this._service.baseUri : ""; element.removeAttribute("data-uri"); element.classList.add("wx-workflow-editor"); @@ -232,26 +237,21 @@ webexpress.webapp.WorkflowEditorCtrl = class extends webexpress.webui.GraphEdito } const fetchUrl = this._restUri.startsWith("http") ? urlObj.href : (urlObj.pathname + urlObj.search); - fetch(fetchUrl, { signal: this._abortController.signal }) + webexpress.webapp.ServiceRegistry.request(fetchUrl, { signal: this._abortController.signal }) .then(res => { + if (res.error && res.error.kind === "abort") { + const abort = new Error("aborted"); + abort.name = "AbortError"; + throw abort; + } if (!res.ok) { throw new Error("workflow editor: load request failed (" + res.status + ")"); } - return res.json(); + return res.data; }) .then(response => { - this._meta = { - id: response.id || "", - name: response.name || "", - state: response.state || "", - version: response.version || "", - description: response.description || "" - }; - this._catalog = { - guards: Array.isArray(response.guards) ? response.guards : [], - validations: Array.isArray(response.validations) ? response.validations : [], - postfunctions: Array.isArray(response.postfunctions) ? response.postfunctions : [] - }; + this._meta = webexpress.webapp.workflowEditorModel.normalizeMeta(response); + this._catalog = webexpress.webapp.workflowEditorModel.normalizeCatalog(response); this.model = this._fromWireFormat(response); this._element.classList.remove("placeholder-glow"); this._isLoading = false; @@ -276,23 +276,7 @@ webexpress.webapp.WorkflowEditorCtrl = class extends webexpress.webui.GraphEdito * @returns {{nodes: Array, edges: Array}} */ _fromWireFormat(response) { - const nodesIn = Array.isArray(response.nodes) - ? response.nodes - : (Array.isArray(response.states) ? response.states : []); - const edgesIn = Array.isArray(response.edges) - ? response.edges - : (Array.isArray(response.transitions) ? response.transitions : []); - - const nodes = nodesIn.map(n => Object.assign({}, n)); - const edges = edgesIn.map(e => { - const out = Object.assign({}, e); - // accept the prototype's source/target alias for compatibility - if (out.from === undefined && out.source !== undefined) { out.from = out.source; } - if (out.to === undefined && out.target !== undefined) { out.to = out.target; } - return out; - }); - - return { nodes, edges }; + return webexpress.webapp.workflowEditorModel.fromWireFormat(response); } /** @@ -404,31 +388,14 @@ webexpress.webapp.WorkflowEditorCtrl = class extends webexpress.webui.GraphEdito } } - const payload = { - id: this._meta.id, - name: this._meta.name, - state: this._meta.state, - version: this._meta.version, - description: this._meta.description, - nodes: this._model.nodes, - edges: this._model.edges, - // mirror payload using the REST wire names so backends that prefer - // states / transitions can read either field. - states: this._model.nodes, - transitions: this._model.edges - }; + const payload = webexpress.webapp.workflowEditorModel.toWirePayload(this._meta, this._model); - fetch(this._restUri, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload) - }) + this._service.update(payload) .then(res => { if (!res.ok) { console.warn("workflow editor: save returned " + res.status); } - }) - .catch(err => console.error("workflow editor: save failed", err)); + }); } /** diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.workflow.editor.model.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.workflow.editor.model.js new file mode 100644 index 0000000..dd6f02b --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.workflow.editor.model.js @@ -0,0 +1,112 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Pure model helpers for the workflow editor control (View, State and Service + * migration). These functions carry no DOM or network dependency, so they can + * be unit tested in isolation. The control composes them with a RestService: + * the load is fetched through the shared request (it keeps its own abort and + * loading state), the meta, catalog and graph are read out of the wire format + * through the model and the debounced autosave is persisted with the service + * update from a wire payload the model builds. + * + * See WebExpress.WebApp/docs/architecture/view-state-service.md. + */ +webexpress.webapp.workflowEditorModel = { + /** + * Builds the legacy service descriptor used when the host element does not + * carry a data-wx-service island. The workflow is loaded with GET and the + * autosave persists it with PUT. + * @param {string} uri - The REST endpoint backing the workflow. + * @returns {object} A rest service descriptor. + */ + legacyDescriptor(uri) { + return { name: "data", kind: "rest", baseUri: uri || "", method: "GET", updateMethod: "PUT" }; + }, + + /** + * Reads the workflow meta fields out of a response, defaulting each to an + * empty string. + * @param {object} response - The raw workflow response. + * @returns {object} The meta object. + */ + normalizeMeta(response) { + response = response || {}; + return { + id: response.id || "", + name: response.name || "", + state: response.state || "", + version: response.version || "", + description: response.description || "" + }; + }, + + /** + * Reads the rule catalog (guards, validations, postfunctions) out of a + * response, defaulting each to an empty array. + * @param {object} response - The raw workflow response. + * @returns {object} The catalog object. + */ + normalizeCatalog(response) { + response = response || {}; + return { + guards: Array.isArray(response.guards) ? response.guards : [], + validations: Array.isArray(response.validations) ? response.validations : [], + postfunctions: Array.isArray(response.postfunctions) ? response.postfunctions : [] + }; + }, + + /** + * Reads the graph out of the wire format, accepting the nodes/states and + * edges/transitions aliases and mapping the source/target edge alias to + * from/to. Each node and edge is shallow copied so the response is not + * mutated. + * @param {object} response - The raw workflow response. + * @returns {{nodes: Array, edges: Array}} The graph. + */ + fromWireFormat(response) { + response = response || {}; + const nodesIn = Array.isArray(response.nodes) + ? response.nodes + : (Array.isArray(response.states) ? response.states : []); + const edgesIn = Array.isArray(response.edges) + ? response.edges + : (Array.isArray(response.transitions) ? response.transitions : []); + + const nodes = nodesIn.map((n) => Object.assign({}, n)); + const edges = edgesIn.map((e) => { + const out = Object.assign({}, e); + // accept the prototype's source/target alias for compatibility + if (out.from === undefined && out.source !== undefined) { out.from = out.source; } + if (out.to === undefined && out.target !== undefined) { out.to = out.target; } + return out; + }); + + return { nodes: nodes, edges: edges }; + }, + + /** + * Builds the wire payload for the autosave, mirroring the nodes and edges + * under the states and transitions names so a backend can read either field. + * @param {object} meta - The workflow meta fields. + * @param {object} model - The graph model with nodes and edges. + * @returns {object} The wire payload. + */ + toWirePayload(meta, model) { + meta = meta || {}; + model = model || {}; + const nodes = model.nodes || []; + const edges = model.edges || []; + return { + id: meta.id, + name: meta.name, + state: meta.state, + version: meta.version, + description: meta.description, + nodes: nodes, + edges: edges, + states: nodes, + transitions: edges + }; + } +}; diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.wql.prompt.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.wql.prompt.js index c49ec33..05c3129 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.wql.prompt.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.wql.prompt.js @@ -274,10 +274,10 @@ webexpress.webapp.WqlPromptCtrl = class extends webexpress.webui.Ctrl { */ async _loadHistoryFromApi(retryCount = 0) { try { - const resp = await fetch(this._apiUri + "/history"); + const resp = await webexpress.webapp.ServiceRegistry.request(this._apiUri + "/history"); if (resp.ok) { - const data = await resp.json(); + const data = resp.data; this._history = Array.isArray(data.history) ? data.history : []; this._historyIndex = this._history.length; @@ -343,10 +343,10 @@ webexpress.webapp.WqlPromptCtrl = class extends webexpress.webui.Ctrl { const fetchUrl = this._apiUri.startsWith("http") ? urlObj.href : (urlObj.pathname + urlObj.search); try { - const analyzeResp = await fetch(fetchUrl, { signal: this._abortController.signal }); + const analyzeResp = await webexpress.webapp.ServiceRegistry.request(fetchUrl, { signal: this._abortController.signal }); if (analyzeResp.ok) { - const analyzeData = await analyzeResp.json(); + const analyzeData = analyzeResp.data; if (analyzeData.isValidSoFar) { this._setValidState(); diff --git a/src/WebExpress.WebApp/WebControl/ControlRestAvatarDropdown.cs b/src/WebExpress.WebApp/WebControl/ControlDataAvatarDropdown.cs similarity index 92% rename from src/WebExpress.WebApp/WebControl/ControlRestAvatarDropdown.cs rename to src/WebExpress.WebApp/WebControl/ControlDataAvatarDropdown.cs index b06f541..a486556 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestAvatarDropdown.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataAvatarDropdown.cs @@ -11,7 +11,7 @@ namespace WebExpress.WebApp.WebApiControl /// Represents an avatar dropdown control that uses the avatar image as the interactive /// menu button and supports loading items dynamically via a REST API endpoint. /// - public class ControlRestAvatarDropdown : ControlAvatarDropdown, IControlRest + public class ControlDataAvatarDropdown : ControlAvatarDropdown, IControlData { /// /// Gets or sets the REST API endpoint used to populate the dropdown. @@ -22,7 +22,7 @@ public class ControlRestAvatarDropdown : ControlAvatarDropdown, IControlRest /// Initializes a new instance of the class. /// /// The control id. - public ControlRestAvatarDropdown(string id = null) + public ControlDataAvatarDropdown(string id = null) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestComment.cs b/src/WebExpress.WebApp/WebControl/ControlDataComment.cs similarity index 82% rename from src/WebExpress.WebApp/WebControl/ControlRestComment.cs rename to src/WebExpress.WebApp/WebControl/ControlDataComment.cs index f2b711c..cbfb4f1 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestComment.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataComment.cs @@ -1,4 +1,6 @@ using System; +using System.Net; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -13,20 +15,8 @@ namespace WebExpress.WebApp.WebControl /// which talks to the configured REST endpoint to load, post, edit, /// delete, like, pin and reply to comments. /// - public class ControlRestComment : Control + public class ControlDataComment : Control, IDataIsland { - /// - /// Gets or sets the REST URI that backs this comment surface. The - /// JS controller issues - /// GET/POST {Uri}, - /// PUT/DELETE {Uri}/{id}, - /// POST {Uri}/{id}/reactions, - /// POST {Uri}/{id}/likes, - /// POST {Uri}/{id}/pin and - /// POST {Uri}/{id}/replies. - /// - public Func RestUri { get; set; } - /// /// Gets or sets the URI used to resolve user records referenced by /// authors, likes, reactions and replies. The JS controller calls @@ -60,11 +50,24 @@ public class ControlRestComment : Control /// public Func Categories { get; set; } + /// + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island the JavaScript engine consumes + /// in preference to the legacy data-uri fallback. See + /// WebExpress/docs/view-state-service.md. + /// + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state island. + /// + public Func StateFactory { get; set; } + /// /// Initializes a new instance of the class. /// /// Optional host element id. - public ControlRestComment(string id = null) + public ControlDataComment(string id = null) : base(id) { } @@ -83,7 +86,6 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre return null; } - var restUri = RestUri?.Invoke(renderContext)?.BindParameters(renderContext.Request); var usersUri = UsersUri?.Invoke(renderContext)?.BindParameters(renderContext.Request); var imageUploadUri = ImageUploadUri?.Invoke(renderContext)?.BindParameters(renderContext.Request); var readOnly = Readonly?.Invoke(renderContext) ?? false; @@ -95,12 +97,12 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre Style = GetStyles(renderContext), Role = Role?.Invoke(renderContext) } - .AddUserAttribute("data-uri", restUri?.ToString()) .AddUserAttribute("data-users-uri", usersUri?.ToString()) .AddUserAttribute("data-current-user", CurrentUser?.Invoke(renderContext)) .AddUserAttribute("data-image-upload-uri", imageUploadUri?.ToString()) .AddUserAttribute("data-readonly", readOnly ? "true" : null) - .AddUserAttribute("data-categories", Categories?.Invoke(renderContext)); + .AddUserAttribute("data-categories", Categories?.Invoke(renderContext)) + .EmitDataIslands(this, renderContext); } } } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestCommentComposer.cs b/src/WebExpress.WebApp/WebControl/ControlDataCommentComposer.cs similarity index 96% rename from src/WebExpress.WebApp/WebControl/ControlRestCommentComposer.cs rename to src/WebExpress.WebApp/WebControl/ControlDataCommentComposer.cs index 3ba6383..a7669a3 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestCommentComposer.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataCommentComposer.cs @@ -14,9 +14,9 @@ namespace WebExpress.WebApp.WebControl /// webexpress.webapp.CommentComposerCtrl, which POSTs the /// authored comment to the configured REST endpoint and dispatches a /// COMMENT_ADDED_EVENT so that any sibling - /// on the same page picks it up. + /// on the same page picks it up. /// - public class ControlRestCommentComposer : Control + public class ControlDataCommentComposer : Control { /// /// Gets or sets the REST URI the composer POSTs new comments to. @@ -69,7 +69,7 @@ public class ControlRestCommentComposer : Control /// Initializes a new instance of the class. /// /// Optional host element id. - public ControlRestCommentComposer(string id = null) + public ControlDataCommentComposer(string id = null) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestDashboard.cs b/src/WebExpress.WebApp/WebControl/ControlDataDashboard.cs similarity index 75% rename from src/WebExpress.WebApp/WebControl/ControlRestDashboard.cs rename to src/WebExpress.WebApp/WebControl/ControlDataDashboard.cs index 0e29ac1..e7e8f24 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestDashboard.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataDashboard.cs @@ -1,4 +1,6 @@ using System; +using System.Net; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -9,13 +11,8 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a control panel for API dashboard interactions. /// - public class ControlRestDashboard : ControlPanel, IControlRestDashboard + public class ControlDataDashboard : ControlPanel, IControlDataDashboard, IDataIsland { - /// - /// Gets or sets the uri that determines the data. - /// - public Func RestUri { get; set; } - /// /// Gets or sets a value indicating whether the column headers can be /// renamed inline (smart-edit). The new column layout is persisted to @@ -36,11 +33,24 @@ public class ControlRestDashboard : ControlPanel, IControlRestDashboard /// public Func DeletableColumn { get; set; } + /// + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island the JavaScript engine consumes + /// in preference to the legacy data-uri fallback. See + /// WebExpress/docs/view-state-service.md. + /// + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state island. + /// + public Func StateFactory { get; set; } + /// /// Initializes a new instance of the class. /// /// The control id. - public ControlRestDashboard(string id = null) + public ControlDataDashboard(string id = null) : base(id ?? RandomId.Create()) { } @@ -53,8 +63,6 @@ public ControlRestDashboard(string id = null) /// An HTML node representing the rendered control. public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree) { - var uri = RestUri?.Invoke(renderContext); - var resultUri = uri?.BindParameters(renderContext.Request); var editableColumn = EditableColumn?.Invoke(renderContext) ?? false; var movableColumn = MovableColumn?.Invoke(renderContext) ?? false; var deletableColumn = DeletableColumn?.Invoke(renderContext) ?? false; @@ -65,10 +73,10 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre Class = Css.Concatenate("wx-webapp-dashboard", GetClasses(renderContext)), Style = GetStyles(renderContext) } - .AddUserAttribute("data-uri", resultUri?.ToString()) .AddUserAttribute("data-editable-column", editableColumn ? "true" : null) .AddUserAttribute("data-movable-column", movableColumn ? "true" : null) - .AddUserAttribute("data-deletable-column", deletableColumn ? "true" : null); + .AddUserAttribute("data-deletable-column", deletableColumn ? "true" : null) + .EmitDataIslands(this, renderContext); return html; } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestDropdown.cs b/src/WebExpress.WebApp/WebControl/ControlDataDropdown.cs similarity index 95% rename from src/WebExpress.WebApp/WebControl/ControlRestDropdown.cs rename to src/WebExpress.WebApp/WebControl/ControlDataDropdown.cs index d37fdee..6404b6f 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestDropdown.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataDropdown.cs @@ -11,7 +11,7 @@ namespace WebExpress.WebApp.WebApiControl /// /// Represents a dropdown control that can be rendered as HTML within a RESTful web application context. /// - public class ControlRestDropdown : ControlDropdown, IControlRest + public class ControlDataDropdown : ControlDropdown, IControlData { /// /// Gets or sets the REST API endpoint used to populate the dropdown. @@ -32,7 +32,7 @@ public class ControlRestDropdown : ControlDropdown, IControlRest /// Initializes a new instance of the class. /// /// The control id. - public ControlRestDropdown(string id = null) + public ControlDataDropdown(string id = null) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestForm.cs b/src/WebExpress.WebApp/WebControl/ControlDataForm.cs similarity index 98% rename from src/WebExpress.WebApp/WebControl/ControlRestForm.cs rename to src/WebExpress.WebApp/WebControl/ControlDataForm.cs index 301f2cf..2ead233 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestForm.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataForm.cs @@ -15,7 +15,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a form that retrieves and displays data from /// a RESTful resource specified by a URI. /// - public class ControlRestForm : ControlForm + public class ControlDataForm : ControlForm { /// /// Gets or sets the mode that determines how the form behaves @@ -33,7 +33,7 @@ public class ControlRestForm : ControlForm /// Initializes a new instance of the class. /// /// The control id. - public ControlRestForm(string id = null) + public ControlDataForm(string id = null) : base(id ?? RandomId.Create()) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormAdd.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormAdd.cs similarity index 92% rename from src/WebExpress.WebApp/WebControl/ControlRestFormAdd.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormAdd.cs index b71c764..fad42fe 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormAdd.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormAdd.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a new form that retrieves and displays data from /// a RESTful resource specified by a URI. /// - public class ControlRestFormAdd : ControlRestForm + public class ControlDataFormAdd : ControlDataForm { /// /// Gets or sets the mode that determines how the form behaves @@ -32,7 +32,7 @@ public class ControlRestFormAdd : ControlRestForm /// Initializes a new instance of the class. /// /// The control id. - public ControlRestFormAdd(string id = null) + public ControlDataFormAdd(string id = null) : base(id) { Method = _ => RequestMethod.POST; diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormClone.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormClone.cs similarity index 92% rename from src/WebExpress.WebApp/WebControl/ControlRestFormClone.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormClone.cs index 42d40aa..e01355f 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormClone.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormClone.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a clone form that retrieves and displays data from /// a RESTful resource specified by a URI. /// - public class ControlRestFormClone : ControlRestForm + public class ControlDataFormClone : ControlDataForm { /// /// Gets or sets the mode that determines how the form behaves @@ -32,7 +32,7 @@ public class ControlRestFormClone : ControlRestForm /// Initializes a new instance of the class. /// /// The control id. - public ControlRestFormClone(string id = null) + public ControlDataFormClone(string id = null) : base(id) { Method = _ => RequestMethod.POST; diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormDelete.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormDelete.cs similarity index 93% rename from src/WebExpress.WebApp/WebControl/ControlRestFormDelete.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormDelete.cs index 9c1d304..74f7a65 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormDelete.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormDelete.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a delete form that retrieves and displays data from /// a RESTful resource specified by a URI. /// - public class ControlRestFormDelete : ControlRestForm + public class ControlDataFormDelete : ControlDataForm { /// /// Gets or sets the mode that determines how the form behaves @@ -40,7 +40,7 @@ public class ControlRestFormDelete : ControlRestForm /// Initializes a new instance of the class. /// /// The control id. - public ControlRestFormDelete(string id = null) + public ControlDataFormDelete(string id = null) : base(id) { Method = _ => RequestMethod.DELETE; diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormEdit.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormEdit.cs similarity index 92% rename from src/WebExpress.WebApp/WebControl/ControlRestFormEdit.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormEdit.cs index 67aac87..90927cc 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormEdit.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormEdit.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a edit form that retrieves and displays data from /// a RESTful resource specified by a URI. /// - public class ControlRestFormEdit : ControlRestForm + public class ControlDataFormEdit : ControlDataForm { /// /// Gets or sets the mode that determines how the form behaves @@ -32,7 +32,7 @@ public class ControlRestFormEdit : ControlRestForm /// Initializes a new instance of the class. /// /// The control id. - public ControlRestFormEdit(string id = null) + public ControlDataFormEdit(string id = null) : base(id) { Method = _ => RequestMethod.PUT; diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormEditor.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormEditor.cs similarity index 96% rename from src/WebExpress.WebApp/WebControl/ControlRestFormEditor.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormEditor.cs index 6b91ba3..45920b8 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormEditor.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormEditor.cs @@ -15,7 +15,7 @@ namespace WebExpress.WebApp.WebControl /// host element with the full Designer UI (tab bar, structure tree, live /// preview, palette, QuickAdd picker, drag-and-drop, keyboard shortcuts). /// - public class ControlRestFormEditor : Control, IControlRestFormEditor + public class ControlDataFormEditor : Control, IControlDataFormEditor { public const int _defaultIndent = 18; @@ -43,7 +43,7 @@ public class ControlRestFormEditor : Control, IControlRestFormEditor /// Initializes a new instance of the class. /// /// The id of the control. - public ControlRestFormEditor(string id = null) + public ControlDataFormEditor(string id = null) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormItemInputCheck.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormItemInputCheck.cs similarity index 95% rename from src/WebExpress.WebApp/WebControl/ControlRestFormItemInputCheck.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormItemInputCheck.cs index 2e543e1..c84b6e9 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormItemInputCheck.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormItemInputCheck.cs @@ -16,7 +16,7 @@ namespace WebExpress.WebApp.WebApiControl /// precedence and no GET request is issued. Subsequent state changes /// are forwarded to the same endpoint via POST. /// - public class ControlRestFormItemInputCheck : ControlFormItemInputCheck, IControlRest + public class ControlDataFormItemInputCheck : ControlFormItemInputCheck, IControlData { /// /// Gets or sets the uri of the REST endpoint used to read and @@ -35,7 +35,7 @@ public class ControlRestFormItemInputCheck : ControlFormItemInputCheck, IControl /// /// Initializes a new instance of the class with an automatically assigned ID. /// - public ControlRestFormItemInputCheck() + public ControlDataFormItemInputCheck() : base() { } @@ -44,7 +44,7 @@ public ControlRestFormItemInputCheck() /// Initializes a new instance of the class. /// /// The control id. - public ControlRestFormItemInputCheck(string id) + public ControlDataFormItemInputCheck(string id) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormItemInputPassword.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormItemInputPassword.cs similarity index 89% rename from src/WebExpress.WebApp/WebControl/ControlRestFormItemInputPassword.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormItemInputPassword.cs index e992008..729cbac 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormItemInputPassword.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormItemInputPassword.cs @@ -8,12 +8,12 @@ namespace WebExpress.WebApp.WebApiControl /// Represents a REST-enabled password input control that extends the base /// from WebUI. /// - public class ControlRestFormItemInputPassword : ControlFormItemInputPassword + public class ControlDataFormItemInputPassword : ControlFormItemInputPassword { /// /// Initializes a new instance of the class with an automatically assigned ID. /// - public ControlRestFormItemInputPassword() + public ControlDataFormItemInputPassword() : this(DeterministicId.Create()) { } @@ -22,7 +22,7 @@ public ControlRestFormItemInputPassword() /// Initializes a new instance of the class. /// /// The control id. - public ControlRestFormItemInputPassword(string id) + public ControlDataFormItemInputPassword(string id) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormItemInputSelection.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormItemInputSelection.cs similarity index 92% rename from src/WebExpress.WebApp/WebControl/ControlRestFormItemInputSelection.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormItemInputSelection.cs index 5e919bd..5b43d33 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormItemInputSelection.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormItemInputSelection.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.WebApiControl /// /// Represents a form item input selection that retrieves options from a specified URI. /// - public class ControlRestFormItemInputSelection : ControlFormItemInputSelection, IControlRest + public class ControlDataFormItemInputSelection : ControlFormItemInputSelection, IControlData { /// /// Gets or sets the uri that determines the options. @@ -30,7 +30,7 @@ public class ControlRestFormItemInputSelection : ControlFormItemInputSelection, /// /// Initializes a new instance of the class with an automatically assigned ID. /// - public ControlRestFormItemInputSelection() + public ControlDataFormItemInputSelection() : this(DeterministicId.Create()) { } @@ -40,7 +40,7 @@ public ControlRestFormItemInputSelection() /// /// The control id. /// The entries. - public ControlRestFormItemInputSelection(string id, params ControlFormItemInputSelectionItem[] items) + public ControlDataFormItemInputSelection(string id, params ControlFormItemInputSelectionItem[] items) : base(id, items) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestFormItemInputUnique.cs b/src/WebExpress.WebApp/WebControl/ControlDataFormItemInputUnique.cs similarity index 96% rename from src/WebExpress.WebApp/WebControl/ControlRestFormItemInputUnique.cs rename to src/WebExpress.WebApp/WebControl/ControlDataFormItemInputUnique.cs index 94890fd..b538d71 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestFormItemInputUnique.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataFormItemInputUnique.cs @@ -12,7 +12,7 @@ namespace WebExpress.WebApp.WebApiControl /// /// Represents a form input control that ensures uniqueness. /// - public class ControlRestFormItemInputUnique : ControlFormItemInput, IControlRest + public class ControlDataFormItemInputUnique : ControlFormItemInput, IControlData { /// /// Gets or sets the uri that determines the options. @@ -47,7 +47,7 @@ public class ControlRestFormItemInputUnique : ControlFormItemInput /// Initializes a new instance of the class with an automatically assigned ID. /// - public ControlRestFormItemInputUnique() + public ControlDataFormItemInputUnique() : base(DeterministicId.Create()) { } @@ -56,7 +56,7 @@ public ControlRestFormItemInputUnique() /// Initializes a new instance of the class. /// /// The control id. - public ControlRestFormItemInputUnique(string id) + public ControlDataFormItemInputUnique(string id) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestKanban.cs b/src/WebExpress.WebApp/WebControl/ControlDataKanban.cs similarity index 75% rename from src/WebExpress.WebApp/WebControl/ControlRestKanban.cs rename to src/WebExpress.WebApp/WebControl/ControlDataKanban.cs index 442f709..8323283 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestKanban.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataKanban.cs @@ -1,4 +1,6 @@ using System; +using System.Net; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -9,13 +11,8 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a control panel for API kanban interactions. /// - public class ControlRestKanban : ControlPanel, IControlRestKanban + public class ControlDataKanban : ControlPanel, IControlDataKanban, IDataIsland { - /// - /// Gets or sets the uri that determines the data. - /// - public Func RestUri { get; set; } - /// /// Gets or sets a value indicating whether the column headers can be /// renamed inline (smart-edit). The new column layout is persisted to @@ -36,11 +33,24 @@ public class ControlRestKanban : ControlPanel, IControlRestKanban /// public Func DeletableColumn { get; set; } + /// + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island the JavaScript engine consumes + /// in preference to the legacy data-uri fallback. See + /// WebExpress/docs/view-state-service.md. + /// + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state island. + /// + public Func StateFactory { get; set; } + /// /// Initializes a new instance of the class. /// /// The control id. - public ControlRestKanban(string id = null) + public ControlDataKanban(string id = null) : base(id ?? RandomId.Create()) { } @@ -53,8 +63,6 @@ public ControlRestKanban(string id = null) /// An HTML node representing the rendered control. public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree) { - var uri = RestUri?.Invoke(renderContext); - var resultUri = uri?.BindParameters(renderContext.Request); var editableColumn = EditableColumn?.Invoke(renderContext) ?? false; var movableColumn = MovableColumn?.Invoke(renderContext) ?? false; var deletableColumn = DeletableColumn?.Invoke(renderContext) ?? false; @@ -65,10 +73,10 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre Class = Css.Concatenate("wx-webapp-kanban", GetClasses(renderContext)), Style = GetStyles(renderContext) } - .AddUserAttribute("data-uri", resultUri?.ToString()) .AddUserAttribute("data-editable-column", editableColumn ? "true" : null) .AddUserAttribute("data-movable-column", movableColumn ? "true" : null) - .AddUserAttribute("data-deletable-column", deletableColumn ? "true" : null); + .AddUserAttribute("data-deletable-column", deletableColumn ? "true" : null) + .EmitDataIslands(this, renderContext); return html; } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestList.cs b/src/WebExpress.WebApp/WebControl/ControlDataList.cs similarity index 65% rename from src/WebExpress.WebApp/WebControl/ControlRestList.cs rename to src/WebExpress.WebApp/WebControl/ControlDataList.cs index fbbf631..c099fd0 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestList.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataList.cs @@ -1,8 +1,8 @@ using System; using System.Linq; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebHtml; -using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; using WebExpress.WebUI.WebPage; @@ -11,23 +11,36 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a control panel for API list interactions. /// - public class ControlRestList : ControlList, IControlRestList + public class ControlDataList : ControlList, IControlDataList, IDataIsland { /// - /// Gets or sets the uri that determines the data. + /// Gets or sets the binding. /// - public Func RestUri { get; set; } + public Func Bind { get; set; } /// - /// Gets or sets the binding. + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island that the JavaScript engine + /// consumes in preference to the legacy data-uri fallback, which keeps + /// the endpoint and parameter knowledge authored in C#. When not set, the + /// control behaves exactly as before and the client uses its legacy + /// descriptor. See WebExpress/docs/view-state-service.md. /// - public Func Bind { get; set; } + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state. When set, the control emits a + /// data-wx-state island that the JavaScript Component seeds its store + /// from on the first render, so the first paint can avoid a round trip. + /// See WebExpress/docs/view-state-service.md. + /// + public Func StateFactory { get; set; } /// /// Initializes a new instance of the class. /// /// The control id. - public ControlRestList(string id = null) + public ControlDataList(string id = null) : base(id ?? RandomId.Create()) { } @@ -40,8 +53,6 @@ public ControlRestList(string id = null) /// An HTML node representing the rendered control. public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree) { - var uri = RestUri?.Invoke(renderContext); - var resultUri = uri?.BindParameters(renderContext.Request); var selectable = Selectable?.Invoke(renderContext) ?? false; var title = Title?.Invoke(renderContext); var sortable = Sortable?.Invoke(renderContext) ?? false; @@ -58,9 +69,10 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre .AddUserAttribute("data-sortable", sortable ? "true" : null) .AddUserAttribute("data-selectable", selectable ? "true" : null) .AddUserAttribute("data-layout", layout?.ToClass()) - .AddUserAttribute("data-uri", resultUri?.ToString()) .Add(Items.Select(x => x.Render(renderContext, visualTree))); + html.EmitDataIslands(this, renderContext); + bind?.ApplyUserAttributes(html); return html; diff --git a/src/WebExpress.WebApp/WebControl/ControlRestListOptionItem.cs b/src/WebExpress.WebApp/WebControl/ControlDataListOptionItem.cs similarity index 94% rename from src/WebExpress.WebApp/WebControl/ControlRestListOptionItem.cs rename to src/WebExpress.WebApp/WebControl/ControlDataListOptionItem.cs index de57b0e..d5859f2 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestListOptionItem.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataListOptionItem.cs @@ -5,7 +5,7 @@ namespace WebExpress.WebApp.WebControl /// /// Meta information of a CRUD option (e.g. Edit, Delete, ...). /// - public class ControlRestListOptionItem + public class ControlDataListOptionItem { /// /// The types of an option entry. @@ -67,7 +67,7 @@ public enum OptionType { Item, Header, Divider }; /// /// Initializes a new instance of the class. /// - public ControlRestListOptionItem() + public ControlDataListOptionItem() { Type = OptionType.Divider; } @@ -77,7 +77,7 @@ public ControlRestListOptionItem() /// /// The label of the column. /// The type of option entry. - public ControlRestListOptionItem(string label, OptionType type = OptionType.Item) + public ControlDataListOptionItem(string label, OptionType type = OptionType.Item) { Label = label; Type = type; diff --git a/src/WebExpress.WebApp/WebControl/ControlRestLogin.cs b/src/WebExpress.WebApp/WebControl/ControlDataLogin.cs similarity index 94% rename from src/WebExpress.WebApp/WebControl/ControlRestLogin.cs rename to src/WebExpress.WebApp/WebControl/ControlDataLogin.cs index c80fbec..85c53aa 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestLogin.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataLogin.cs @@ -12,7 +12,7 @@ namespace WebExpress.WebApp.WebApiControl /// from WebUI with REST API endpoint /// configuration and redirect support. /// - public class ControlRestLogin : ControlLogin, IControlRest + public class ControlDataLogin : ControlLogin, IControlData { /// /// Gets or sets the REST API endpoint used for login authentication. @@ -28,7 +28,7 @@ public class ControlRestLogin : ControlLogin, IControlRest /// Initializes a new instance of the class. /// /// The control id. - public ControlRestLogin(string id = null) + public ControlDataLogin(string id = null) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestModalProgressTask.cs b/src/WebExpress.WebApp/WebControl/ControlDataModalProgressTask.cs similarity index 95% rename from src/WebExpress.WebApp/WebControl/ControlRestModalProgressTask.cs rename to src/WebExpress.WebApp/WebControl/ControlDataModalProgressTask.cs index aa75d12..69cbcce 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestModalProgressTask.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataModalProgressTask.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebApp.WebControl /// /// Dialog that contains the progress bar of a web task. /// - public class ControlRestModalProgressTask : ControlModal + public class ControlDataModalProgressTask : ControlModal { /// /// Gets or sets the progress bar. @@ -23,7 +23,7 @@ public class ControlRestModalProgressTask : ControlModal /// Initializes a new instance of the class. /// /// The control id. - public ControlRestModalProgressTask(string id) + public ControlDataModalProgressTask(string id) : base(id ?? RandomId.Create()) { Progress = new ControlProgress($"progressbar-{Id}") diff --git a/src/WebExpress.WebApp/WebControl/ControlRestQuickfilter.cs b/src/WebExpress.WebApp/WebControl/ControlDataQuickfilter.cs similarity index 94% rename from src/WebExpress.WebApp/WebControl/ControlRestQuickfilter.cs rename to src/WebExpress.WebApp/WebControl/ControlDataQuickfilter.cs index 0e9ae97..4cd4835 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestQuickfilter.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataQuickfilter.cs @@ -11,7 +11,7 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a control panel for API quickfilter interactions. /// - public class ControlRestQuickfilter : ControlQuickfilter, IControlRestQuickfilter + public class ControlDataQuickfilter : ControlQuickfilter, IControlDataQuickfilter { /// /// Gets or sets the uri that determines the data. @@ -22,7 +22,7 @@ public class ControlRestQuickfilter : ControlQuickfilter, IControlRestQuickfilte /// Initializes a new instance of the class. /// /// The control id. - public ControlRestQuickfilter(string id = null) + public ControlDataQuickfilter(string id = null) : base(id ?? RandomId.Create()) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestScrumBacklog.cs b/src/WebExpress.WebApp/WebControl/ControlDataScrumBacklog.cs similarity index 84% rename from src/WebExpress.WebApp/WebControl/ControlRestScrumBacklog.cs rename to src/WebExpress.WebApp/WebControl/ControlDataScrumBacklog.cs index b237767..c0b4877 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestScrumBacklog.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataScrumBacklog.cs @@ -1,4 +1,6 @@ using System; +using System.Net; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebUri; @@ -10,13 +12,8 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a REST-backed scrum backlog control. /// - public class ControlRestScrumBacklog : ControlPanel, IControlRestScrumBacklog + public class ControlDataScrumBacklog : ControlPanel, IControlDataScrumBacklog, IDataIsland { - /// - /// Gets or sets the uri that determines the data. - /// - public Func RestUri { get; set; } - /// /// Gets or sets the title displayed by the backlog control. /// @@ -77,11 +74,24 @@ public class ControlRestScrumBacklog : ControlPanel, IControlRestScrumBacklog /// public Func IconDeleteSprint { get; set; } + /// + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island the JavaScript engine consumes + /// in preference to the legacy data-rest-uri fallback. See + /// WebExpress/docs/view-state-service.md. + /// + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state island. + /// + public Func StateFactory { get; set; } + /// /// Initializes a new instance of the class. /// /// The control id. - public ControlRestScrumBacklog(string id = null) + public ControlDataScrumBacklog(string id = null) : base(id ?? RandomId.Create()) { } @@ -94,8 +104,6 @@ public ControlRestScrumBacklog(string id = null) /// An HTML node representing the rendered control. public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree) { - var uri = RestUri?.Invoke(renderContext); - var resultUri = uri?.BindParameters(renderContext.Request); var title = Title?.Invoke(renderContext); var selectable = Selectable?.Invoke(renderContext) ?? true; var @readonly = Readonly?.Invoke(renderContext) ?? false; @@ -106,7 +114,6 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre Class = Css.Concatenate("wx-webapp-scrum-backlog", GetClasses(renderContext)), Style = GetStyles(renderContext) } - .AddUserAttribute("data-rest-uri", resultUri?.ToString()) .AddUserAttribute("data-title", I18N.Translate(renderContext, title)) .AddUserAttribute("data-selectable", selectable ? null : "false") .AddUserAttribute("data-readonly", @readonly ? "true" : null) @@ -118,7 +125,8 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre .AddUserAttribute("data-icon-start-sprint", IconStartSprint?.Invoke(renderContext)) .AddUserAttribute("data-icon-complete-sprint", IconCompleteSprint?.Invoke(renderContext)) .AddUserAttribute("data-icon-edit-sprint", IconEditSprint?.Invoke(renderContext)) - .AddUserAttribute("data-icon-delete-sprint", IconDeleteSprint?.Invoke(renderContext)); + .AddUserAttribute("data-icon-delete-sprint", IconDeleteSprint?.Invoke(renderContext)) + .EmitDataIslands(this, renderContext); return html; } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestScrumSprint.cs b/src/WebExpress.WebApp/WebControl/ControlDataScrumSprint.cs similarity index 92% rename from src/WebExpress.WebApp/WebControl/ControlRestScrumSprint.cs rename to src/WebExpress.WebApp/WebControl/ControlDataScrumSprint.cs index 73f0487..9e5f839 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestScrumSprint.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataScrumSprint.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a REST-backed scrum sprint overview control. /// - public class ControlRestScrumSprint : ControlPanel, IControlRestScrumSprint + public class ControlDataScrumSprint : ControlPanel, IControlDataScrumSprint { /// /// Gets or sets the uri that determines the data. @@ -20,7 +20,7 @@ public class ControlRestScrumSprint : ControlPanel, IControlRestScrumSprint /// Initializes a new instance of the class. /// /// The control id. - public ControlRestScrumSprint(string id = null) + public ControlDataScrumSprint(string id = null) : base(id ?? RandomId.Create()) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestSelectionTheme.cs b/src/WebExpress.WebApp/WebControl/ControlDataSelectionTheme.cs similarity index 94% rename from src/WebExpress.WebApp/WebControl/ControlRestSelectionTheme.cs rename to src/WebExpress.WebApp/WebControl/ControlDataSelectionTheme.cs index 8708525..150b849 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestSelectionTheme.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataSelectionTheme.cs @@ -29,7 +29,7 @@ namespace WebExpress.WebApp.WebControl /// different theme - never to "no theme". /// /// - public class ControlRestSelectionTheme : ControlDropdown, IControlRest + public class ControlDataSelectionTheme : ControlDropdown, IControlData { /// /// REST URI exposing a RestApiTheme-compatible endpoint @@ -41,7 +41,7 @@ public class ControlRestSelectionTheme : ControlDropdown, IControlRest /// Initializes a new instance of the class with an automatically /// assigned id. /// - public ControlRestSelectionTheme() + public ControlDataSelectionTheme() : this(null) { } @@ -50,7 +50,7 @@ public ControlRestSelectionTheme() /// Initializes a new instance of the class. /// /// The control id. - public ControlRestSelectionTheme(string id) + public ControlDataSelectionTheme(string id) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestTab.cs b/src/WebExpress.WebApp/WebControl/ControlDataTab.cs similarity index 78% rename from src/WebExpress.WebApp/WebControl/ControlRestTab.cs rename to src/WebExpress.WebApp/WebControl/ControlDataTab.cs index 93e2313..4c0dcec 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestTab.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataTab.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using WebExpress.WebApp.WebData; using WebExpress.WebApp.WebFragment; using WebExpress.WebApp.WebSection; using WebExpress.WebCore; @@ -15,14 +17,9 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a control panel for API tab interactions. /// - public class ControlRestTab : ControlPanel, IControlRestTab + public class ControlDataTab : ControlPanel, IControlDataTab, IDataIsland { - private readonly List _templates = []; - - /// - /// Gets or sets the uri that determines the data. - /// - public Func RestUri { get; set; } + private readonly List _templates = []; /// /// Gets or sets the binding. @@ -32,7 +29,7 @@ public class ControlRestTab : ControlPanel, IControlRestTab /// /// Gets the collection of templates associated with the tab. /// - public IEnumerable Templates => _templates; + public IEnumerable Templates => _templates; /// /// Gets or sets a value indicating whether the control is read-only. @@ -47,11 +44,24 @@ public class ControlRestTab : ControlPanel, IControlRestTab /// public Func MovableTab { get; set; } + /// + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island the JavaScript engine consumes + /// in preference to the legacy data-uri fallback. See + /// WebExpress/docs/view-state-service.md. + /// + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state island. + /// + public Func StateFactory { get; set; } + /// /// Initializes a new instance of the class. /// /// The control id. - public ControlRestTab(string id = null) + public ControlDataTab(string id = null) : base(id ?? RandomId.Create()) { } @@ -61,7 +71,7 @@ public ControlRestTab(string id = null) /// /// The templates to add. /// The current instance for method chaining. - public virtual IControlRestTab Add(params IControlRestTabTemplate[] templates) + public virtual IControlDataTab Add(params IControlDataTabTemplate[] templates) { _templates.AddRange(templates); @@ -73,7 +83,7 @@ public virtual IControlRestTab Add(params IControlRestTabTemplate[] templates) /// /// The templates to add. /// The current instance for method chaining. - public virtual IControlRestTab Add(IEnumerable templates) + public virtual IControlDataTab Add(IEnumerable templates) { _templates.AddRange(templates); @@ -85,7 +95,7 @@ public virtual IControlRestTab Add(IEnumerable template /// /// The template to remove. /// The current instance for method chaining. - public virtual IControlRestTab Remove(IControlRestTabTemplate template) + public virtual IControlDataTab Remove(IControlDataTabTemplate template) { _templates.Remove(template); @@ -100,26 +110,24 @@ public virtual IControlRestTab Remove(IControlRestTabTemplate template) /// An HTML node representing the rendered control. public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree) { - var uri = RestUri?.Invoke(renderContext); var bind = Bind?.Invoke(renderContext); - var resultUri = uri?.BindParameters(renderContext.Request); var @readonly = Readonly?.Invoke(renderContext) ?? false; var movableTab = MovableTab?.Invoke(renderContext) ?? false; var fragmentManager = WebEx.ComponentHub.FragmentManager; var applicationContext = renderContext?.PageContext?.ApplicationContext; // templates - var templatePreferences = fragmentManager.GetFragments + var templatePreferences = fragmentManager.GetFragments ( applicationContext, [GetType()] ); - var templatePrimary = fragmentManager.GetFragments + var templatePrimary = fragmentManager.GetFragments ( applicationContext, [GetType()] ); - var templateSecondary = fragmentManager.GetFragments + var templateSecondary = fragmentManager.GetFragments ( applicationContext, [GetType()] @@ -131,7 +139,6 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre Class = Css.Concatenate("wx-webapp-tab", GetClasses(renderContext)), Style = GetStyles(renderContext) } - .AddUserAttribute("data-uri", resultUri?.ToString()) .AddUserAttribute("data-readonly", @readonly ? "true" : null) .AddUserAttribute("data-movable-tab", movableTab ? "true" : null) .Add(templatePreferences.Select(x => x.Render(renderContext, visualTree))) @@ -139,6 +146,8 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre .Add(_templates.Select(x => x.Render(renderContext, visualTree))) .Add(templateSecondary.Select(x => x.Render(renderContext, visualTree))); + html.EmitDataIslands(this, renderContext); + bind?.ApplyUserAttributes(html); return html; diff --git a/src/WebExpress.WebApp/WebControl/ControlRestTabTemplate.cs b/src/WebExpress.WebApp/WebControl/ControlDataTabTemplate.cs similarity index 94% rename from src/WebExpress.WebApp/WebControl/ControlRestTabTemplate.cs rename to src/WebExpress.WebApp/WebControl/ControlDataTabTemplate.cs index f770691..e67446d 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestTabTemplate.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataTabTemplate.cs @@ -12,7 +12,7 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a template for a REST tab control that can be rendered as HTML. /// - public class ControlRestTabTemplate : IControlRestTabTemplate + public class ControlDataTabTemplate : IControlDataTabTemplate { private readonly List _content = []; @@ -56,7 +56,7 @@ public class ControlRestTabTemplate : IControlRestTabTemplate /// Initializes a new instance of the tab template class. /// /// The template id. - public ControlRestTabTemplate(string id = null) + public ControlDataTabTemplate(string id = null) { Id = id; } @@ -66,7 +66,7 @@ public ControlRestTabTemplate(string id = null) /// /// The items to add. /// The current instance for method chaining. - public IControlRestTabTemplate Add(params IControl[] items) + public IControlDataTabTemplate Add(params IControl[] items) { _content.AddRange(items); @@ -78,7 +78,7 @@ public IControlRestTabTemplate Add(params IControl[] items) /// /// The items to add. /// The current instance for method chaining. - public IControlRestTabTemplate Add(IEnumerable items) + public IControlDataTabTemplate Add(IEnumerable items) { _content.AddRange(items); @@ -90,7 +90,7 @@ public IControlRestTabTemplate Add(IEnumerable items) /// /// The control to remove. /// The current instance for method chaining. - public IControlRestTabTemplate Remove(IControl item) + public IControlDataTabTemplate Remove(IControl item) { _content.Remove(item); diff --git a/src/WebExpress.WebApp/WebControl/ControlRestTable.cs b/src/WebExpress.WebApp/WebControl/ControlDataTable.cs similarity index 70% rename from src/WebExpress.WebApp/WebControl/ControlRestTable.cs rename to src/WebExpress.WebApp/WebControl/ControlDataTable.cs index e1e00a0..4b55246 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestTable.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataTable.cs @@ -1,5 +1,7 @@ using System; using System.Linq; +using System.Net; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -10,13 +12,8 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a control panel for API table interactions. /// - public class ControlRestTable : ControlPanel, IControlRestTable + public class ControlDataTable : ControlPanel, IControlDataTable, IDataIsland { - /// - /// Gets or sets the uri that determines the data. - /// - public Func RestUri { get; set; } - /// /// Retruns or sets the number of items to display on each page in a /// paginated collection. @@ -35,11 +32,26 @@ public class ControlRestTable : ControlPanel, IControlRestTable /// public Func MovableRow { get; set; } + /// + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island that the JavaScript engine + /// consumes in preference to the legacy data-uri fallback, which keeps + /// the endpoint and parameter knowledge authored in C#. When not set, the + /// control behaves exactly as before and the client uses its legacy + /// descriptor. See WebExpress/docs/view-state-service.md. + /// + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state island. + /// + public Func StateFactory { get; set; } + /// /// Initializes a new instance of the class. /// /// The control id. - public ControlRestTable(string id = null) + public ControlDataTable(string id = null) : base(id ?? RandomId.Create()) { } @@ -51,10 +63,8 @@ public ControlRestTable(string id = null) /// An HTML node representing the rendered control. public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree) { - var uri = RestUri?.Invoke(renderContext); var pageSize = PageSize?.Invoke(renderContext); var bind = Bind?.Invoke(renderContext); - var resultUri = uri?.BindParameters(renderContext.Request); var html = new HtmlElementTextContentDiv() { @@ -62,9 +72,9 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre Class = Css.Concatenate("wx-webapp-table", GetClasses(renderContext)), Style = GetStyles(renderContext) } - .AddUserAttribute("data-uri", resultUri?.ToString()) .AddUserAttribute("data-page-size", pageSize > 0 ? pageSize.ToString() : null) - .AddUserAttribute("data-movable-row", MovableRow?.Invoke(renderContext) == true ? "true" : null); + .AddUserAttribute("data-movable-row", MovableRow?.Invoke(renderContext) == true ? "true" : null) + .EmitDataIslands(this, renderContext); bind?.ApplyUserAttributes(html); diff --git a/src/WebExpress.WebApp/WebControl/ControlRestTableOptionItem.cs b/src/WebExpress.WebApp/WebControl/ControlDataTableOptionItem.cs similarity index 94% rename from src/WebExpress.WebApp/WebControl/ControlRestTableOptionItem.cs rename to src/WebExpress.WebApp/WebControl/ControlDataTableOptionItem.cs index 7043a46..8120245 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestTableOptionItem.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataTableOptionItem.cs @@ -5,7 +5,7 @@ namespace WebExpress.WebApp.WebControl /// /// Meta information of a CRUD option (e.g. Edit, Delete, ...). /// - public class ControlRestTableOptionItem + public class ControlDataTableOptionItem { /// /// The types of an option entry. @@ -67,7 +67,7 @@ public enum OptionType { Item, Header, Divider }; /// /// Initializes a new instance of the class. /// - public ControlRestTableOptionItem() + public ControlDataTableOptionItem() { Type = OptionType.Divider; } @@ -77,7 +77,7 @@ public ControlRestTableOptionItem() /// /// The label of the column. /// The type of option entry. - public ControlRestTableOptionItem(string label, OptionType type = OptionType.Item) + public ControlDataTableOptionItem(string label, OptionType type = OptionType.Item) { Label = label; Type = type; diff --git a/src/WebExpress.WebApp/WebControl/ControlRestTag.cs b/src/WebExpress.WebApp/WebControl/ControlDataTag.cs similarity index 97% rename from src/WebExpress.WebApp/WebControl/ControlRestTag.cs rename to src/WebExpress.WebApp/WebControl/ControlDataTag.cs index d793312..97868bc 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestTag.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataTag.cs @@ -18,7 +18,7 @@ namespace WebExpress.WebApp.WebControl /// endpoint to load, add and delete tags. Autocomplete suggestions are /// served by the same endpoint via the q query parameter. /// - public class ControlRestTag : Control, IControlRest + public class ControlDataTag : Control, IControlData { /// /// Gets or sets the REST endpoint that backs this tag surface. The JS @@ -59,7 +59,7 @@ public class ControlRestTag : Control, IControlRest /// Initializes a new instance of the class. /// /// Optional host element id. - public ControlRestTag(string id = null) + public ControlDataTag(string id = null) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestTile.cs b/src/WebExpress.WebApp/WebControl/ControlDataTile.cs similarity index 69% rename from src/WebExpress.WebApp/WebControl/ControlRestTile.cs rename to src/WebExpress.WebApp/WebControl/ControlDataTile.cs index 06eab73..d71f4bb 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestTile.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataTile.cs @@ -1,4 +1,6 @@ using System; +using System.Net; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -9,13 +11,8 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a control panel for API tile interactions. /// - public class ControlRestTile : ControlPanel, IControlRestTile + public class ControlDataTile : ControlPanel, IControlDataTile, IDataIsland { - /// - /// Gets or sets the uri that determines the data. - /// - public Func RestUri { get; set; } - /// /// Retruns or sets the number of items to display on each page in a /// paginated collection. @@ -27,11 +24,24 @@ public class ControlRestTile : ControlPanel, IControlRestTile /// public Func Bind { get; set; } + /// + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island the JavaScript engine consumes + /// in preference to the legacy data-uri fallback. See + /// WebExpress/docs/view-state-service.md. + /// + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state island. + /// + public Func StateFactory { get; set; } + /// /// Initializes a new instance of the class. /// /// The control id. - public ControlRestTile(string id = null) + public ControlDataTile(string id = null) : base(id ?? RandomId.Create()) { } @@ -44,10 +54,8 @@ public ControlRestTile(string id = null) /// An HTML node representing the rendered control. public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree) { - var uri = RestUri?.Invoke(renderContext); var pageSize = PageSize?.Invoke(renderContext) ?? 0; var bind = Bind?.Invoke(renderContext); - var resultUri = uri?.BindParameters(renderContext.Request); var html = new HtmlElementTextContentDiv() { @@ -55,8 +63,8 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre Class = Css.Concatenate("wx-webapp-tile", GetClasses(renderContext)), Style = GetStyles(renderContext) } - .AddUserAttribute("data-uri", resultUri?.ToString()) - .AddUserAttribute("data-page-size", pageSize > 0 ? pageSize.ToString() : null); + .AddUserAttribute("data-page-size", pageSize > 0 ? pageSize.ToString() : null) + .EmitDataIslands(this, renderContext); bind?.ApplyUserAttributes(html); diff --git a/src/WebExpress.WebApp/WebControl/ControlRestWatcher.cs b/src/WebExpress.WebApp/WebControl/ControlDataWatcher.cs similarity index 97% rename from src/WebExpress.WebApp/WebControl/ControlRestWatcher.cs rename to src/WebExpress.WebApp/WebControl/ControlDataWatcher.cs index ded2c46..7ee0dcf 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestWatcher.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataWatcher.cs @@ -13,7 +13,7 @@ namespace WebExpress.WebApp.WebControl /// webexpress.webapp.WatcherCtrl, which talks to the configured /// REST endpoint to load, add and remove watchers. /// - public class ControlRestWatcher : Control + public class ControlDataWatcher : Control { /// /// Gets or sets the REST URI that backs this watcher surface. The @@ -48,7 +48,7 @@ public class ControlRestWatcher : Control /// Initializes a new instance of the class. /// /// Optional host element id. - public ControlRestWatcher(string id = null) + public ControlDataWatcher(string id = null) : base(id) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestWizard.cs b/src/WebExpress.WebApp/WebControl/ControlDataWizard.cs similarity index 89% rename from src/WebExpress.WebApp/WebControl/ControlRestWizard.cs rename to src/WebExpress.WebApp/WebControl/ControlDataWizard.cs index e6ffbdf..f8183ab 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestWizard.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataWizard.cs @@ -12,14 +12,14 @@ namespace WebExpress.WebApp.WebControl /// Represents a form that retrieves and displays data wizard from /// a RESTful resource specified by a URI. /// - public class ControlRestWizard : ControlPanel, IControlRestWizard + public class ControlDataWizard : ControlPanel, IControlDataWizard { - private readonly List _pages = []; + private readonly List _pages = []; /// /// Gets the collection of wizard pages associated with the control. /// - public IEnumerable Pages => _pages; + public IEnumerable Pages => _pages; /// /// Gets or sets the uri that determines the data. @@ -42,7 +42,7 @@ public class ControlRestWizard : ControlPanel, IControlRestWizard /// Initializes a new instance of the class. /// /// The control id. - public ControlRestWizard(string id = null) + public ControlDataWizard(string id = null) : base(id ?? RandomId.Create()) { } @@ -52,7 +52,7 @@ public ControlRestWizard(string id = null) /// /// The pages to add. /// The current instance for method chaining. - public virtual IControlRestWizard Add(params IControlRestWizardPage[] pages) + public virtual IControlDataWizard Add(params IControlDataWizardPage[] pages) { _pages.AddRange(pages); @@ -64,7 +64,7 @@ public virtual IControlRestWizard Add(params IControlRestWizardPage[] pages) /// /// The pages to add. /// The current instance for method chaining. - public virtual IControlRestWizard Add(IEnumerable pages) + public virtual IControlDataWizard Add(IEnumerable pages) { _pages.AddRange(pages); @@ -76,7 +76,7 @@ public virtual IControlRestWizard Add(IEnumerable pages) /// /// The page to remove. /// The current instance for method chaining. - public virtual IControlRestWizard Remove(IControlRestWizardPage page) + public virtual IControlDataWizard Remove(IControlDataWizardPage page) { _pages.Remove(page); @@ -101,7 +101,7 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre /// The visual tree. /// The pages to render. /// An HTML node representing the rendered control. - public virtual IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree, IEnumerable pages) + public virtual IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree, IEnumerable pages) { var uri = RestUri?.Invoke(renderContext); var resultUri = uri?.BindParameters(renderContext.Request); diff --git a/src/WebExpress.WebApp/WebControl/ControlRestWizardPage.cs b/src/WebExpress.WebApp/WebControl/ControlDataWizardPage.cs similarity index 95% rename from src/WebExpress.WebApp/WebControl/ControlRestWizardPage.cs rename to src/WebExpress.WebApp/WebControl/ControlDataWizardPage.cs index dba0624..a5e19e4 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestWizardPage.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataWizardPage.cs @@ -14,7 +14,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a form that retrieves and displays data wizard page from /// a RESTful resource specified by a URI. /// - public class ControlRestWizardPage : IControlRestWizardPage + public class ControlDataWizardPage : IControlDataWizardPage { private readonly List _items = []; @@ -43,7 +43,7 @@ public class ControlRestWizardPage : IControlRestWizardPage /// Initializes a new instance of the class. /// /// The control id. - public ControlRestWizardPage(string id = null) + public ControlDataWizardPage(string id = null) { Id = id; } @@ -53,7 +53,7 @@ public ControlRestWizardPage(string id = null) /// /// The items to add. /// The current instance for method chaining. - public virtual IControlRestWizardPage Add(params IControlFormItem[] items) + public virtual IControlDataWizardPage Add(params IControlFormItem[] items) { _items.AddRange(items); @@ -65,7 +65,7 @@ public virtual IControlRestWizardPage Add(params IControlFormItem[] items) /// /// The items to add. /// The current instance for method chaining. - public virtual IControlRestWizardPage Add(IEnumerable items) + public virtual IControlDataWizardPage Add(IEnumerable items) { _items.AddRange(items); @@ -77,7 +77,7 @@ public virtual IControlRestWizardPage Add(IEnumerable items) /// /// The control to remove. /// The current instance for method chaining. - public virtual IControlRestWizardPage Remove(IControlFormItem item) + public virtual IControlDataWizardPage Remove(IControlFormItem item) { _items.Remove(item); diff --git a/src/WebExpress.WebApp/WebControl/ControlRestWorkflow.cs b/src/WebExpress.WebApp/WebControl/ControlDataWorkflow.cs similarity index 61% rename from src/WebExpress.WebApp/WebControl/ControlRestWorkflow.cs rename to src/WebExpress.WebApp/WebControl/ControlDataWorkflow.cs index 4995b6e..dc1dec6 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestWorkflow.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataWorkflow.cs @@ -1,4 +1,6 @@ using System; +using System.Net; +using WebExpress.WebApp.WebData; using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebUri; using WebExpress.WebUI.WebControl; @@ -9,18 +11,26 @@ namespace WebExpress.WebApp.WebControl /// /// Represents a control panel for API workflow interactions. /// - public class ControlRestWorkflow : ControlPanel, IControlRestWorkflow + public class ControlDataWorkflow : ControlPanel, IControlDataWorkflow, IDataIsland { /// - /// Gets or sets the uri that determines the data. + /// Gets or sets the optional data service descriptor. When set, the + /// control emits a data-wx-service island the JavaScript engine consumes + /// in preference to the legacy data-uri fallback. See + /// WebExpress/docs/view-state-service.md. /// - public Func RestUri { get; set; } + public Func ServiceFactory { get; set; } + + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state island. + /// + public Func StateFactory { get; set; } /// /// Initializes a new instance of the class. /// /// The control id. - public ControlRestWorkflow(string id = null) + public ControlDataWorkflow(string id = null) : base(id ?? RandomId.Create()) { } @@ -33,8 +43,6 @@ public ControlRestWorkflow(string id = null) /// An HTML node representing the rendered control. public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTreeControl visualTree) { - var uri = RestUri?.Invoke(renderContext); - var resultUri = uri?.BindParameters(renderContext.Request); var html = new HtmlElementTextContentDiv() { @@ -42,7 +50,7 @@ public override IHtmlNode Render(IRenderControlContext renderContext, IVisualTre Class = Css.Concatenate("wx-webapp-workflow-editor", GetClasses(renderContext)), Style = GetStyles(renderContext) } - .AddUserAttribute("data-uri", resultUri?.ToString()); + .EmitDataIslands(this, renderContext); return html; } diff --git a/src/WebExpress.WebApp/WebControl/ControlRestWqlPrompt.cs b/src/WebExpress.WebApp/WebControl/ControlDataWqlPrompt.cs similarity index 93% rename from src/WebExpress.WebApp/WebControl/ControlRestWqlPrompt.cs rename to src/WebExpress.WebApp/WebControl/ControlDataWqlPrompt.cs index 478dc3d..ce4be10 100644 --- a/src/WebExpress.WebApp/WebControl/ControlRestWqlPrompt.cs +++ b/src/WebExpress.WebApp/WebControl/ControlDataWqlPrompt.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a control for composing and editing WQL expressions with REST-based suggestions, /// syntax validation, and history navigation. /// - public class ControlRestWqlPrompt : Control, IControlRestWqlPrompt + public class ControlDataWqlPrompt : Control, IControlDataWqlPrompt { /// /// Gets or sets the uri that determines the data. @@ -21,7 +21,7 @@ public class ControlRestWqlPrompt : Control, IControlRestWqlPrompt /// Initializes a new instance of the class. /// /// The control id. - public ControlRestWqlPrompt(string id = null) + public ControlDataWqlPrompt(string id = null) : base(id ?? RandomId.Create()) { } diff --git a/src/WebExpress.WebApp/WebControl/ControlWebAppHeaderAvatar.cs b/src/WebExpress.WebApp/WebControl/ControlWebAppHeaderAvatar.cs index 5159d1d..3ad67aa 100644 --- a/src/WebExpress.WebApp/WebControl/ControlWebAppHeaderAvatar.cs +++ b/src/WebExpress.WebApp/WebControl/ControlWebAppHeaderAvatar.cs @@ -14,7 +14,7 @@ namespace WebExpress.WebApp.WebControl { /// /// Avatar control for a web app header. Uses the avatar image as the interactive menu - /// button via and supports dynamic item loading + /// button via and supports dynamic item loading /// through a REST API endpoint. /// public class ControlWebAppHeaderAvatar : Control, IControlWebAppHeaderAvatar diff --git a/src/WebExpress.WebApp/WebControl/IControlData.cs b/src/WebExpress.WebApp/WebControl/IControlData.cs new file mode 100644 index 0000000..f5c4267 --- /dev/null +++ b/src/WebExpress.WebApp/WebControl/IControlData.cs @@ -0,0 +1,14 @@ +using WebExpress.WebUI.WebControl; + +namespace WebExpress.WebApp.WebControl +{ + /// + /// Marker interface for controls that participate in the data layer. The + /// endpoint and service contract are authored through the fluent data surface + /// (IDataIsland / .Service()); the legacy RestUri/data-uri path is no longer + /// part of this interface. + /// + public interface IControlData : IControl + { + } +} diff --git a/src/WebExpress.WebApp/WebControl/IControlRestDashboard.cs b/src/WebExpress.WebApp/WebControl/IControlDataDashboard.cs similarity index 92% rename from src/WebExpress.WebApp/WebControl/IControlRestDashboard.cs rename to src/WebExpress.WebApp/WebControl/IControlDataDashboard.cs index 73d7bed..d4a6176 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestDashboard.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataDashboard.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed dashboard control. /// - public interface IControlRestDashboard : IControl, IControlRest + public interface IControlDataDashboard : IControl, IControlData { /// /// Gets a value indicating whether the column headers can be renamed inline. diff --git a/src/WebExpress.WebApp/WebControl/IControlRestFormEditor.cs b/src/WebExpress.WebApp/WebControl/IControlDataFormEditor.cs similarity index 93% rename from src/WebExpress.WebApp/WebControl/IControlRestFormEditor.cs rename to src/WebExpress.WebApp/WebControl/IControlDataFormEditor.cs index 688dacf..0a568a2 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestFormEditor.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataFormEditor.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.WebControl /// fields) via REST. All behaviour is driven client-side by the /// webexpress.webui.FormEditorCtrl JavaScript controller. /// - public interface IControlRestFormEditor : IControl, IControlRest + public interface IControlDataFormEditor : IControl, IControlData { /// /// Whether the live preview pane is shown initially. diff --git a/src/WebExpress.WebApp/WebControl/IControlRestKanban.cs b/src/WebExpress.WebApp/WebControl/IControlDataKanban.cs similarity index 92% rename from src/WebExpress.WebApp/WebControl/IControlRestKanban.cs rename to src/WebExpress.WebApp/WebControl/IControlDataKanban.cs index d8ec554..5192f6a 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestKanban.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataKanban.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed kanban control. /// - public interface IControlRestKanban : IControl, IControlRest + public interface IControlDataKanban : IControl, IControlData { /// /// Gets a value indicating whether the column headers can be renamed inline. diff --git a/src/WebExpress.WebApp/WebControl/IControlRestList.cs b/src/WebExpress.WebApp/WebControl/IControlDataList.cs similarity index 85% rename from src/WebExpress.WebApp/WebControl/IControlRestList.cs rename to src/WebExpress.WebApp/WebControl/IControlDataList.cs index d0c87ec..77358b5 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestList.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataList.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed list control. /// - public interface IControlRestList : IControl, IControlRest + public interface IControlDataList : IControl, IControlData { /// /// Gets the binding. diff --git a/src/WebExpress.WebApp/WebControl/IControlRestQuickfilter.cs b/src/WebExpress.WebApp/WebControl/IControlDataQuickfilter.cs similarity index 73% rename from src/WebExpress.WebApp/WebControl/IControlRestQuickfilter.cs rename to src/WebExpress.WebApp/WebControl/IControlDataQuickfilter.cs index fad5fb4..5871e47 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestQuickfilter.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataQuickfilter.cs @@ -5,7 +5,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed quickfilter control. /// - public interface IControlRestQuickfilter : IControl, IControlRest + public interface IControlDataQuickfilter : IControl, IControlData { } } \ No newline at end of file diff --git a/src/WebExpress.WebApp/WebControl/IControlRestScrumBacklog.cs b/src/WebExpress.WebApp/WebControl/IControlDataScrumBacklog.cs similarity index 97% rename from src/WebExpress.WebApp/WebControl/IControlRestScrumBacklog.cs rename to src/WebExpress.WebApp/WebControl/IControlDataScrumBacklog.cs index de8ee75..794c69e 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestScrumBacklog.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataScrumBacklog.cs @@ -6,7 +6,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed scrum backlog control. /// - public interface IControlRestScrumBacklog : IControlRest + public interface IControlDataScrumBacklog : IControlData { /// /// Gets the title displayed by the backlog control. diff --git a/src/WebExpress.WebApp/WebControl/IControlRestScrumSprint.cs b/src/WebExpress.WebApp/WebControl/IControlDataScrumSprint.cs similarity index 73% rename from src/WebExpress.WebApp/WebControl/IControlRestScrumSprint.cs rename to src/WebExpress.WebApp/WebControl/IControlDataScrumSprint.cs index 10d285c..1f69a9a 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestScrumSprint.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataScrumSprint.cs @@ -3,7 +3,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed scrum sprint overview control. /// - public interface IControlRestScrumSprint : IControlRest + public interface IControlDataScrumSprint : IControlData { } } diff --git a/src/WebExpress.WebApp/WebControl/IControlRestTab.cs b/src/WebExpress.WebApp/WebControl/IControlDataTab.cs similarity index 85% rename from src/WebExpress.WebApp/WebControl/IControlRestTab.cs rename to src/WebExpress.WebApp/WebControl/IControlDataTab.cs index 7b1b89e..bf2b6b7 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestTab.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataTab.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed tab control. /// - public interface IControlRestTab : IControl, IControlRest + public interface IControlDataTab : IControl, IControlData { /// /// Gets the binding. @@ -18,7 +18,7 @@ public interface IControlRestTab : IControl, IControlRest /// /// Gets the collection of templates associated with the tab. /// - IEnumerable Templates { get; } + IEnumerable Templates { get; } /// /// Gets a value indicating whether the control is read-only. @@ -37,20 +37,20 @@ public interface IControlRestTab : IControl, IControlRest /// /// The templates to add. /// The current instance for method chaining. - IControlRestTab Add(params IControlRestTabTemplate[] templates); + IControlDataTab Add(params IControlDataTabTemplate[] templates); /// /// Adds one or more templates to the tab control. /// /// The templates to add. /// The current instance for method chaining. - IControlRestTab Add(IEnumerable templates); + IControlDataTab Add(IEnumerable templates); /// /// Removes the specified template from the tab control. /// /// The template to remove. /// The current instance for method chaining. - IControlRestTab Remove(IControlRestTabTemplate templates); + IControlDataTab Remove(IControlDataTabTemplate templates); } } diff --git a/src/WebExpress.WebApp/WebControl/IControlRestTabTemplate.cs b/src/WebExpress.WebApp/WebControl/IControlDataTabTemplate.cs similarity index 89% rename from src/WebExpress.WebApp/WebControl/IControlRestTabTemplate.cs rename to src/WebExpress.WebApp/WebControl/IControlDataTabTemplate.cs index 597d2a4..ddf274c 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestTabTemplate.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataTabTemplate.cs @@ -10,7 +10,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed tab template control. /// - public interface IControlRestTabTemplate : IWebUIElement + public interface IControlDataTabTemplate : IWebUIElement { /// /// Gets the optional declarative binding configuration for template content. @@ -43,20 +43,20 @@ public interface IControlRestTabTemplate : IWebUIElement /// The items to add. /// The current instance for method chaining. - IControlRestTabTemplate Add(params IControl[] items); + IControlDataTabTemplate Add(params IControl[] items); /// /// Adds one or more items to the tab control. /// /// The items to add. /// The current instance for method chaining. - IControlRestTabTemplate Add(IEnumerable items); + IControlDataTabTemplate Add(IEnumerable items); /// /// Removes the specified control from the tab. /// /// The control to remove. /// The current instance for method chaining. - IControlRestTabTemplate Remove(IControl item); + IControlDataTabTemplate Remove(IControl item); } } diff --git a/src/WebExpress.WebApp/WebControl/IControlRestTable.cs b/src/WebExpress.WebApp/WebControl/IControlDataTable.cs similarity index 94% rename from src/WebExpress.WebApp/WebControl/IControlRestTable.cs rename to src/WebExpress.WebApp/WebControl/IControlDataTable.cs index b363877..9097524 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestTable.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataTable.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a control that provides table-like functionality for /// REST-based user interfaces. /// - public interface IControlRestTable : IControlRest + public interface IControlDataTable : IControlData { /// /// Gets the number of items to display on each page in a diff --git a/src/WebExpress.WebApp/WebControl/IControlRestTile.cs b/src/WebExpress.WebApp/WebControl/IControlDataTile.cs similarity index 85% rename from src/WebExpress.WebApp/WebControl/IControlRestTile.cs rename to src/WebExpress.WebApp/WebControl/IControlDataTile.cs index 40b05b6..c92613b 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestTile.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataTile.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed tile control. /// - public interface IControlRestTile : IControl, IControlRest + public interface IControlDataTile : IControl, IControlData { /// /// Gets or sets the binding. diff --git a/src/WebExpress.WebApp/WebControl/IControlRestWizard.cs b/src/WebExpress.WebApp/WebControl/IControlDataWizard.cs similarity index 83% rename from src/WebExpress.WebApp/WebControl/IControlRestWizard.cs rename to src/WebExpress.WebApp/WebControl/IControlDataWizard.cs index 4856cc2..46ef5fa 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestWizard.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataWizard.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a form that retrieves and displays data wizard from /// a RESTful resource specified by a URI. /// - public interface IControlRestWizard : IControlPanel, IControlRest + public interface IControlDataWizard : IControlPanel, IControlData { /// /// Gets the mode that determines how the form behaves @@ -20,7 +20,7 @@ public interface IControlRestWizard : IControlPanel, IControlRest /// /// Gets the collection of wizard pages associated with the control. /// - IEnumerable Pages { get; } + IEnumerable Pages { get; } /// /// Gets a delegate that returns the unique identifier for an item within @@ -33,20 +33,20 @@ public interface IControlRestWizard : IControlPanel, IControlRest /// /// The pages to add. /// The current instance for method chaining. - IControlRestWizard Add(params IControlRestWizardPage[] pages); + IControlDataWizard Add(params IControlDataWizardPage[] pages); /// /// Adds one or more pages to the wizard control. /// /// The pages to add. /// The current instance for method chaining. - IControlRestWizard Add(IEnumerable pages); + IControlDataWizard Add(IEnumerable pages); /// /// Removes the specified page from the wizard control. /// /// The page to remove. /// The current instance for method chaining. - IControlRestWizard Remove(IControlRestWizardPage page); + IControlDataWizard Remove(IControlDataWizardPage page); } } \ No newline at end of file diff --git a/src/WebExpress.WebApp/WebControl/IControlRestWizardPage.cs b/src/WebExpress.WebApp/WebControl/IControlDataWizardPage.cs similarity index 86% rename from src/WebExpress.WebApp/WebControl/IControlRestWizardPage.cs rename to src/WebExpress.WebApp/WebControl/IControlDataWizardPage.cs index 4e7b70a..1546b1e 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestWizardPage.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataWizardPage.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed wizard page control. /// - public interface IControlRestWizardPage : IWebUIElement + public interface IControlDataWizardPage : IWebUIElement { /// /// Gets the form layout. @@ -31,20 +31,20 @@ public interface IControlRestWizardPage : IWebUIElement /// The items to add. /// The current instance for method chaining. - IControlRestWizardPage Add(params IControlFormItem[] items); + IControlDataWizardPage Add(params IControlFormItem[] items); /// /// Adds one or more items to the wizard page control. /// /// The items to add. /// The current instance for method chaining. - IControlRestWizardPage Add(IEnumerable items); + IControlDataWizardPage Add(IEnumerable items); /// /// Removes the specified control from wizard page tab. /// /// The control to remove. /// The current instance for method chaining. - IControlRestWizardPage Remove(IControlFormItem item); + IControlDataWizardPage Remove(IControlFormItem item); } } \ No newline at end of file diff --git a/src/WebExpress.WebApp/WebControl/IControlRestWorkflow.cs b/src/WebExpress.WebApp/WebControl/IControlDataWorkflow.cs similarity index 74% rename from src/WebExpress.WebApp/WebControl/IControlRestWorkflow.cs rename to src/WebExpress.WebApp/WebControl/IControlDataWorkflow.cs index 76bf47a..c9124f2 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestWorkflow.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataWorkflow.cs @@ -5,7 +5,7 @@ namespace WebExpress.WebApp.WebControl /// /// Defines the contract for a REST-backed workflow control. /// - public interface IControlRestWorkflow : IControl, IControlRest + public interface IControlDataWorkflow : IControl, IControlData { } } \ No newline at end of file diff --git a/src/WebExpress.WebApp/WebControl/IControlRestWqlPrompt.cs b/src/WebExpress.WebApp/WebControl/IControlDataWqlPrompt.cs similarity index 80% rename from src/WebExpress.WebApp/WebControl/IControlRestWqlPrompt.cs rename to src/WebExpress.WebApp/WebControl/IControlDataWqlPrompt.cs index 25bffcb..48ffbdd 100644 --- a/src/WebExpress.WebApp/WebControl/IControlRestWqlPrompt.cs +++ b/src/WebExpress.WebApp/WebControl/IControlDataWqlPrompt.cs @@ -6,7 +6,7 @@ namespace WebExpress.WebApp.WebControl /// Represents a control for composing and editing WQL expressions with REST-based suggestions, /// syntax validation, and history navigation. /// - public interface IControlRestWqlPrompt : IControl, IControlRest + public interface IControlDataWqlPrompt : IControl, IControlData { } } \ No newline at end of file diff --git a/src/WebExpress.WebApp/WebControl/IControlRest.cs b/src/WebExpress.WebApp/WebControl/IControlRest.cs deleted file mode 100644 index a71b3bd..0000000 --- a/src/WebExpress.WebApp/WebControl/IControlRest.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using WebExpress.WebCore.WebUri; -using WebExpress.WebUI.WebControl; -using WebExpress.WebUI.WebPage; - -namespace WebExpress.WebApp.WebControl -{ - /// - /// Interface for controlling API interactions. - /// - public interface IControlRest : IControl - { - /// - /// Gets the uri that determines the data. - /// - public Func RestUri { get; } - } -} diff --git a/src/WebExpress.WebApp/WebControl/IControlSearch.cs b/src/WebExpress.WebApp/WebControl/IControlSearch.cs index db282f5..502d1b4 100644 --- a/src/WebExpress.WebApp/WebControl/IControlSearch.cs +++ b/src/WebExpress.WebApp/WebControl/IControlSearch.cs @@ -11,7 +11,7 @@ namespace WebExpress.WebApp.WebControl /// prompt, normalizes their payloads and re-emits a unified /// webexpress.webui.Event.CHANGE_FILTER_EVENT. /// - public interface IControlSearch : IControl, IControlRest + public interface IControlSearch : IControl, IControlData { /// /// Gets the content of the control (e.g., save button). diff --git a/src/WebExpress.WebApp/WebData/DataAuthoringExtensions.cs b/src/WebExpress.WebApp/WebData/DataAuthoringExtensions.cs new file mode 100644 index 0000000..b6711bb --- /dev/null +++ b/src/WebExpress.WebApp/WebData/DataAuthoringExtensions.cs @@ -0,0 +1,59 @@ +using System; + +namespace WebExpress.WebApp.WebData +{ + /// + /// The fluent C# authoring surface of the data layer. These extensions let a + /// control declare its initial state and its data service inline and by + /// chaining, matching the View, State and Service concept, while the control + /// keeps its existing base class (WebExpress.WebUI stays untouched). They are + /// extension methods rather than control methods because the underlying + /// and + /// are the storage that the EmitDataIslands emission reads; the methods only + /// populate them and return the control so the call site stays a single chain. + /// + public static class DataAuthoringExtensions + { + /// + /// Declares the initial state of the control, emitted as the data-wx-state + /// island. The configured state seeds the client store on the first render. + /// + /// The control type. + /// The data bound control. + /// The state configuration. + /// The control for chaining. + public static T State(this T control, Action configure) where T : IDataIsland + { + control.StateFactory = _ => + { + var state = DataState.Create(); + configure?.Invoke(state); + return state; + }; + + return control; + } + + /// + /// Declares a named data service of the control, emitted as the + /// data-wx-service island. The endpoint is resolved through the sitemap at + /// render time, so routing stays authoritative in C#. + /// + /// The control type. + /// The data bound control. + /// The logical service name, for example "data". + /// The service configuration. + /// The control for chaining. + public static T Service(this T control, string name, Action configure) where T : IDataIsland + { + control.ServiceFactory = renderContext => + { + var builder = new DataServiceBuilder(name); + configure?.Invoke(builder); + return builder.Build(renderContext); + }; + + return control; + } + } +} diff --git a/src/WebExpress.WebApp/WebData/DataIslandExtensions.cs b/src/WebExpress.WebApp/WebData/DataIslandExtensions.cs new file mode 100644 index 0000000..a3cb0ca --- /dev/null +++ b/src/WebExpress.WebApp/WebData/DataIslandExtensions.cs @@ -0,0 +1,43 @@ +using System.Net; +using WebExpress.WebCore.WebHtml; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.WebData +{ + /// + /// The shared emission helper for data bound controls. It turns the declared + /// state and service of an into the additive data + /// attribute contract: the data-wx-state island that the engine seeds its + /// store from and the data-wx-service island that the ServiceRegistry resolves + /// into a configured service. Both are HTML attribute encoded so their json + /// quotes do not break the markup, and an absent or empty island is omitted. + /// + public static class DataIslandExtensions + { + /// + /// Emits the data-wx-state and data-wx-service islands on a host element + /// from the control's declared state and service. The attributes are added + /// last, after the control's own attributes, so the legacy attributes keep + /// their place and the islands sit beside them. + /// + /// The host element. + /// The data bound control. + /// The context in which the control is rendered. + /// The host element for chaining. + public static IHtmlNode EmitDataIslands(this IHtmlNode html, IDataIsland control, IRenderControlContext renderContext) + { + if (control == null || html == null) + { + return html; + } + + var state = control.StateFactory?.Invoke(renderContext); + var service = control.ServiceFactory?.Invoke(renderContext); + + html.AddUserAttribute("data-wx-state", state != null && !state.IsEmpty ? WebUtility.HtmlEncode(state.ToIsland()) : null); + html.AddUserAttribute("data-wx-service", service != null ? WebUtility.HtmlEncode(service.ToIsland()) : null); + + return html; + } + } +} diff --git a/src/WebExpress.WebApp/WebData/DataServiceBuilder.cs b/src/WebExpress.WebApp/WebData/DataServiceBuilder.cs new file mode 100644 index 0000000..df5e9b2 --- /dev/null +++ b/src/WebExpress.WebApp/WebData/DataServiceBuilder.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using WebExpress.WebCore; +using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.WebData +{ + /// + /// A fluent builder for a named data service. It is the C# authoring surface + /// of the View, State and Service concept: the endpoint is declared once + /// through and resolved through the sitemap + /// at render time, so routing stays authoritative in C# and no endpoint string + /// is repeated. The builder produces a that + /// the control serializes into the data-wx-service island. + /// + public class DataServiceBuilder + { + private readonly string _name; + private Func _endpoint; + private string _method; + private string _updateMethod; + private readonly List> _query = new(); + private readonly List> _response = new(); + + /// + /// Initializes a new instance for the named service. + /// + /// The logical service name, for example "data". + public DataServiceBuilder(string name) + { + _name = name; + } + + /// + /// Declares the service endpoint by its endpoint type. The route is + /// resolved through the sitemap at render time, so routing stays in C# and + /// the page does not repeat the endpoint. + /// + /// The endpoint type that owns the route. + /// The builder for chaining. + public DataServiceBuilder Endpoint() where TEndpoint : IEndpoint + { + _endpoint = renderContext => WebEx.ComponentHub.SitemapManager + .GetUri(renderContext?.PageContext?.ApplicationContext)?.ToString(); + return this; + } + + /// + /// Sets the HTTP method used for the load and query operations. + /// + /// The HTTP method, for example GET. + /// The builder for chaining. + public DataServiceBuilder Method(HttpMethod method) + { + _method = method?.Method; + return this; + } + + /// + /// Sets the HTTP method used for the update operation. + /// + /// The HTTP method, for example PUT. + /// The builder for chaining. + public DataServiceBuilder UpdateMethod(HttpMethod method) + { + _updateMethod = method?.Method; + return this; + } + + /// + /// Maps logical query parameter names to their wire names. + /// + /// The query map configuration. + /// The builder for chaining. + public DataServiceBuilder Query(Action configure) + { + var map = new DataQueryMap(); + configure?.Invoke(map); + _query.AddRange(map.Pairs); + return this; + } + + /// + /// Maps logical response keys to their wire names. + /// + /// The response map configuration. + /// The builder for chaining. + public DataServiceBuilder Response(Action configure) + { + var map = new DataResponseMap(); + configure?.Invoke(map); + _response.AddRange(map.Pairs); + return this; + } + + /// + /// Builds the descriptor, resolving the endpoint in the given render context. + /// + /// The context in which the control is rendered. + /// The configured descriptor. + public DataServiceDescriptor Build(IRenderControlContext renderContext) + { + var descriptor = DataServiceDescriptor.Rest(_name) + .WithBaseUri(_endpoint?.Invoke(renderContext)); + + if (_method != null) + { + descriptor = descriptor.WithMethod(_method); + } + + if (_updateMethod != null) + { + descriptor = descriptor.WithUpdateMethod(_updateMethod); + } + + foreach (var pair in _query) + { + descriptor = descriptor.MapQuery(pair.Key, pair.Value); + } + + foreach (var pair in _response) + { + descriptor = descriptor.MapResponse(pair.Key, pair.Value); + } + + return descriptor; + } + } + + /// + /// Collects the mapping of logical query parameter names to their wire names, + /// for the fluent surface. + /// + public class DataQueryMap + { + internal List> Pairs { get; } = new(); + + /// + /// Maps a logical query parameter name to its wire name. + /// + /// The logical name, for example "search". + /// The wire name, for example "q". + /// The map for chaining. + public DataQueryMap Map(string logical, string wire) + { + Pairs.Add(new KeyValuePair(logical, wire)); + return this; + } + } + + /// + /// Collects the mapping of logical response keys to their wire names, for the + /// fluent surface. + /// + public class DataResponseMap + { + internal List> Pairs { get; } = new(); + + /// + /// Maps a logical response key to its wire name. + /// + /// The logical key, for example "items". + /// The wire name. + /// The map for chaining. + public DataResponseMap Map(string logical, string wire) + { + Pairs.Add(new KeyValuePair(logical, wire)); + return this; + } + + /// + /// Maps the items collection of the response. + /// + /// The wire name, default "items". + /// The map for chaining. + public DataResponseMap Items(string wire = "items") => Map("items", wire); + + /// + /// Maps the rows collection of the response. + /// + /// The wire name, default "rows". + /// The map for chaining. + public DataResponseMap Rows(string wire = "rows") => Map("rows", wire); + + /// + /// Maps the total count of the response. + /// + /// The wire name, default "total". + /// The map for chaining. + public DataResponseMap Total(string wire = "total") => Map("total", wire); + } +} diff --git a/src/WebExpress.WebApp/WebData/DataServiceDescriptor.cs b/src/WebExpress.WebApp/WebData/DataServiceDescriptor.cs new file mode 100644 index 0000000..82c3fa7 --- /dev/null +++ b/src/WebExpress.WebApp/WebData/DataServiceDescriptor.cs @@ -0,0 +1,270 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace WebExpress.WebApp.WebData +{ + /// + /// A typed, C# authored description of a named REST service for a View, State + /// and Service component. The descriptor is the single source of truth for + /// endpoint knowledge: it carries the service name, the base address, the + /// HTTP methods and the mapping of logical query and response names to their + /// wire names. It serializes into the compact data-wx-service JSON island + /// that the JavaScript ServiceRegistry consumes, so the client carries no + /// hard-coded endpoint or parameter knowledge. + /// + /// This is a C# artifact of the architecture described in + /// WebExpress/docs/view-state-service.md (section 3.2). Controls opt into the + /// emission by implementing IDataIsland and calling EmitDataIslands, which + /// turns the declared service and state into the data-wx-service and + /// data-wx-state islands beside the legacy attributes. + /// + public class DataServiceDescriptor + { + private static readonly JsonSerializerOptions IslandOptions = new() + { + WriteIndented = false + }; + + /// + /// Gets the logical service name, for example "data". The component + /// resolves a service from the island by this name. + /// + public string Name { get; } + + /// + /// Gets or sets the service kind. The only kind the default engine knows + /// is "rest". + /// + public string Kind { get; set; } = "rest"; + + /// + /// Gets or sets the base address the service calls. The route is resolved + /// through the sitemap in C#, so routing stays authoritative on the server. + /// + public string BaseUri { get; set; } + + /// + /// Gets or sets the HTTP method used for the load and query operations. + /// + public string Method { get; set; } + + /// + /// Gets or sets the HTTP method used for the update operation, for + /// example "PUT" or "PATCH". + /// + public string UpdateMethod { get; set; } + + /// + /// Gets the mapping of logical query parameter names to their wire names, + /// for example "search" to "q". + /// + public IDictionary Query { get; } = new Dictionary(); + + /// + /// Gets the mapping of logical response keys to their wire names, for + /// example "items" to "items" and "total" to "total". + /// + public IDictionary Response { get; } = new Dictionary(); + + /// + /// Initializes a new instance of the class. + /// + /// The logical service name. + public DataServiceDescriptor(string name) + { + Name = name; + } + + /// + /// Creates a rest service descriptor with the given name. + /// + /// The logical service name. + /// The descriptor for chaining. + public static DataServiceDescriptor Rest(string name) + { + return new DataServiceDescriptor(name) { Kind = "rest" }; + } + + /// + /// Creates the data service descriptor for the REST list control. It + /// reproduces the historical query parameter and response names of the + /// list, which is the same shape that the JavaScript legacyDescriptor + /// fallback carries, so the island and the fallback are equivalent. + /// + /// The resolved list endpoint. + /// The configured descriptor. + public static DataServiceDescriptor ListData(string baseUri) + { + return Rest("data") + .WithBaseUri(baseUri) + .WithMethod("GET") + .MapQuery("search", "q") + .MapQuery("wql", "wql") + .MapQuery("filter", "f") + .MapQuery("page", "p") + .MapQuery("pageSize", "l") + .MapQuery("orderBy", "o") + .MapQuery("orderDir", "d") + .MapResponse("items", "items") + .MapResponse("total", "total"); + } + + /// + /// Creates the common data service descriptor for a control that loads + /// its state with GET and persists it with PUT and carries no query or + /// response mapping. This is the shape that the kanban, tile, dashboard, + /// comment, scrum backlog and workflow controls share through their + /// JavaScript legacyDescriptor. + /// + /// The resolved endpoint. + /// The configured descriptor. + public static DataServiceDescriptor Data(string baseUri) + { + return Rest("data") + .WithBaseUri(baseUri) + .WithMethod("GET") + .WithUpdateMethod("PUT"); + } + + /// + /// Creates the data service descriptor for the REST tab control. It loads + /// with GET and persists with PUT, maps the logical id parameter and + /// projects the items response, which mirrors + /// webexpress.webapp.tabModel.legacyDescriptor. + /// + /// The resolved tab endpoint. + /// The configured descriptor. + public static DataServiceDescriptor TabData(string baseUri) + { + return Rest("data") + .WithBaseUri(baseUri) + .WithMethod("GET") + .WithUpdateMethod("PUT") + .MapQuery("id", "id") + .MapResponse("items", "items"); + } + + /// + /// Creates the data service descriptor for the REST table control. It + /// reproduces the historical query parameter and response names of the + /// table, which is the same shape that the JavaScript legacyDescriptor + /// fallback carries. The table differs from the list in that it persists + /// a reordered row set with a PUT update and projects rows rather than + /// items. + /// + /// The resolved table endpoint. + /// The configured descriptor. + public static DataServiceDescriptor TableData(string baseUri) + { + return Rest("data") + .WithBaseUri(baseUri) + .WithMethod("GET") + .WithUpdateMethod("PUT") + .MapQuery("search", "q") + .MapQuery("wql", "wql") + .MapQuery("filter", "f") + .MapQuery("page", "p") + .MapQuery("pageSize", "l") + .MapQuery("orderBy", "o") + .MapQuery("orderDir", "d") + .MapResponse("rows", "rows") + .MapResponse("total", "total"); + } + + /// + /// Sets the base address. + /// + /// The base address. + /// The descriptor for chaining. + public DataServiceDescriptor WithBaseUri(string baseUri) + { + BaseUri = baseUri; + return this; + } + + /// + /// Sets the load and query method. + /// + /// The HTTP method. + /// The descriptor for chaining. + public DataServiceDescriptor WithMethod(string method) + { + Method = method; + return this; + } + + /// + /// Sets the update method. + /// + /// The HTTP method. + /// The descriptor for chaining. + public DataServiceDescriptor WithUpdateMethod(string method) + { + UpdateMethod = method; + return this; + } + + /// + /// Maps a logical query parameter name to its wire name. + /// + /// The logical name. + /// The wire name. + /// The descriptor for chaining. + public DataServiceDescriptor MapQuery(string logical, string wire) + { + Query[logical] = wire; + return this; + } + + /// + /// Maps a logical response key to its wire name. + /// + /// The logical key. + /// The wire name. + /// The descriptor for chaining. + public DataServiceDescriptor MapResponse(string logical, string wire) + { + Response[logical] = wire; + return this; + } + + /// + /// Serializes the descriptor into the compact JSON island that the + /// JavaScript ServiceRegistry consumes. Empty parts are omitted so the + /// island stays small. The caller is responsible for HTML attribute + /// encoding when the result is written into a data-wx-service attribute. + /// + /// The compact JSON representation. + public string ToIsland() + { + var map = new Dictionary + { + ["name"] = Name, + ["kind"] = string.IsNullOrEmpty(Kind) ? "rest" : Kind, + ["baseUri"] = BaseUri ?? string.Empty + }; + + if (!string.IsNullOrEmpty(Method)) + { + map["method"] = Method; + } + + if (!string.IsNullOrEmpty(UpdateMethod)) + { + map["updateMethod"] = UpdateMethod; + } + + if (Query.Count > 0) + { + map["query"] = Query; + } + + if (Response.Count > 0) + { + map["response"] = Response; + } + + return JsonSerializer.Serialize(map, IslandOptions); + } + } +} diff --git a/src/WebExpress.WebApp/WebData/DataState.cs b/src/WebExpress.WebApp/WebData/DataState.cs new file mode 100644 index 0000000..26ffeb8 --- /dev/null +++ b/src/WebExpress.WebApp/WebData/DataState.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace WebExpress.WebApp.WebData +{ + /// + /// A typed, C# authored description of the initial state of a View, State + /// and Service component. The author sets keys and values, where the values + /// come from the existing C# lambdas, and the state serializes into the + /// compact data-wx-state JSON island that the JavaScript Component seeds its + /// store from on the first render. Server side initial data can be embedded + /// here so the first paint needs no round trip. + /// + /// This is a C# artifact of the architecture described in + /// WebExpress/docs/view-state-service.md (section 3.2, 8). It is consumed by + /// the engine through webexpress.webapp.Data.readState. + /// + public class DataState + { + private static readonly JsonSerializerOptions IslandOptions = new() + { + WriteIndented = false + }; + + private readonly Dictionary _values = new(); + + /// + /// Gets a value indicating whether the state carries no keys, in which + /// case the control omits the island entirely. + /// + public bool IsEmpty => _values.Count == 0; + + /// + /// Creates an empty state. + /// + /// The state for chaining. + public static DataState Create() + { + return new DataState(); + } + + /// + /// Sets a state key to a value. The value is serialized by its runtime + /// type, so numbers, strings, booleans, arrays and objects are all + /// supported. A later set with the same key replaces the earlier value. + /// + /// The state key. + /// The initial value. + /// The state for chaining. + public DataState Set(string key, object value) + { + _values[key] = value; + return this; + } + + /// + /// Serializes the state into the compact JSON island that the JavaScript + /// Component seeds its store from. The caller is responsible for HTML + /// attribute encoding when the result is written into a data-wx-state + /// attribute. + /// + /// The compact JSON representation. + public string ToIsland() + { + return JsonSerializer.Serialize(_values, IslandOptions); + } + } +} diff --git a/src/WebExpress.WebApp/WebData/IDataIsland.cs b/src/WebExpress.WebApp/WebData/IDataIsland.cs new file mode 100644 index 0000000..1b47af4 --- /dev/null +++ b/src/WebExpress.WebApp/WebData/IDataIsland.cs @@ -0,0 +1,36 @@ +using System; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.WebData +{ + /// + /// Marks a WebApp control as data bound. A data bound control declares an + /// optional initial state and an optional data service descriptor, which it + /// emits as the data-wx-state and data-wx-service islands that the JavaScript + /// engine consumes (webexpress.webapp.Data seeds its store from the state + /// island, webexpress.webapp.ServiceRegistry resolves the service island). + /// The emission itself is shared through the EmitDataIslands extension, so a + /// control only declares the two members and calls the extension from Render. + /// + /// This is the C# data layer of the View, State and Service architecture. The + /// name "Data" is used in place of "Component", which is a distinct concept in + /// WebExpress. The whole concept lives in WebExpress.WebApp; WebExpress.WebUI + /// carries only static controls. See WebExpress/docs/view-state-service.md. + /// + public interface IDataIsland + { + /// + /// Gets or sets the optional initial state, emitted as the data-wx-state + /// island. When null or empty, no island is emitted and the client loads + /// on mount. + /// + Func StateFactory { get; set; } + + /// + /// Gets or sets the optional data service descriptor, emitted as the + /// data-wx-service island. When null, the client uses its legacy + /// descriptor fallback. + /// + Func ServiceFactory { get; set; } + } +} diff --git a/src/WebExpress.WebApp/WebExpress.WebApp.csproj b/src/WebExpress.WebApp/WebExpress.WebApp.csproj index bba522f..3112c93 100644 --- a/src/WebExpress.WebApp/WebExpress.WebApp.csproj +++ b/src/WebExpress.WebApp/WebExpress.WebApp.csproj @@ -44,7 +44,7 @@ \ True - + diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestDashboard.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataDashboard.cs similarity index 89% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestDashboard.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataDashboard.cs index 5d3d7bc..261db61 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestDashboard.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataDashboard.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a dashboard control within a fragment context. /// - public abstract class FragmentControlRestDashboard : ControlRestDashboard, IFragmentControl + public abstract class FragmentControlDataDashboard : ControlDataDashboard, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestDashboard : ControlRestDashboard, IFrag /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestDashboard(IFragmentContext fragmentContext) + public FragmentControlDataDashboard(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestDropdown.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataDropdown.cs similarity index 89% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestDropdown.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataDropdown.cs index ed8a589..d6971e5 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestDropdown.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataDropdown.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a dropdown control within a fragment context. /// - public abstract class FragmentControlRestDropdown : ControlRestDropdown, IFragmentControl + public abstract class FragmentControlDataDropdown : ControlDataDropdown, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestDropdown : ControlRestDropdown, IFragme /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestDropdown(IFragmentContext fragmentContext) + public FragmentControlDataDropdown(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestForm.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataForm.cs similarity index 91% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestForm.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataForm.cs index d6fc1aa..d44a53b 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestForm.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataForm.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a fragment control for create REST form data. /// - public abstract class FragmentControlRestForm : ControlRestForm, IFragmentControl + public abstract class FragmentControlDataForm : ControlDataForm, IFragmentControl { /// /// Gets the context of the fragment. @@ -24,7 +24,7 @@ public abstract class FragmentControlRestForm : ControlRestForm, IFragmentContro /// The unique identifier for the modal remote form control. If null, the fragment /// Id will be used. /// - public FragmentControlRestForm(IFragmentContext fragmentContext, string id = null) + public FragmentControlDataForm(IFragmentContext fragmentContext, string id = null) : base(id ?? fragmentContext?.FragmentId?.ToString()) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormAdd.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormAdd.cs similarity index 90% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestFormAdd.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataFormAdd.cs index 93ca149..7332f45 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormAdd.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormAdd.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a fragment control for create REST form data. /// - public abstract class FragmentControlRestFormAdd : ControlRestFormAdd, IFragmentControl + public abstract class FragmentControlDataFormAdd : ControlDataFormAdd, IFragmentControl { /// /// Gets the context of the fragment. @@ -24,7 +24,7 @@ public abstract class FragmentControlRestFormAdd : ControlRestFormAdd, IFragment /// The unique identifier for the modal remote form control. If null, the fragment /// Id will be used. /// - public FragmentControlRestFormAdd(IFragmentContext fragmentContext, string id = null) + public FragmentControlDataFormAdd(IFragmentContext fragmentContext, string id = null) : base(id ?? fragmentContext?.FragmentId?.ToString()) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormClone.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormClone.cs similarity index 90% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestFormClone.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataFormClone.cs index b121259..fd3e727 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormClone.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormClone.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a fragment control for cloning REST form data. /// - public abstract class FragmentControlRestFormClone : ControlRestFormClone, IFragmentControl + public abstract class FragmentControlDataFormClone : ControlDataFormClone, IFragmentControl { /// /// Gets the context of the fragment. @@ -24,7 +24,7 @@ public abstract class FragmentControlRestFormClone : ControlRestFormClone, IFrag /// The unique identifier for the modal remote form control. If null, the fragment /// Id will be used. /// - public FragmentControlRestFormClone(IFragmentContext fragmentContext, string id = null) + public FragmentControlDataFormClone(IFragmentContext fragmentContext, string id = null) : base(id ?? fragmentContext?.FragmentId?.ToString()) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormDelete.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormDelete.cs similarity index 90% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestFormDelete.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataFormDelete.cs index 2cc2413..12d000f 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormDelete.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormDelete.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a fragment control for deleting REST form data. /// - public abstract class FragmentControlRestFormDelete : ControlRestFormDelete, IFragmentControl + public abstract class FragmentControlDataFormDelete : ControlDataFormDelete, IFragmentControl { /// /// Gets the context of the fragment. @@ -24,7 +24,7 @@ public abstract class FragmentControlRestFormDelete : ControlRestFormDelete, IFr /// The unique identifier for the modal remote form control. If null, the fragment /// Id will be used. /// - public FragmentControlRestFormDelete(IFragmentContext fragmentContext, string id = null) + public FragmentControlDataFormDelete(IFragmentContext fragmentContext, string id = null) : base(id ?? fragmentContext?.FragmentId?.ToString()) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormEdit.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormEdit.cs similarity index 90% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestFormEdit.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataFormEdit.cs index 30ea222..7bec225 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormEdit.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormEdit.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a fragment control for editing REST form data. /// - public abstract class FragmentControlRestFormEdit : ControlRestFormEdit, IFragmentControl + public abstract class FragmentControlDataFormEdit : ControlDataFormEdit, IFragmentControl { /// /// Gets the context of the fragment. @@ -24,7 +24,7 @@ public abstract class FragmentControlRestFormEdit : ControlRestFormEdit, IFragme /// The unique identifier for the modal remote form control. If null, the fragment /// Id will be used. /// - public FragmentControlRestFormEdit(IFragmentContext fragmentContext, string id = null) + public FragmentControlDataFormEdit(IFragmentContext fragmentContext, string id = null) : base(id ?? fragmentContext?.FragmentId?.ToString()) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormEditor.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormEditor.cs similarity index 89% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestFormEditor.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataFormEditor.cs index 94c0a94..e00c149 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestFormEditor.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataFormEditor.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a form editor control within a fragment context. /// - public abstract class FragmentControlRestFormEditor : ControlRestFormEditor, IFragmentControl + public abstract class FragmentControlDataFormEditor : ControlDataFormEditor, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestFormEditor : ControlRestFormEditor, IFr /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestFormEditor(IFragmentContext fragmentContext) + public FragmentControlDataFormEditor(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestKanban.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataKanban.cs similarity index 89% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestKanban.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataKanban.cs index a4a4b7b..4b89cef 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestKanban.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataKanban.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a kanban control within a fragment context. /// - public abstract class FragmentControlRestKanban : ControlRestKanban, IFragmentControl + public abstract class FragmentControlDataKanban : ControlDataKanban, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestKanban : ControlRestKanban, IFragmentCo /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestKanban(IFragmentContext fragmentContext) + public FragmentControlDataKanban(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestList.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataList.cs similarity index 90% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestList.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataList.cs index ca8499a..0f57d55 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestList.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataList.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a list control within a fragment context. /// - public abstract class FragmentControlRestList : ControlRestList, IFragmentControl + public abstract class FragmentControlDataList : ControlDataList, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestList : ControlRestList, IFragmentContro /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestList(IFragmentContext fragmentContext) + public FragmentControlDataList(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestQuickfilter.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataQuickfilter.cs similarity index 89% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestQuickfilter.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataQuickfilter.cs index 3543a60..a788af9 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestQuickfilter.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataQuickfilter.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a quickfilter control within a fragment context. /// - public abstract class FragmentControlRestQuickfilter : ControlRestQuickfilter, IFragmentControl + public abstract class FragmentControlDataQuickfilter : ControlDataQuickfilter, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestQuickfilter : ControlRestQuickfilter, I /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestQuickfilter(IFragmentContext fragmentContext) + public FragmentControlDataQuickfilter(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestTab.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataTab.cs similarity index 90% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestTab.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataTab.cs index c40e6c9..5b063b6 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestTab.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataTab.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a tab control within a fragment context. /// - public abstract class FragmentControlRestTab : ControlRestTab, IFragmentControl + public abstract class FragmentControlDataTab : ControlDataTab, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestTab : ControlRestTab, IFragmentControl< /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestTab(IFragmentContext fragmentContext) + public FragmentControlDataTab(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestTabTemplate.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataTabTemplate.cs similarity index 89% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestTabTemplate.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataTabTemplate.cs index 9e4de34..fa3d74d 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestTabTemplate.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataTabTemplate.cs @@ -8,7 +8,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a tab template control within a fragment context. /// - public abstract class FragmentControlRestTabTemplate : ControlRestTabTemplate, IFragmentControlRestTabTemplate + public abstract class FragmentControlDataTabTemplate : ControlDataTabTemplate, IFragmentControlDataTabTemplate { /// /// Gets the context of the fragment. @@ -19,7 +19,7 @@ public abstract class FragmentControlRestTabTemplate : ControlRestTabTemplate, I /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestTabTemplate(IFragmentContext fragmentContext) + public FragmentControlDataTabTemplate(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestTable.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataTable.cs similarity index 90% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestTable.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataTable.cs index ed2c70f..757c7a5 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestTable.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataTable.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a table control within a fragment context. /// - public abstract class FragmentControlRestTable : ControlRestTable, IFragmentControl + public abstract class FragmentControlDataTable : ControlDataTable, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestTable : ControlRestTable, IFragmentCont /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestTable(IFragmentContext fragmentContext) + public FragmentControlDataTable(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestTile.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataTile.cs similarity index 90% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestTile.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataTile.cs index 5a1aab0..a967b88 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestTile.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataTile.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a tile control within a fragment context. /// - public abstract class FragmentControlRestTile : ControlRestTile, IFragmentControl + public abstract class FragmentControlDataTile : ControlDataTile, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestTile : ControlRestTile, IFragmentContro /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestTile(IFragmentContext fragmentContext) + public FragmentControlDataTile(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestWizard.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataWizard.cs similarity index 89% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestWizard.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataWizard.cs index a5b2e0b..86b113a 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestWizard.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataWizard.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a wizard control within a fragment context. /// - public abstract class FragmentControlRestWizard : ControlRestWizard, IFragmentControl + public abstract class FragmentControlDataWizard : ControlDataWizard, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestWizard : ControlRestWizard, IFragmentCo /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestWizard(IFragmentContext fragmentContext) + public FragmentControlDataWizard(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/FragmentControlRestWorkfloew.cs b/src/WebExpress.WebApp/WebFragment/FragmentControlDataWorkfloew.cs similarity index 89% rename from src/WebExpress.WebApp/WebFragment/FragmentControlRestWorkfloew.cs rename to src/WebExpress.WebApp/WebFragment/FragmentControlDataWorkfloew.cs index 9060775..1f5e53d 100644 --- a/src/WebExpress.WebApp/WebFragment/FragmentControlRestWorkfloew.cs +++ b/src/WebExpress.WebApp/WebFragment/FragmentControlDataWorkfloew.cs @@ -9,7 +9,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a workflow editor control within a fragment context. /// - public abstract class FragmentControlRestWorkflow : ControlRestWorkflow, IFragmentControl + public abstract class FragmentControlDataWorkflow : ControlDataWorkflow, IFragmentControl { /// /// Gets the context of the fragment. @@ -20,7 +20,7 @@ public abstract class FragmentControlRestWorkflow : ControlRestWorkflow, IFragme /// Initializes a new instance of the class. /// /// The context of the fragment. - public FragmentControlRestWorkflow(IFragmentContext fragmentContext) + public FragmentControlDataWorkflow(IFragmentContext fragmentContext) : base(fragmentContext?.FragmentId?.ToString()?.Replace(".", "-")) { FragmentContext = fragmentContext; diff --git a/src/WebExpress.WebApp/WebFragment/IFragmentControlRestTabTemplate.cs b/src/WebExpress.WebApp/WebFragment/IFragmentControlDataTabTemplate.cs similarity index 83% rename from src/WebExpress.WebApp/WebFragment/IFragmentControlRestTabTemplate.cs rename to src/WebExpress.WebApp/WebFragment/IFragmentControlDataTabTemplate.cs index 77cb815..b94315e 100644 --- a/src/WebExpress.WebApp/WebFragment/IFragmentControlRestTabTemplate.cs +++ b/src/WebExpress.WebApp/WebFragment/IFragmentControlDataTabTemplate.cs @@ -6,7 +6,7 @@ namespace WebExpress.WebApp.WebFragment /// /// Represents a template for a REST tab control fragment in the web UI. /// - public interface IFragmentControlRestTabTemplate : IFragmentWebUIElement, IFragmentBase + public interface IFragmentControlDataTabTemplate : IFragmentWebUIElement, IFragmentBase { } } diff --git a/src/WebExpress.WebApp/WebInclude/IncludeJavaScript.cs b/src/WebExpress.WebApp/WebInclude/IncludeJavaScript.cs index 1f568e5..4ab72a7 100644 --- a/src/WebExpress.WebApp/WebInclude/IncludeJavaScript.cs +++ b/src/WebExpress.WebApp/WebInclude/IncludeJavaScript.cs @@ -12,19 +12,34 @@ namespace WebExpress.WebApp.WebInclude /// files required for the functionality of a web application. /// [Asset("/assets/js/webexpress.webapp.js")] + [Asset("/assets/js/webexpress.webapp.store.js")] + [Asset("/assets/js/webexpress.webapp.service.js")] + [Asset("/assets/js/webexpress.webapp.renderer.js")] + [Asset("/assets/js/webexpress.webapp.intent.js")] + [Asset("/assets/js/webexpress.webapp.data.js")] + [Asset("/assets/js/service/default.js")] + [Asset("/assets/js/intent/default.js")] [Asset("/assets/js/webexpress.webapp.avatar.dropdown.js")] [Asset("/assets/js/webexpress.webapp.chat.js")] [Asset("/assets/js/webexpress.webapp.collaborative.js")] + [Asset("/assets/js/webexpress.webapp.comment.model.js")] [Asset("/assets/js/webexpress.webapp.comment.js")] + [Asset("/assets/js/webexpress.webapp.comment.composer.model.js")] [Asset("/assets/js/webexpress.webapp.comment.composer.js")] + [Asset("/assets/js/webexpress.webapp.dashboard.model.js")] [Asset("/assets/js/webexpress.webapp.dashboard.js")] [Asset("/assets/js/webexpress.webapp.dropdown.js")] + [Asset("/assets/js/webexpress.webapp.dropdown.theme.model.js")] [Asset("/assets/js/webexpress.webapp.dropdown.theme.js")] + [Asset("/assets/js/webexpress.webapp.input.selection.model.js")] [Asset("/assets/js/webexpress.webapp.input.selection.js")] [Asset("/assets/js/webexpress.webapp.input.tile.js")] [Asset("/assets/js/webexpress.webapp.input.password.js")] + [Asset("/assets/js/webexpress.webapp.input.unique.model.js")] [Asset("/assets/js/webexpress.webapp.input.unique.js")] + [Asset("/assets/js/webexpress.webapp.kanban.model.js")] [Asset("/assets/js/webexpress.webapp.kanban.js")] + [Asset("/assets/js/webexpress.webapp.list.model.js")] [Asset("/assets/js/webexpress.webapp.list.js")] [Asset("/assets/js/webexpress.webapp.login.js")] [Asset("/assets/js/webexpress.webapp.message.queue.status.js")] @@ -32,18 +47,27 @@ namespace WebExpress.WebApp.WebInclude [Asset("/assets/js/webexpress.webapp.popupnotification.js")] [Asset("/assets/js/webexpress.webapp.progress.task.js")] [Asset("/assets/js/webexpress.webapp.quickfilter.js")] + [Asset("/assets/js/webexpress.webapp.restform.model.js")] [Asset("/assets/js/webexpress.webapp.restform.js")] [Asset("/assets/js/webexpress.webapp.restform.editor.js")] + [Asset("/assets/js/webexpress.webapp.restwizard.model.js")] [Asset("/assets/js/webexpress.webapp.restwizard.js")] + [Asset("/assets/js/webexpress.webapp.scrum.backlog.model.js")] [Asset("/assets/js/webexpress.webapp.scrum.backlog.js")] [Asset("/assets/js/webexpress.webapp.scrum.sprint.js")] [Asset("/assets/js/webexpress.webapp.search.js")] + [Asset("/assets/js/webexpress.webapp.selection.model.js")] [Asset("/assets/js/webexpress.webapp.selection.js")] + [Asset("/assets/js/webexpress.webapp.tab.model.js")] [Asset("/assets/js/webexpress.webapp.tab.js")] + [Asset("/assets/js/webexpress.webapp.table.model.js")] [Asset("/assets/js/webexpress.webapp.table.js")] [Asset("/assets/js/webexpress.webapp.tag.js")] + [Asset("/assets/js/webexpress.webapp.tile.model.js")] [Asset("/assets/js/webexpress.webapp.tile.js")] + [Asset("/assets/js/webexpress.webapp.watcher.model.js")] [Asset("/assets/js/webexpress.webapp.watcher.js")] + [Asset("/assets/js/webexpress.webapp.workflow.editor.model.js")] [Asset("/assets/js/webexpress.webapp.workflow.editor.js")] [Asset("/assets/js/webexpress.webapp.wql.prompt.js")] [Asset("/assets/js/action/default.js")] diff --git a/src/WebExpress.WebApp/WebPage/VisualTreeWebAppLogin.cs b/src/WebExpress.WebApp/WebPage/VisualTreeWebAppLogin.cs index 989ae77..acbacaa 100644 --- a/src/WebExpress.WebApp/WebPage/VisualTreeWebAppLogin.cs +++ b/src/WebExpress.WebApp/WebPage/VisualTreeWebAppLogin.cs @@ -117,7 +117,7 @@ public override IHtmlNode Render(IVisualTreeContext context) var html = new HtmlElementRootHtml(); var body = new HtmlElementSectionBody(); var renderContext = new RenderControlContext(context.RenderContext); - var login = new ControlRestLogin() + var login = new ControlDataLogin() { RestUri = _ => LoginUri, Padding = _ => new PropertySpacingPadding(PropertySpacing.Space.Five) diff --git a/src/WebExpress.WebApp/WebRestApi/RestApiFormEditorItem.cs b/src/WebExpress.WebApp/WebRestApi/RestApiFormEditorItem.cs index 4afd0f6..8b1d422 100644 --- a/src/WebExpress.WebApp/WebRestApi/RestApiFormEditorItem.cs +++ b/src/WebExpress.WebApp/WebRestApi/RestApiFormEditorItem.cs @@ -5,7 +5,7 @@ namespace WebExpress.WebApp.WebRestApi { /// /// Represents the root DTO of a form structure exchanged with the visual - /// form-editor (ControlRestFormEditor / webexpress.webapp.RestFormEditorCtrl). + /// form-editor (ControlDataFormEditor / webexpress.webapp.RestFormEditorCtrl). /// public class RestApiFormEditorItem { diff --git a/src/WebExpress.WebApp/WebRestApi/RestApiTheme.cs b/src/WebExpress.WebApp/WebRestApi/RestApiTheme.cs index 5e831d7..2768f0f 100644 --- a/src/WebExpress.WebApp/WebRestApi/RestApiTheme.cs +++ b/src/WebExpress.WebApp/WebRestApi/RestApiTheme.cs @@ -15,7 +15,7 @@ namespace WebExpress.WebApp.WebRestApi { /// - /// REST endpoint that backs the ControlRestSelectionTheme selector. + /// REST endpoint that backs the ControlDataSelectionTheme selector. /// /// A GET returns the themes registered for the request's application /// in the shape consumed by the JS dropdown: From 0aa86a93718cdd1a09e785091e1217c5f6eacb04 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 10 Jun 2026 21:06:04 +0200 Subject: [PATCH 2/7] feat: general improvements and minor bugs --- .../JsTest/dom-stub.mjs | 48 ++- .../JsTest/engine.test.mjs | 283 ++++++++++++++++++ src/WebExpress.WebApp.Test/JsTest/harness.mjs | 33 +- .../JsTest/tile.model.test.mjs | 16 + .../JsTest/tile.test.mjs | 132 ++++++++ .../WebData/UnitTestDataAuthoring.cs | 131 ++++++++ .../WebData/UnitTestDataServiceDescriptor.cs | 38 +++ .../WebInclude/UnitTestEngineAssets.cs | 13 +- .../Assets/js/action/default.js | 73 +++++ .../Assets/js/bind/default.js | 221 ++++++++++++++ .../Assets/js/intent/default.js | 57 +++- .../Assets/js/webexpress.webapp.data.js | 36 ++- .../Assets/js/webexpress.webapp.list.js | 56 ++-- .../Assets/js/webexpress.webapp.service.js | 160 +++++++++- .../Assets/js/webexpress.webapp.table.js | 73 ++--- .../Assets/js/webexpress.webapp.template.js | 89 ++++++ .../Assets/js/webexpress.webapp.tile.js | 269 ++++++++--------- .../Assets/js/webexpress.webapp.tile.model.js | 29 ++ .../ControlDataAuthoringExtensions.cs | 193 ++++++++++++ .../WebControl/ControlDataComment.cs | 39 ++- .../WebControl/ControlDataDashboard.cs | 39 ++- .../WebControl/ControlDataKanban.cs | 39 ++- .../WebControl/ControlDataList.cs | 41 ++- .../WebControl/ControlDataScrumBacklog.cs | 39 ++- .../WebControl/ControlDataTab.cs | 38 ++- .../WebControl/ControlDataTable.cs | 41 ++- .../WebControl/ControlDataTile.cs | 39 ++- .../WebControl/ControlDataWorkflow.cs | 39 ++- .../WebData/DataAuthoringExtensions.cs | 44 ++- .../WebData/DataIslandExtensions.cs | 53 +++- .../WebData/DataServiceBuilder.cs | 120 +++++++- .../WebData/DataServiceDescriptor.cs | 85 ++++++ src/WebExpress.WebApp/WebData/DataState.cs | 31 ++ src/WebExpress.WebApp/WebData/IDataIsland.cs | 36 ++- .../WebInclude/IncludeJavaScript.cs | 2 + 35 files changed, 2374 insertions(+), 301 deletions(-) create mode 100644 src/WebExpress.WebApp.Test/JsTest/tile.test.mjs create mode 100644 src/WebExpress.WebApp/Assets/js/bind/default.js create mode 100644 src/WebExpress.WebApp/Assets/js/webexpress.webapp.template.js create mode 100644 src/WebExpress.WebApp/WebControl/ControlDataAuthoringExtensions.cs diff --git a/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs b/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs index e435fd3..baf056c 100644 --- a/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs +++ b/src/WebExpress.WebApp.Test/JsTest/dom-stub.mjs @@ -80,6 +80,10 @@ class Element { return node; } + prepend(node) { + this.insertBefore(node, this.childNodes[0] || null); + } + removeChild(node) { const index = this.childNodes.indexOf(node); if (index !== -1) { this.childNodes.splice(index, 1); } @@ -172,20 +176,58 @@ function makeStyle() { } /** - * Creates a fresh document stub. + * Finds an element with the given id in a subtree. + * @param {Element} node - The subtree root. + * @param {string} id - The id to find. + * @returns {Element|null} The element or null. + */ +function findById(node, id) { + if (node.nodeType === 1 && node.id === id) { + return node; + } + for (const child of node.childNodes || []) { + const found = findById(child, id); + if (found) { + return found; + } + } + return null; +} + +/** + * Creates a fresh document stub. The document carries a body, an id lookup + * over the body subtree and working event listeners, so the engine's global + * channels (for example the service error event and the component mount + * event) can be exercised. * @returns {object} The document stub. */ export function createDocument() { + const body = new Element("body"); + const listeners = {}; + return { baseURI: "http://localhost/", readyState: "complete", cookie: "", + body, createElement(tag) { return new Element(tag); }, createElementNS(namespace, tag) { return new Element(tag); }, createDocumentFragment() { return new Element("#document-fragment"); }, createTextNode(text) { return new TextNode(text); }, - addEventListener() {}, - removeEventListener() {} + getElementById(id) { return findById(body, String(id)); }, + querySelector() { return null; }, + querySelectorAll() { return []; }, + addEventListener(type, handler) { + (listeners[type] || (listeners[type] = new Set())).add(handler); + }, + removeEventListener(type, handler) { + if (listeners[type]) { listeners[type].delete(handler); } + }, + dispatchEvent(event) { + const set = listeners[event.type]; + if (set) { Array.from(set).forEach((fn) => fn(event)); } + return true; + } }; } diff --git a/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs b/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs index b2c75ea..63fdd8b 100644 --- a/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs +++ b/src/WebExpress.WebApp.Test/JsTest/engine.test.mjs @@ -425,3 +425,286 @@ test("component destroy stops further renders", () => { assert.equal(component.renders, rendersAfterMount); }); + +// Multiple services per component + +test("service registry parses an island array into named services", () => { + const { wxapp, createElement } = loadEngine(); + const element = createElement("div"); + element.setAttribute("data-wx-service", JSON.stringify([ + { name: "load", kind: "rest", baseUri: "/api/form" }, + { name: "submit", kind: "rest", baseUri: "/api/form" } + ])); + + const services = wxapp.ServiceRegistry.fromElement(element); + + assert.ok(services.load); + assert.ok(services.submit); + assert.equal(typeof services.load.query, "function"); + assert.equal(typeof services.submit.create, "function"); +}); + +// Retry policy and error channel + +test("rest service retries a retriable failure per the descriptor policy", async () => { + const { wxapp, setFetch } = loadEngine(); + let calls = 0; + setFetch(async () => { + calls += 1; + if (calls === 1) { + return { ok: false, status: 503 }; + } + return { ok: true, status: 200, json: async () => ({ items: [] }) }; + }); + + const service = new wxapp.RestService({ + name: "data", + baseUri: "/api/orders", + retry: { count: 1, delayMs: 0 } + }); + + const result = await service.query({}); + + assert.equal(calls, 2); + assert.equal(result.ok, true); +}); + +test("rest service does not retry a non retriable failure", async () => { + const { wxapp, setFetch } = loadEngine(); + let calls = 0; + setFetch(async () => { + calls += 1; + return { ok: false, status: 404 }; + }); + + const service = new wxapp.RestService({ + name: "data", + baseUri: "/api/orders", + retry: { count: 3, delayMs: 0 } + }); + + const result = await service.query({}); + + assert.equal(calls, 1); + assert.equal(result.ok, false); +}); + +test("error channel reports a failure with the mapped message key", async () => { + const { wxapp, setFetch, document } = loadEngine(); + setFetch(async () => ({ ok: false, status: 404 })); + + const reported = []; + document.addEventListener("webexpress.webapp.service.error", (event) => reported.push(event.detail)); + + const service = new wxapp.RestService({ + name: "data", + baseUri: "/api/orders", + errors: { "404": "webexpress.webapp:error.notfound" } + }); + + const result = await service.query({}); + + assert.equal(result.error.message, "webexpress.webapp:error.notfound"); + assert.equal(reported.length, 1); + assert.equal(reported[0].service, "data"); + assert.equal(reported[0].kind, "http"); + assert.equal(reported[0].status, 404); + assert.equal(reported[0].message, "webexpress.webapp:error.notfound"); +}); + +test("error channel stays silent on success and on abort", async () => { + const { wxapp, setFetch, document } = loadEngine(); + + const reported = []; + document.addEventListener("webexpress.webapp.service.error", (event) => reported.push(event.detail)); + + setFetch(async () => ({ ok: true, status: 200, json: async () => ({}) })); + const service = new wxapp.RestService({ name: "data", baseUri: "/api/orders" }); + await service.query({}); + + setFetch(async () => { const error = new Error("aborted"); error.name = "AbortError"; throw error; }); + await service.query({}); + + assert.equal(reported.length, 0); +}); + +// Templates + +test("component without a render uses the referenced registered template", () => { + const { wxapp, createElement } = loadEngine(); + + wxapp.Templates.register("orders-view", (state) => wxapp.h("p", { class: "t" }, String(state.count))); + + class Probe extends wxapp.Data { + constructor(element) { + super(element); + this.mount(); + } + } + + const element = createElement("div"); + element.setAttribute("data-wx-template", "orders-view"); + element.setAttribute("data-wx-state", JSON.stringify({ count: 3 })); + const component = new Probe(element); + + assert.equal(element.childNodes.length, 1); + assert.equal(element.childNodes[0].tagName, "P"); + assert.equal(element.childNodes[0].textContent, "3"); + + component.setState({ count: 4 }); + component.store.flush(); + + assert.equal(element.childNodes[0].textContent, "4"); +}); + +// State and model binds + +test("state bind reflects a store path as text", () => { + const { wx, wxapp, createElement, document } = loadEngine(); + + class Probe extends wxapp.Data { + constructor(element, options) { + super(element, options); + this.mount(); + } + } + + const host = createElement("div"); + host.id = "orders"; + document.body.appendChild(host); + const component = new Probe(host, { state: { total: 7 } }); + wx.Controller.getInstanceByElement = (el) => (el === host ? component : null); + + const label = createElement("span"); + label.setAttribute("data-wx-bind", "state"); + label.setAttribute("data-wx-bind-store", "orders"); + label.setAttribute("data-wx-bind-path", "total"); + document.body.appendChild(label); + + wx.Binds.get("state").bind(label); + assert.equal(label.textContent, "7"); + + component.setState({ total: 9 }); + component.store.flush(); + assert.equal(label.textContent, "9"); +}); + +test("model bind patches the store on input and reflects store changes", () => { + const { wx, wxapp, createElement, document } = loadEngine(); + + class Probe extends wxapp.Data { + constructor(element, options) { + super(element, options); + this.mount(); + } + } + + const host = createElement("div"); + host.id = "form"; + document.body.appendChild(host); + const component = new Probe(host, { state: { model: { name: "Guybrush" } } }); + wx.Controller.getInstanceByElement = (el) => (el === host ? component : null); + + const input = createElement("input"); + input.setAttribute("data-wx-bind", "model"); + input.setAttribute("data-wx-bind-store", "form"); + input.setAttribute("data-wx-model", "model.name"); + document.body.appendChild(input); + + wx.Binds.get("model").bind(input); + assert.equal(input.value, "Guybrush"); + + input.value = "LeChuck"; + input.dispatchEvent({ type: "input" }); + component.store.flush(); + assert.equal(component.state.model.name, "LeChuck"); + + component.setState({ model: { name: "Elaine" } }); + component.store.flush(); + assert.equal(input.value, "Elaine"); +}); + +test("binds resolve a component that mounts after the bind", () => { + const { wx, wxapp, createElement, document } = loadEngine(); + + const label = createElement("span"); + label.setAttribute("data-wx-bind", "state"); + label.setAttribute("data-wx-bind-store", "late"); + label.setAttribute("data-wx-bind-path", "total"); + document.body.appendChild(label); + + wx.Binds.get("state").bind(label); + + class Probe extends wxapp.Data { + constructor(element, options) { + super(element, options); + this.mount(); + } + } + + const host = createElement("div"); + host.id = "late"; + document.body.appendChild(host); + const component = new Probe(host, { state: { total: 42 } }); + wx.Controller.getInstanceByElement = (el) => (el === host ? component : null); + + // the component announces its mount through a bubbling document event; + // the stub document does not bubble, so the event is replayed on it + document.dispatchEvent({ type: "webexpress.webapp.data.mount", detail: { component } }); + + assert.equal(label.textContent, "42"); +}); + +// Query intents of the data query families + +test("query intents reduce state and trigger the load for list, table and tile", () => { + const { wxapp } = loadEngine(); + + for (const domain of ["list", "table", "tile"]) { + const store = new wxapp.Store({ search: "", wql: "", filter: "", page: 3 }); + let loads = 0; + const component = { load() { loads += 1; } }; + + wxapp.Intents.dispatch(domain + "/search", { store, payload: { pattern: "guybrush", searchType: "basic" }, component }); + assert.equal(store.getState().search, "guybrush", domain); + assert.equal(store.getState().wql, null, domain); + assert.equal(store.getState().page, 0, domain); + assert.equal(loads, 1, domain); + + wxapp.Intents.dispatch(domain + "/search", { store, payload: { pattern: "monkey", searchType: "wql" }, component }); + assert.equal(store.getState().search, null, domain); + assert.equal(store.getState().wql, "monkey", domain); + assert.equal(loads, 2, domain); + + wxapp.Intents.dispatch(domain + "/page", { store, payload: { page: 2 }, component }); + assert.equal(store.getState().page, 2, domain); + assert.equal(loads, 3, domain); + + wxapp.Intents.dispatch(domain + "/filter", { store, payload: { pattern: "insult" }, component }); + assert.equal(store.getState().filter, "insult", domain); + assert.equal(store.getState().page, 0, domain); + assert.equal(loads, 4, domain); + } +}); + +test("rest service maps the closed vocabulary to default wire names without a query mapping", async () => { + const { wxapp, setFetch } = loadEngine(); + let capturedUrl = null; + setFetch(async (url) => { + capturedUrl = url; + return { ok: true, status: 200, json: async () => ({ items: [] }) }; + }); + + // the common GET/PUT descriptor shape carries no query mapping; the + // logical names still travel as the historical wire names + const service = new wxapp.RestService({ name: "data", baseUri: "/api/tiles", method: "GET", updateMethod: "PUT" }); + await service.query({ search: "abc", filter: "", page: 1, pageSize: 25, orderBy: "label", orderDir: "asc" }); + + assert.match(capturedUrl, /q=abc/); + assert.match(capturedUrl, /f=/); + assert.match(capturedUrl, /p=1/); + assert.match(capturedUrl, /l=25/); + assert.match(capturedUrl, /o=label/); + assert.match(capturedUrl, /d=asc/); + assert.doesNotMatch(capturedUrl, /search=/); +}); diff --git a/src/WebExpress.WebApp.Test/JsTest/harness.mjs b/src/WebExpress.WebApp.Test/JsTest/harness.mjs index fcbfd99..c689c4a 100644 --- a/src/WebExpress.WebApp.Test/JsTest/harness.mjs +++ b/src/WebExpress.WebApp.Test/JsTest/harness.mjs @@ -15,9 +15,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { createDocument } from "./dom-stub.mjs"; +// the harness lives in WebExpress.WebApp/src/WebExpress.WebApp.Test/JsTest and +// loads the shipped engine sources from the sibling WebExpress.WebApp project const here = path.dirname(fileURLToPath(import.meta.url)); -const assetsJs = path.resolve(here, "..", "src", "WebExpress.WebUI", "Assets", "js"); -const webappAssetsJs = path.resolve(here, "..", "..", "WebExpress.WebApp", "src", "WebExpress.WebApp", "Assets", "js"); +const webappAssetsJs = path.resolve(here, "..", "..", "WebExpress.WebApp", "Assets", "js"); /** * Resolves the absolute path of a WebExpress.WebApp asset by file name, so that @@ -35,16 +36,28 @@ const ENGINE_FILES = [ "webexpress.webapp.store.js", "webexpress.webapp.service.js", "webexpress.webapp.renderer.js", + "webexpress.webapp.template.js", "webexpress.webapp.intent.js", "webexpress.webapp.data.js", "service/default.js", - "intent/default.js" + "intent/default.js", + "bind/default.js" ]; // a minimal Ctrl base, defined inside the context, that mirrors the parts of // webexpress.webui.Ctrl the Component relies on, without the DOM heavy runtime const BOOTSTRAP = ` var webexpress = { webui: {}, webapp: {} }; + // a minimal CustomEvent so the engine's mount and error events construct + // without a browser runtime + class CustomEvent { + constructor(type, init) { + init = init || {}; + this.type = type; + this.detail = init.detail; + this.bubbles = !!init.bubbles; + } + } webexpress.webui.Ctrl = class { constructor(element) { this._element = element; } render() { } @@ -69,6 +82,14 @@ const BOOTSTRAP = ` getInstanceByElement() { return null; }, getClosestInstance() { return null; } }; + // a minimal Binds registry so the webapp bind defaults, which register the + // state and model binds, can be loaded and exercised in the harness + webexpress.webui.Binds = { + _binds: new Map(), + register(name, definition) { this._binds.set(name, definition); return this; }, + get(name) { return this._binds.get(name) || null; }, + unregister(name) { this._binds.delete(name); } + }; // event name constants live in the full webexpress.webui.js, which the engine // harness does not load; an empty map lets controls dispatch without throwing // (the dispatched type is simply undefined, which the stub element ignores). @@ -105,6 +126,12 @@ export function loadEngine(options = {}) { vm.runInContext(code, sandbox, { filename: full }); } + // optional test specific bootstrap, for example a base class stub that an + // application control file extends, run before the extra files load + if (options.bootstrap) { + vm.runInContext(options.bootstrap, sandbox, { filename: "test-bootstrap" }); + } + // optional additional modules (for example application level helpers), // loaded after the engine so they can build on it for (const full of options.extraFiles || []) { diff --git a/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs b/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs index cf19620..47e892d 100644 --- a/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs +++ b/src/WebExpress.WebApp.Test/JsTest/tile.model.test.mjs @@ -38,6 +38,22 @@ test("slice items caps to the page size and tolerates non arrays", () => { assert.deepEqual(wxapp.tileModel.sliceItems(null, 5), []); }); +test("query params always carry search, wql and filter and include order only when set", () => { + const { wxapp } = load(); + + assert.deepEqual( + wxapp.tileModel.queryParams({ search: "abc", page: 2, pageSize: 25 }), + { search: "abc", wql: "", filter: "", page: 2, pageSize: 25 }); + + assert.deepEqual( + wxapp.tileModel.queryParams({ orderBy: "label", orderDir: "desc" }), + { search: "", wql: "", filter: "", page: 0, pageSize: 50, orderBy: "label", orderDir: "desc" }); + + assert.deepEqual( + wxapp.tileModel.queryParams(null), + { search: "", wql: "", filter: "", page: 0, pageSize: 50 }); +}); + test("reduce total uses the response total and otherwise infers it", () => { const { wxapp } = load(); assert.equal(wxapp.tileModel.reduceTotal({ total: 42 }, 10, 0, 50), 42); diff --git a/src/WebExpress.WebApp.Test/JsTest/tile.test.mjs b/src/WebExpress.WebApp.Test/JsTest/tile.test.mjs new file mode 100644 index 0000000..6c8249f --- /dev/null +++ b/src/WebExpress.WebApp.Test/JsTest/tile.test.mjs @@ -0,0 +1,132 @@ +/** + * Headless tests for the REST tile control (View, State and Service). + * + * They instantiate the real webexpress.webapp.TileCtrl on the DOM stub with a + * stubbed WebUI tile base and assert that the control seeds its store from + * the data-wx-state island, queries through the configured data service with + * the default wire vocabulary, and routes search, filter and paging through + * the tile domain intents. + * + * Run with Node 18 or newer from the jstest folder: + * node --test + */ + +import { test } from "node:test"; +import assert from "node:assert"; +import { loadEngine, webappAsset } from "./harness.mjs"; + +// the webapp tile extends the static WebUI tile, which the engine harness does +// not load; the stub carries the members the webapp control calls +const TILE_BASE_STUB = ` + webexpress.webui.TileCtrl = class extends webexpress.webui.Ctrl { + render() { } + searchTiles() { return []; } + _markSearchDirty() { } + _dispatchSortEvent() { } + }; +`; + +function load(options) { + return loadEngine(Object.assign({ + bootstrap: TILE_BASE_STUB, + extraFiles: [ + webappAsset("webexpress.webapp.tile.model.js"), + webappAsset("webexpress.webapp.tile.js") + ] + }, options)); +} + +/** + * Builds a tile host element carrying the common GET/PUT service island and an + * optional state island. + * @param {object} engine - The loaded engine. + * @param {object} [state] - The optional initial state island. + * @returns {object} The host element. + */ +function createHost(engine, state) { + const element = engine.createElement("div"); + element.setAttribute("data-wx-service", JSON.stringify({ + name: "data", kind: "rest", baseUri: "/api/tiles", method: "GET", updateMethod: "PUT" + })); + if (state) { + element.setAttribute("data-wx-state", JSON.stringify(state)); + } + return element; +} + +/** + * Awaits the pending load turns of the control. + * @returns {Promise} A promise that resolves after the pending turns. + */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +test("tile seeds its store from the state island and queries with default wire names", async () => { + const engine = load(); + const urls = []; + engine.setFetch(async (url) => { + urls.push(url); + return { ok: true, status: 200, json: async () => ({ items: [{ id: "a", label: "A" }], total: 9 }) }; + }); + + const element = createHost(engine, { page: 1, pageSize: 2 }); + const tile = new engine.wxapp.TileCtrl(element); + await settle(); + + assert.equal(urls.length, 1); + assert.match(urls[0], /\/api\/tiles\?/); + assert.match(urls[0], /p=1/); + assert.match(urls[0], /l=2/); + assert.match(urls[0], /q=/); + assert.match(urls[0], /f=/); + assert.equal(tile._page, 1); + assert.equal(tile._totalRecords, 9); +}); + +test("tile search dispatches the tile/search intent and reloads the first page", async () => { + const engine = load(); + const urls = []; + engine.setFetch(async (url) => { + urls.push(url); + return { ok: true, status: 200, json: async () => ({ items: [], total: 0 }) }; + }); + + const element = createHost(engine, { page: 4 }); + const tile = new engine.wxapp.TileCtrl(element); + await settle(); + + tile.search("guybrush"); + await settle(); + + assert.equal(urls.length, 2); + assert.match(urls[1], /q=guybrush/); + assert.match(urls[1], /p=0/); + assert.equal(tile._search, "guybrush"); + assert.equal(tile._page, 0); +}); + +test("tile paging and filter dispatch their intents through the store", async () => { + const engine = load(); + const urls = []; + engine.setFetch(async (url) => { + urls.push(url); + return { ok: true, status: 200, json: async () => ({ items: [], total: 0 }) }; + }); + + const element = createHost(engine); + const tile = new engine.wxapp.TileCtrl(element); + await settle(); + + tile.paging(3); + await settle(); + assert.match(urls[1], /p=3/); + assert.equal(tile._page, 3); + + tile.filter("insult"); + await settle(); + assert.match(urls[2], /f=insult/); + assert.match(urls[2], /p=0/); + assert.equal(tile._filter, "insult"); +}); diff --git a/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs b/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs index 40ce135..754009b 100644 --- a/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs +++ b/src/WebExpress.WebApp.Test/WebData/UnitTestDataAuthoring.cs @@ -85,5 +85,136 @@ public void FluentServiceEmitsTheServiceIsland() Assert.Contains(""name":"data"", html); Assert.Contains(""method":"GET"", html); } + + /// + /// Tests that two declared services emit one data-wx-service island that + /// carries a json array of both descriptors, which is the shape a form + /// with a load and a submit service produces and that + /// ServiceRegistry.fromElement already consumes. + /// + [Fact] + public void TwoServicesEmitOneIslandArray() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList("myList") + .Service("load", svc => svc.Method(HttpMethod.Get)) + .Service("submit", svc => svc.Method(HttpMethod.Post)); + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation: the island is an array carrying both named services + Assert.Contains("data-wx-service=\"[", html); + Assert.Contains(""name":"load"", html); + Assert.Contains(""name":"submit"", html); + } + + /// + /// Tests that assigning the singular ServiceFactory convenience replaces + /// all previously declared services, so the property keeps its historical + /// single service semantics. + /// + [Fact] + public void ServiceFactoryConvenienceReplacesAllServices() + { + // arrange + var control = new ControlDataList("myList") + .Service("load", svc => svc.Method(HttpMethod.Get)) + .Service("submit", svc => svc.Method(HttpMethod.Post)); + + // act + control.ServiceFactory = _ => DataServiceDescriptor.ListData("/api/orders"); + + // validation + Assert.Single(control.ServiceFactories); + Assert.NotNull(control.ServiceFactory); + } + + /// + /// Tests that the typed query helpers map the closed logical vocabulary + /// to the historical wire names, so the standard mapping needs no string + /// at the call site. + /// + [Fact] + public void TypedQueryHelpersMapTheVocabulary() + { + // act + var island = new DataServiceBuilder("data") + .Method(HttpMethod.Get) + .Query(q => q.Search().Wql().Filter().Page().PageSize().OrderBy().OrderDir()) + .Response(r => r.Items().Total()) + .Build(null) + .ToIsland(); + + // validation: identical to the historical list mapping + Assert.Contains( + "\"query\":{\"search\":\"q\",\"wql\":\"wql\",\"filter\":\"f\",\"page\":\"p\",\"pageSize\":\"l\",\"orderBy\":\"o\",\"orderDir\":\"d\"}", + island); + Assert.Contains("\"response\":{\"items\":\"items\",\"total\":\"total\"}", island); + } + + /// + /// Tests that the typed state helpers set the closed state vocabulary, + /// so the initial state needs no string at the call site. + /// + [Fact] + public void TypedStateHelpersSetTheVocabulary() + { + // act + var island = DataState.Create().Page(0).PageSize(25).ToIsland(); + + // validation + Assert.Equal("{\"page\":0,\"pageSize\":25}", island); + } + + /// + /// Tests that the fluent Template extension makes the control emit the + /// data-wx-template attribute that the client Templates registry resolves. + /// + [Fact] + public void FluentTemplateEmitsTheTemplateAttribute() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeControl(componentHub, context.PageContext); + var control = new ControlDataList("myList") + .Template("orders-view"); + + // act + var html = control.Render(context, visualTree).ToString(); + + // validation + Assert.Contains("data-wx-template=\"orders-view\"", html); + } + + /// + /// Tests that the family preset declares the standard data service in a + /// single typed call. The endpoint resolution through the sitemap is + /// exercised by the tutorial pages; this test asserts that the preset + /// registers exactly one service factory. + /// + [Fact] + public void FamilyPresetRegistersTheStandardService() + { + // act + var control = new ControlDataList("myList") + .State(s => s.Page(0).PageSize(25)) + .DataService(); + + // validation + Assert.Single(control.ServiceFactories); + Assert.NotNull(control.StateFactory); + } + + /// + /// A marker endpoint for the preset test. + /// + private sealed class FakeEndpoint : WebExpress.WebCore.WebEndpoint.IEndpoint + { + } } } diff --git a/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs b/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs index 86e1c7d..18e804d 100644 --- a/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs +++ b/src/WebExpress.WebApp.Test/WebData/UnitTestDataServiceDescriptor.cs @@ -116,5 +116,43 @@ public void MissingBaseUriBecomesEmptyString() Assert.Equal("{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"\",\"method\":\"GET\"}", json); } + + /// + /// Tests that the declared policies, which are the request headers, the + /// status to message mapping and the retry policy, are emitted in the + /// island shape that the JavaScript RestService consumes. + /// + [Fact] + public void PoliciesAreEmittedWhenSet() + { + var json = DataServiceDescriptor.Rest("data") + .WithBaseUri("/api/x") + .WithMethod("GET") + .WithHeader("X-Api-Version", "1") + .MapError(404, "webexpress.webapp:error.notfound") + .WithRetry(2, 300) + .ToIsland(); + + Assert.Equal( + "{\"name\":\"data\",\"kind\":\"rest\",\"baseUri\":\"/api/x\",\"method\":\"GET\"," + + "\"headers\":{\"X-Api-Version\":\"1\"}," + + "\"errors\":{\"404\":\"webexpress.webapp:error.notfound\"}," + + "\"retry\":{\"count\":2,\"delayMs\":300}}", + json); + } + + /// + /// Tests that the policies stay omitted when they are not declared, so + /// the island keeps its historical compact shape. + /// + [Fact] + public void PoliciesAreOmittedByDefault() + { + var json = DataServiceDescriptor.Rest("data").WithBaseUri("/api/x").ToIsland(); + + Assert.DoesNotContain("headers", json); + Assert.DoesNotContain("errors", json); + Assert.DoesNotContain("retry", json); + } } } diff --git a/src/WebExpress.WebApp.Test/WebInclude/UnitTestEngineAssets.cs b/src/WebExpress.WebApp.Test/WebInclude/UnitTestEngineAssets.cs index 9c7dd56..6652cb5 100644 --- a/src/WebExpress.WebApp.Test/WebInclude/UnitTestEngineAssets.cs +++ b/src/WebExpress.WebApp.Test/WebInclude/UnitTestEngineAssets.cs @@ -20,10 +20,12 @@ public class UnitTestEngineAssets private const string Store = "/assets/js/webexpress.webapp.store.js"; private const string Service = "/assets/js/webexpress.webapp.service.js"; private const string Renderer = "/assets/js/webexpress.webapp.renderer.js"; + private const string Template = "/assets/js/webexpress.webapp.template.js"; private const string Intent = "/assets/js/webexpress.webapp.intent.js"; private const string Data = "/assets/js/webexpress.webapp.data.js"; private const string ServiceDefault = "/assets/js/service/default.js"; private const string IntentDefault = "/assets/js/intent/default.js"; + private const string BindDefault = "/assets/js/bind/default.js"; private const string FirstControl = "/assets/js/webexpress.webapp.avatar.dropdown.js"; /// @@ -58,10 +60,12 @@ private static string Normalize(string resourceName) [InlineData(Store)] [InlineData(Service)] [InlineData(Renderer)] + [InlineData(Template)] [InlineData(Intent)] [InlineData(Data)] [InlineData(ServiceDefault)] [InlineData(IntentDefault)] + [InlineData(BindDefault)] public void Registered(string assetPath) { Assert.Contains(assetPath, GetAssetOrder()); @@ -76,10 +80,12 @@ public void Registered(string assetPath) [InlineData(Store)] [InlineData(Service)] [InlineData(Renderer)] + [InlineData(Template)] [InlineData(Intent)] [InlineData(Data)] [InlineData(ServiceDefault)] [InlineData(IntentDefault)] + [InlineData(BindDefault)] public void Embedded(string assetPath) { var suffix = assetPath.Substring("/assets/".Length).Replace('/', '.'); @@ -101,6 +107,7 @@ public void EngineLoadsAfterCoreAndBeforeControls() int store = order.IndexOf(Store); int service = order.IndexOf(Service); int renderer = order.IndexOf(Renderer); + int template = order.IndexOf(Template); int intent = order.IndexOf(Intent); int data = order.IndexOf(Data); int firstControl = order.IndexOf(FirstControl); @@ -110,7 +117,8 @@ public void EngineLoadsAfterCoreAndBeforeControls() Assert.True(store < service, "service must load after store"); Assert.True(service < renderer, "renderer must load after service"); - Assert.True(renderer < intent, "intent must load after renderer"); + Assert.True(renderer < template, "template must load after renderer"); + Assert.True(template < intent, "intent must load after template"); Assert.True(intent < data, "the data base must load after intent"); Assert.True(firstControl >= 0, "a control must be registered"); @@ -128,11 +136,14 @@ public void DefaultsLoadAfterEngine() int service = order.IndexOf(Service); int intent = order.IndexOf(Intent); + int data = order.IndexOf(Data); int serviceDefault = order.IndexOf(ServiceDefault); int intentDefault = order.IndexOf(IntentDefault); + int bindDefault = order.IndexOf(BindDefault); Assert.True(serviceDefault > service, "service default must load after the service engine"); Assert.True(intentDefault > intent, "intent default must load after the intent engine"); + Assert.True(bindDefault > data, "bind default must load after the data base it resolves"); } } } diff --git a/src/WebExpress.WebApp/Assets/js/action/default.js b/src/WebExpress.WebApp/Assets/js/action/default.js index 66f7c53..a5f53f9 100644 --- a/src/WebExpress.WebApp/Assets/js/action/default.js +++ b/src/WebExpress.WebApp/Assets/js/action/default.js @@ -184,3 +184,76 @@ webexpress.webui.Actions.register("popup", { } } }); + +/** + * Dispatch action - sends a named intent with a payload to the store of a + * target Data component, part of the View, State and Service architecture. + * It is the bridge that lets any actionable element feed the unidirectional + * loop without touching the component's DOM or services directly. + * + * Supported attributes: + * data-wx-{primary|secondary}-intent - the intent name (required) + * data-wx-{primary|secondary}-target - id of the target component + * (optional, default: the nearest + * ancestor component) + * data-wx-{primary|secondary}-payload - a json payload (optional) + * + * Example: + * + */ +webexpress.webui.Actions.register("dispatch", { + execute: function (element, prefix, controller, event) { + if (event && typeof event.preventDefault === "function") { + event.preventDefault(); + } + + function attr(name) { + return element.getAttribute("data-wx-" + prefix + "-" + name); + } + + var intent = attr("intent"); + if (!intent) { + console.warn("dispatch action without intent", element); + return; + } + + var payload = null; + var payloadRaw = attr("payload"); + if (payloadRaw) { + try { + payload = JSON.parse(payloadRaw); + } catch (error) { + console.warn("dispatch action with invalid payload", element, error); + return; + } + } + + // resolve the target component: an explicit target id wins, otherwise + // the nearest ancestor component is used + var component = null; + var targetId = attr("target"); + if (targetId) { + var host = document.getElementById(targetId); + component = host ? controller.getInstanceByElement(host) : null; + } else { + var current = element; + while (current && !component) { + var instance = controller.getInstanceByElement(current); + if (instance && typeof instance.dispatch === "function") { + component = instance; + } + current = current.parentElement; + } + } + + if (component && typeof component.dispatch === "function") { + component.dispatch(intent, payload); + } else { + console.warn("dispatch action found no target component", element); + } + } +}); diff --git a/src/WebExpress.WebApp/Assets/js/bind/default.js b/src/WebExpress.WebApp/Assets/js/bind/default.js new file mode 100644 index 0000000..df4ba51 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/bind/default.js @@ -0,0 +1,221 @@ +/** + * The state oriented default binds of WebExpress.WebApp, part of the View, + * State and Service architecture. They complete the existing declarative + * binds of WebExpress.WebUI with the read direction of a controlled component + * (the state bind) and the controlled input pattern (the model bind). Both + * resolve the store of a Data component and feed the same unidirectional + * loop that actions and intents use. + * + * Markup contract: + * data-wx-bind="state" - reflects a store path on the element + * data-wx-bind-store="id" - id of the owning component (optional, + * default: the nearest ancestor component) + * data-wx-bind-path="a.b" - the observed state path + * data-wx-bind-as="text|value|show|class" - the reflection (default text) + * data-wx-bind-class="cls" - the class toggled when as="class" + * + * data-wx-bind="model" - two way binding for inputs + * data-wx-model="a.b" - the bound state path + * data-wx-bind-store="id" - id of the owning component (optional) + */ +(function () { + /** + * Reads a dotted path from a state object. + * @param {object} state - The state. + * @param {string} path - The dotted path. + * @returns {*} The value at the path, or undefined. + */ + function readPath(state, path) { + return String(path || "") + .split(".") + .filter((key) => key.length > 0) + .reduce((current, key) => (current == null ? undefined : current[key]), state); + } + + /** + * Builds a shallow top level patch that sets a dotted path to a value. + * Nested objects along the path are copied, so the patch stays compatible + * with the shallow merge contract of the store. + * @param {object} state - The current state. + * @param {string} path - The dotted path. + * @param {*} value - The new value. + * @returns {object} The patch. + */ + function buildPatch(state, path, value) { + const keys = String(path || "").split(".").filter((key) => key.length > 0); + + if (keys.length === 0) { + return {}; + } + + if (keys.length === 1) { + return { [keys[0]]: value }; + } + + const root = Object.assign({}, state ? state[keys[0]] : null); + let cursor = root; + + for (let i = 1; i < keys.length - 1; i++) { + cursor[keys[i]] = Object.assign({}, cursor[keys[i]]); + cursor = cursor[keys[i]]; + } + + cursor[keys[keys.length - 1]] = value; + + return { [keys[0]]: root }; + } + + /** + * Returns the Data component that owns the given element, by walking up + * the ancestors until an instance with a store is found. + * @param {HTMLElement} element - The starting element. + * @returns {object|null} The component or null. + */ + function componentOf(element) { + let current = element; + + while (current) { + const instance = webexpress.webui.Controller.getInstanceByElement(current); + if (instance && instance.store && typeof instance.setState === "function") { + return instance; + } + current = current.parentElement; + } + + return null; + } + + /** + * Resolves the component a bound element targets and invokes the callback + * once it is available. The component may not be instantiated yet when the + * bind runs, in which case the resolution waits for the mount event that + * every Data component dispatches. + * @param {HTMLElement} element - The bound element. + * @param {Function} callback - Receives the resolved component. + */ + function withComponent(element, callback) { + const id = element.getAttribute("data-wx-bind-store"); + + const resolve = () => { + if (id) { + const host = document.getElementById(id); + const instance = host ? webexpress.webui.Controller.getInstanceByElement(host) : null; + return instance && instance.store ? instance : null; + } + return componentOf(element); + }; + + const component = resolve(); + + if (component) { + callback(component); + return; + } + + const handler = () => { + const resolved = resolve(); + if (resolved) { + document.removeEventListener("webexpress.webapp.data.mount", handler); + callback(resolved); + } + }; + + document.addEventListener("webexpress.webapp.data.mount", handler); + } + + /** + * Registers a cleanup that the controller runs when the element is + * removed from the document, so a bind subscription has a deterministic + * owner and teardown. + * @param {HTMLElement} element - The bound element. + * @param {Function} cleanup - The cleanup function. + */ + function onRemove(element, cleanup) { + (element._wxCleanup = element._wxCleanup || []).push(cleanup); + } + + // state bind - subscribes an element to a store path and reflects it as + // text, as a value, as visibility or as a class (the read direction of a + // controlled component) + webexpress.webui.Binds.register("state", { + bind(element) { + const path = element.getAttribute("data-wx-bind-path") || ""; + + if (!path) { + console.warn("state bind without data-wx-bind-path", element); + return; + } + + withComponent(element, (component) => { + const as = element.getAttribute("data-wx-bind-as") || "text"; + const cls = element.getAttribute("data-wx-bind-class") || ""; + + const apply = (value) => { + if (as === "value") { + const next = value == null ? "" : String(value); + if (element.value !== next) { + element.value = next; + } + } else if (as === "show") { + element.style.display = value ? "" : "none"; + } else if (as === "class") { + if (cls) { + element.classList.toggle(cls, !!value); + } + } else { + element.textContent = value == null ? "" : String(value); + } + }; + + const unsubscribe = component.store.watch((state) => readPath(state, path), apply); + apply(readPath(component.store.getState(), path)); + onRemove(element, unsubscribe); + }); + } + }); + + // model bind - two way binding for inputs: an input event patches the + // store path and a store change updates the input (the controlled input + // pattern expressed declaratively) + webexpress.webui.Binds.register("model", { + bind(element) { + const path = element.getAttribute("data-wx-model") || element.getAttribute("data-wx-bind-path") || ""; + + if (!path) { + console.warn("model bind without data-wx-model", element); + return; + } + + withComponent(element, (component) => { + const isCheckbox = element.type === "checkbox"; + const eventName = isCheckbox || element.tagName === "SELECT" ? "change" : "input"; + + const write = (value) => { + if (isCheckbox) { + element.checked = !!value; + return; + } + const next = value == null ? "" : String(value); + if (document.activeElement !== element && element.value !== next) { + element.value = next; + } + }; + + const onInput = () => { + const value = isCheckbox ? !!element.checked : element.value; + component.setState(buildPatch(component.state, path, value)); + }; + + element.addEventListener(eventName, onInput); + + const unsubscribe = component.store.watch((state) => readPath(state, path), write); + write(readPath(component.store.getState(), path)); + + onRemove(element, () => { + element.removeEventListener(eventName, onInput); + unsubscribe(); + }); + }); + } + }); +})(); diff --git a/src/WebExpress.WebApp/Assets/js/intent/default.js b/src/WebExpress.WebApp/Assets/js/intent/default.js index 089e636..870a0ef 100644 --- a/src/WebExpress.WebApp/Assets/js/intent/default.js +++ b/src/WebExpress.WebApp/Assets/js/intent/default.js @@ -1,8 +1,12 @@ /** - * Default intent definitions for the WebExpress.WebUI intent registry. - * Registers the generic, reusable intents that controls and binds compose with. - * Domain specific intents, for example list search or tab add, are registered - * by the controls that own them during their migration. + * Default intent definitions for the intent registry. + * Registers the generic, reusable intents that controls and binds compose + * with, plus the query intents of the data query control families: the list, + * the table and the tile share the query state contract (search, wql, filter, + * page) and the load surface, so one definition serves all three domains and + * each family keeps its own domain name per the naming convention. Domain + * specific intents that belong to a single control, for example tab add, are + * registered by the control that owns them. * * An intent definition has an optional reduce, which is a pure state transition * that returns a patch, and an optional effect, which performs input or output. @@ -28,3 +32,48 @@ webexpress.webapp.Intents.register("wx/set", { return patch; } }); + +// the query intents of the data query control families (list, table, tile). +// each reducer is a pure state transition and each effect triggers the load +// through the dispatching component, so a test can dispatch an intent and +// assert the resulting state without involving the network. +(function () { + const loadEffect = (context) => ( + typeof context.component?.load === "function" ? context.component.load() : undefined + ); + + for (const domain of ["list", "table", "tile"]) { + // /search - sets the search or wql pattern, resets the page and loads + webexpress.webapp.Intents.register(domain + "/search", { + reduce(state, payload) { + const searchType = payload && payload.searchType != null ? payload.searchType : "basic"; + const pattern = payload && payload.pattern != null ? payload.pattern : ""; + return { + search: searchType === "basic" ? pattern : null, + wql: searchType === "wql" ? pattern : null, + page: 0 + }; + }, + effect: loadEffect + }); + + // /filter - sets the filter, resets the page and loads + webexpress.webapp.Intents.register(domain + "/filter", { + reduce(state, payload) { + return { + filter: payload && payload.pattern != null ? payload.pattern : "", + page: 0 + }; + }, + effect: loadEffect + }); + + // /page - sets the page and loads + webexpress.webapp.Intents.register(domain + "/page", { + reduce(state, payload) { + return { page: payload && payload.page != null ? Number(payload.page) || 0 : 0 }; + }, + effect: loadEffect + }); + } +})(); diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.data.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.data.js index 718556a..12d7cfd 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.data.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.data.js @@ -43,6 +43,8 @@ webexpress.webapp.Data = class extends webexpress.webui.Ctrl { } this._services = options.services || webexpress.webapp.ServiceRegistry.fromElement(element); + this._templateId = options.template + || (element && typeof element.getAttribute === "function" ? element.getAttribute("data-wx-template") : null); } /** @@ -114,19 +116,45 @@ webexpress.webapp.Data = class extends webexpress.webui.Ctrl { this.onMount(this._store.getState()); } + // announce the mount, so binds that target this component can resolve + // its store even when they were bound before the component existed + if (this._element && typeof this._element.dispatchEvent === "function") { + this._element.dispatchEvent(new CustomEvent("webexpress.webapp.data.mount", { + bubbles: true, + detail: { component: this } + })); + } + return this; } /** * Renders the current state into the render root and runs onUpdate after * the first render. The first render is driven by mount and runs onMount - * instead of onUpdate. + * instead of onUpdate. A view referenced through data-wx-template is the + * C# authored view of the component and wins; otherwise the subclass + * render method is used (the Ctrl base carries a no operation render, so + * the template could never win the other way around). A virtual node tree + * is patched by the keyed reconciler, a DOM node replaces the content of + * the render root. * @param {object} state - The current state. */ _apply(state) { - if (typeof this.render === "function" && webexpress.webapp.Renderer) { - const tree = this.render(state); - if (tree !== undefined && tree !== null) { + let tree; + const template = this._templateId && webexpress.webapp.Templates + ? webexpress.webapp.Templates.resolve(this._templateId) + : null; + + if (template) { + tree = template(state, this); + } else if (typeof this.render === "function") { + tree = this.render(state); + } + + if (tree !== undefined && tree !== null) { + if (typeof Node !== "undefined" && tree instanceof Node) { + (this._renderRoot || this._element).replaceChildren(tree); + } else if (webexpress.webapp.Renderer) { webexpress.webapp.Renderer.patch(this._renderRoot || this._element, tree); } } diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.js index 16162e6..eea6873 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.list.js @@ -189,18 +189,43 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { } } + /** + * Dispatches an intent against the list's store and service, mirroring the + * dispatch surface of the Data base, so that the search, paging and filter + * binds and the dispatch action all feed the same unidirectional loop. + * @param {string} name The intent name. + * @param {*} payload The intent payload. + * @returns {*} The return value of the intent effect, when present. + */ + dispatch(name, payload) { + return webexpress.webapp.Intents.dispatch(name, { + store: this._store, + payload: payload, + services: { data: this._service }, + component: this, + element: this._element + }); + } + + /** + * Loads the list when it is backed by a service and visible. Intent + * effects call this after their reducer updated the store. + * @returns {Promise|undefined} Resolves when the load completes. + */ + load() { + if (this._restUri && this._isVisible()) { + return this._load(); + } + return undefined; + } + /** * Sets the search filter and reloads the first page (without modifying order or paging settings). * @param {string} pattern The search pattern (optional, defaults to empty string). * @param {string} searchType The filter type ("basic" or "wql"). */ search(pattern = "", searchType = "basic") { - this._search = searchType === "basic" ? pattern : null; - this._wql = searchType === "wql" ? pattern : null; - this._page = 0; - if (this._restUri && this._isVisible()) { - this._load(); - } + this.dispatch("list/search", { pattern: pattern, searchType: searchType }); } /** @@ -208,12 +233,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { * @param {string} pattern The filter pattern. */ filter(pattern = "") { - this._filter = pattern; - this._page = 0; - - if (this._restUri && this._isVisible()) { - this._load(); - } + this.dispatch("list/filter", { pattern: pattern }); } /** @@ -221,11 +241,7 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { * @param {number} page The current page index. */ paging(page = 0) { - this._page = page; - - if (this._restUri && this._isVisible()) { - this._load(); - } + this.dispatch("list/page", { page: page }); } /** @@ -309,11 +325,13 @@ webexpress.webapp.ListCtrl = class extends webexpress.webui.ListCtrl { totalPages = Math.max(1, Math.ceil(total / this._pageSize)); } - // clamp current page to available range + // clamp current page to available range. the upper bound only applies + // when the total is known, so a page seeded through the data-wx-state + // island survives until the first response reports the real total if (this._page < 0) { this._page = 0; } - if (this._page >= totalPages) { + if (total > 0 && this._page >= totalPages) { this._page = totalPages - 1; } diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.service.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.service.js index d8b12a3..2d54cfb 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.service.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.service.js @@ -35,6 +35,77 @@ webexpress.webapp.ServiceResult = { } }; +/** + * The global error channel of the service layer. Every service reports its + * non abort failures here, which dispatches the + * "webexpress.webapp.service.error" CustomEvent on the document so that an + * unexpected failure is observable in one place without crashing a component. + * An optional toast presents the failure through the existing popup + * notification pipeline; it is opt in through the toast flag because + * components that render their error state inline would otherwise present the + * failure twice. + */ +webexpress.webapp.ErrorChannel = new class { + /** + * Creates the channel. + */ + constructor() { + this.toast = false; + } + + /** + * Reports a failed service result. Dispatches the + * "webexpress.webapp.service.error" event and optionally shows a toast. + * @param {object} result - The normalised failure result. + * @param {object} [context={}] - The reporting context: service, operation. + */ + report(result, context = {}) { + const error = (result && result.error) || {}; + const detail = { + service: context.service || null, + operation: context.operation || null, + kind: error.kind || "unknown", + status: error.status || 0, + message: error.message || "", + retriable: !!error.retriable, + result: result + }; + + document.dispatchEvent(new CustomEvent("webexpress.webapp.service.error", { detail: detail })); + + if (this.toast) { + this._notify(detail); + } + } + + /** + * Shows the failure as a popup notification through the local message + * queue, reusing the PopupNotificationCtrl pipeline. + * @param {object} detail - The reported error detail. + */ + _notify(detail) { + const queue = webexpress.webapp.MessageQueue; + + if (!queue || typeof queue.dispatchLocal !== "function") { + return; + } + + queue.dispatchLocal({ + type: "webexpress.webapp.popup.show", + notification: { + id: "service-error-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8), + heading: detail.service ? "Service \"" + detail.service + "\"" : "Service", + message: detail.message || "request failed", + type: "alert-danger", + icon: null, + durability: 5000, + progress: -1, + created: new Date().toISOString() + } + }); + } +}; + /** * Base class for services. It holds the descriptor configuration and provides * a no operation abort. Concrete services implement the operations they @@ -91,7 +162,8 @@ webexpress.webapp.Service = class { * query: { search: "q", page: "p", pageSize: "l" }, * response: { items: "items", total: "total" }, * headers: { ... }, - * errors: { "404": "webexpress.webapp:error.notfound" } + * errors: { "404": "webexpress.webapp:error.notfound" }, + * retry: { count: 2, delayMs: 300 } * } */ webexpress.webapp.RestService = class extends webexpress.webapp.Service { @@ -102,6 +174,7 @@ webexpress.webapp.RestService = class extends webexpress.webapp.Service { constructor(descriptor) { super(descriptor); this._abort = null; + this._generation = 0; } /** @@ -213,7 +286,7 @@ webexpress.webapp.RestService = class extends webexpress.webapp.Service { if (!response.ok) { const mapped = this._descriptor.errors && this._descriptor.errors[String(response.status)]; const message = mapped || ("request failed with status " + response.status); - return { + const result = { ok: false, data: data, error: { kind: "http", status: response.status, message: message, retriable: response.status >= 500 }, @@ -221,6 +294,8 @@ webexpress.webapp.RestService = class extends webexpress.webapp.Service { response: response, contentType: contentType }; + webexpress.webapp.ErrorChannel.report(result, { service: this._name, operation: (init && init.method) || "GET" }); + return result; } return { ok: true, data: data, error: null, status: response.status, response: response, contentType: contentType }; @@ -229,6 +304,9 @@ webexpress.webapp.RestService = class extends webexpress.webapp.Service { const result = webexpress.webapp.ServiceResult.fail(kind, 0, networkError ? networkError.message : "network error", kind === "network"); result.response = null; result.contentType = ""; + if (kind !== "abort") { + webexpress.webapp.ErrorChannel.report(result, { service: this._name, operation: (init && init.method) || "GET" }); + } return result; } } @@ -250,7 +328,29 @@ webexpress.webapp.RestService = class extends webexpress.webapp.Service { } /** - * Builds the request url from the base uri and the mapped query parameters. + * The default wire names of the closed logical query vocabulary (see the + * naming table in WebExpress/docs/view-state-service.md). A descriptor + * without an explicit query mapping speaks the common REST interaction + * model of WebExpress.WebApp, so the logical names map to the historical + * wire names rather than leaking onto the wire verbatim. + */ + static get defaultQueryMap() { + return { + search: "q", + wql: "wql", + filter: "f", + page: "p", + pageSize: "l", + orderBy: "o", + orderDir: "d", + id: "id" + }; + } + + /** + * Builds the request url from the base uri and the mapped query + * parameters. The descriptor mapping wins, the default vocabulary covers + * the rest, and an unknown logical name passes through verbatim. * @param {object} params - Logical query parameters. * @param {string} [path] - An optional path segment appended to the base. * @returns {string} The request url, absolute or root relative. @@ -259,13 +359,14 @@ webexpress.webapp.RestService = class extends webexpress.webapp.Service { const base = (this._descriptor.baseUri || "") + (path ? path : ""); const url = new URL(base, document.baseURI); const map = this._descriptor.query || {}; + const defaults = webexpress.webapp.RestService.defaultQueryMap; for (const logical of Object.keys(params || {})) { const value = params[logical]; if (value === undefined || value === null) { continue; } - const wire = map[logical] || logical; + const wire = map[logical] || defaults[logical] || logical; url.searchParams.set(wire, String(value)); } @@ -273,14 +374,59 @@ webexpress.webapp.RestService = class extends webexpress.webapp.Service { } /** - * Performs a request and normalises the outcome. A superseded abortable - * request is cancelled, and the abort channel is only cleared when the - * request that owns it completes, so that a newer request is not affected. + * Performs a request, honours the declared retry policy and reports the + * final failure to the error channel. A retriable failure, which is a + * network error or an http 5xx response, is retried up to the configured + * count with the configured delay. A retry that has been superseded by a + * newer abortable request gives up with an abort result, so a stale retry + * never races a fresh query. Aborts are never reported, because an abort + * is the expected consequence of a newer request replacing an older one. * @param {string} method - The http method. * @param {object} request - The request descriptor. * @returns {Promise} A normalised result. */ async _send(method, request) { + const retry = this._descriptor.retry || {}; + const attempts = 1 + Math.max(0, Number(retry.count) || 0); + const delay = Math.max(0, Number(retry.delayMs) || 0); + const generation = request.abortable ? ++this._generation : null; + + let result = null; + + for (let attempt = 0; attempt < attempts; attempt++) { + if (attempt > 0 && delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)); + } + + if (generation !== null && generation !== this._generation) { + result = webexpress.webapp.ServiceResult.fail("abort", 0, "request was superseded", false); + break; + } + + result = await this._sendOnce(method, request); + + if (result.ok || !result.error || !result.error.retriable || result.error.kind === "abort") { + break; + } + } + + if (!result.ok && result.error && result.error.kind !== "abort") { + webexpress.webapp.ErrorChannel.report(result, { service: this._name, operation: method }); + } + + return result; + } + + /** + * Performs a single request and normalises the outcome. A superseded + * abortable request is cancelled, and the abort channel is only cleared + * when the request that owns it completes, so that a newer request is not + * affected. + * @param {string} method - The http method. + * @param {object} request - The request descriptor. + * @returns {Promise} A normalised result. + */ + async _sendOnce(method, request) { let abort = null; if (request.abortable) { diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.js index ad28620..1bdfb20 100644 --- a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.js +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.table.js @@ -226,11 +226,13 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl totalPages = Math.max(1, Math.ceil(total / this._pageSize)); } - // clamp current page to available range + // clamp current page to available range. the upper bound only applies + // when the total is known, so a page seeded through the data-wx-state + // island survives until the first response reports the real total if (this._page < 0) { this._page = 0; } - if (this._page >= totalPages) { + if (total > 0 && this._page >= totalPages) { this._page = totalPages - 1; } @@ -535,30 +537,44 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl } } + /** + * Dispatches an intent against the table's store and service, mirroring + * the dispatch surface of the Data base, so that the search, paging and + * filter binds and the dispatch action all feed the same unidirectional + * loop. + * @param {string} name The intent name. + * @param {*} payload The intent payload. + * @returns {*} The return value of the intent effect, when present. + */ + dispatch(name, payload) { + return webexpress.webapp.Intents.dispatch(name, { + store: this._store, + payload: payload, + services: { data: this._service }, + component: this, + element: this._element + }); + } + + /** + * Loads the table when it is backed by a service and visible. Intent + * effects call this after their reducer updated the store. + * @returns {Promise|undefined} Resolves when the load completes. + */ + load() { + if (this._restUri && this._isVisible()) { + return this._load(); + } + return undefined; + } + /** * Sets the search filter and reloads the first page. * @param {string} pattern - Search pattern * @param {string} [searchType="basic"] - Filter type ("basic" or "wql"). */ search(pattern = "", searchType = "basic") { - if (searchType === "basic") { - this._search = pattern; - this._wql = null; - } else if (searchType === "wql") { - this._search = null; - this._wql = pattern; - } else { - this._search = null; - this._wql = null; - } - - this._page = 0; - - if (this._restUri) { - if (this._isVisible()) { - this._load(); - } - } + this.dispatch("table/search", { pattern: pattern, searchType: searchType }); } /** @@ -566,14 +582,7 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl * @param {string} pattern - Filter pattern. */ filter(pattern = "") { - this._filter = pattern; - this._page = 0; - - if (this._restUri) { - if (this._isVisible()) { - this._load(); - } - } + this.dispatch("table/filter", { pattern: pattern }); } /** @@ -581,13 +590,7 @@ webexpress.webapp.TableCtrl = class extends webexpress.webui.TableCtrlReorderabl * @param {string} page - The current page pattern. */ paging(page = 0) { - this._page = page; - - if (this._restUri) { - if (this._isVisible()) { - this._load(); - } - } + this.dispatch("table/page", { page: page }); } /** diff --git a/src/WebExpress.WebApp/Assets/js/webexpress.webapp.template.js b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.template.js new file mode 100644 index 0000000..bfacc42 --- /dev/null +++ b/src/WebExpress.WebApp/Assets/js/webexpress.webapp.template.js @@ -0,0 +1,89 @@ +var webexpress = webexpress || {} +webexpress.webapp = webexpress.webapp || {} + +/** + * Registry of view templates, part of the View, State and Service + * architecture. A template is a render function that receives the current + * state and the owning component and returns a virtual node tree for the + * keyed reconciler or a DOM node. Components reference a template through the + * data-wx-template attribute that the C# layer emits, so a view can be + * authored in C# and reused on the client. + * + * The registry follows the same register, get and unregister shape as the + * Actions, Binds, Intents and Services registries. A template id that is not + * registered resolves against a server rendered