From 8ab4f2a663e2619b2bd1b299f82314c5c93075a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 18 Feb 2019 15:59:54 +0100 Subject: [PATCH] Boards module (#7008) * Hack spike to show D&D use case [ci skip] * Add ordered work packages * Save order on existing work packages * Boards WIP * CDK drag * Add dragula handler [ci skip] * Add filter to return all manual sorted work packages * Print icon on hover * Boards routing and list components * Better loading indicator on list with streaming result [ci skip] * Add new board and list buttons [ci skip] * Post new query [ci skip] * Added creation of new board lists with persisted queries [ci skip] * Render placeholder row in empty queries [ci skip] * Push boards on grid * Use base class in scope [ci skip] * Extend api for options * Hack spike to show D&D use case [ci skip] * Add ordered work packages * Save order on existing work packages * Boards WIP * CDK drag * Add dragula handler [ci skip] * Add filter to return all manual sorted work packages * Print icon on hover * Boards routing and list components * Better loading indicator on list with streaming result [ci skip] * Add new board and list buttons [ci skip] * Post new query [ci skip] * Added creation of new board lists with persisted queries [ci skip] * Render placeholder row in empty queries [ci skip] * Save queries in grids [ci skip] * Renaming queries [ci skip] * Add existing work packages to board [ci skip] * Introduce card view component for work packages * Extend grids to allow project scope for boards (#7025) Extends the grid backend to also be able to handle boards. In particular, it adds the ability of boards to be attached to projects and changes the page property of grids to a scope property that better describes that more than one board can belong to the same scope (e.g. /projects/:project_id/boards). For a fully featured board, though, widgets need to be able to store options, so that they can store queries. Those widgets might also need to have custom processing and validation. That part has not been implemented. * introduce project association for boards * have dedicated grid registration classes * update and create form for board grids * extract defaults into grid registration [ci skip] * Add drag and drop to card view [ci skip] * Add options to grid * Fix option migration name * Renaming boards [ci skip] * Frontend deletion of boards * Avoid map on NodeList which doesnt exist [ci skip] * Add inline create to boards [ci skip] * Smaller create button [ci skip] * Add navigation for boards * Make inner grid same height * Replace index page with table * Workaround for widget registration [ci skip] * Fixed height for cards and tables [ci skip] * Implement escape as cancel d&d action [ci skip] * Fix and extend grid specs for name and options * Extend board specs for required name * Fix migration for MySQL references https://stackoverflow.com/a/45825566/420614 * Make board list extend from widget Since we cannot configure widgets yet, it's not yet possible to use a board-list widget anywhere. * Fix specs * Fix escape listener removal [ci skip] * Fix renamed to_path in relation spec [ci skip] * Allow deletion of grids for boards * Avoid reloading resource multiple times with replays * Frontend synchronization on deletion [ci skip] * Delete through table * Use work packages board path * Use work packages board path * Fix augmented columns breaking re-rendering * Fix duplicated permission with forums * Strengthen tab switch in specs * Add hidden flag for project-context queries Allows the API to create a hidden query that will not be rendered to the user even if it is within a project context. * private queries * Add hidden flag for project-context queries Allows the API to create a hidden query that will not be rendered to the user even if it is within a project context. * Move boards below work packages * Add Board configuration modal * Fix reloading with onPush * Saving / Switching of display mode [ci skip] * Extract wp-query-selectable-title into common component * Fix renaming of board-list * Fix auto-hide notifications in boards * Add permissions to seeders * Reorder lists in board * Linting * Remove default gravatar from settings * Show assignees avatar in the card view of WPs * Fix specs * Add missing method * Fix timeline icon * Use URL as input to be able to show avatars for groups, too * Fix test * Add further specs * Use correct data attribute to avoid unnecessary data base calls * Add further specs * Deletion of board lists * Pass permission via gon to decide whether we can create boards * Fix rename spec * Cherry-pick of 7873d59 and 30abc7f --- Gemfile | 1 + Gemfile.lock | 9 + Gemfile.modules | 1 + .../openproject-icon-font.svg | 355 ++++----- .../openproject-icon-font.ttf | Bin 43860 -> 43976 bytes .../openproject-icon-font.woff | Bin 25612 -> 25660 bytes .../openproject-icon-font.woff2 | Bin 20736 -> 20740 bytes .../openproject_icon/src/drag-handle.svg | 1 + .../openproject_icon/src/view-timeline.svg | 1 + .../content/_editable_toolbar.sass | 1 + .../stylesheets/content/_in_place_editing.lsg | 6 +- .../content/work_packages/_table_content.sass | 11 +- .../fonts/_openproject_icon_definitions.scss | 702 +++++++++--------- .../fonts/_openproject_icon_font.lsg | 1 + .../fonts/_openproject_icon_font.sass | 4 + app/assets/stylesheets/layout/_main_menu.sass | 2 +- app/assets/stylesheets/layout/_toolbar.sass | 6 - .../stylesheets/openproject/_generic.sass | 12 + app/assets/stylesheets/vendor/_dragula.sass | 4 - app/contracts/model_contract.rb | 16 + app/contracts/queries/base_contract.rb | 2 + app/controllers/application_controller.rb | 8 +- app/models/application_record.rb | 3 + app/models/ordered_work_package.rb | 36 + app/models/queries/filters.rb | 3 +- .../queries/filters/strategies/empty_value.rb | 41 + .../operators/ordered_work_packages.rb | 41 + app/models/queries/work_packages.rb | 1 + .../columns/manual_sorting_column.rb | 43 ++ .../filter/manual_sort_filter.rb | 70 ++ app/models/query.rb | 7 +- app/models/query/manual_sorting.rb | 77 ++ config/locales/en.yml | 1 + config/locales/js-en.yml | 4 + ...1121174153_create_ordered_work_packages.rb | 10 + .../20190129083842_add_project_to_grid.rb | 7 + .../20190205090102_add_options_to_grid.rb | 8 + docs/api/apiv3/endpoints/grids.apib | 70 +- frontend/angular.json | 5 + frontend/npm-shrinkwrap.json | 5 + frontend/package.json | 2 + frontend/src/app/angular4-modules.ts | 3 + .../api-work-packages.service.ts | 4 +- .../op-file-upload.service.spec.ts | 2 + .../op-settings-dropdown-menu.directive.ts | 6 +- .../routing/my-page/my-page.component.ts | 4 +- .../components/states/state-cache.service.ts | 44 ++ .../table-pagination/pagination-service.ts | 6 +- .../user-avatar/user-avatar.component.html | 2 +- .../user/user-avatar/user-avatar.component.ts | 51 +- .../card-reorder-query.service.ts | 13 + .../wp-card-view/wp-card-view.component.html | 50 ++ .../wp-card-view/wp-card-view.component.sass | 50 ++ .../wp-card-view/wp-card-view.component.ts | 249 +++++++ .../wp-edit-form/work-package-edit-form.ts | 1 + .../drag-and-drop/drag-drop-handle-builder.ts | 21 + .../modes/grouped/grouped-rows-builder.ts | 9 +- .../modes/hierarchy/hierarchy-rows-builder.ts | 7 +- .../modes/plain/plain-rows-builder.ts | 6 +- .../builders/primary-render-pass.ts | 13 +- .../builders/rows/single-row-builder.ts | 38 +- .../state/drag-and-drop-transformer.ts | 73 ++ .../handlers/table-handler-registry.ts | 4 +- .../components/wp-fast-table/wp-fast-table.ts | 10 + .../wp-inline-create.component.ts | 15 +- .../wp-inline-create.service.ts | 4 +- .../wp-list/wp-list-invalid-query.service.ts | 6 +- .../components/wp-new/wp-create.service.ts | 10 +- .../wp-query-select-dropdown.component.ts | 3 +- .../wp-query-selectable-title.html | 29 - .../wp-query/query-filters.service.ts | 30 + .../configuration-modal/tab-portal-outlet.ts | 5 +- .../embedded/wp-embedded-base.component.ts | 2 +- .../embedded/wp-embedded-table.component.ts | 10 +- .../sort-header/sort-header.directive.html | 5 + .../sort-header/sort-header.directive.ts | 17 +- .../wp-table/wp-table-configuration.ts | 6 + .../wp-table/wp-table.directive.html | 4 +- .../components/wp-table/wp-table.directive.ts | 4 +- .../components/wp-table/wp-table.styles.sass | 4 +- .../app/helpers/rxjs/with-loading-toggle.ts | 22 + .../boards/board/board-cache.service.ts | 40 + .../modules/boards/board/board-dm.service.ts | 104 +++ .../board-list/board-inline-create.service.ts | 82 ++ .../board-list/board-list.component.html | 36 + .../board-list/board-list.component.sass | 28 + .../board/board-list/board-list.component.ts | 147 ++++ .../board/board-list/board-lists.service.ts | 95 +++ .../modules/boards/board/board.component.html | 64 ++ .../modules/boards/board/board.component.sass | 57 ++ .../modules/boards/board/board.component.ts | 120 +++ .../app/modules/boards/board/board.service.ts | 95 +++ .../src/app/modules/boards/board/board.ts | 50 ++ .../board-configuration.modal.html | 48 ++ .../board-configuration.modal.ts | 127 ++++ .../board-configuration.service.ts | 23 + .../tabs/display-settings-tab.component.html | 31 + .../tabs/display-settings-tab.component.ts | 39 + ...oard-inline-add-autocompleter.component.ts | 125 ++++ .../board-inline-add-autocompleter.html | 16 + .../board-inline-add-autocompleter.sass | 5 + .../boards-toolbar-menu.directive.ts | 128 ++++ .../boards-root/boards-root.component.ts | 12 + .../boards-sidebar/boards-menu.component.html | 11 + .../boards-sidebar/boards-menu.component.ts | 25 + .../drag-and-drop/drag-and-drop.service.ts | 94 +++ .../drag-and-drop/reorder-query.service.ts | 70 ++ .../boards-index-page.component.html | 90 +++ .../index-page/boards-index-page.component.ts | 62 ++ .../boards/openproject-boards.module.ts | 105 +++ .../editable-toolbar-title.component.ts} | 104 +-- .../editable-toolbar-title.html | 31 + .../editable-toolbar-title.sass} | 8 +- .../src/app/modules/common/gon/gon.service.ts | 53 ++ .../loading-indicator.service.ts | 88 ++- .../common/openproject-common.module.ts | 10 + .../common/path-helper/apiv3/apiv3-paths.ts | 14 +- .../path-helper/apiv3/path-resources.ts | 6 + .../apiv3/projects/apiv3-project-paths.ts | 5 +- .../common/path-helper/path-helper.service.ts | 16 +- .../wp-editing-portal-service.ts | 2 - .../hal/dm-services/abstract-dm.service.ts | 2 +- .../dm-services/configuration-dm.service.ts | 16 +- .../hal/dm-services/grid-dm.service.ts | 3 +- .../hal/dm-services/payload-dm.service.ts | 2 +- .../hal/dm-services/query-dm.service.ts | 50 +- .../hal/dm-services/query-form-dm.service.ts | 19 +- .../modules/hal/resources/grid-resource.ts | 16 + .../hal/resources/grid-widget-resource.ts | 2 + .../app/modules/hal/resources/hal-resource.ts | 3 - .../query-filter-instance-schema-resource.ts | 11 +- .../modules/hal/resources/query-resource.ts | 8 + .../modules/hal/resources/schema-resource.ts | 4 +- .../hal/services/hal-resource.service.ts | 14 + .../app/modules/router/openproject.routes.ts | 12 +- .../openproject-work-packages.module.ts | 16 +- .../routing/wp-list/wp-list.component.ts | 16 +- .../routing/wp-list/wp.list.component.html | 11 +- frontend/src/assets/.gitkeep | 0 frontend/src/assets/sass/_helpers.sass | 1 + frontend/src/typings/shims.d.ts | 3 + lib/api/v3/queries/query_representer.rb | 52 +- ...nual_sort_filter_dependency_representer.rb | 39 + .../client_preference_extractor.rb | 1 + .../openproject-auth_plugins.gemspec | 1 + .../settings/_openproject_avatars.html.erb | 1 - .../lib/open_project/avatars/engine.rb | 1 - .../avatars/patches/avatar_helper_patch.rb | 2 +- .../spec/features/shared_avatar_examples.rb | 4 +- .../spec/helpers/avatar_helper_spec.rb | 6 +- .../app/controllers/boards/base_controller.rb | 4 + .../controllers/boards/boards_controller.rb | 37 + modules/boards/app/models/boards/grid.rb | 60 ++ modules/boards/app/seeders/role_seeder.rb | 24 + .../app/views/boards/boards/_menu_board.html | 1 + .../app/views/boards/boards/index.html.erb | 1 + modules/boards/config/locales/en.yml | 8 + modules/boards/config/locales/js-en.yml | 10 + modules/boards/config/routes.rb | 9 + modules/boards/lib/open_project/boards.rb | 5 + .../boards/lib/open_project/boards/engine.rb | 57 ++ .../open_project/boards/grid_registration.rb | 62 ++ .../boards/lib/open_project/boards/version.rb | 7 + modules/boards/lib/openproject-boards.rb | 1 + modules/boards/openproject-boards.gemspec | 19 + .../contracts/grids/create_contract_spec.rb | 56 ++ .../boards/spec/factories/board_factory.rb | 36 + .../spec/features/board_management_spec.rb | 157 ++++ .../spec/features/support/board_index_page.rb | 70 ++ .../spec/features/support/board_page.rb | 191 +++++ .../boards/grid_registration_spec.rb | 37 + .../boards/spec/models/boards/grid_spec.rb | 52 ++ .../queries/grids/query_integration_spec.rb | 83 +++ .../grids/grids_create_form_resource_spec.rb | 175 +++++ .../api/v3/grids/grids_resource_spec.rb | 504 +++++++++++++ .../grids/grids_update_form_resource_spec.rb | 169 +++++ .../spec/routing/boards_routing_spec.rb | 43 ++ .../app/views/roles/_form.html.erb | 1 + .../app/contracts/grids/base_contract.rb | 20 +- .../app/contracts/grids/create_contract.rb | 11 +- .../app/contracts/grids/delete_contract.rb | 57 ++ .../api/v3/grids/create_form_api.rb | 3 +- .../api/v3/grids/grid_representer.rb | 58 +- .../app/controllers/api/v3/grids/grids_api.rb | 33 +- .../grids/schemas/grid_schema_representer.rb | 10 +- .../api/v3/grids/update_form_api.rb | 6 +- .../api/v3/grids/widget_representer.rb | 2 + modules/grids/app/models/grids/grid.rb | 10 +- modules/grids/app/models/grids/my_page.rb | 28 - .../app/queries/grids/filters/page_filter.rb | 53 +- .../app/queries/grids/filters/scope_filter.rb | 99 +++ modules/grids/app/queries/grids/query.rb | 8 +- .../app/services/grids/create_service.rb | 7 +- .../app/services/grids/delete_service.rb | 60 ++ .../app/services/grids/update_service.rb | 10 +- modules/grids/config/locales/en.yml | 2 +- modules/grids/lib/grids/configuration.rb | 162 +++- modules/grids/lib/grids/engine.rb | 12 +- modules/grids/lib/grids/factory.rb | 72 ++ .../lib/grids/my_page_grid_registration.rb | 49 ++ .../contracts/grids/create_contract_spec.rb | 50 +- .../spec/contracts/grids/shared_examples.rb | 36 +- .../contracts/grids/update_contract_spec.rb | 14 +- modules/grids/spec/factories/grid_factory.rb | 21 + .../grid_payload_representer_parsing_spec.rb | 6 +- .../grids/grid_representer_rendering_spec.rb | 9 +- .../schemas/grid_schema_representer_spec.rb | 28 +- .../grids/spec/models/grids/my_page_spec.rb | 1 + .../grids/spec/models/grids/shared_model.rb | 23 + ...ge_filter_spec.rb => scope_filter_spec.rb} | 15 +- ...uery_spec.rb => query_integration_spec.rb} | 23 +- .../grids/grids_create_form_resource_spec.rb | 60 +- .../api/v3/grids/grids_resource_spec.rb | 38 +- .../grids/grids_update_form_resource_spec.rb | 33 +- .../services/grids/create_service_spec.rb | 127 ++-- .../grids/set_attributes_service_spec.rb | 4 +- .../work_packages_controller_spec.rb | 2 +- .../multi_user_custom_field_spec.rb | 2 +- .../multi_value_custom_field_spec.rb | 2 +- .../menu_items/query_menu_item_spec.rb | 5 +- ...{indem_sums_spec.rb => index_sums_spec.rb} | 0 .../table/queries/filter_spec.rb | 12 +- .../queries/query_name_inline_edit_spec.rb | 2 +- .../queries/query_representer_parsing_spec.rb | 20 + .../filter/manual_sort_filter_spec.rb | 49 ++ .../work_packages/manual_sorting_spec.rb | 60 ++ .../components/work_packages/query_title.rb | 22 +- .../table_configuration_modal.rb | 14 +- spec/support/contracts/shared.rb | 26 + .../matchers/has_conditional_selector.rb | 43 ++ .../abstract_work_package.rb | 0 .../abstract_work_package_create.rb | 2 +- .../embedded_work_packages_table.rb | 2 +- .../{ => work_packages}/full_work_package.rb | 2 +- .../full_work_package_create.rb | 2 +- .../{ => work_packages}/split_work_package.rb | 4 +- .../split_work_package_create.rb | 2 +- .../pages/work_packages/work_package_card.rb | 41 + .../work_packages_table.rb | 2 +- .../work_packages_timeline.rb | 2 +- tslint.json | 2 +- 241 files changed, 7267 insertions(+), 1193 deletions(-) create mode 100644 app/assets/fonts/openproject_icon/src/drag-handle.svg create mode 100644 app/assets/fonts/openproject_icon/src/view-timeline.svg create mode 100644 app/models/application_record.rb create mode 100644 app/models/ordered_work_package.rb create mode 100644 app/models/queries/filters/strategies/empty_value.rb create mode 100644 app/models/queries/operators/ordered_work_packages.rb create mode 100644 app/models/queries/work_packages/columns/manual_sorting_column.rb create mode 100644 app/models/queries/work_packages/filter/manual_sort_filter.rb create mode 100644 app/models/query/manual_sorting.rb create mode 100644 db/migrate/20181121174153_create_ordered_work_packages.rb create mode 100644 db/migrate/20190129083842_add_project_to_grid.rb create mode 100644 db/migrate/20190205090102_add_options_to_grid.rb create mode 100644 frontend/src/app/components/wp-card-view/card-reorder-query.service.ts create mode 100644 frontend/src/app/components/wp-card-view/wp-card-view.component.html create mode 100644 frontend/src/app/components/wp-card-view/wp-card-view.component.sass create mode 100644 frontend/src/app/components/wp-card-view/wp-card-view.component.ts create mode 100644 frontend/src/app/components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-builder.ts create mode 100644 frontend/src/app/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts delete mode 100644 frontend/src/app/components/wp-query-select/wp-query-selectable-title.html create mode 100644 frontend/src/app/components/wp-query/query-filters.service.ts create mode 100644 frontend/src/app/helpers/rxjs/with-loading-toggle.ts create mode 100644 frontend/src/app/modules/boards/board/board-cache.service.ts create mode 100644 frontend/src/app/modules/boards/board/board-dm.service.ts create mode 100644 frontend/src/app/modules/boards/board/board-list/board-inline-create.service.ts create mode 100644 frontend/src/app/modules/boards/board/board-list/board-list.component.html create mode 100644 frontend/src/app/modules/boards/board/board-list/board-list.component.sass create mode 100644 frontend/src/app/modules/boards/board/board-list/board-list.component.ts create mode 100644 frontend/src/app/modules/boards/board/board-list/board-lists.service.ts create mode 100644 frontend/src/app/modules/boards/board/board.component.html create mode 100644 frontend/src/app/modules/boards/board/board.component.sass create mode 100644 frontend/src/app/modules/boards/board/board.component.ts create mode 100644 frontend/src/app/modules/boards/board/board.service.ts create mode 100644 frontend/src/app/modules/boards/board/board.ts create mode 100644 frontend/src/app/modules/boards/board/configuration-modal/board-configuration.modal.html create mode 100644 frontend/src/app/modules/boards/board/configuration-modal/board-configuration.modal.ts create mode 100644 frontend/src/app/modules/boards/board/configuration-modal/board-configuration.service.ts create mode 100644 frontend/src/app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component.html create mode 100644 frontend/src/app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component.ts create mode 100644 frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.component.ts create mode 100644 frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.html create mode 100644 frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.sass create mode 100644 frontend/src/app/modules/boards/board/toolbar-menu/boards-toolbar-menu.directive.ts create mode 100644 frontend/src/app/modules/boards/boards-root/boards-root.component.ts create mode 100644 frontend/src/app/modules/boards/boards-sidebar/boards-menu.component.html create mode 100644 frontend/src/app/modules/boards/boards-sidebar/boards-menu.component.ts create mode 100644 frontend/src/app/modules/boards/drag-and-drop/drag-and-drop.service.ts create mode 100644 frontend/src/app/modules/boards/drag-and-drop/reorder-query.service.ts create mode 100644 frontend/src/app/modules/boards/index-page/boards-index-page.component.html create mode 100644 frontend/src/app/modules/boards/index-page/boards-index-page.component.ts create mode 100644 frontend/src/app/modules/boards/openproject-boards.module.ts rename frontend/src/app/{components/wp-query-select/wp-query-selectable-title.component.ts => modules/common/editable-toolbar-title/editable-toolbar-title.component.ts} (60%) create mode 100644 frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.html rename frontend/src/app/{components/wp-query-select/wp-query-selectable-title.sass => modules/common/editable-toolbar-title/editable-toolbar-title.sass} (59%) create mode 100644 frontend/src/app/modules/common/gon/gon.service.ts delete mode 100644 frontend/src/assets/.gitkeep create mode 100644 frontend/src/assets/sass/_helpers.sass create mode 100644 lib/api/v3/queries/schemas/manual_sort_filter_dependency_representer.rb create mode 100644 modules/boards/app/controllers/boards/base_controller.rb create mode 100644 modules/boards/app/controllers/boards/boards_controller.rb create mode 100644 modules/boards/app/models/boards/grid.rb create mode 100644 modules/boards/app/seeders/role_seeder.rb create mode 100644 modules/boards/app/views/boards/boards/_menu_board.html create mode 100644 modules/boards/app/views/boards/boards/index.html.erb create mode 100644 modules/boards/config/locales/en.yml create mode 100644 modules/boards/config/locales/js-en.yml create mode 100644 modules/boards/config/routes.rb create mode 100644 modules/boards/lib/open_project/boards.rb create mode 100644 modules/boards/lib/open_project/boards/engine.rb create mode 100644 modules/boards/lib/open_project/boards/grid_registration.rb create mode 100644 modules/boards/lib/open_project/boards/version.rb create mode 100644 modules/boards/lib/openproject-boards.rb create mode 100644 modules/boards/openproject-boards.gemspec create mode 100644 modules/boards/spec/contracts/grids/create_contract_spec.rb create mode 100644 modules/boards/spec/factories/board_factory.rb create mode 100644 modules/boards/spec/features/board_management_spec.rb create mode 100644 modules/boards/spec/features/support/board_index_page.rb create mode 100644 modules/boards/spec/features/support/board_page.rb create mode 100644 modules/boards/spec/lib/open_project/boards/grid_registration_spec.rb create mode 100644 modules/boards/spec/models/boards/grid_spec.rb create mode 100644 modules/boards/spec/queries/grids/query_integration_spec.rb create mode 100644 modules/boards/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb create mode 100644 modules/boards/spec/requests/api/v3/grids/grids_resource_spec.rb create mode 100644 modules/boards/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb create mode 100644 modules/boards/spec/routing/boards_routing_spec.rb create mode 100644 modules/grids/app/contracts/grids/delete_contract.rb create mode 100644 modules/grids/app/queries/grids/filters/scope_filter.rb create mode 100644 modules/grids/app/services/grids/delete_service.rb create mode 100644 modules/grids/lib/grids/factory.rb create mode 100644 modules/grids/lib/grids/my_page_grid_registration.rb rename modules/grids/spec/queries/grids/filters/{page_filter_spec.rb => scope_filter_spec.rb} (84%) rename modules/grids/spec/queries/grids/{query_spec.rb => query_integration_spec.rb} (77%) rename spec/features/work_packages/{indem_sums_spec.rb => index_sums_spec.rb} (100%) create mode 100644 spec/models/queries/work_packages/filter/manual_sort_filter_spec.rb create mode 100644 spec/models/queries/work_packages/manual_sorting_spec.rb create mode 100644 spec/support/contracts/shared.rb create mode 100644 spec/support/matchers/has_conditional_selector.rb rename spec/support/pages/{ => work_packages}/abstract_work_package.rb (100%) rename spec/support/pages/{ => work_packages}/abstract_work_package_create.rb (97%) rename spec/support/pages/{ => work_packages}/embedded_work_packages_table.rb (96%) rename spec/support/pages/{ => work_packages}/full_work_package.rb (96%) rename spec/support/pages/{ => work_packages}/full_work_package_create.rb (96%) rename spec/support/pages/{ => work_packages}/split_work_package.rb (94%) rename spec/support/pages/{ => work_packages}/split_work_package_create.rb (96%) create mode 100644 spec/support/pages/work_packages/work_package_card.rb rename spec/support/pages/{ => work_packages}/work_packages_table.rb (98%) rename spec/support/pages/{ => work_packages}/work_packages_timeline.rb (98%) diff --git a/Gemfile b/Gemfile index 2816dbd7b5c..e342cab14ce 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,7 @@ ruby '~> 2.6.1' gem 'actionpack-xml_parser', '~> 2.0.0' gem 'activemodel-serializers-xml', '~> 1.0.1' +gem 'activerecord-import', '~> 0.28.1' gem 'activerecord-session_store', '~> 1.1.0' gem 'rails', '~> 5.2.2' gem 'responders', '~> 2.4' diff --git a/Gemfile.lock b/Gemfile.lock index f9edb34a27e..184c18dc638 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -117,6 +117,11 @@ PATH acts_as_silent_list (~> 3.0.0) openproject-pdf_export (= 8.3.0) +PATH + remote: modules/boards + specs: + openproject-boards (8.3.0) + PATH remote: modules/costs specs: @@ -250,6 +255,8 @@ GEM activemodel (= 5.2.2) activesupport (= 5.2.2) arel (>= 9.0) + activerecord-import (0.28.1) + activerecord (>= 3.2) activerecord-session_store (1.1.1) actionpack (>= 4.0) activerecord (>= 4.0) @@ -887,6 +894,7 @@ PLATFORMS DEPENDENCIES actionpack-xml_parser (~> 2.0.0) activemodel-serializers-xml (~> 1.0.1) + activerecord-import (~> 0.28.1) activerecord-session_store (~> 1.1.0) acts_as_list (~> 0.9.9) acts_as_tree (~> 2.8.0) @@ -954,6 +962,7 @@ DEPENDENCIES openproject-auth_saml! openproject-avatars! openproject-backlogs! + openproject-boards! openproject-costs! openproject-documents! openproject-github_integration! diff --git a/Gemfile.modules b/Gemfile.modules index 7cbe4c9df16..f44339d05ec 100644 --- a/Gemfile.modules +++ b/Gemfile.modules @@ -40,4 +40,5 @@ group :opf_plugins do gem 'openproject-ldap_groups', path: 'modules/ldap_groups' gem 'grids', path: 'modules/grids' + gem 'openproject-boards', path: 'modules/boards' end diff --git a/app/assets/fonts/openproject_icon/openproject-icon-font.svg b/app/assets/fonts/openproject_icon/openproject-icon-font.svg index 839193dbc1e..9a49fcda398 100644 --- a/app/assets/fonts/openproject_icon/openproject-icon-font.svg +++ b/app/assets/fonts/openproject_icon/openproject-icon-font.svg @@ -208,527 +208,530 @@ - + - - + - - + diff --git a/app/assets/fonts/openproject_icon/openproject-icon-font.ttf b/app/assets/fonts/openproject_icon/openproject-icon-font.ttf index a0e4b9f84b482e2cddac31b5c49ad8b394b6b55e..2eca193d106b57ce4315c0ce69719b5dad41f12a 100644 GIT binary patch delta 893 zcmZ8fdq`7Z6hGfp=Wy42&AVya-10iznr^u{Z+qx%=;DShA4~%BZE+Y^Nsv8q7na-xJ78pQDz_v5EJ!u*e2k;%@9k( zVy0Lucy*3aYWxz*<-b>tO?I1Sf2QTCnkh0zHcRgl7saKaWSLxSL$s zP2^e9_=Wk9@Kg+{^n1x{RPFC0_b1k3>MehO38aZsJh>QN8i&gUG1q+5C!pUw%M8sh`%*8I*Om~v$>5KVIHiOt-fy2TG}mFEl;dstJ&ISJ!uWu zz_!A6-!8Q0*|*wz>>o?$5_d^osjRfC^aHz&<=De)key`1Wd&u|%Dy`oN0Vd3FpWsRAm13;`ATodsp#Z*oJcu9((m;Zbm~JeJ$B_JIk%Ifd+5Z9sI1X{#v#5R% q)qKcQv>VOyRB#3nXn_x3d`gfa_g>e|e2=Te-PFk68#*h%-}5)TXaS}G delta 767 zcmXv|ZAepL6h8OPuXKLT>zvzk>_g|SZfgbI!t7{PPMvBIu13 z+K(V=ZSQieGDGi>-xdJF*VfVK7+vb>2VjwiTW@!`I&lKKi)uE~XI^)-H*n~|V0;fC=JI{Nus8r1(jr*at%aB34Zk4cfi;2U-CCH zsEn~pXXZyin1B*=3tk8ogd4)YB1+UL>Jbfzro|fZMe%d-TCaqZ*d$KLf@Cv`lU0?~ zpS6=cFJ(#XQm^!N4w>^HXEtX=7A8~3)=7%&A^o}h+^Srsd`dnmpOYUb_=-w}Q!%Aj z$*;>FQW8pqvP!wB+*h$xH7bwFuR2s0seJ_|%^8hLv#f2<4r_hdE$u$Vq9l}#s-!OT zQ;(^=!a*Ibv+DYEle!&!oW4@uqW9^)>HiqKh7BVa)y5{{yz#KeP}EkmS6pBG#*}T+ zm~1AOY09)%5>axyWZ9fxE;IL=r_Af65vA5r4*;D>eBsll3)I)J0Np*YhSB!PC7jNG HIvVj0p&aF1 diff --git a/app/assets/fonts/openproject_icon/openproject-icon-font.woff b/app/assets/fonts/openproject_icon/openproject-icon-font.woff index 6094ac60abbbffc38cf8e3fe7cffdc2d515c9adf..3d7ddc4d403d83ef040657068191673191d46bf8 100644 GIT binary patch delta 24567 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 delta 24491 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 OTU-AFO-kn?0002ztGCer diff --git a/app/assets/fonts/openproject_icon/openproject-icon-font.woff2 b/app/assets/fonts/openproject_icon/openproject-icon-font.woff2 index 64a19bc4acce58310e73e33d9926401fc58afa71..bc0b5777d78d902c38a4cc15d4f098a62c33002e 100644 GIT binary patch literal 20740 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 \ No newline at end of file diff --git a/app/assets/fonts/openproject_icon/src/view-timeline.svg b/app/assets/fonts/openproject_icon/src/view-timeline.svg new file mode 100644 index 00000000000..85c00431380 --- /dev/null +++ b/app/assets/fonts/openproject_icon/src/view-timeline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/content/_editable_toolbar.sass b/app/assets/stylesheets/content/_editable_toolbar.sass index d3b0c8093ad..6f04b07ba8b 100644 --- a/app/assets/stylesheets/content/_editable_toolbar.sass +++ b/app/assets/stylesheets/content/_editable_toolbar.sass @@ -7,6 +7,7 @@ // Avoid default float of toolbar .toolbar-items float: none + margin-left: 1rem // Fix up default margins of items in floating toolbar .button diff --git a/app/assets/stylesheets/content/_in_place_editing.lsg b/app/assets/stylesheets/content/_in_place_editing.lsg index ea52245b6af..5c4be0ef261 100644 --- a/app/assets/stylesheets/content/_in_place_editing.lsg +++ b/app/assets/stylesheets/content/_in_place_editing.lsg @@ -5,15 +5,15 @@ ```
- -
+ +
- +
``` diff --git a/app/assets/stylesheets/content/work_packages/_table_content.sass b/app/assets/stylesheets/content/work_packages/_table_content.sass index 91d05a62df9..a434d9e8416 100644 --- a/app/assets/stylesheets/content/work_packages/_table_content.sass +++ b/app/assets/stylesheets/content/work_packages/_table_content.sass @@ -30,6 +30,11 @@ .wp--row user-select: none +// A placeholder row when the table is empty +.wp--placeholder-row + height: 5px + border-bottom: none !important + .wp-table--row cursor: pointer @@ -80,11 +85,13 @@ html:not(.-browser-mobile) // Override the default td line-height line-height: initial !important - .wp-table-context-menu-icon + .wp-table-context-menu-icon, + .wp-table--drag-and-drop-handle // Hide from viewers, but allow to be focused opacity: 0 .issue:hover .wp-table-context-menu-icon, + .issue:hover .wp-table--drag-and-drop-handle, .wp-table--context-menu-td:focus, .wp-table-context-menu-icon:focus // Hide by default @@ -133,7 +140,7 @@ html:not(.-browser-mobile) // Some padding for the inner cells of the display fields .wp-table--cell-span - padding: 2px 5px 2px 5px + padding: 2px // On edge, pointer-events only work on // block or inline-block elements diff --git a/app/assets/stylesheets/fonts/_openproject_icon_definitions.scss b/app/assets/stylesheets/fonts/_openproject_icon_definitions.scss index 47894ce326f..5ea57592b7c 100644 --- a/app/assets/stylesheets/fonts/_openproject_icon_definitions.scss +++ b/app/assets/stylesheets/fonts/_openproject_icon_definitions.scss @@ -406,1047 +406,1053 @@ .icon-download:before { content: "\f143"; } -@mixin icon-mixin-duplicate { +@mixin icon-mixin-drag-handle { content: "\f144"; } -.icon-duplicate:before { +.icon-drag-handle:before { content: "\f144"; } -@mixin icon-mixin-edit { +@mixin icon-mixin-duplicate { content: "\f145"; } -.icon-edit:before { +.icon-duplicate:before { content: "\f145"; } -@mixin icon-mixin-enterprise { +@mixin icon-mixin-edit { content: "\f146"; } -.icon-enterprise:before { +.icon-edit:before { content: "\f146"; } -@mixin icon-mixin-enumerations { +@mixin icon-mixin-enterprise { content: "\f147"; } -.icon-enumerations:before { +.icon-enterprise:before { content: "\f147"; } -@mixin icon-mixin-error { +@mixin icon-mixin-enumerations { content: "\f148"; } -.icon-error:before { +.icon-enumerations:before { content: "\f148"; } -@mixin icon-mixin-export-atom { +@mixin icon-mixin-error { content: "\f149"; } -.icon-export-atom:before { +.icon-error:before { content: "\f149"; } -@mixin icon-mixin-export-csv { +@mixin icon-mixin-export-atom { content: "\f14a"; } -.icon-export-csv:before { +.icon-export-atom:before { content: "\f14a"; } -@mixin icon-mixin-export-pdf-descr { +@mixin icon-mixin-export-csv { content: "\f14b"; } -.icon-export-pdf-descr:before { +.icon-export-csv:before { content: "\f14b"; } -@mixin icon-mixin-export-pdf-with-descriptions { +@mixin icon-mixin-export-pdf-descr { content: "\f14c"; } -.icon-export-pdf-with-descriptions:before { +.icon-export-pdf-descr:before { content: "\f14c"; } -@mixin icon-mixin-export-pdf { +@mixin icon-mixin-export-pdf-with-descriptions { content: "\f14d"; } -.icon-export-pdf:before { +.icon-export-pdf-with-descriptions:before { content: "\f14d"; } -@mixin icon-mixin-export-xls-descr { +@mixin icon-mixin-export-pdf { content: "\f14e"; } -.icon-export-xls-descr:before { +.icon-export-pdf:before { content: "\f14e"; } -@mixin icon-mixin-export-xls-with-descriptions { +@mixin icon-mixin-export-xls-descr { content: "\f14f"; } -.icon-export-xls-with-descriptions:before { +.icon-export-xls-descr:before { content: "\f14f"; } -@mixin icon-mixin-export-xls-with-relations { +@mixin icon-mixin-export-xls-with-descriptions { content: "\f150"; } -.icon-export-xls-with-relations:before { +.icon-export-xls-with-descriptions:before { content: "\f150"; } -@mixin icon-mixin-export-xls { +@mixin icon-mixin-export-xls-with-relations { content: "\f151"; } -.icon-export-xls:before { +.icon-export-xls-with-relations:before { content: "\f151"; } -@mixin icon-mixin-export { +@mixin icon-mixin-export-xls { content: "\f152"; } -.icon-export:before { +.icon-export-xls:before { content: "\f152"; } -@mixin icon-mixin-faq { +@mixin icon-mixin-export { content: "\f153"; } -.icon-faq:before { +.icon-export:before { content: "\f153"; } -@mixin icon-mixin-filter { +@mixin icon-mixin-faq { content: "\f154"; } -.icon-filter:before { +.icon-faq:before { content: "\f154"; } -@mixin icon-mixin-flag { +@mixin icon-mixin-filter { content: "\f155"; } -.icon-flag:before { +.icon-filter:before { content: "\f155"; } -@mixin icon-mixin-folder-add { +@mixin icon-mixin-flag { content: "\f156"; } -.icon-folder-add:before { +.icon-flag:before { content: "\f156"; } -@mixin icon-mixin-folder-locked { +@mixin icon-mixin-folder-add { content: "\f157"; } -.icon-folder-locked:before { +.icon-folder-add:before { content: "\f157"; } -@mixin icon-mixin-folder-open { +@mixin icon-mixin-folder-locked { content: "\f158"; } -.icon-folder-open:before { +.icon-folder-locked:before { content: "\f158"; } -@mixin icon-mixin-folder-remove { +@mixin icon-mixin-folder-open { content: "\f159"; } -.icon-folder-remove:before { +.icon-folder-open:before { content: "\f159"; } -@mixin icon-mixin-folder { +@mixin icon-mixin-folder-remove { content: "\f15a"; } -.icon-folder:before { +.icon-folder-remove:before { content: "\f15a"; } -@mixin icon-mixin-forums { +@mixin icon-mixin-folder { content: "\f15b"; } -.icon-forums:before { +.icon-folder:before { content: "\f15b"; } -@mixin icon-mixin-from-fullscreen { +@mixin icon-mixin-forums { content: "\f15c"; } -.icon-from-fullscreen:before { +.icon-forums:before { content: "\f15c"; } -@mixin icon-mixin-getting-started { +@mixin icon-mixin-from-fullscreen { content: "\f15d"; } -.icon-getting-started:before { +.icon-from-fullscreen:before { content: "\f15d"; } -@mixin icon-mixin-glossar { +@mixin icon-mixin-getting-started { content: "\f15e"; } -.icon-glossar:before { +.icon-getting-started:before { content: "\f15e"; } -@mixin icon-mixin-google-plus { +@mixin icon-mixin-glossar { content: "\f15f"; } -.icon-google-plus:before { +.icon-glossar:before { content: "\f15f"; } -@mixin icon-mixin-group-by { +@mixin icon-mixin-google-plus { content: "\f160"; } -.icon-group-by:before { +.icon-google-plus:before { content: "\f160"; } -@mixin icon-mixin-group { +@mixin icon-mixin-group-by { content: "\f161"; } -.icon-group:before { +.icon-group-by:before { content: "\f161"; } -@mixin icon-mixin-hamburger { +@mixin icon-mixin-group { content: "\f162"; } -.icon-hamburger:before { +.icon-group:before { content: "\f162"; } -@mixin icon-mixin-headline1 { +@mixin icon-mixin-hamburger { content: "\f163"; } -.icon-headline1:before { +.icon-hamburger:before { content: "\f163"; } -@mixin icon-mixin-headline2 { +@mixin icon-mixin-headline1 { content: "\f164"; } -.icon-headline2:before { +.icon-headline1:before { content: "\f164"; } -@mixin icon-mixin-headline3 { +@mixin icon-mixin-headline2 { content: "\f165"; } -.icon-headline3:before { +.icon-headline2:before { content: "\f165"; } -@mixin icon-mixin-headset { +@mixin icon-mixin-headline3 { content: "\f166"; } -.icon-headset:before { +.icon-headline3:before { content: "\f166"; } -@mixin icon-mixin-help { +@mixin icon-mixin-headset { content: "\f167"; } -.icon-help:before { +.icon-headset:before { content: "\f167"; } -@mixin icon-mixin-help1 { +@mixin icon-mixin-help { content: "\f168"; } -.icon-help1:before { +.icon-help:before { content: "\f168"; } -@mixin icon-mixin-help2 { +@mixin icon-mixin-help1 { content: "\f169"; } -.icon-help2:before { +.icon-help1:before { content: "\f169"; } -@mixin icon-mixin-hierarchy { +@mixin icon-mixin-help2 { content: "\f16a"; } -.icon-hierarchy:before { +.icon-help2:before { content: "\f16a"; } -@mixin icon-mixin-home { +@mixin icon-mixin-hierarchy { content: "\f16b"; } -.icon-home:before { +.icon-hierarchy:before { content: "\f16b"; } -@mixin icon-mixin-hosting { +@mixin icon-mixin-home { content: "\f16c"; } -.icon-hosting:before { +.icon-home:before { content: "\f16c"; } -@mixin icon-mixin-image1 { +@mixin icon-mixin-hosting { content: "\f16d"; } -.icon-image1:before { +.icon-hosting:before { content: "\f16d"; } -@mixin icon-mixin-image2 { +@mixin icon-mixin-image1 { content: "\f16e"; } -.icon-image2:before { +.icon-image1:before { content: "\f16e"; } -@mixin icon-mixin-info1 { +@mixin icon-mixin-image2 { content: "\f16f"; } -.icon-info1:before { +.icon-image2:before { content: "\f16f"; } -@mixin icon-mixin-info2 { +@mixin icon-mixin-info1 { content: "\f170"; } -.icon-info2:before { +.icon-info1:before { content: "\f170"; } -@mixin icon-mixin-installation-services { +@mixin icon-mixin-info2 { content: "\f171"; } -.icon-installation-services:before { +.icon-info2:before { content: "\f171"; } -@mixin icon-mixin-italic { +@mixin icon-mixin-installation-services { content: "\f172"; } -.icon-italic:before { +.icon-installation-services:before { content: "\f172"; } -@mixin icon-mixin-key { +@mixin icon-mixin-italic { content: "\f173"; } -.icon-key:before { +.icon-italic:before { content: "\f173"; } -@mixin icon-mixin-link { +@mixin icon-mixin-key { content: "\f174"; } -.icon-link:before { +.icon-key:before { content: "\f174"; } -@mixin icon-mixin-loading1 { +@mixin icon-mixin-link { content: "\f175"; } -.icon-loading1:before { +.icon-link:before { content: "\f175"; } -@mixin icon-mixin-loading2 { +@mixin icon-mixin-loading1 { content: "\f176"; } -.icon-loading2:before { +.icon-loading1:before { content: "\f176"; } -@mixin icon-mixin-location { +@mixin icon-mixin-loading2 { content: "\f177"; } -.icon-location:before { +.icon-loading2:before { content: "\f177"; } -@mixin icon-mixin-locked { +@mixin icon-mixin-location { content: "\f178"; } -.icon-locked:before { +.icon-location:before { content: "\f178"; } -@mixin icon-mixin-logout { +@mixin icon-mixin-locked { content: "\f179"; } -.icon-logout:before { +.icon-locked:before { content: "\f179"; } -@mixin icon-mixin-mail1 { +@mixin icon-mixin-logout { content: "\f17a"; } -.icon-mail1:before { +.icon-logout:before { content: "\f17a"; } -@mixin icon-mixin-mail2 { +@mixin icon-mixin-mail1 { content: "\f17b"; } -.icon-mail2:before { +.icon-mail1:before { content: "\f17b"; } -@mixin icon-mixin-maintenance-support { +@mixin icon-mixin-mail2 { content: "\f17c"; } -.icon-maintenance-support:before { +.icon-mail2:before { content: "\f17c"; } -@mixin icon-mixin-meetings { +@mixin icon-mixin-maintenance-support { content: "\f17d"; } -.icon-meetings:before { +.icon-maintenance-support:before { content: "\f17d"; } -@mixin icon-mixin-menu { +@mixin icon-mixin-meetings { content: "\f17e"; } -.icon-menu:before { +.icon-meetings:before { content: "\f17e"; } -@mixin icon-mixin-microphone { +@mixin icon-mixin-menu { content: "\f17f"; } -.icon-microphone:before { +.icon-menu:before { content: "\f17f"; } -@mixin icon-mixin-milestone { +@mixin icon-mixin-microphone { content: "\f180"; } -.icon-milestone:before { +.icon-microphone:before { content: "\f180"; } -@mixin icon-mixin-minus1 { +@mixin icon-mixin-milestone { content: "\f181"; } -.icon-minus1:before { +.icon-milestone:before { content: "\f181"; } -@mixin icon-mixin-minus2 { +@mixin icon-mixin-minus1 { content: "\f182"; } -.icon-minus2:before { +.icon-minus1:before { content: "\f182"; } -@mixin icon-mixin-mobile { +@mixin icon-mixin-minus2 { content: "\f183"; } -.icon-mobile:before { +.icon-minus2:before { content: "\f183"; } -@mixin icon-mixin-modules { +@mixin icon-mixin-mobile { content: "\f184"; } -.icon-modules:before { +.icon-mobile:before { content: "\f184"; } -@mixin icon-mixin-more { +@mixin icon-mixin-modules { content: "\f185"; } -.icon-more:before { +.icon-modules:before { content: "\f185"; } -@mixin icon-mixin-move { +@mixin icon-mixin-more { content: "\f186"; } -.icon-move:before { +.icon-more:before { content: "\f186"; } -@mixin icon-mixin-movie { +@mixin icon-mixin-move { content: "\f187"; } -.icon-movie:before { +.icon-move:before { content: "\f187"; } -@mixin icon-mixin-music { +@mixin icon-mixin-movie { content: "\f188"; } -.icon-music:before { +.icon-movie:before { content: "\f188"; } -@mixin icon-mixin-new-planning-element { +@mixin icon-mixin-music { content: "\f189"; } -.icon-new-planning-element:before { +.icon-music:before { content: "\f189"; } -@mixin icon-mixin-news { +@mixin icon-mixin-new-planning-element { content: "\f18a"; } -.icon-news:before { +.icon-new-planning-element:before { content: "\f18a"; } -@mixin icon-mixin-no-hierarchy { +@mixin icon-mixin-news { content: "\f18b"; } -.icon-no-hierarchy:before { +.icon-news:before { content: "\f18b"; } -@mixin icon-mixin-no-zen-mode { +@mixin icon-mixin-no-hierarchy { content: "\f18c"; } -.icon-no-zen-mode:before { +.icon-no-hierarchy:before { content: "\f18c"; } -@mixin icon-mixin-not-supported { +@mixin icon-mixin-no-zen-mode { content: "\f18d"; } -.icon-not-supported:before { +.icon-no-zen-mode:before { content: "\f18d"; } -@mixin icon-mixin-notes { +@mixin icon-mixin-not-supported { content: "\f18e"; } -.icon-notes:before { +.icon-not-supported:before { content: "\f18e"; } -@mixin icon-mixin-openproject { +@mixin icon-mixin-notes { content: "\f18f"; } -.icon-openproject:before { +.icon-notes:before { content: "\f18f"; } -@mixin icon-mixin-ordered-list { +@mixin icon-mixin-openproject { content: "\f190"; } -.icon-ordered-list:before { +.icon-openproject:before { content: "\f190"; } -@mixin icon-mixin-outline { +@mixin icon-mixin-ordered-list { content: "\f191"; } -.icon-outline:before { +.icon-ordered-list:before { content: "\f191"; } -@mixin icon-mixin-paragraph-left { +@mixin icon-mixin-outline { content: "\f192"; } -.icon-paragraph-left:before { +.icon-outline:before { content: "\f192"; } -@mixin icon-mixin-paragraph-right { +@mixin icon-mixin-paragraph-left { content: "\f193"; } -.icon-paragraph-right:before { +.icon-paragraph-left:before { content: "\f193"; } -@mixin icon-mixin-paragraph { +@mixin icon-mixin-paragraph-right { content: "\f194"; } -.icon-paragraph:before { +.icon-paragraph-right:before { content: "\f194"; } -@mixin icon-mixin-payment-history { +@mixin icon-mixin-paragraph { content: "\f195"; } -.icon-payment-history:before { +.icon-paragraph:before { content: "\f195"; } -@mixin icon-mixin-phone { +@mixin icon-mixin-payment-history { content: "\f196"; } -.icon-phone:before { +.icon-payment-history:before { content: "\f196"; } -@mixin icon-mixin-pin { +@mixin icon-mixin-phone { content: "\f197"; } -.icon-pin:before { +.icon-phone:before { content: "\f197"; } -@mixin icon-mixin-play { +@mixin icon-mixin-pin { content: "\f198"; } -.icon-play:before { +.icon-pin:before { content: "\f198"; } -@mixin icon-mixin-plugins { +@mixin icon-mixin-play { content: "\f199"; } -.icon-plugins:before { +.icon-play:before { content: "\f199"; } -@mixin icon-mixin-plus { +@mixin icon-mixin-plugins { content: "\f19a"; } -.icon-plus:before { +.icon-plugins:before { content: "\f19a"; } -@mixin icon-mixin-pre { +@mixin icon-mixin-plus { content: "\f19b"; } -.icon-pre:before { +.icon-plus:before { content: "\f19b"; } -@mixin icon-mixin-presentation { +@mixin icon-mixin-pre { content: "\f19c"; } -.icon-presentation:before { +.icon-pre:before { content: "\f19c"; } -@mixin icon-mixin-preview { +@mixin icon-mixin-presentation { content: "\f19d"; } -.icon-preview:before { +.icon-presentation:before { content: "\f19d"; } -@mixin icon-mixin-print { +@mixin icon-mixin-preview { content: "\f19e"; } -.icon-print:before { +.icon-preview:before { content: "\f19e"; } -@mixin icon-mixin-priority { +@mixin icon-mixin-print { content: "\f19f"; } -.icon-priority:before { +.icon-print:before { content: "\f19f"; } -@mixin icon-mixin-project-types { +@mixin icon-mixin-priority { content: "\f1a0"; } -.icon-project-types:before { +.icon-priority:before { content: "\f1a0"; } -@mixin icon-mixin-projects { +@mixin icon-mixin-project-types { content: "\f1a1"; } -.icon-projects:before { +.icon-project-types:before { content: "\f1a1"; } -@mixin icon-mixin-publish { +@mixin icon-mixin-projects { content: "\f1a2"; } -.icon-publish:before { +.icon-projects:before { content: "\f1a2"; } -@mixin icon-mixin-pulldown-up { +@mixin icon-mixin-publish { content: "\f1a3"; } -.icon-pulldown-up:before { +.icon-publish:before { content: "\f1a3"; } -@mixin icon-mixin-pulldown { +@mixin icon-mixin-pulldown-up { content: "\f1a4"; } -.icon-pulldown:before { +.icon-pulldown-up:before { content: "\f1a4"; } -@mixin icon-mixin-quote { +@mixin icon-mixin-pulldown { content: "\f1a5"; } -.icon-quote:before { +.icon-pulldown:before { content: "\f1a5"; } -@mixin icon-mixin-quote2 { +@mixin icon-mixin-quote { content: "\f1a6"; } -.icon-quote2:before { +.icon-quote:before { content: "\f1a6"; } -@mixin icon-mixin-redo { +@mixin icon-mixin-quote2 { content: "\f1a7"; } -.icon-redo:before { +.icon-quote2:before { content: "\f1a7"; } -@mixin icon-mixin-relation-follows { +@mixin icon-mixin-redo { content: "\f1a8"; } -.icon-relation-follows:before { +.icon-redo:before { content: "\f1a8"; } -@mixin icon-mixin-relation-new-child { +@mixin icon-mixin-relation-follows { content: "\f1a9"; } -.icon-relation-new-child:before { +.icon-relation-follows:before { content: "\f1a9"; } -@mixin icon-mixin-relation-precedes { +@mixin icon-mixin-relation-new-child { content: "\f1aa"; } -.icon-relation-precedes:before { +.icon-relation-new-child:before { content: "\f1aa"; } -@mixin icon-mixin-relations { +@mixin icon-mixin-relation-precedes { content: "\f1ab"; } -.icon-relations:before { +.icon-relation-precedes:before { content: "\f1ab"; } -@mixin icon-mixin-reload { +@mixin icon-mixin-relations { content: "\f1ac"; } -.icon-reload:before { +.icon-relations:before { content: "\f1ac"; } -@mixin icon-mixin-reminder { +@mixin icon-mixin-reload { content: "\f1ad"; } -.icon-reminder:before { +.icon-reload:before { content: "\f1ad"; } -@mixin icon-mixin-remove { +@mixin icon-mixin-reminder { content: "\f1ae"; } -.icon-remove:before { +.icon-reminder:before { content: "\f1ae"; } -@mixin icon-mixin-rename { +@mixin icon-mixin-remove { content: "\f1af"; } -.icon-rename:before { +.icon-remove:before { content: "\f1af"; } -@mixin icon-mixin-reported-by-me { +@mixin icon-mixin-rename { content: "\f1b0"; } -.icon-reported-by-me:before { +.icon-rename:before { content: "\f1b0"; } -@mixin icon-mixin-resizer-vertical-lines { +@mixin icon-mixin-reported-by-me { content: "\f1b1"; } -.icon-resizer-vertical-lines:before { +.icon-reported-by-me:before { content: "\f1b1"; } -@mixin icon-mixin-roadmap { +@mixin icon-mixin-resizer-vertical-lines { content: "\f1b2"; } -.icon-roadmap:before { +.icon-resizer-vertical-lines:before { content: "\f1b2"; } -@mixin icon-mixin-rss { +@mixin icon-mixin-roadmap { content: "\f1b3"; } -.icon-rss:before { +.icon-roadmap:before { content: "\f1b3"; } -@mixin icon-mixin-rubber { +@mixin icon-mixin-rss { content: "\f1b4"; } -.icon-rubber:before { +.icon-rss:before { content: "\f1b4"; } -@mixin icon-mixin-save { +@mixin icon-mixin-rubber { content: "\f1b5"; } -.icon-save:before { +.icon-rubber:before { content: "\f1b5"; } -@mixin icon-mixin-search { +@mixin icon-mixin-save { content: "\f1b6"; } -.icon-search:before { +.icon-save:before { content: "\f1b6"; } -@mixin icon-mixin-send-mail { +@mixin icon-mixin-search { content: "\f1b7"; } -.icon-send-mail:before { +.icon-search:before { content: "\f1b7"; } -@mixin icon-mixin-server-key { +@mixin icon-mixin-send-mail { content: "\f1b8"; } -.icon-server-key:before { +.icon-send-mail:before { content: "\f1b8"; } -@mixin icon-mixin-settings { +@mixin icon-mixin-server-key { content: "\f1b9"; } -.icon-settings:before { +.icon-server-key:before { content: "\f1b9"; } -@mixin icon-mixin-settings2 { +@mixin icon-mixin-settings { content: "\f1ba"; } -.icon-settings2:before { +.icon-settings:before { content: "\f1ba"; } -@mixin icon-mixin-settings3 { +@mixin icon-mixin-settings2 { content: "\f1bb"; } -.icon-settings3:before { +.icon-settings2:before { content: "\f1bb"; } -@mixin icon-mixin-settings4 { +@mixin icon-mixin-settings3 { content: "\f1bc"; } -.icon-settings4:before { +.icon-settings3:before { content: "\f1bc"; } -@mixin icon-mixin-shortcuts { +@mixin icon-mixin-settings4 { content: "\f1bd"; } -.icon-shortcuts:before { +.icon-settings4:before { content: "\f1bd"; } -@mixin icon-mixin-show-all-projects { +@mixin icon-mixin-shortcuts { content: "\f1be"; } -.icon-show-all-projects:before { +.icon-shortcuts:before { content: "\f1be"; } -@mixin icon-mixin-show-more-horizontal { +@mixin icon-mixin-show-all-projects { content: "\f1bf"; } -.icon-show-more-horizontal:before { +.icon-show-all-projects:before { content: "\f1bf"; } -@mixin icon-mixin-show-more { +@mixin icon-mixin-show-more-horizontal { content: "\f1c0"; } -.icon-show-more:before { +.icon-show-more-horizontal:before { content: "\f1c0"; } -@mixin icon-mixin-sort-ascending { +@mixin icon-mixin-show-more { content: "\f1c1"; } -.icon-sort-ascending:before { +.icon-show-more:before { content: "\f1c1"; } -@mixin icon-mixin-sort-by { +@mixin icon-mixin-sort-ascending { content: "\f1c2"; } -.icon-sort-by:before { +.icon-sort-ascending:before { content: "\f1c2"; } -@mixin icon-mixin-sort-descending { +@mixin icon-mixin-sort-by { content: "\f1c3"; } -.icon-sort-descending:before { +.icon-sort-by:before { content: "\f1c3"; } -@mixin icon-mixin-sort-down { +@mixin icon-mixin-sort-descending { content: "\f1c4"; } -.icon-sort-down:before { +.icon-sort-descending:before { content: "\f1c4"; } -@mixin icon-mixin-sort-up { +@mixin icon-mixin-sort-down { content: "\f1c5"; } -.icon-sort-up:before { +.icon-sort-down:before { content: "\f1c5"; } -@mixin icon-mixin-square { +@mixin icon-mixin-sort-up { content: "\f1c6"; } -.icon-square:before { +.icon-sort-up:before { content: "\f1c6"; } -@mixin icon-mixin-star { +@mixin icon-mixin-square { content: "\f1c7"; } -.icon-star:before { +.icon-square:before { content: "\f1c7"; } -@mixin icon-mixin-status-reporting { +@mixin icon-mixin-star { content: "\f1c8"; } -.icon-status-reporting:before { +.icon-star:before { content: "\f1c8"; } -@mixin icon-mixin-status { +@mixin icon-mixin-status-reporting { content: "\f1c9"; } -.icon-status:before { +.icon-status-reporting:before { content: "\f1c9"; } -@mixin icon-mixin-strike-through { +@mixin icon-mixin-status { content: "\f1ca"; } -.icon-strike-through:before { +.icon-status:before { content: "\f1ca"; } -@mixin icon-mixin-text { +@mixin icon-mixin-strike-through { content: "\f1cb"; } -.icon-text:before { +.icon-strike-through:before { content: "\f1cb"; } -@mixin icon-mixin-ticket-checked { +@mixin icon-mixin-text { content: "\f1cc"; } -.icon-ticket-checked:before { +.icon-text:before { content: "\f1cc"; } -@mixin icon-mixin-ticket-down { +@mixin icon-mixin-ticket-checked { content: "\f1cd"; } -.icon-ticket-down:before { +.icon-ticket-checked:before { content: "\f1cd"; } -@mixin icon-mixin-ticket-edit { +@mixin icon-mixin-ticket-down { content: "\f1ce"; } -.icon-ticket-edit:before { +.icon-ticket-down:before { content: "\f1ce"; } -@mixin icon-mixin-ticket-minus { +@mixin icon-mixin-ticket-edit { content: "\f1cf"; } -.icon-ticket-minus:before { +.icon-ticket-edit:before { content: "\f1cf"; } -@mixin icon-mixin-ticket-note { +@mixin icon-mixin-ticket-minus { content: "\f1d0"; } -.icon-ticket-note:before { +.icon-ticket-minus:before { content: "\f1d0"; } -@mixin icon-mixin-ticket { +@mixin icon-mixin-ticket-note { content: "\f1d1"; } -.icon-ticket:before { +.icon-ticket-note:before { content: "\f1d1"; } -@mixin icon-mixin-time { +@mixin icon-mixin-ticket { content: "\f1d2"; } -.icon-time:before { +.icon-ticket:before { content: "\f1d2"; } -@mixin icon-mixin-to-fullscreen { +@mixin icon-mixin-time { content: "\f1d3"; } -.icon-to-fullscreen:before { +.icon-time:before { content: "\f1d3"; } -@mixin icon-mixin-toggle { +@mixin icon-mixin-to-fullscreen { content: "\f1d4"; } -.icon-toggle:before { +.icon-to-fullscreen:before { content: "\f1d4"; } -@mixin icon-mixin-training-consulting { +@mixin icon-mixin-toggle { content: "\f1d5"; } -.icon-training-consulting:before { +.icon-toggle:before { content: "\f1d5"; } -@mixin icon-mixin-two-factor-authentication { +@mixin icon-mixin-training-consulting { content: "\f1d6"; } -.icon-two-factor-authentication:before { +.icon-training-consulting:before { content: "\f1d6"; } -@mixin icon-mixin-types { +@mixin icon-mixin-two-factor-authentication { content: "\f1d7"; } -.icon-types:before { +.icon-two-factor-authentication:before { content: "\f1d7"; } -@mixin icon-mixin-underline { +@mixin icon-mixin-types { content: "\f1d8"; } -.icon-underline:before { +.icon-types:before { content: "\f1d8"; } -@mixin icon-mixin-undo { +@mixin icon-mixin-underline { content: "\f1d9"; } -.icon-undo:before { +.icon-underline:before { content: "\f1d9"; } -@mixin icon-mixin-unit { +@mixin icon-mixin-undo { content: "\f1da"; } -.icon-unit:before { +.icon-undo:before { content: "\f1da"; } -@mixin icon-mixin-unlocked { +@mixin icon-mixin-unit { content: "\f1db"; } -.icon-unlocked:before { +.icon-unit:before { content: "\f1db"; } -@mixin icon-mixin-unordered-list { +@mixin icon-mixin-unlocked { content: "\f1dc"; } -.icon-unordered-list:before { +.icon-unlocked:before { content: "\f1dc"; } -@mixin icon-mixin-unwatched { +@mixin icon-mixin-unordered-list { content: "\f1dd"; } -.icon-unwatched:before { +.icon-unordered-list:before { content: "\f1dd"; } -@mixin icon-mixin-upload { +@mixin icon-mixin-unwatched { content: "\f1de"; } -.icon-upload:before { +.icon-unwatched:before { content: "\f1de"; } -@mixin icon-mixin-user-minus { +@mixin icon-mixin-upload { content: "\f1df"; } -.icon-user-minus:before { +.icon-upload:before { content: "\f1df"; } -@mixin icon-mixin-user-plus { +@mixin icon-mixin-user-minus { content: "\f1e0"; } -.icon-user-plus:before { +.icon-user-minus:before { content: "\f1e0"; } -@mixin icon-mixin-user { +@mixin icon-mixin-user-plus { content: "\f1e1"; } -.icon-user:before { +.icon-user-plus:before { content: "\f1e1"; } -@mixin icon-mixin-view-fullscreen { +@mixin icon-mixin-user { content: "\f1e2"; } -.icon-view-fullscreen:before { +.icon-user:before { content: "\f1e2"; } -@mixin icon-mixin-view-list { +@mixin icon-mixin-view-fullscreen { content: "\f1e3"; } -.icon-view-list:before { +.icon-view-fullscreen:before { content: "\f1e3"; } -@mixin icon-mixin-view-split { +@mixin icon-mixin-view-list { content: "\f1e4"; } -.icon-view-split:before { +.icon-view-list:before { content: "\f1e4"; } -@mixin icon-mixin-view-timeline { +@mixin icon-mixin-view-split { content: "\f1e5"; } -.icon-view-timeline:before { +.icon-view-split:before { content: "\f1e5"; } -@mixin icon-mixin-warning { +@mixin icon-mixin-view-timeline { content: "\f1e6"; } -.icon-warning:before { +.icon-view-timeline:before { content: "\f1e6"; } -@mixin icon-mixin-watched { +@mixin icon-mixin-warning { content: "\f1e7"; } -.icon-watched:before { +.icon-warning:before { content: "\f1e7"; } -@mixin icon-mixin-wiki-edit { +@mixin icon-mixin-watched { content: "\f1e8"; } -.icon-wiki-edit:before { +.icon-watched:before { content: "\f1e8"; } -@mixin icon-mixin-wiki { +@mixin icon-mixin-wiki-edit { content: "\f1e9"; } -.icon-wiki:before { +.icon-wiki-edit:before { content: "\f1e9"; } -@mixin icon-mixin-wiki2 { +@mixin icon-mixin-wiki { content: "\f1ea"; } -.icon-wiki2:before { +.icon-wiki:before { content: "\f1ea"; } -@mixin icon-mixin-work-packages { +@mixin icon-mixin-wiki2 { content: "\f1eb"; } -.icon-work-packages:before { +.icon-wiki2:before { content: "\f1eb"; } -@mixin icon-mixin-workflow { +@mixin icon-mixin-work-packages { content: "\f1ec"; } -.icon-workflow:before { +.icon-work-packages:before { content: "\f1ec"; } -@mixin icon-mixin-yes { +@mixin icon-mixin-workflow { content: "\f1ed"; } -.icon-yes:before { +.icon-workflow:before { content: "\f1ed"; } -@mixin icon-mixin-zen-mode { +@mixin icon-mixin-yes { content: "\f1ee"; } -.icon-zen-mode:before { +.icon-yes:before { content: "\f1ee"; } -@mixin icon-mixin-zoom-auto { +@mixin icon-mixin-zen-mode { content: "\f1ef"; } -.icon-zoom-auto:before { +.icon-zen-mode:before { content: "\f1ef"; } -@mixin icon-mixin-zoom-in { +@mixin icon-mixin-zoom-auto { content: "\f1f0"; } -.icon-zoom-in:before { +.icon-zoom-auto:before { content: "\f1f0"; } -@mixin icon-mixin-zoom-out { +@mixin icon-mixin-zoom-in { content: "\f1f1"; } -.icon-zoom-out:before { +.icon-zoom-in:before { content: "\f1f1"; } +@mixin icon-mixin-zoom-out { + content: "\f1f2"; +} +.icon-zoom-out:before { + content: "\f1f2"; +} diff --git a/app/assets/stylesheets/fonts/_openproject_icon_font.lsg b/app/assets/stylesheets/fonts/_openproject_icon_font.lsg index 1d94c259a81..395bd810e6e 100644 --- a/app/assets/stylesheets/fonts/_openproject_icon_font.lsg +++ b/app/assets/stylesheets/fonts/_openproject_icon_font.lsg @@ -70,6 +70,7 @@
  • double-arrow-left
  • double-arrow-right
  • download
  • +
  • drag-handle
  • duplicate
  • edit
  • enterprise
  • diff --git a/app/assets/stylesheets/fonts/_openproject_icon_font.sass b/app/assets/stylesheets/fonts/_openproject_icon_font.sass index 3e9944d87b9..6f58142569d 100644 --- a/app/assets/stylesheets/fonts/_openproject_icon_font.sass +++ b/app/assets/stylesheets/fonts/_openproject_icon_font.sass @@ -189,6 +189,10 @@ .icon-small:before @include icon-small-rules +// used for non-linked color icons +.icon-no-color + @include varprop(color, body-font-color) + #errorExplanation:before @include icon-common diff --git a/app/assets/stylesheets/layout/_main_menu.sass b/app/assets/stylesheets/layout/_main_menu.sass index 9020033d157..a89a3c3d6fc 100644 --- a/app/assets/stylesheets/layout/_main_menu.sass +++ b/app/assets/stylesheets/layout/_main_menu.sass @@ -173,7 +173,7 @@ $arrow-left-width: 40px li, li:hover background: initial - a + a:not(.main-menu--children-sub-item) padding: initial &:hover diff --git a/app/assets/stylesheets/layout/_toolbar.sass b/app/assets/stylesheets/layout/_toolbar.sass index 9b4c6eb44e5..3f56c30f1ec 100644 --- a/app/assets/stylesheets/layout/_toolbar.sass +++ b/app/assets/stylesheets/layout/_toolbar.sass @@ -232,12 +232,6 @@ $nm-color-success-background: #d8fdd1 background: $nm-color-error-background !important border-color: $nm-color-error-border !important - span - @include varprop(color, content-link-color) - - &:hover - text-decoration: underline - ul margin: 0 padding: 0 diff --git a/app/assets/stylesheets/openproject/_generic.sass b/app/assets/stylesheets/openproject/_generic.sass index 11293fcf3e1..7eab6137d42 100644 --- a/app/assets/stylesheets/openproject/_generic.sass +++ b/app/assets/stylesheets/openproject/_generic.sass @@ -35,6 +35,10 @@ .-no-border border: none !important +// Disable user selection +.-no-text-select + user-select: none + // Table borders .-table-border-top border-top: 1px solid $table-row-border-color @@ -72,3 +76,11 @@ span + span:before content: "| " + +.-bold + font-weight: bold +.-italic + font-style: italic +.-small-font + font-size: 12px + diff --git a/app/assets/stylesheets/vendor/_dragula.sass b/app/assets/stylesheets/vendor/_dragula.sass index d262c8d7ad8..88ac8dd1c05 100644 --- a/app/assets/stylesheets/vendor/_dragula.sass +++ b/app/assets/stylesheets/vendor/_dragula.sass @@ -3,8 +3,6 @@ margin: 0 !important z-index: 9999 !important opacity: 0.8 - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=80)" - filter: alpha(opacity=80) .gu-hide display: none !important @@ -17,8 +15,6 @@ .gu-transit opacity: 0.2 - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)" - filter: alpha(opacity=20) .dragula-handle cursor: pointer diff --git a/app/contracts/model_contract.rb b/app/contracts/model_contract.rb index 87f724dc6d0..3b5202a37fe 100644 --- a/app/contracts/model_contract.rb +++ b/app/contracts/model_contract.rb @@ -118,6 +118,10 @@ class ModelContract < Reform::Contract run_attribute_validations super + + # Allow subclasses to check only contract errors + return errors.empty? unless validate_model? + model.valid? # We need to merge the contract errors with the model errors in @@ -147,6 +151,18 @@ class ModelContract < Reform::Contract end # end Methods required to get ActiveModel error messages working + protected + + ## + # Allow subclasses to disable model validation + # during contract validation. + # + # This is necessary during, e.g., deletion contract validations + # to ensure invalid models can be deleted when allowed. + def validate_model? + true + end + private def readonly_attributes_unchanged diff --git a/app/contracts/queries/base_contract.rb b/app/contracts/queries/base_contract.rb index 9ca24290d0f..b456ec701a5 100644 --- a/app/contracts/queries/base_contract.rb +++ b/app/contracts/queries/base_contract.rb @@ -51,6 +51,8 @@ module Queries attribute :sort_criteria # => sortBy attribute :group_by # => groupBy + attribute :ordered_work_packages # => manual sort + def self.model Query end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d7f59a9b6b2..f209bcd9ece 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -334,12 +334,10 @@ class ApplicationController < ActionController::Base render_404 end - def find_optional_project_and_raise_error(controller_name = nil) - controller_name = params[:controller] if controller_name.nil? - + def find_optional_project_and_raise_error @project = Project.find(params[:project_id]) unless params[:project_id].blank? - allowed = User.current.allowed_to?({ controller: controller_name, action: params[:action] }, - @project, global: true) + allowed = User.current.allowed_to?({ controller: params[:controller], action: params[:action] }, + @project, global: @project.nil?) allowed ? true : deny_access end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 00000000000..10a4cba84df --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true +end diff --git a/app/models/ordered_work_package.rb b/app/models/ordered_work_package.rb new file mode 100644 index 00000000000..2a04eaeea65 --- /dev/null +++ b/app/models/ordered_work_package.rb @@ -0,0 +1,36 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ +# +class OrderedWorkPackage < ApplicationRecord + belongs_to :query + belongs_to :work_package + + acts_as_list scope: :query, top_of_list: 0 +end diff --git a/app/models/queries/filters.rb b/app/models/queries/filters.rb index e34b7d26d0f..df6424be888 100644 --- a/app/models/queries/filters.rb +++ b/app/models/queries/filters.rb @@ -40,7 +40,8 @@ module Queries::Filters text: Queries::Filters::Strategies::Text, search: Queries::Filters::Strategies::Search, float: Queries::Filters::Strategies::Float, - inexistent: Queries::Filters::Strategies::Inexistent + inexistent: Queries::Filters::Strategies::Inexistent, + empty_value: Queries::Filters::Strategies::EmptyValue }.freeze ## diff --git a/app/models/queries/filters/strategies/empty_value.rb b/app/models/queries/filters/strategies/empty_value.rb new file mode 100644 index 00000000000..cd2f1203e1e --- /dev/null +++ b/app/models/queries/filters/strategies/empty_value.rb @@ -0,0 +1,41 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +module Queries::Filters::Strategies + class EmptyValue < BaseStrategy + def validate + super + + unless values.empty? + errors.add(:values, "must be empty") + end + end + end +end diff --git a/app/models/queries/operators/ordered_work_packages.rb b/app/models/queries/operators/ordered_work_packages.rb new file mode 100644 index 00000000000..46a87ba0c1f --- /dev/null +++ b/app/models/queries/operators/ordered_work_packages.rb @@ -0,0 +1,41 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +module Queries::Operators + class OrderedWorkPackages < Base + label 'open_work_packages' + set_symbol 'ow' + require_value false + + def self.sql_for_field(_values, _db_table, _db_field) + "#{OrderedWorkPackage.table_name}.position IS NOT NULL" + end + end +end diff --git a/app/models/queries/work_packages.rb b/app/models/queries/work_packages.rb index 53ffb51c71c..85b36aaa698 100644 --- a/app/models/queries/work_packages.rb +++ b/app/models/queries/work_packages.rb @@ -74,6 +74,7 @@ module Queries::WorkPackages register.filter Query, filters_module::SearchFilter register.filter Query, filters_module::CommentFilter register.filter Query, filters_module::SubjectOrIdFilter + register.filter Query, filters_module::ManualSortFilter columns_module = Queries::WorkPackages::Columns diff --git a/app/models/queries/work_packages/columns/manual_sorting_column.rb b/app/models/queries/work_packages/columns/manual_sorting_column.rb new file mode 100644 index 00000000000..042c40d0dd8 --- /dev/null +++ b/app/models/queries/work_packages/columns/manual_sorting_column.rb @@ -0,0 +1,43 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +class Queries::WorkPackages::Columns::ManualSortingColumn < Queries::WorkPackages::Columns::WorkPackageColumn + def initialize + super :manual_sorting, + default_order: 'asc', + sortable: %w[ordered_work_packages.position], + sortable_join: <<-SQL + LEFT OUTER JOIN + ordered_work_packages + ON + ordered_work_packages.work_package_id = work_packages.id + SQL + end +end diff --git a/app/models/queries/work_packages/filter/manual_sort_filter.rb b/app/models/queries/work_packages/filter/manual_sort_filter.rb new file mode 100644 index 00000000000..fad3092388c --- /dev/null +++ b/app/models/queries/work_packages/filter/manual_sort_filter.rb @@ -0,0 +1,70 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2017 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Foperatorree Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +class Queries::WorkPackages::Filter::ManualSortFilter < + Queries::WorkPackages::Filter::WorkPackageFilter + + def available_operators + [Queries::Operators::OrderedWorkPackages] + end + + def available? + true + end + + def joins + :ordered_work_packages + end + + def type + :empty_value + end + + def where + WorkPackage + .arel_table[:id] + .in(context.ordered_work_packages) + .to_sql + end + + def self.key + :manual_sort + end + + def ar_object_filter? + true + end + + private + + def operator_strategy + Queries::Operators::OrderedWorkPackages + end +end diff --git a/app/models/query.rb b/app/models/query.rb index a6fd19c6d3d..19702d6e807 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -31,6 +31,7 @@ class Query < ActiveRecord::Base include Timelines include Highlighting + include ManualSorting include Queries::AvailableFilters belongs_to :project @@ -238,7 +239,7 @@ class Query < ActiveRecord::Base end def self.sortable_columns - available_columns.select(&:sortable) + available_columns.select(&:sortable) + [manual_sorting_column] end # Returns an array of columns that can be used to group the results @@ -248,12 +249,12 @@ class Query < ActiveRecord::Base # Returns an array of columns that can be used to sort the results def sortable_columns - available_columns.select(&:sortable) + available_columns.select(&:sortable) + [manual_sorting_column] end # Returns a Hash of sql columns for sorting by column def sortable_key_by_column_name - column_sortability = available_columns.inject({}) do |h, column| + column_sortability = sortable_columns.inject({}) do |h, column| h[column.name.to_s] = column.sortable h end diff --git a/app/models/query/manual_sorting.rb b/app/models/query/manual_sorting.rb new file mode 100644 index 00000000000..def7f20cb83 --- /dev/null +++ b/app/models/query/manual_sorting.rb @@ -0,0 +1,77 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ +# +module Query::ManualSorting + extend ActiveSupport::Concern + + included do + include Concerns::VirtualAttribute + after_save :persist_ordered_work_packages! + + virtual_attribute :ordered_work_packages do + ::OrderedWorkPackage + .where(query_id: id) + .order(:position) + .pluck(:work_package_id) + end + + private + + def self.manual_sorting_column + ::Queries::WorkPackages::Columns::ManualSortingColumn.new + end + delegate :manual_sorting_column, to: :class + + ## + # Replace the current set of ordered work packages + def persist_ordered_work_packages! + return unless previous_changes[:ordered_work_packages] + + OrderedWorkPackage.transaction do + ::OrderedWorkPackage.where(query_id: id).delete_all + store_ordered_work_packages! + end + end + + ## + # Bulk insert the current set of ordered IDs + def store_ordered_work_packages! + bulk = ordered_work_packages.each_with_index.map do |wp_id, position| + { + query_id: id, + work_package_id: wp_id, + position: position + } + end + + OrderedWorkPackage.import bulk + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 6ed27c7c8d3..d6a25fdeafa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -509,6 +509,7 @@ en: too_long: "is too long (maximum is %{count} characters)." too_short: "is too short (minimum is %{count} characters)." unchangeable: "cannot be changed." + unremovable: "cannot be removed." wrong_length: "is the wrong length (should be %{count} characters)." models: custom_field: diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 7821221d139..0fc77128693 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -193,7 +193,9 @@ en: label_ascending: "Ascending" label_author: "Author: %{user}" label_between: "between" + label_board: "Board" label_board_locked: "Locked" + label_board_plural: "Boards" label_board_sticky: "Sticky" label_create_work_package: "Create new work package" label_created_by: "Created by" @@ -312,6 +314,7 @@ en: label_add_description: "Add a description for %{file}" label_upload_notification: "Uploading files..." label_work_package_upload_notification: "Uploading files for Work package #%{id}: %{subject}" + label_wp_id_added_by: "#%{id} added by %{author}" label_files_to_upload: "These files will be uploaded:" label_rejected_files: "These files cannot be uploaded:" label_rejected_files_reason: "These files cannot be uploaded as their size is greater than %{maximumFilesize}" @@ -781,6 +784,7 @@ en: confirm_deletion_children: "I acknowledge that ALL descendants of the listed work packages will be recursively removed." deletes_children: "All child work packages and their descendants will also be recursively deleted." + notice_no_results_to_display: "No visible results to display." notice_successful_create: "Successful creation." notice_successful_delete: "Successful deletion." notice_successful_update: "Successful update." diff --git a/db/migrate/20181121174153_create_ordered_work_packages.rb b/db/migrate/20181121174153_create_ordered_work_packages.rb new file mode 100644 index 00000000000..cdaecadee1e --- /dev/null +++ b/db/migrate/20181121174153_create_ordered_work_packages.rb @@ -0,0 +1,10 @@ +class CreateOrderedWorkPackages < ActiveRecord::Migration[5.1] + def change + create_table :ordered_work_packages do |t| + t.integer :position, index: true, null: false + + t.references :query, type: :integer, foreign_key: { index: true, on_delete: :cascade } + t.references :work_package, type: :integer, foreign_key: { index: true, on_delete: :cascade } + end + end +end diff --git a/db/migrate/20190129083842_add_project_to_grid.rb b/db/migrate/20190129083842_add_project_to_grid.rb new file mode 100644 index 00000000000..544a040ff63 --- /dev/null +++ b/db/migrate/20190129083842_add_project_to_grid.rb @@ -0,0 +1,7 @@ +class AddProjectToGrid < ActiveRecord::Migration[5.2] + def change + change_table :grids do |t| + t.references :project + end + end +end diff --git a/db/migrate/20190205090102_add_options_to_grid.rb b/db/migrate/20190205090102_add_options_to_grid.rb new file mode 100644 index 00000000000..f47025be4f9 --- /dev/null +++ b/db/migrate/20190205090102_add_options_to_grid.rb @@ -0,0 +1,8 @@ +class AddOptionsToGrid < ActiveRecord::Migration[5.2] + def change + change_table :grids do |t| + t.text :name + t.text :options + end + end +end diff --git a/docs/api/apiv3/endpoints/grids.apib b/docs/api/apiv3/endpoints/grids.apib index 09c7e941ddc..7032f3ba463 100644 --- a/docs/api/apiv3/endpoints/grids.apib +++ b/docs/api/apiv3/endpoints/grids.apib @@ -43,6 +43,7 @@ Currently, the following pages employ grids: | endRow | The row the widget ends. The widget's area does not include the row itself. | Integer | x > 0, x <= rowCount of the grid, x > startRow | READ/WRITE | | | startColumn | The column the widget starts at (1 based) | Integer | x > 0, x < columnCount of the grid, x < endColumn | READ/WRITE | | | endColumn | The column the widget ends. The widget's area does not include the column itself. | Integer | x > 0, x <= columnCount of the grid, x > startColumn | READ/WRITE | | +| options | An options hash of values customizable by the widget | JSON | | READ/WRITE | | ## Grid [/api/v3/grids/{id}] @@ -83,7 +84,7 @@ Currently, the following pages employ grids: "createdAt": "2018-12-03T16:58:30Z", "updatedAt": "2018-12-13T19:36:40Z", "_links": { - "page": { + "scope": { "href": "/my/page", "type": "text/html" }, @@ -172,7 +173,7 @@ Currently, the following pages employ grids: "createdAt": "2018-12-03T16:58:30Z", "updatedAt": "2018-12-13T19:36:40Z", "_links": { - "page": { + "scope": { "href": "/my/page", "type": "text/html" }, @@ -188,19 +189,70 @@ Currently, the following pages employ grids: "href": "/api/v3/grids/2" } } + }, + { + "_type": "Grid", + "id": 5, + "rowCount": 1, + "columnCount": 4, + "widgets": [ + { + "_type": "GridWidget", + "identifier": "work_package_query", + "startRow": 1, + "endRow": 8, + "startColumn": 1, + "endColumn": 3 + }, + { + "_type": "GridWidget", + "identifier": "work_package_query", + "startRow": 3, + "endRow": 8, + "startColumn": 4, + "endColumn": 5 + }, + { + "_type": "GridWidget", + "identifier": "work_package_query", + "startRow": 1, + "endRow": 3, + "startColumn": 3, + "endColumn": 6 + } + ], + "createdAt": "2019-01-05T16:58:30Z", + "updatedAt": "2019-01-07T19:36:40Z", + "_links": { + "scope": { + "href": "/projects/a_project/boards", + "type": "text/html" + }, + "updateImmediately": { + "href": "/api/v3/grids/5", + "method": "patch" + }, + "update": { + "href": "/api/v3/grids/5/form", + "method": "post" + }, + "self": { + "href": "/api/v3/grids/5" + } + } } ] }, "_links": { "self": { - "href": "/api/v3/time_entries?offset=1&pageSize=30" + "href": "/api/v3/grids?offset=1&pageSize=30" }, "jumpTo": { - "href": "/api/v3/time_entries?offset=%7Boffset%7D&pageSize=30", + "href": "/api/v3/grids?offset=%7Boffset%7D&pageSize=30", "templated": true }, "changeSize": { - "href": "/api/v3/time_entries?offset=1&pageSize=%7Bsize%7D", + "href": "/api/v3/grids?offset=1&pageSize=%7Bsize%7D", "templated": true } } @@ -285,7 +337,7 @@ Creates a new grid applying the attributes provided in the body. The constraints } ], "_links": { - "page": { + "scope": { "href": "/my/page" } } @@ -466,7 +518,7 @@ A page link must be provided in the body when calling this end point. } ], "_links": { - "page": { + "scope": { "href": "/my/page", "type": "text/html" } @@ -509,7 +561,7 @@ A page link must be provided in the body when calling this end point. "hasDefault": false, "writable": true }, - "page": { + "scope": { "type": "Href", "name": "Page", "required": true, @@ -706,7 +758,7 @@ For more details and all possible responses see the general specification of [Fo "hasDefault": false, "writable": true }, - "page": { + "scope": { "type": "Href", "name": "Page", "required": true, diff --git a/frontend/angular.json b/frontend/angular.json index d50ad5d443e..9ff7cacf89b 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -30,6 +30,11 @@ "node_modules/jquery-ui/themes/base/dialog.css", "node_modules/fullcalendar/dist/fullcalendar.css" ], + "stylePreprocessorOptions": { + "includePaths": [ + "src/assets/sass/" + ] + }, "scripts": [] }, "configurations": { diff --git a/frontend/npm-shrinkwrap.json b/frontend/npm-shrinkwrap.json index e419e6ae188..40643c76bca 100644 --- a/frontend/npm-shrinkwrap.json +++ b/frontend/npm-shrinkwrap.json @@ -851,6 +851,11 @@ "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-0.0.70.tgz", "integrity": "sha512-NHpD8C5J9P+6M/Swm+jIkOs8EywFMeSYzX9c1917QBNfvj2fuS0djROLoLNzSYEMHUWWaEVPLYJ8zDR2M6qaaQ==" }, + "@types/dragula": { + "version": "2.1.34", + "resolved": "https://registry.npmjs.org/@types/dragula/-/dragula-2.1.34.tgz", + "integrity": "sha512-tZbauiqJgEpKKtBQI8pQ24FrkDNGhiXCFsygqQPeAJTPJJC1RI0BOmHv7VHI9qDehhXXffq9aJEUhqEQyY/PVA==" + }, "@types/es6-shim": { "version": "0.31.39", "resolved": "https://registry.npmjs.org/@types/es6-shim/-/es6-shim-0.31.39.tgz", diff --git a/frontend/package.json b/frontend/package.json index 00171df4940..f4d059eab55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,6 +48,7 @@ "@types/assertion-error": "^1.0.30", "@types/chart.js": "^2.7.40", "@types/codemirror": "0.0.70", + "@types/dragula": "^2.1.34", "@types/es6-shim": "^0.31.39", "@types/jquery": "^3.3.22", "@types/jqueryui": "^1.12.6", @@ -129,6 +130,7 @@ "build": "ng build --prod && npm run legacy-webpack", "preserve": "./scripts/link_plugin_placeholder.js", "serve": "node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng serve", + "serve_no_reload": "node --max_old_space_size=4096 ./node_modules/@angular/cli/bin/ng serve --live-reload=false", "pretest": "./scripts/link_plugin_placeholder.js", "test": "ng test --watch=false", "tslint_typechecks": "./node_modules/.bin/tslint -p . -c tslint_typechecks.json", diff --git a/frontend/src/app/angular4-modules.ts b/frontend/src/app/angular4-modules.ts index 175ab5b67fa..5766829059a 100644 --- a/frontend/src/app/angular4-modules.ts +++ b/frontend/src/app/angular4-modules.ts @@ -79,6 +79,7 @@ import {OpenprojectWorkPackageRoutesModule} from "core-app/modules/work_packages import {BrowserModule} from "@angular/platform-browser"; import {OpenprojectCalendarModule} from "core-app/modules/calendar/openproject-calendar.module"; import {FullCalendarModule} from "ng-fullcalendar"; +import {OpenprojectBoardsModule} from "core-app/modules/boards/openproject-boards.module"; import {OpenprojectGlobalSearchModule} from "core-app/modules/global_search/openproject-global-search.module"; import {DeviceService} from "core-app/modules/common/browser/device.service"; @@ -92,6 +93,8 @@ import {DeviceService} from "core-app/modules/common/browser/device.service"; OpenprojectRouterModule, // Hal Module OpenprojectHalModule, + // Boards module + OpenprojectBoardsModule, // CKEditor OpenprojectEditorModule, diff --git a/frontend/src/app/components/api/api-work-packages/api-work-packages.service.ts b/frontend/src/app/components/api/api-work-packages/api-work-packages.service.ts index 94352edd847..f6c8314d50a 100644 --- a/frontend/src/app/components/api/api-work-packages/api-work-packages.service.ts +++ b/frontend/src/app/components/api/api-work-packages/api-work-packages.service.ts @@ -91,7 +91,7 @@ export class ApiWorkPackagesService { * @param projectIdentifier: The project to which the work package is initialized * @returns An empty work package form resource. */ - public typedCreateForm(typeId:number, projectIdentifier?:string):Promise { + public typedCreateForm(typeId:number, projectIdentifier:string|undefined|null):Promise { const typeUrl = this.pathHelper.api.v3.types.id(typeId).toString(); const request = { _links: { type: { href: typeUrl } } }; @@ -113,7 +113,7 @@ export class ApiWorkPackagesService { .toPromise(); } - private workPackagesFormPath(projectIdentifier?:string|null):string { + private workPackagesFormPath(projectIdentifier:string|null|undefined):string { if (projectIdentifier) { return this.pathHelper.api.v3.projects.id(projectIdentifier).work_packages.form.toString(); } else { diff --git a/frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts b/frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts index 0d739a4065d..b33417f7cf0 100644 --- a/frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts +++ b/frontend/src/app/components/api/op-file-upload/op-file-upload.service.spec.ts @@ -32,11 +32,13 @@ import {getTestBed, TestBed} from "@angular/core/testing"; import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; import {States} from "core-components/states.service"; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; describe('opFileUpload service', () => { let injector:TestBed; let service:OpenProjectFileUploadService; let httpMock:HttpTestingController; + let currentProject:string = 'foobar'; beforeEach(() => { TestBed.configureTestingModule({ diff --git a/frontend/src/app/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts b/frontend/src/app/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts index 1ea3af67883..d86ae7da96c 100644 --- a/frontend/src/app/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts +++ b/frontend/src/app/components/op-context-menu/handlers/op-settings-dropdown-menu.directive.ts @@ -42,11 +42,11 @@ import {WpTableExportModal} from "core-components/modals/export-modal/wp-table-e import {SaveQueryModal} from "core-components/modals/save-modal/save-query.modal"; import {QuerySharingModal} from "core-components/modals/share-modal/query-sharing.modal"; import {WpTableConfigurationModalComponent} from 'core-components/wp-table/configuration-modal/wp-table-configuration.modal'; +import {TableState} from "core-components/wp-table/table-state/table-state"; import { selectableTitleIdentifier, triggerEditingEvent -} from "core-components/wp-query-select/wp-query-selectable-title.component"; -import {TableState} from "core-components/wp-table/table-state/table-state"; +} from "core-app/modules/common/editable-toolbar-title/editable-toolbar-title.component"; @Directive({ selector: '[opSettingsContextMenu]' @@ -180,7 +180,7 @@ export class OpSettingsMenuDirective extends OpContextMenuTrigger implements OnD onClick: ($event:JQueryEventObject) => { if (this.allowQueryAction($event, 'update')) { this.focusAfterClose = false; - jQuery(`#${selectableTitleIdentifier}`).trigger(triggerEditingEvent); + jQuery(`${selectableTitleIdentifier}`).trigger(triggerEditingEvent); } return true; diff --git a/frontend/src/app/components/routing/my-page/my-page.component.ts b/frontend/src/app/components/routing/my-page/my-page.component.ts index 62341661b3f..64633f9c391 100644 --- a/frontend/src/app/components/routing/my-page/my-page.component.ts +++ b/frontend/src/app/components/routing/my-page/my-page.component.ts @@ -33,7 +33,7 @@ export class MyPageComponent implements OnInit { private loadMyPage():Promise { return this .gridDm - .list({ filters: [['page', '=', [this.pathHelper.myPagePath()]]] }) + .list({ filters: [['scope', '=', [this.pathHelper.myPagePath()]]] }) .then(collection => { if (collection.total === 0) { return this.myPageForm(); @@ -47,7 +47,7 @@ export class MyPageComponent implements OnInit { return new Promise((resolve, reject) => { let payload = { '_links': { - 'page': { + 'scope': { 'href': this.pathHelper.myPagePath() } } diff --git a/frontend/src/app/components/states/state-cache.service.ts b/frontend/src/app/components/states/state-cache.service.ts index d85804f2dfb..8df03dfa0f3 100644 --- a/frontend/src/app/components/states/state-cache.service.ts +++ b/frontend/src/app/components/states/state-cache.service.ts @@ -28,6 +28,7 @@ import {InputState, MultiInputState, State} from 'reactivestates'; import {Observable} from "rxjs"; +import {auditTime, debounceTime, map, startWith, throttleTime} from "rxjs/operators"; export abstract class StateCacheService { private cacheDurationInMs:number; @@ -65,6 +66,28 @@ export abstract class StateCacheService { return this.state(id).values$(); } + /** + * Observe the entire set of loaded results + */ + public observeAll():Observable { + return this.multiState + .observeChange() + .pipe( + startWith([]), + auditTime(250), + map(() => { + let mapped:T[] = []; + _.each(this.multiState.getValueOr({}), (state:State) => { + if (state.value) { + mapped.push(state.value); + } + }); + + return mapped; + }) + ); + } + /** * Clear a set of cached states. * @param ids @@ -92,6 +115,27 @@ export abstract class StateCacheService { return state.valuesPromise() as Promise; } + /** + * Require the value to be loaded either when forced or the value is stale + * according to the cache interval specified for this service. + * + * Returns an observable to the values stream of the state. + * + * @param id The value's identifier. + * @param force Load the value anyway. + */ + public requireAndStream(id:string, force:boolean = false):Observable { + const state = this.multiState.get(id); + + // Refresh when stale or being forced + if (this.stale(state) || force) { + state.clear(); + state.putFromPromiseIfPristine(() => this.load(id)); + } + + return state.values$(); + } + /** * Require the states of the given ids to be loaded if they're empty or stale, * or all when force is given. diff --git a/frontend/src/app/components/table-pagination/pagination-service.ts b/frontend/src/app/components/table-pagination/pagination-service.ts index 0acfb1a6ba8..d027a38e9c1 100644 --- a/frontend/src/app/components/table-pagination/pagination-service.ts +++ b/frontend/src/app/components/table-pagination/pagination-service.ts @@ -28,6 +28,7 @@ import {Injectable} from '@angular/core'; import {ConfigurationDmService} from 'core-app/modules/hal/dm-services/configuration-dm.service'; +import {take} from "rxjs/operators"; export const DEFAULT_PAGINATION_OPTIONS = { maxVisiblePageOptions: 6, @@ -113,7 +114,10 @@ export class PaginationService { } public loadPaginationOptions() { - return this.ConfigurationDm.load().then((configuration:any) => { + return this.ConfigurationDm + .load() + .toPromise() + .then((configuration:any) => { this.setPerPageOptions(configuration.perPageOptions); return this.paginationOptions; }); diff --git a/frontend/src/app/components/user/user-avatar/user-avatar.component.html b/frontend/src/app/components/user/user-avatar/user-avatar.component.html index dc96ba01100..ffae3291636 100644 --- a/frontend/src/app/components/user/user-avatar/user-avatar.component.html +++ b/frontend/src/app/components/user/user-avatar/user-avatar.component.html @@ -5,7 +5,7 @@ [alt]="userName" (error)="replaceWithDefault()" /> -
    diff --git a/frontend/src/app/components/user/user-avatar/user-avatar.component.ts b/frontend/src/app/components/user/user-avatar/user-avatar.component.ts index d4828750071..e148cd170e5 100644 --- a/frontend/src/app/components/user/user-avatar/user-avatar.component.ts +++ b/frontend/src/app/components/user/user-avatar/user-avatar.component.ts @@ -30,6 +30,8 @@ import {UserResource} from 'core-app/modules/hal/resources/user-resource'; import {AfterViewInit, ChangeDetectorRef, Component, ElementRef} from "@angular/core"; import {UserCacheService} from "core-components/user/user-cache.service"; import {DynamicBootstrapper} from "core-app/globals/dynamic-bootstrapper"; +import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; +import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; @Component({ selector: 'user-avatar', @@ -37,31 +39,46 @@ import {DynamicBootstrapper} from "core-app/globals/dynamic-bootstrapper"; }) export class UserAvatarComponent implements AfterViewInit { public $element:JQuery; + + public user:string; public userInitials:string; public userAvatar:string; public userName:string; public colorCode:string; public userID:string; public classes:string; + public useFallback:boolean; + public isGroup:boolean = false; constructor(readonly userCacheService:UserCacheService, protected elementRef:ElementRef, - protected ref:ChangeDetectorRef) { + protected ref:ChangeDetectorRef, + readonly halResourceService:HalResourceService) { } public ngAfterViewInit() { this.$element = jQuery(this.elementRef.nativeElement); - this.userID = this.$element.data('user-id')!; + this.user = this.$element.data('user')!; this.classes = this.$element.data('class-list')!; this.useFallback = this.$element.data('use-fallback')!; this.userAvatar = this.$element.data('user-avatar-src')!; this.userName = this.$element.data('user-name')!; - // When a userID is given, - // we have to get the information from the database - if (this.userID) { + this.isGroup = this.isUserAGroup(); + if (this.isGroup) { + this.showGroupAvatar(); + } else { + this.showUserAvatar(); + } + } + + public showUserAvatar() { + // When a user url is given, + // we have to get the information from the database. + if (this.user) { + this.userID = WorkPackageResource.idFromLink(this.user); this.userCacheService .require(this.userID) .then((user:UserResource) => { @@ -78,12 +95,23 @@ export class UserAvatarComponent implements AfterViewInit { } } + public showGroupAvatar() { + this.halResourceService.get(this.user, {}) + .subscribe(res => { + this.useFallback = true; + this.userName = res.name; + this.userInitials = this.getInitials(this.userName); + this.colorCode = this.computeColor(this.userName); + this.ref.detectChanges(); + }); + } + public replaceWithDefault() { this.useFallback = true; this.ref.detectChanges(); } - public getInitials(name:string) { + private getInitials(name:string) { var names = name.split(' '), initials = names[0].substring(0, 1).toUpperCase(); @@ -94,7 +122,7 @@ export class UserAvatarComponent implements AfterViewInit { return initials; } - public computeColor(name:string) { + private computeColor(name:string) { let hash = 0; for (var i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); @@ -104,6 +132,15 @@ export class UserAvatarComponent implements AfterViewInit { return 'hsl('+ h +', 50%, 50%)'; } + + private isUserAGroup() { + // When an ID or an avatar is given, it must be a user. + // Otherwise we have to check the url. + return !this.userID && + !this.userAvatar && + !!this.user && + this.user.includes('group'); + } } DynamicBootstrapper.register({ selector: 'user-avatar', cls: UserAvatarComponent }); diff --git a/frontend/src/app/components/wp-card-view/card-reorder-query.service.ts b/frontend/src/app/components/wp-card-view/card-reorder-query.service.ts new file mode 100644 index 00000000000..3c4b0687685 --- /dev/null +++ b/frontend/src/app/components/wp-card-view/card-reorder-query.service.ts @@ -0,0 +1,13 @@ +import {Injectable} from "@angular/core"; +import {TableState} from "core-components/wp-table/table-state/table-state"; +import {ReorderQueryService} from "core-app/modules/boards/drag-and-drop/reorder-query.service"; + +@Injectable() +export class CardReorderQueryService extends ReorderQueryService { + + protected getCurrentOrder(tableState:TableState):string[] { + return tableState + .results + .mapOr((results) => results.elements.map(el => el.id.toString()), []); + } +} diff --git a/frontend/src/app/components/wp-card-view/wp-card-view.component.html b/frontend/src/app/components/wp-card-view/wp-card-view.component.html new file mode 100644 index 00000000000..947d8b86ef0 --- /dev/null +++ b/frontend/src/app/components/wp-card-view/wp-card-view.component.html @@ -0,0 +1,50 @@ +
    +
    + + + + + + + + +
    +
    + +
    +
    + + + +
    +
    +
    + + +
    +
    +
    + + + {{ text.addNewCard }} + +
    diff --git a/frontend/src/app/components/wp-card-view/wp-card-view.component.sass b/frontend/src/app/components/wp-card-view/wp-card-view.component.sass new file mode 100644 index 00000000000..df34e73ea16 --- /dev/null +++ b/frontend/src/app/components/wp-card-view/wp-card-view.component.sass @@ -0,0 +1,50 @@ +@import 'helpers' + +.work-package--cards-container + display: flex + flex-direction: column + overflow: auto + // Full height - inline create + height: calc(100% - 55px) + +.work-package--card + width: 100% + border: 1px solid var(--widget-box-block-border-color) + border-radius: 5px + padding: 5px 5px 5px 10px + margin-top: 10px + background-color: #eeeeee6e + position: relative + + .work-package--card--assignee + position: absolute + bottom: 5px + right: 0 + +.work-package--card--subject .wp-edit-field--container + margin-left: -5px + +.work-package--card--additional-info-container + margin-top: 15px + +.work-package--card--additional-info + display: flex + line-height: 22px + +wp-edit-field + width: initial + +.work-package--card--additional-info-attribute + max-width: 50% + @include text-shortener + margin-right: 5px + +.wp-inline-create-button + font-size: 0.9rem + padding-top: 1rem + text-align: center + +.work-package-card--inline-cancel-wrapper + position: absolute + right: 0 + bottom: 2px diff --git a/frontend/src/app/components/wp-card-view/wp-card-view.component.ts b/frontend/src/app/components/wp-card-view/wp-card-view.component.ts new file mode 100644 index 00000000000..b5bc087f82b --- /dev/null +++ b/frontend/src/app/components/wp-card-view/wp-card-view.component.ts @@ -0,0 +1,249 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Inject, + Injector, + OnInit, + ViewChild +} from "@angular/core"; +import {QueryResource} from 'core-app/modules/hal/resources/query-resource'; +import {TableState} from "core-components/wp-table/table-state/table-state"; +import {untilComponentDestroyed} from "ng2-rx-componentdestroyed"; +import {QueryColumn} from "app/components/wp-query/query-column"; +import {combine} from "reactivestates/dist"; +import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; +import {WorkPackageEmbeddedTableComponent} from "core-components/wp-table/embedded/wp-embedded-table.component"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {WorkPackageStatesInitializationService} from "core-components/wp-list/wp-states-initialization.service"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; +import {OpTableActionsService} from "core-components/wp-table/table-actions/table-actions.service"; +import {WorkPackageTableTimelineService} from "core-components/wp-fast-table/state/wp-table-timeline.service"; +import {WorkPackageTablePaginationService} from "core-components/wp-fast-table/state/wp-table-pagination.service"; +import {WorkPackageInlineCreateService} from "core-components/wp-inline-create/wp-inline-create.service"; +import {WorkPackageTableRelationColumnsService} from "core-components/wp-fast-table/state/wp-table-relation-columns.service"; +import {WorkPackageTableHierarchiesService} from "core-components/wp-fast-table/state/wp-table-hierarchy.service"; +import {WorkPackageTableGroupByService} from "core-components/wp-fast-table/state/wp-table-group-by.service"; +import {WorkPackageTableFiltersService} from "core-components/wp-fast-table/state/wp-table-filters.service"; +import {WorkPackageTableColumnsService} from "core-components/wp-fast-table/state/wp-table-columns.service"; +import {WorkPackageTableSortByService} from "core-components/wp-fast-table/state/wp-table-sort-by.service"; +import {WorkPackageTableSelection} from "core-components/wp-fast-table/state/wp-table-selection.service"; +import {WorkPackageTableSumService} from "core-components/wp-fast-table/state/wp-table-sum.service"; +import {WorkPackageTableAdditionalElementsService} from "core-components/wp-fast-table/state/wp-table-additional-elements.service"; +import {WorkPackageTableRefreshService} from "core-components/wp-table/wp-table-refresh-request.service"; +import {WorkPackageTableHighlightingService} from "core-components/wp-fast-table/state/wp-table-highlighting.service"; +import {IWorkPackageCreateServiceToken} from "core-components/wp-new/wp-create.service.interface"; +import {WorkPackageCreateService} from "core-components/wp-new/wp-create.service"; +import {DragAndDropService} from "core-app/modules/boards/drag-and-drop/drag-and-drop.service"; +import {CardReorderQueryService} from "core-components/wp-card-view/card-reorder-query.service"; +import {ReorderQueryService} from "core-app/modules/boards/drag-and-drop/reorder-query.service"; +import {AngularTrackingHelpers} from "core-components/angular/tracking-functions"; +import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset"; + + +@Component({ + selector: 'wp-card-view', + styleUrls: ['./wp-card-view.component.sass'], + templateUrl: './wp-card-view.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + TableState, + OpTableActionsService, + WorkPackageInlineCreateService, + WorkPackageTableRelationColumnsService, + WorkPackageTablePaginationService, + WorkPackageTableGroupByService, + WorkPackageTableHierarchiesService, + WorkPackageTableSortByService, + WorkPackageTableColumnsService, + WorkPackageTableFiltersService, + WorkPackageTableTimelineService, + WorkPackageTableSelection, + WorkPackageTableSumService, + WorkPackageTableAdditionalElementsService, + WorkPackageTableRefreshService, + WorkPackageTableHighlightingService, + { provide: IWorkPackageCreateServiceToken, useClass: WorkPackageCreateService }, + // Order is important here, to avoid this service + // getting global injections + WorkPackageStatesInitializationService, + { provide: ReorderQueryService, useClass: CardReorderQueryService }, + ] +}) +export class WorkPackageCardViewComponent extends WorkPackageEmbeddedTableComponent implements OnInit { + public trackByHref = AngularTrackingHelpers.trackByHref; + public query:QueryResource; + public workPackages:any[]; + public columns:QueryColumn[]; + public availableColumns:QueryColumn[]; + public text = { + addNewCard: 'Add new card', + wpAddedBy: (wp:WorkPackageResource) => + this.I18n.t('js.label_wp_id_added_by', {id: wp.id, author: wp.author.name}) + }; + + @ViewChild('container') public container:ElementRef; + + /** Whether the card view has an active inline created wp */ + public activeInlineCreateWp?:WorkPackageResource; + + constructor(readonly tableState:TableState, + readonly injector:Injector, + readonly I18n:I18nService, + readonly currentProject:CurrentProjectService, + @Inject(IWorkPackageCreateServiceToken) readonly wpCreate:WorkPackageCreateService, + readonly wpInlineCreate:WorkPackageInlineCreateService, + readonly dragService:DragAndDropService, + readonly reorderService:ReorderQueryService, + readonly wpTableRefresh:WorkPackageTableRefreshService, + readonly cdRef:ChangeDetectorRef) { + + super(injector); + } + + ngOnInit() { + super.ngOnInit(); + + this.registerDragAndDrop(); + + this.registerCreationCallback(); + + combine( + this.tableState.columns, + this.tableState.results + ) + .values$() + .pipe( + untilComponentDestroyed(this) + ) + .subscribe(([columns, results]) => { + + if (this.activeInlineCreateWp) { + this.workPackages = [...results.$embedded.elements, this.activeInlineCreateWp]; + } else { + this.workPackages = results.$embedded.elements; + } + + this.removeDragged(); + + this.columns = columns.current; + this.availableColumns = this.columns.filter(function (column) { + return column.id !== 'id' && column.id !== 'subject' && column.id !== 'author'; + }); + + this.cdRef.detectChanges(); + }); + } + + ngOnDestroy():void { + this.dragService.remove(this.container.nativeElement); + } + + public hasAssignee(wp:WorkPackageResource) { + return !!wp.assignee; + } + + public get canAdd() { + return this.wpInlineCreate.canAdd; + } + + public get isDraggable() { + return this.configuration.dragAndDropEnabled; + } + + removeDragged() { + this.container.nativeElement + .querySelectorAll('.__was_dragged') + .forEach((el:HTMLElement) => { + el.parentElement && el.parentElement!.removeChild(el); + }); + } + + registerDragAndDrop() { + if (!this.configuration.dragAndDropEnabled) { + return; + } + + this.dragService.register({ + container: this.container.nativeElement, + moves: (card:HTMLElement) => !card.dataset.isNew, + onMoved: (card:HTMLElement) => { + const wpId:string = card.dataset.workPackageId!; + const toIndex = this.getIndex(card); + + this.reorderService + .move(this.tableState, wpId, toIndex) + .then(() => this.wpTableRefresh.request('Drag and Drop moved item')); + }, + onRemoved: (card:HTMLElement) => { + const wpId:string = card.dataset.workPackageId!; + + this.reorderService + .remove(this.tableState, wpId) + .then(() => this.wpTableRefresh.request('Drag and Drop removed item')); + }, + onAdded: (card:HTMLElement) => { + // Fix to ensure items that are virtually added get removed quickly + card.classList.add('__was_dragged'); + const wpId:string = card.dataset.workPackageId!; + const toIndex = this.getIndex(card); + + this.reorderService + .add(this.tableState, wpId, toIndex) + .then(() => this.wpTableRefresh.request('Drag and Drop added item')); + } + }); + } + + + /** + * Inline create a new card + */ + addNewCard() { + this.wpCreate + .createOrContinueWorkPackage(this.currentProject.identifier) + .then((changeset:WorkPackageChangeset) => { + this.activeInlineCreateWp = changeset.workPackage; + this.workPackages = [...this.workPackages, this.activeInlineCreateWp]; + this.cdRef.detectChanges(); + }); + } + + /** + * Listen to newly created work packages to detect whether the WP is the one we created, + * and properly reset inline create in this case + */ + private registerCreationCallback() { + this.wpCreate + .onNewWorkPackage() + .pipe(untilComponentDestroyed(this)) + .subscribe((wp:WorkPackageResource) => { + if (this.activeInlineCreateWp && this.activeInlineCreateWp.__initialized_at === wp.__initialized_at) { + const index = this.workPackages.indexOf(this.activeInlineCreateWp); + this.activeInlineCreateWp = undefined; + + // Add this item to the results + this.reorderService.add(this.tableState, wp.id, index); + + // Notify inline create service + this.wpInlineCreate.newInlineWorkPackageCreated.next(wp.id); + } + }); + } + + /** + * Remove the new card + */ + removeNewCard(wp:WorkPackageResource) { + const index = this.workPackages.indexOf(wp); + this.workPackages.splice(index, 1); + this.activeInlineCreateWp = undefined; + this.cdRef.detectChanges(); + } + + private getIndex(card:HTMLElement) { + return _.findIndex(card.parentElement!.children, el => card === el); + } + +} diff --git a/frontend/src/app/components/wp-edit-form/work-package-edit-form.ts b/frontend/src/app/components/wp-edit-form/work-package-edit-form.ts index 382dcc6887c..35e0b82e6ba 100644 --- a/frontend/src/app/components/wp-edit-form/work-package-edit-form.ts +++ b/frontend/src/app/components/wp-edit-form/work-package-edit-form.ts @@ -40,6 +40,7 @@ import {WorkPackageEditContext} from './work-package-edit-context'; import {WorkPackageEditFieldHandler} from './work-package-edit-field-handler'; import {IWorkPackageEditingServiceToken} from "core-components/wp-edit-form/work-package-editing.service.interface"; import {IFieldSchema} from "core-app/modules/fields/field.base"; +import {TableRowEditContext} from "core-components/wp-edit-form/table-row-edit-context"; export const activeFieldContainerClassName = 'wp-inline-edit--active-field'; export const activeFieldClassName = 'wp-inline-edit--field'; diff --git a/frontend/src/app/components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-builder.ts b/frontend/src/app/components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-builder.ts new file mode 100644 index 00000000000..068868fbf33 --- /dev/null +++ b/frontend/src/app/components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-builder.ts @@ -0,0 +1,21 @@ +import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; +import {wpCellTdClassName} from "core-components/wp-fast-table/builders/cell-builder"; + +export class DragDropHandleBuilder { + + /** + * Renders an angular CDK drag component into the column + */ + public build(workPackage:WorkPackageResource):HTMLElement { + // Append sort handle + let td = document.createElement('td'); + td.classList.add(wpCellTdClassName, 'wp-table--sort-td', 'hide-when-print'); + + // Wrap handle as span + let span = document.createElement('span'); + span.classList.add('wp-table--drag-and-drop-handle', 'icon-toggle'); + td.appendChild(span); + + return td; + } +} diff --git a/frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts b/frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts index 6b4450a8e6a..4bb86db56dc 100644 --- a/frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts +++ b/frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-rows-builder.ts @@ -23,11 +23,8 @@ export class GroupedRowsBuilder extends RowsBuilder { public wpTableColumns:WorkPackageTableColumnsService = this.injector.get(WorkPackageTableColumnsService); public I18n:I18nService = this.injector.get(I18nService); - private headerBuilder:GroupHeaderBuilder; - constructor(public readonly injector:Injector, workPackageTable:WorkPackageTable) { super(injector, workPackageTable); - this.headerBuilder = new GroupHeaderBuilder(this.injector); } /** @@ -56,11 +53,12 @@ export class GroupedRowsBuilder extends RowsBuilder { } public buildRows() { + const builder = new GroupHeaderBuilder(this.injector); return new GroupedRenderPass( this.injector, this.workPackageTable, this.getGroupData(), - this.headerBuilder, + builder, this.colspan ).render(); } @@ -72,6 +70,7 @@ export class GroupedRowsBuilder extends RowsBuilder { const groups = this.getGroupData(); const colspan = this.wpTableColumns.columnCount + 1; const rendered = this.tableState.rendered.value!; + const builder = new GroupHeaderBuilder(this.injector); jQuery(this.workPackageTable.container) .find(`.${rowGroupClassName}`) @@ -80,7 +79,7 @@ export class GroupedRowsBuilder extends RowsBuilder { let group = groups[groupIndex]; // Refresh the group header - let newRow = this.headerBuilder.buildGroupRow(group, colspan); + let newRow = builder.buildGroupRow(group, colspan); if (oldRow.parentNode) { oldRow.parentNode.replaceChild(newRow, oldRow); diff --git a/frontend/src/app/components/wp-fast-table/builders/modes/hierarchy/hierarchy-rows-builder.ts b/frontend/src/app/components/wp-fast-table/builders/modes/hierarchy/hierarchy-rows-builder.ts index e3e2c3f0200..b173f628bf2 100644 --- a/frontend/src/app/components/wp-fast-table/builders/modes/hierarchy/hierarchy-rows-builder.ts +++ b/frontend/src/app/components/wp-fast-table/builders/modes/hierarchy/hierarchy-rows-builder.ts @@ -1,5 +1,4 @@ import {Injector} from '@angular/core'; -import {I18nService} from 'core-app/modules/common/i18n/i18n.service'; import {States} from '../../../../states.service'; import {WorkPackageTableColumnsService} from '../../../state/wp-table-columns.service'; import {WorkPackageTableHierarchiesService} from '../../../state/wp-table-hierarchy.service'; @@ -15,12 +14,9 @@ export class HierarchyRowsBuilder extends RowsBuilder { public wpTableColumns = this.injector.get(WorkPackageTableColumnsService); public wpTableHierarchies = this.injector.get(WorkPackageTableHierarchiesService); - protected rowBuilder:SingleHierarchyRowBuilder; - // The group expansion state constructor(public readonly injector:Injector, public workPackageTable:WorkPackageTable) { super(injector, workPackageTable); - this.rowBuilder = new SingleHierarchyRowBuilder(injector, this.workPackageTable); } /** @@ -34,6 +30,7 @@ export class HierarchyRowsBuilder extends RowsBuilder { * Rebuild the entire grouped tbody from the given table */ public buildRows():HierarchyRenderPass { - return new HierarchyRenderPass(this.injector, this.workPackageTable, this.rowBuilder).render(); + const builder = new SingleHierarchyRowBuilder(this.injector, this.workPackageTable); + return new HierarchyRenderPass(this.injector, this.workPackageTable, builder).render(); } } diff --git a/frontend/src/app/components/wp-fast-table/builders/modes/plain/plain-rows-builder.ts b/frontend/src/app/components/wp-fast-table/builders/modes/plain/plain-rows-builder.ts index cdf1f962cd3..8f633280495 100644 --- a/frontend/src/app/components/wp-fast-table/builders/modes/plain/plain-rows-builder.ts +++ b/frontend/src/app/components/wp-fast-table/builders/modes/plain/plain-rows-builder.ts @@ -11,18 +11,16 @@ export class PlainRowsBuilder extends RowsBuilder { // Injections public I18n:I18nService = this.injector.get(I18nService); - protected rowBuilder:SingleRowBuilder; - // The group expansion state constructor(public readonly injector:Injector, workPackageTable:WorkPackageTable) { super(injector, workPackageTable); - this.rowBuilder = new SingleRowBuilder(injector, this.workPackageTable); } /** * Rebuild the entire grouped tbody from the given table */ public buildRows():PrimaryRenderPass { - return new PlainRenderPass(this.injector, this.workPackageTable, this.rowBuilder).render(); + const builder = new SingleRowBuilder(this.injector, this.workPackageTable); + return new PlainRenderPass(this.injector, this.workPackageTable, builder).render(); } } diff --git a/frontend/src/app/components/wp-fast-table/builders/primary-render-pass.ts b/frontend/src/app/components/wp-fast-table/builders/primary-render-pass.ts index e100b9993ea..f5a9557e361 100644 --- a/frontend/src/app/components/wp-fast-table/builders/primary-render-pass.ts +++ b/frontend/src/app/components/wp-fast-table/builders/primary-render-pass.ts @@ -75,6 +75,9 @@ export abstract class PrimaryRenderPass { // Render into the table fragment this.doRender(); + + // Post render + this.postRender(); }); // Render subsequent passes @@ -144,7 +147,6 @@ export abstract class PrimaryRenderPass { this.renderedOrder.splice(index + 1, 0, renderedInfo); } - protected prepare() { this.timeline = new TimelineRenderPass(this.injector, this.workPackageTable, this); this.relations = new RelationsRenderPass(this.injector, this.workPackageTable, this); @@ -158,6 +160,15 @@ export abstract class PrimaryRenderPass { */ protected abstract doRender():void; + /** + * Post render shared among all sub passes + */ + protected postRender():void { + if (this.renderedOrder.length === 0 && this.workPackageTable.renderPlaceholderRow) { + this.tableBody.appendChild(this.rowBuilder.placeholderRow); + } + } + /** * Append a work package row to both containers * @param workPackage The work package, if the row belongs to one diff --git a/frontend/src/app/components/wp-fast-table/builders/rows/single-row-builder.ts b/frontend/src/app/components/wp-fast-table/builders/rows/single-row-builder.ts index 436332a0bb2..95bb068069d 100644 --- a/frontend/src/app/components/wp-fast-table/builders/rows/single-row-builder.ts +++ b/frontend/src/app/components/wp-fast-table/builders/rows/single-row-builder.ts @@ -11,12 +11,17 @@ import {CellBuilder, wpCellTdClassName} from '../cell-builder'; import {RelationCellbuilder} from '../relation-cell-builder'; import {checkedClassName} from '../ui-state-link-builder'; import {TableActionRenderer} from 'core-components/wp-fast-table/builders/table-action-renderer'; +import {DragDropHandleBuilder} from "core-components/wp-fast-table/builders/drag-and-drop/drag-drop-handle-builder"; // Work package table row entries export const tableRowClassName = 'wp-table--row'; // Work package and timeline rows export const commonRowClassName = 'wp--row'; +export const internalSortColumn = { + id: '__internal-sorthandle' +} as QueryColumn; + export const internalContextMenuColumn = { id: '__internal-contextMenu' } as QueryColumn; @@ -36,6 +41,12 @@ export class SingleRowBuilder { // Details Link builder protected contextLinkBuilder = new TableActionRenderer(this.injector); + // Drag & Drop handle builder + protected dragDropHandleBuilder = new DragDropHandleBuilder(); + + // Build the augmented columns set to render with + protected readonly augmentedColumns:QueryColumn[] = this.buildAugmentedColumns(); + constructor(public readonly injector:Injector, protected workPackageTable:WorkPackageTable) { } @@ -51,8 +62,14 @@ export class SingleRowBuilder { * Returns the current set of columns, augmented by the internal columns * we add for buttons and timeline. */ - public get augmentedColumns():QueryColumn[] { - return this.columns.concat([internalContextMenuColumn]); + private buildAugmentedColumns():QueryColumn[] { + let columns = [...this.columns, internalContextMenuColumn]; + + if (this.workPackageTable.configuration.dragAndDropEnabled) { + columns.unshift(internalSortColumn); + } + + return columns; } public buildCell(workPackage:WorkPackageResource, column:QueryColumn):HTMLElement|null { @@ -63,6 +80,8 @@ export class SingleRowBuilder { // Handle property types switch (column.id) { + case internalSortColumn.id: + return this.dragDropHandleBuilder.build(workPackage); case internalContextMenuColumn.id: if (this.workPackageTable.configuration.actionsColumnEnabled) { return this.contextLinkBuilder.build(workPackage); @@ -106,6 +125,21 @@ export class SingleRowBuilder { return tr; } + /** + * In case the table will end up empty, we insert a placeholder + * row to provide some space within the tbody. + */ + public get placeholderRow() { + const tr:HTMLTableRowElement = document.createElement('tr'); + const td:HTMLTableCellElement = document.createElement('td'); + + tr.classList.add('wp--placeholder-row'); + td.colSpan = this.augmentedColumns.length; + tr.appendChild(td); + + return tr; + } + public classIdentifier(workPackage:WorkPackageResource) { return `wp-row-${workPackage.id}`; } diff --git a/frontend/src/app/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts b/frontend/src/app/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts new file mode 100644 index 00000000000..f1b195c7bf2 --- /dev/null +++ b/frontend/src/app/components/wp-fast-table/handlers/state/drag-and-drop-transformer.ts @@ -0,0 +1,73 @@ +import {Injector} from '@angular/core'; +import {WorkPackageTable} from '../../wp-fast-table'; +import {TableState} from 'core-components/wp-table/table-state/table-state'; +import {States} from 'core-components/states.service'; +import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; +import {DragAndDropService} from "core-app/modules/boards/drag-and-drop/drag-and-drop.service"; +import {RenderedRow, RowRenderInfo} from "core-components/wp-fast-table/builders/primary-render-pass"; +import {take, takeUntil} from "rxjs/operators"; +import {WorkPackageInlineCreateService} from "core-components/wp-inline-create/wp-inline-create.service"; +import {WorkPackageTableRefreshService} from "core-components/wp-table/wp-table-refresh-request.service"; +import {ReorderQueryService} from "core-app/modules/boards/drag-and-drop/reorder-query.service"; + +export class DragAndDropTransformer { + + private readonly tableState:TableState = this.injector.get(TableState); + private readonly states:States = this.injector.get(States); + private readonly pathHelper = this.injector.get(PathHelperService); + private readonly dragService = this.injector.get(DragAndDropService, null); + private readonly reorderService = this.injector.get(ReorderQueryService); + private readonly inlineCreateService = this.injector.get(WorkPackageInlineCreateService); + private readonly wpTableRefresh = this.injector.get(WorkPackageTableRefreshService); + + constructor(public readonly injector:Injector, + public table:WorkPackageTable) { + + // The DragService may not have been provided + // in which case we do not provide drag and drop + if (this.dragService === null) { + return; + } + + this.inlineCreateService.newInlineWorkPackageCreated + .pipe(takeUntil(this.tableState.stopAllSubscriptions)) + .subscribe((wpId) => { + this.reorderService + .add(this.tableState, wpId) + .then(() => this.wpTableRefresh.request('Drag and Drop added item')); + }); + + this.tableState.stopAllSubscriptions + .pipe(take(1)) + .subscribe(() => { + this.dragService + .remove(this.table.tbody) + .then(() => this.wpTableRefresh.request('Drag and Drop change')); + }); + + this.dragService.register({ + container: this.table.tbody, + moves: function(el:any, source:any, handle:HTMLElement) { + return handle.classList.contains('wp-table--drag-and-drop-handle'); + }, + onMoved: (row:HTMLTableRowElement) => { + const wpId:string = row.dataset.workPackageId!; + this.reorderService + .move(this.tableState, wpId, row.rowIndex - 1) + .then(() => this.wpTableRefresh.request('Drag and Drop moved item')); + }, + onRemoved: (row:HTMLTableRowElement) => { + const wpId:string = row.dataset.workPackageId!; + this.reorderService + .remove(this.tableState, wpId) + .then(() => this.wpTableRefresh.request('Drag and Drop removed item')); + }, + onAdded: (row:HTMLTableRowElement) => { + const wpId:string = row.dataset.workPackageId!; + this.reorderService + .add(this.tableState, wpId, row.rowIndex - 1) + .then(() => this.wpTableRefresh.request('Drag and Drop added item')); + } + }); + } +} diff --git a/frontend/src/app/components/wp-fast-table/handlers/table-handler-registry.ts b/frontend/src/app/components/wp-fast-table/handlers/table-handler-registry.ts index 4f0f89cf797..feec2a86c08 100644 --- a/frontend/src/app/components/wp-fast-table/handlers/table-handler-registry.ts +++ b/frontend/src/app/components/wp-fast-table/handlers/table-handler-registry.ts @@ -17,6 +17,7 @@ import {RowsTransformer} from './state/rows-transformer'; import {SelectionTransformer} from './state/selection-transformer'; import {TimelineTransformer} from './state/timeline-transformer'; import {HighlightingTransformer} from "core-components/wp-fast-table/handlers/state/highlighting-transformer"; +import {DragAndDropTransformer} from "core-components/wp-fast-table/handlers/state/drag-and-drop-transformer"; export interface TableEventHandler { EVENT:string; @@ -67,7 +68,8 @@ export class TableHandlerRegistry { TimelineTransformer, HierarchyTransformer, RelationsTransformer, - HighlightingTransformer + HighlightingTransformer, + DragAndDropTransformer ]; attachTo(table:WorkPackageTable) { diff --git a/frontend/src/app/components/wp-fast-table/wp-fast-table.ts b/frontend/src/app/components/wp-fast-table/wp-fast-table.ts index c530719d026..cc57473a6a6 100644 --- a/frontend/src/app/components/wp-fast-table/wp-fast-table.ts +++ b/frontend/src/app/components/wp-fast-table/wp-fast-table.ts @@ -131,6 +131,16 @@ export class WorkPackageTable { }); } + /** + * Determine whether we need an empty placeholder row. + * When D&D is enabled, the table requires a drag target that is non-empty, + * and the tbody cannot be resized appropriately. + */ + public get renderPlaceholderRow() { + return this.configuration.dragAndDropEnabled; + } + + private performRenderPass() { this.editing.reset(); const renderPass = this.lastRenderPass = this.rowBuilder.buildRows(); diff --git a/frontend/src/app/components/wp-inline-create/wp-inline-create.component.ts b/frontend/src/app/components/wp-inline-create/wp-inline-create.component.ts index e97ccbdd699..c512777eeb1 100644 --- a/frontend/src/app/components/wp-inline-create/wp-inline-create.component.ts +++ b/frontend/src/app/components/wp-inline-create/wp-inline-create.component.ts @@ -43,7 +43,6 @@ import {filter} from 'rxjs/operators'; import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource'; import {WorkPackageChangeset} from '../wp-edit-form/work-package-changeset'; import {WorkPackageEditForm} from '../wp-edit-form/work-package-edit-form'; -import {TimelineRowBuilder} from '../wp-fast-table/builders/timeline/timeline-row-builder'; import {onClickOrEnter} from '../wp-fast-table/handlers/click-or-enter-handler'; import {WorkPackageTableColumnsService} from '../wp-fast-table/state/wp-table-columns.service'; import {WorkPackageTable} from '../wp-fast-table/wp-fast-table'; @@ -84,9 +83,6 @@ export class WorkPackageInlineCreateComponent implements OnInit, OnChanges, OnDe private workPackageEditForm:WorkPackageEditForm | undefined; - private rowBuilder:InlineCreateRowBuilder; - - private timelineBuilder:TimelineRowBuilder; private editingSubscription:Subscription|undefined; private $element:JQuery; @@ -121,9 +117,6 @@ export class WorkPackageInlineCreateComponent implements OnInit, OnChanges, OnDe return; } - this.rowBuilder = new InlineCreateRowBuilder(this.injector, this.table); - this.timelineBuilder = new TimelineRowBuilder(this.injector, this.table); - // Mirror the row height in timeline const container = jQuery(this.table.timelineBody); container.addClass('-inline-create-mirror'); @@ -251,10 +244,11 @@ export class WorkPackageInlineCreateComponent implements OnInit, OnChanges, OnDe } private refreshRow() { + const builder = new InlineCreateRowBuilder(this.injector, this.table); const rowElement = this.$element.find(`.${inlineCreateRowClassName}`); if (rowElement.length && this.currentWorkPackage) { - this.rowBuilder.refreshRow(this.currentWorkPackage, rowElement); + builder.refreshRow(this.currentWorkPackage, rowElement); } } @@ -266,9 +260,10 @@ export class WorkPackageInlineCreateComponent implements OnInit, OnChanges, OnDe * @returns The work package form of the row */ private renderInlineCreateRow(wp:WorkPackageResource):WorkPackageEditForm { - const form = this.table.editing.startEditing(wp, this.rowBuilder.classIdentifier(wp)); + const builder = new InlineCreateRowBuilder(this.injector, this.table); + const form = this.table.editing.startEditing(wp, builder.classIdentifier(wp)); - const [row, ] = this.rowBuilder.buildNew(wp, form); + const [row, ] = builder.buildNew(wp, form); this.$element.append(row); return form; diff --git a/frontend/src/app/components/wp-inline-create/wp-inline-create.service.ts b/frontend/src/app/components/wp-inline-create/wp-inline-create.service.ts index 9f30c7108cd..3c38e09082c 100644 --- a/frontend/src/app/components/wp-inline-create/wp-inline-create.service.ts +++ b/frontend/src/app/components/wp-inline-create/wp-inline-create.service.ts @@ -28,7 +28,7 @@ import {Injectable, Injector, OnDestroy} from '@angular/core'; import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource'; -import {Subject} from "rxjs"; +import {Observable, of, Subject} from "rxjs"; import {ComponentType} from "@angular/cdk/portal"; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; import {AuthorisationService} from "core-app/modules/common/model-auth/model-auth.service"; @@ -47,7 +47,6 @@ export class WorkPackageInlineCreateService implements OnDestroy { */ public readonly referenceComponentClass:ComponentType|null = null; - /** * A related work package for the inline create context */ @@ -86,5 +85,4 @@ export class WorkPackageInlineCreateService implements OnDestroy { this.newInlineWorkPackageCreated.complete(); this.newInlineWorkPackageReferenced.complete(); } - } diff --git a/frontend/src/app/components/wp-list/wp-list-invalid-query.service.ts b/frontend/src/app/components/wp-list/wp-list-invalid-query.service.ts index b1edc942321..d2ec3f341bd 100644 --- a/frontend/src/app/components/wp-list/wp-list-invalid-query.service.ts +++ b/frontend/src/app/components/wp-list/wp-list-invalid-query.service.ts @@ -36,13 +36,15 @@ import {QueryFilterInstanceSchemaResource} from 'core-app/modules/hal/resources/ import {QueryColumn} from '../wp-query/query-column'; import {Injectable} from '@angular/core'; import {HalResourceService} from 'core-app/modules/hal/services/hal-resource.service'; +import {QueryFormDmService} from "core-app/modules/hal/dm-services/query-form-dm.service"; @Injectable() export class WorkPackagesListInvalidQueryService { - constructor(protected halResourceService:HalResourceService) {} + constructor(protected halResourceService:HalResourceService, + protected queryFormDm:QueryFormDmService) {} public restoreQuery(query:QueryResource, form:QueryFormResource) { - let payload = this.halResourceService.createHalResourceOfType('Query', form.payload); + let payload = this.queryFormDm.buildQueryResource(form); this.restoreFilters(query, payload, form.schema); this.restoreColumns(query, payload, form.schema); diff --git a/frontend/src/app/components/wp-new/wp-create.service.ts b/frontend/src/app/components/wp-new/wp-create.service.ts index d4add4a1a23..d9254a2f8d0 100644 --- a/frontend/src/app/components/wp-new/wp-create.service.ts +++ b/frontend/src/app/components/wp-new/wp-create.service.ts @@ -67,13 +67,13 @@ export class WorkPackageCreateService implements IWorkPackageCreateService { return this.newWorkPackageCreatedSubject.asObservable(); } - public createNewWorkPackage(projectIdentifier:string) { + public createNewWorkPackage(projectIdentifier:string|undefined|null) { return this.getEmptyForm(projectIdentifier).then(form => { return this.fromCreateForm(form); }); } - public createNewTypedWorkPackage(projectIdentifier:string, type:number) { + public createNewTypedWorkPackage(projectIdentifier:string|undefined|null, type:number) { return this.apiWorkPackages.typedCreateForm(type, projectIdentifier).then(form => { return this.fromCreateForm(form); }); @@ -115,7 +115,7 @@ export class WorkPackageCreateService implements IWorkPackageCreateService { }); } - public getEmptyForm(projectIdentifier:string|null):Promise { + public getEmptyForm(projectIdentifier:string|null|undefined):Promise { if (!this.form) { this.form = this.apiWorkPackages.emptyCreateForm({}, projectIdentifier); } @@ -135,7 +135,7 @@ export class WorkPackageCreateService implements IWorkPackageCreateService { .values$(); } - public createOrContinueWorkPackage(projectIdentifier:string, type?:number) { + public createOrContinueWorkPackage(projectIdentifier:string|null|undefined, type?:number) { let changesetPromise = this.continueExistingEdit(type); if (!changesetPromise) { @@ -167,7 +167,7 @@ export class WorkPackageCreateService implements IWorkPackageCreateService { return null; } - protected createNewWithDefaults(projectIdentifier:string, type?:number) { + protected createNewWithDefaults(projectIdentifier:string|null|undefined, type?:number) { let changesetPromise = null; if (type) { diff --git a/frontend/src/app/components/wp-query-select/wp-query-select-dropdown.component.ts b/frontend/src/app/components/wp-query-select/wp-query-select-dropdown.component.ts index 44549809bc4..e7fda0bec4a 100644 --- a/frontend/src/app/components/wp-query-select/wp-query-select-dropdown.component.ts +++ b/frontend/src/app/components/wp-query-select/wp-query-select-dropdown.component.ts @@ -234,7 +234,8 @@ export class WorkPackageQuerySelectDropdownComponent implements OnInit, OnDestro private loadQueries() { return this.loadingPromise = this.QueryDm - .all(this.CurrentProject.identifier); + .all(this.CurrentProject.identifier) + .toPromise(); } private set loadingPromise(promise:Promise) { diff --git a/frontend/src/app/components/wp-query-select/wp-query-selectable-title.html b/frontend/src/app/components/wp-query-select/wp-query-selectable-title.html deleted file mode 100644 index 714e5c76a95..00000000000 --- a/frontend/src/app/components/wp-query-select/wp-query-selectable-title.html +++ /dev/null @@ -1,29 +0,0 @@ -
    - - - -
    -

    {{ selectedTitle | slice:0:50 }} -

    diff --git a/frontend/src/app/components/wp-query/query-filters.service.ts b/frontend/src/app/components/wp-query/query-filters.service.ts new file mode 100644 index 00000000000..50da9a14bea --- /dev/null +++ b/frontend/src/app/components/wp-query/query-filters.service.ts @@ -0,0 +1,30 @@ +import {Injectable} from "@angular/core"; +import {QueryFilterResource} from "core-app/modules/hal/resources/query-filter-resource"; +import {QueryFormResource} from "core-app/modules/hal/resources/query-form-resource"; +import {QueryFilterInstanceSchemaResource} from "core-app/modules/hal/resources/query-filter-instance-schema-resource"; +import {QueryResource} from "core-app/modules/hal/resources/query-resource"; +import {QueryFilterInstanceResource} from "core-app/modules/hal/resources/query-filter-instance-resource"; + +@Injectable() +export class QueryFiltersService { + + /** + * Get the matching schema of the filter resource + * from the schema + */ + public getFilterSchema(filter:QueryFilterInstanceResource, form:QueryFormResource):QueryFilterInstanceSchemaResource|undefined { + const available = form.$embedded.schema.filtersSchemas.elements; + return _.find(available, schema => schema.allowedFilterValue.href === filter.filter.href); + } + + /** + * Map all filters of the query with the appropriate schema. + * @param query + * @param form + */ + public mapSchemasIntoFilters(query:QueryResource, form:QueryFormResource) { + query.filters.forEach(filter => { + filter.schema = this.getFilterSchema(filter, form)!; + }); + } +} diff --git a/frontend/src/app/components/wp-table/configuration-modal/tab-portal-outlet.ts b/frontend/src/app/components/wp-table/configuration-modal/tab-portal-outlet.ts index adab0dca99f..6c626d7e746 100644 --- a/frontend/src/app/components/wp-table/configuration-modal/tab-portal-outlet.ts +++ b/frontend/src/app/components/wp-table/configuration-modal/tab-portal-outlet.ts @@ -39,7 +39,7 @@ export class TabPortalOutlet { constructor( public availableTabs:TabInterface[], - public outletElement:Element, + public outletElement:HTMLElement, private componentFactoryResolver:ComponentFactoryResolver, private appRef:ApplicationRef, private injector:Injector) { @@ -54,7 +54,7 @@ export class TabPortalOutlet { const tab = _.find(this.availableTabs, tab => tab.name === name); if (!tab) { - throw(`Trying to swtich to unknown tab ${name}.`); + throw(`Trying to switch to unknown tab ${name}.`); } if (tab.disableBecause != null) { @@ -71,6 +71,7 @@ export class TabPortalOutlet { // where we want it to be rendered. this.outletElement.innerHTML = ''; this.outletElement.appendChild(this._getComponentRootNode(instance.componentRef)); + this.outletElement.dataset.tabName = tab.title; this.currentTab = instance; return false; diff --git a/frontend/src/app/components/wp-table/embedded/wp-embedded-base.component.ts b/frontend/src/app/components/wp-table/embedded/wp-embedded-base.component.ts index c83e71096b8..79960e3f0c9 100644 --- a/frontend/src/app/components/wp-table/embedded/wp-embedded-base.component.ts +++ b/frontend/src/app/components/wp-table/embedded/wp-embedded-base.component.ts @@ -44,7 +44,7 @@ export abstract class WorkPackageEmbeddedBaseComponent implements OnInit, AfterV ngAfterViewInit():void { // Load initially - this.refresh(this.initialLoadingIndicator); + this.loadQuery(this.initialLoadingIndicator); // Reload results on refresh requests this.tableState.refreshRequired diff --git a/frontend/src/app/components/wp-table/embedded/wp-embedded-table.component.ts b/frontend/src/app/components/wp-table/embedded/wp-embedded-table.component.ts index 07ef9384947..abf7e5728b0 100644 --- a/frontend/src/app/components/wp-table/embedded/wp-embedded-table.component.ts +++ b/frontend/src/app/components/wp-table/embedded/wp-embedded-table.component.ts @@ -57,6 +57,7 @@ import {WorkPackageTableFilters} from "core-components/wp-fast-table/wp-table-fi export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseComponent implements OnInit, AfterViewInit, OnDestroy { @Input('queryId') public queryId?:number; @Input('queryProps') public queryProps:any = {}; + @Input('loadedQuery') public loadedQuery?:QueryResource; @Input() public tableActions:OpTableActionFactory[] = []; @Input() public compactTableStyle:boolean = false; @Input() public externalHeight:boolean = false; @@ -131,7 +132,14 @@ export class WorkPackageEmbeddedTableComponent extends WorkPackageEmbeddedBaseCo // Nop } - protected loadQuery(visible:boolean = true) { + protected loadQuery(visible:boolean = true):Promise { + if (this.loadedQuery) { + const query = this.loadedQuery; + this.loadedQuery = undefined; + this.initializeStates(query, query.results); + return Promise.resolve(this.loadedQuery!); + } + // HACK: Decrease loading time of queries when results are not needed. // We should allow the backend to disable results embedding instead. diff --git a/frontend/src/app/components/wp-table/sort-header/sort-header.directive.html b/frontend/src/app/components/wp-table/sort-header/sort-header.directive.html index 03e056448a8..f0efe67973d 100644 --- a/frontend/src/app/components/wp-table/sort-header/sort-header.directive.html +++ b/frontend/src/app/components/wp-table/sort-header/sort-header.directive.html @@ -35,6 +35,11 @@ *ngIf="displayDropdownIcon"> + + + diff --git a/frontend/src/app/components/wp-table/sort-header/sort-header.directive.ts b/frontend/src/app/components/wp-table/sort-header/sort-header.directive.ts index e632212f1f1..dce9f124ddc 100644 --- a/frontend/src/app/components/wp-table/sort-header/sort-header.directive.ts +++ b/frontend/src/app/components/wp-table/sort-header/sort-header.directive.ts @@ -58,14 +58,15 @@ export class SortHeaderDirective implements OnDestroy, AfterViewInit { directionClass:string; - text:{ toggleHierarchy:string, openMenu:string } = { - toggleHierarchy: '', - openMenu: '' + public text = { + toggleHierarchy: this.I18n.t('js.work_packages.hierarchy.show'), + openMenu: this.I18n.t('js.label_open_menu'), + sortColumn: 'Sorting column' // TODO }; isHierarchyColumn:boolean; - columnType:'hierarchy' | 'relation'; + columnType:'hierarchy' | 'relation' | 'sort'; columnName:string; @@ -117,14 +118,12 @@ export class SortHeaderDirective implements OnDestroy, AfterViewInit { this.directionClass = this.getDirectionClass(); }); - this.text = { - toggleHierarchy: I18n.t('js.work_packages.hierarchy.show'), - openMenu: I18n.t('js.label_open_menu') - }; - // Place the hierarchy icon left to the subject column this.isHierarchyColumn = this.headerColumn.id === 'subject'; + if (this.headerColumn.id === 'sortHandle') { + this.columnType = 'sort'; + } if (this.isHierarchyColumn) { this.columnType = 'hierarchy'; } else if (this.wpTableRelationColumns.relationColumnType(this.headerColumn) === 'toType') { diff --git a/frontend/src/app/components/wp-table/wp-table-configuration.ts b/frontend/src/app/components/wp-table/wp-table-configuration.ts index 75ace42ce19..0526a176696 100644 --- a/frontend/src/app/components/wp-table/wp-table-configuration.ts +++ b/frontend/src/app/components/wp-table/wp-table-configuration.ts @@ -54,9 +54,15 @@ export class WorkPackageTableConfiguration { /** Whether the hierarchy toggler item in the subject column is enabled */ public hierarchyToggleEnabled:boolean = true; + /** Whether this table supports drag and drop */ + public dragAndDropEnabled:boolean = false; + /** Whether this table is in an embedded context*/ public isEmbedded:boolean = false; + /** Whether the work packages shall be shown in cards instead of a table */ + public isCardView:boolean = false; + /** Whether this table provides a UI for filters*/ public withFilters:boolean = false; diff --git a/frontend/src/app/components/wp-table/wp-table.directive.html b/frontend/src/app/components/wp-table/wp-table.directive.html index b2875afb0cb..75c401c4c94 100644 --- a/frontend/src/app/components/wp-table/wp-table.directive.html +++ b/frontend/src/app/components/wp-table/wp-table.directive.html @@ -12,6 +12,7 @@ + - - + (WpTableConfigurationModalComponent); + this.opModalService.show(WpTableConfigurationModalComponent, {}, this.injector); } public get isEmbedded() { diff --git a/frontend/src/app/components/wp-table/wp-table.styles.sass b/frontend/src/app/components/wp-table/wp-table.styles.sass index 564e58b35a0..86ac5b07484 100644 --- a/frontend/src/app/components/wp-table/wp-table.styles.sass +++ b/frontend/src/app/components/wp-table/wp-table.styles.sass @@ -9,4 +9,6 @@ i.icon:before color: #FFF - +.wp-table--drag-and-drop-handle + &:hover + cursor: move diff --git a/frontend/src/app/helpers/rxjs/with-loading-toggle.ts b/frontend/src/app/helpers/rxjs/with-loading-toggle.ts new file mode 100644 index 00000000000..c2c74ca313c --- /dev/null +++ b/frontend/src/app/helpers/rxjs/with-loading-toggle.ts @@ -0,0 +1,22 @@ +import {Observable} from "rxjs"; +import {tap} from "rxjs/operators"; + +/** + * Manipulate a loading flag on the given component while loading + * + * @param component + * @param attribute The attribute to toggle + */ +export function withLoadingToggle(component:any, attribute:string):(source:Observable) => Observable { + return (source$:Observable) => { + component[attribute] = true; + + return source$.pipe( + tap( + () => component[attribute] = false, + () => component[attribute] = false, + () => component[attribute] = false + ) + ); + }; +} diff --git a/frontend/src/app/modules/boards/board/board-cache.service.ts b/frontend/src/app/modules/boards/board/board-cache.service.ts new file mode 100644 index 00000000000..40d7bc2b91c --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-cache.service.ts @@ -0,0 +1,40 @@ +import {Injectable} from "@angular/core"; +import {StateCacheService} from "core-components/states/state-cache.service"; +import {multiInput, MultiInputState} from "reactivestates"; +import {Board} from "core-app/modules/boards/board/board"; +import {BoardDmService} from "core-app/modules/boards/board/board-dm.service"; + +@Injectable() +export class BoardCacheService extends StateCacheService { + + protected _state = multiInput(); + + constructor(protected boardDm:BoardDmService) { + super(); + } + + protected load(id:string):Promise { + return this.boardDm + .one(parseInt(id)) + .toPromise() + .then((board:Board) => { + this.updateValue(id, board); + return board; + }); + + } + + protected loadAll(ids:string[] = []):Promise { + return Promise + .all(ids.map(id => this.load(id))) + .then(() => undefined); + } + + protected get multiState():MultiInputState { + return this._state; + } + + update(board:Board) { + this.updateValue(board.id, board); + } +} diff --git a/frontend/src/app/modules/boards/board/board-dm.service.ts b/frontend/src/app/modules/boards/board/board-dm.service.ts new file mode 100644 index 00000000000..a5eff7a8b74 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-dm.service.ts @@ -0,0 +1,104 @@ +import {Injectable} from "@angular/core"; +import {from, Observable} from "rxjs"; +import {BoardListsService} from "core-app/modules/boards/board/board-list/board-lists.service"; +import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; +import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; +import {GridDmService} from "core-app/modules/hal/dm-services/grid-dm.service"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; +import {GridResource} from "core-app/modules/hal/resources/grid-resource"; +import {map} from "rxjs/operators"; +import {Board} from "core-app/modules/boards/board/board"; +import {OpenprojectBoardsModule} from "core-app/modules/boards/openproject-boards.module"; + +@Injectable() +export class BoardDmService { + + constructor(protected GridDm:GridDmService, + protected PathHelper:PathHelperService, + protected CurrentProject:CurrentProjectService, + protected halResourceService:HalResourceService) { + } + + /** + * Return all boards in the current scope of the project + * + * @param projectIdentifier + */ + public allInScope(projectIdentifier:string|null = this.CurrentProject.identifier) { + const path = this.boardPath(projectIdentifier); + + return from( + this.GridDm.list({ filters: [['scope', '=', [path]]] }) + ) + .pipe( + map(collection => collection.elements.map(grid => new Board(grid))) + ); + } + + /** + * Load one board based on ID + */ + public one(id:number):Observable { + return from(this.GridDm.one(id)) + .pipe( + map(grid => new Board(grid)) + ); + } + + /** + * Save the changes to the board + */ + public save(board:Board) { + return this.fetchSchema(board) + .then(schema => this.GridDm.update(board.grid, schema)) + .then(grid => { + board.grid = grid; + return board; + }); + } + + private fetchSchema(board:Board) { + return this.GridDm.updateForm(board.grid) + .then((form) => form.schema); + } + + /** + * Retrieve the board path identifier for looking up grids. + * + * @param projectIdentifier The current project identifier + */ + public boardPath(projectIdentifier:string|null = this.CurrentProject.identifier) { + return this.PathHelper.projectBoardsPath(projectIdentifier); + } + + /** + * Create a new board + * @param name + */ + public async create(name:string = 'New board'):Promise { + return this.createGrid() + .then(grid => new Board(grid)); + } + + public delete(board:Board):Promise { + if (!board.grid.delete) { + return Promise.reject("Deletion not possible"); + } + + return board.grid.delete(); + } + + + private createGrid():Promise { + const path = this.boardPath(); + let payload = _.set({ name: 'Unnamed board' }, '_links.scope.href', path); + + return this.GridDm + .createForm(payload) + .then((form) => { + let resource = this.halResourceService.createHalResource(form.payload.$source); + return this.GridDm.create(resource, form.schema); + }); + } + +} diff --git a/frontend/src/app/modules/boards/board/board-list/board-inline-create.service.ts b/frontend/src/app/modules/boards/board/board-list/board-inline-create.service.ts new file mode 100644 index 00000000000..7417ae1e715 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-list/board-inline-create.service.ts @@ -0,0 +1,82 @@ +// -- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +// ++ + +import {Injectable, Injector, OnDestroy} from '@angular/core'; +import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource'; +import {WorkPackageRelationsHierarchyService} from "core-components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.service"; +import {WorkPackageInlineCreateService} from "core-components/wp-inline-create/wp-inline-create.service"; +import {TableState} from "core-components/wp-table/table-state/table-state"; +import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; +import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; +import {BoardInlineAddAutocompleterComponent} from "core-app/modules/boards/board/inline-add/board-inline-add-autocompleter.component"; + +@Injectable() +export class BoardInlineCreateService extends WorkPackageInlineCreateService implements OnDestroy { + + constructor(protected readonly injector:Injector, + protected readonly tableState:TableState, + protected readonly halResourceService:HalResourceService, + protected readonly pathHelperService:PathHelperService, + protected readonly wpRelationsHierarchyService:WorkPackageRelationsHierarchyService) { + super(injector); + } + + /** + * A separate reference pane for the inline create component + */ + public readonly referenceComponentClass = BoardInlineAddAutocompleterComponent; + + /** + * A related work package for the inline create context + */ + public referenceTarget:WorkPackageResource|null = null; + + public get canAdd() { + return this.canCreateWorkPackages; + } + + public get canReference() { + return true; + } + + /** + * Reference button text + */ + public readonly buttonTexts = { + reference: this.I18n.t('js.relation_buttons.add_existing_child'), + create: this.I18n.t('js.relation_buttons.add_new_child') + }; + + /** + * Ensure hierarchical injected versions of this service correctly unregister + */ + ngOnDestroy() { + super.ngOnDestroy(); + } + +} diff --git a/frontend/src/app/modules/boards/board/board-list/board-list.component.html b/frontend/src/app/modules/boards/board/board-list/board-list.component.html new file mode 100644 index 00000000000..0212e0a21b5 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-list/board-list.component.html @@ -0,0 +1,36 @@ +
    + + +
    + + +
    + + + +
    +
    + +
    + + + + +
    +
    +
    diff --git a/frontend/src/app/modules/boards/board/board-list/board-list.component.sass b/frontend/src/app/modules/boards/board/board-list/board-list.component.sass new file mode 100644 index 00000000000..b5e803983a2 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-list/board-list.component.sass @@ -0,0 +1,28 @@ +.board-list--query-container + padding: 20px + height: 75vh + overflow-y: auto + +.board-list--header + padding: 10px 20px + border-bottom: 1px solid #ccc + display: flex + align-items: center + + editable-toolbar-title + flex: 1 + + .board-list--delete-icon + opacity: 0 + position: relative + top: -4px + left: 4px + + .board-list--container:hover & + opacity: 1 + +wp-embedded-table + wp-card-view + + height: 100% + display: block diff --git a/frontend/src/app/modules/boards/board/board-list/board-list.component.ts b/frontend/src/app/modules/boards/board/board-list/board-list.component.ts new file mode 100644 index 00000000000..01110c8c17c --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-list/board-list.component.ts @@ -0,0 +1,147 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, EventEmitter, + OnDestroy, + OnInit, Output, + ViewChild +} from "@angular/core"; +import {QueryDmService} from "core-app/modules/hal/dm-services/query-dm.service"; +import { + LoadingIndicatorService, + withLoadingIndicator +} from "core-app/modules/common/loading-indicator/loading-indicator.service"; +import {QueryResource} from "core-app/modules/hal/resources/query-resource"; +import {Observable, Subject} from "rxjs"; +import {debounceTime, distinctUntilChanged, filter, shareReplay, skip, skipUntil, withLatestFrom} from "rxjs/operators"; +import {untilComponentDestroyed} from "ng2-rx-componentdestroyed"; +import {WorkPackageInlineCreateService} from "core-components/wp-inline-create/wp-inline-create.service"; +import {BoardInlineCreateService} from "core-app/modules/boards/board/board-list/board-inline-create.service"; +import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; +import {StateService} from "@uirouter/core"; +import {Board} from "core-app/modules/boards/board/board"; +import {NotificationsService} from "core-app/modules/common/notifications/notifications.service"; + +@Component({ + selector: 'board-list', + templateUrl: './board-list.component.html', + styleUrls: ['./board-list.component.sass'], + providers: [ + {provide: WorkPackageInlineCreateService, useClass: BoardInlineCreateService} + ] +}) +export class BoardListComponent extends AbstractWidgetComponent implements OnInit, OnDestroy { + /** Output fired upon query removal */ + @Output() onRemove = new EventEmitter(); + + /** Access to the loading indicator element */ + @ViewChild('loadingIndicator') indicator:ElementRef; + + /** The query resource being loaded */ + public query:QueryResource; + + /** Rename events */ + public rename$ = new Subject(); + + /** Rename inFlight */ + public inFlight:boolean; + + public readonly columnsQueryProps = { + 'columns[]': ['id', 'subject'], + 'showHierarchies': false, + 'pageSize': 500, + }; + + public text = { + updateSuccessful: this.I18n.t('js.notice_successful_update'), + areYouSure: this.I18n.t('js.text_are_you_sure'), + }; + + public boardTableConfiguration = { + hierarchyToggleEnabled: false, + columnMenuEnabled: false, + actionsColumnEnabled: false, + // Drag & Drop is enabled when editable + dragAndDropEnabled: false, + isEmbedded: true, + isCardView: true + }; + + constructor(private readonly QueryDm:QueryDmService, + private readonly I18n:I18nService, + private readonly state:StateService, + private readonly boardCache:BoardCacheService, + private readonly notifications:NotificationsService, + private readonly cdRef:ChangeDetectorRef, + private readonly loadingIndicator:LoadingIndicatorService) { + super(I18n); + } + + ngOnInit():void { + const boardId:number = this.state.params.board_id; + + this.loadQuery(); + + this.boardCache + .state(boardId.toString()) + .values$() + .pipe( + untilComponentDestroyed(this) + ) + .subscribe((board) => { + this.boardTableConfiguration = { + ...this.boardTableConfiguration, + isCardView: board.displayMode === 'cards', + dragAndDropEnabled: board.editable, + }; + }); + } + + ngOnDestroy():void { + // Interface compatibility + } + + public deleteList(query:QueryResource) { + if (!window.confirm(this.text.areYouSure)) { + return; + } + + this.QueryDm + .delete(query) + .then(() => this.onRemove.emit()); + } + + public renameQuery(query:QueryResource, value:string) { + this.inFlight = true; + this.query.name = value; + this.QueryDm + .patch(this.query.id, {name: value}) + .then(() => { + this.inFlight = false; + this.notifications.addSuccess(this.text.updateSuccessful); + }) + .catch(() => this.inFlight = false); + } + + public get listName() { + return this.query && this.query.name; + } + + private loadQuery() { + const queryId:number = this.resource.options.query_id as number; + + this.QueryDm + .stream(this.columnsQueryProps, queryId) + .pipe( + withLoadingIndicator(this.indicatorInstance, 50), + ) + .subscribe(query => this.query = query); + } + + private get indicatorInstance() { + return this.loadingIndicator.indicator(jQuery(this.indicator.nativeElement)); + } +} diff --git a/frontend/src/app/modules/boards/board/board-list/board-lists.service.ts b/frontend/src/app/modules/boards/board/board-list/board-lists.service.ts new file mode 100644 index 00000000000..895d8971ee5 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board-list/board-lists.service.ts @@ -0,0 +1,95 @@ +import {Injectable} from "@angular/core"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; +import {QueryFormDmService} from "core-app/modules/hal/dm-services/query-form-dm.service"; +import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; +import {QueryDmService} from "core-app/modules/hal/dm-services/query-dm.service"; +import {QueryResource} from "core-app/modules/hal/resources/query-resource"; +import {Board} from "core-app/modules/boards/board/board"; +import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource"; +import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; + +@Injectable() +export class BoardListsService { + + private readonly v3 = this.pathHelper.api.v3; + + constructor(private readonly CurrentProject:CurrentProjectService, + private readonly pathHelper:PathHelperService, + private readonly QueryDm:QueryDmService, + private readonly halResourceService:HalResourceService, + private readonly QueryFormDm:QueryFormDmService) { + + } + + private create(name:string = 'New list'):Promise { + return this.QueryFormDm + .loadWithParams( + { pageSize: 0 }, + undefined, + this.CurrentProject.identifier, + this.buildQueryRequest(name) + ) + .then(form => { + const query = this.QueryFormDm.buildQueryResource(form); + return this.QueryDm.create(query, form); + }); + } + + /** + * Add an empty query to the board + * @param board + * @param query + */ + public async addQuery(board:Board):Promise { + const count = board.queries.length; + const query = await this.create(); + + let source = { + _type: 'GridWidget', + identifier: 'work_package_query', + startRow: 1, + endRow: 2, + startColumn: count + 1, + endColumn: count + 2, + options: { + query_id: query.id + } + }; + + let resource = this.halResourceService.createHalResourceOfClass(GridWidgetResource, source); + board.addQuery(resource); + + return board; + } + + private buildQueryRequest(name:string) { + return { + name: name, + hidden: true, + public: true, + filters: + [ + { + "_type": "QueryFilter", + "_links": { + "filter": { + "href": this.v3.resource("/queries/filters/manualSort") + }, + "schema": { + "href": this.v3.resource("/queries/filter_instance_schemas/manualSort") + }, + "operator": { + "href": this.v3.resource("/queries/operators/ow") + }, + "values": [] + } + } + ], + "_links": + { + "sortBy": [{ "href": this.v3.resource("/queries/sort_bys/manualSorting-asc") }] + } + }; + } +} + diff --git a/frontend/src/app/modules/boards/board/board.component.html b/frontend/src/app/modules/boards/board/board.component.html new file mode 100644 index 00000000000..3d2c830d576 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board.component.html @@ -0,0 +1,64 @@ +
    + +
    +
    +
    +
    + + + +
    + + + + +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    +
    + +
    +
    + + +
    + +
    +
    + + +
    +
    +
    +
    diff --git a/frontend/src/app/modules/boards/board/board.component.sass b/frontend/src/app/modules/boards/board/board.component.sass new file mode 100644 index 00000000000..745ffe36627 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board.component.sass @@ -0,0 +1,57 @@ +.boards-list--container + display: flex + flex-direction: row + flex-wrap: nowrap + +.boards-list--item + flex: 0 0 20% + border: 1px solid #ccc + margin-left: 2rem + position: relative + + &:first-child + margin-left: 0 + +.board--header-container + display: flex + align-items: flex-start + +.board--name-header + list-style: none + +.boards-list-item-handle + position: absolute + left: 0 + top: 20px + z-index: 1000 + opacity: 0 + cursor: grab + + &:before + padding: 0 + color: #888 + + .boards-list--item:hover & + opacity: 1 + +.boards-list--add-item + flex: 0 0 auto + border: 1px dashed #ccc + height: 200px + margin-left: 30px + + // Center child text + display: flex + align-items: center + justify-items: center + + // Center self in lists + align-self: center + + .boards-list--add-item-text + transform: rotate(90deg) + +// Changes when container is editable +.board--container.-editable + .boards-list--add-item + cursor: pointer diff --git a/frontend/src/app/modules/boards/board/board.component.ts b/frontend/src/app/modules/boards/board/board.component.ts new file mode 100644 index 00000000000..57b8f04a55b --- /dev/null +++ b/frontend/src/app/modules/boards/board/board.component.ts @@ -0,0 +1,120 @@ +import {Component, OnDestroy, OnInit} from "@angular/core"; +import {DragAndDropService} from "core-app/modules/boards/drag-and-drop/drag-and-drop.service"; +import {NotificationsService} from "core-app/modules/common/notifications/notifications.service"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {BoardListsService} from "core-app/modules/boards/board/board-list/board-lists.service"; +import {QueryDmService} from "core-app/modules/hal/dm-services/query-dm.service"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; +import {BoardService} from "core-app/modules/boards/board/board.service"; +import {Board} from "core-app/modules/boards/board/board"; +import {untilComponentDestroyed} from "ng2-rx-componentdestroyed"; +import {StateService} from "@uirouter/core"; +import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource"; +import {CdkDragDrop, moveItemInArray} from "@angular/cdk/drag-drop"; + +@Component({ + selector: 'board', + templateUrl: './board.component.html', + styleUrls: ['./board.component.sass'], + providers: [ + DragAndDropService, + ] +}) +export class BoardComponent implements OnInit, OnDestroy { + + // We only support 4 columns for now while the grid does not autoscale + readonly maxCount = 4; + + /** Board observable */ + public board:Board; + + /** Whether this is a new board just created */ + public isNew:boolean = !!this.state.params.isNew; + + /** Whether we're in flight of updating the board */ + public inFlight = false; + + + public text = { + button_more: this.I18n.t('js.button_more'), + delete: this.I18n.t('js.button_delete'), + areYouSure: this.I18n.t('js.text_are_you_sure'), + deleteSuccessful: this.I18n.t('js.notice_successful_delete'), + updateSuccessful: this.I18n.t('js.notice_successful_update'), + unnamedBoard: this.I18n.t('js.boards.label_unnamed_board'), + loadingError: 'No such board found', + addList: 'Add list' + }; + + trackByQueryId = (index:number, widget:GridWidgetResource) => widget.options.query_id; + + constructor(private readonly state:StateService, + private readonly I18n:I18nService, + private readonly notifications:NotificationsService, + private readonly BoardList:BoardListsService, + private readonly QueryDm:QueryDmService, + private readonly BoardCache:BoardCacheService, + private readonly Boards:BoardService) { + } + + goBack() { + this.state.go('^'); + } + + ngOnInit():void { + const id:string = this.state.params.board_id.toString(); + + this.BoardCache + .requireAndStream(id) + .pipe( + untilComponentDestroyed(this) + ) + .subscribe(board => this.board = board); + } + + ngOnDestroy():void { + // Nothing to do. + } + + renameBoard(board:Board, newName:string) { + board.name = newName; + return this.saveBoard(board); + } + + showError(text = this.text.loadingError) { + this.notifications.addError(text); + } + + saveBoard(board:Board) { + this.inFlight = true; + this.Boards + .save(board) + .then(board => { + this.BoardCache.update(board); + this.notifications.addSuccess(this.text.updateSuccessful); + this.inFlight = false; + }); + } + + addList(board:Board) { + this.BoardList + .addQuery(board) + .then(board => this.Boards.save(board)) + .then(saved => { + this.BoardCache.update(saved); + }) + .catch(error => { + this.notifications.addError(error); + }); + } + + moveList(board:Board, event:CdkDragDrop) { + moveItemInArray(board.queries, event.previousIndex, event.currentIndex); + return this.saveBoard(board); + } + + removeList(board:Board, query:GridWidgetResource) { + board.removeQuery(query); + return this.saveBoard(board); + } +} diff --git a/frontend/src/app/modules/boards/board/board.service.ts b/frontend/src/app/modules/boards/board/board.service.ts new file mode 100644 index 00000000000..8f8d82fa3fa --- /dev/null +++ b/frontend/src/app/modules/boards/board/board.service.ts @@ -0,0 +1,95 @@ +import {Injectable} from "@angular/core"; +import {BoardListsService} from "core-app/modules/boards/board/board-list/board-lists.service"; +import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; +import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; +import {Board} from "core-app/modules/boards/board/board"; +import {BoardDmService} from "core-app/modules/boards/board/board-dm.service"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; +import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource"; +import {GonService} from "core-app/modules/common/gon/gon.service"; + +@Injectable() +export class BoardService { + + private loadAllPromise:Promise|undefined; + + constructor(protected boardDm:BoardDmService, + protected PathHelper:PathHelperService, + protected Gon:GonService, + protected CurrentProject:CurrentProjectService, + protected halResourceService:HalResourceService, + protected boardCache:BoardCacheService, + protected boardsList:BoardListsService) { + } + + /** + * Return all boards in the current scope of the project + * + * @param projectIdentifier + */ + public loadAllBoards(projectIdentifier:string|null = this.CurrentProject.identifier, force = false) { + if (!(force || this.loadAllPromise === undefined)) { + return this.loadAllPromise; + } + + return this.loadAllPromise = this.boardDm + .allInScope() + .toPromise() + .then((boards) => { + boards.forEach(b => this.boardCache.update(b)); + return boards; + }); + } + + /** + * Check whether the current user can manage board-type grids. + */ + public get canManage():boolean { + return !!this.Gon.get('permission_flags', 'manage_board_views'); + } + + + /** + * Save the changes to the board + */ + public save(board:Board) { + this.reorderWidgets(board); + return this.boardDm.save(board) + .then(board => { + this.boardCache.update(board); + return board; + }); + } + + /** + * Create a new board + * @param name + */ + public async create(name:string = 'New board'):Promise { + const board = await this.boardDm.create(name); + + await this.boardsList.addQuery(board); + await this.save(board); + + return board; + } + + public delete(board:Board):Promise { + return this.boardDm + .delete(board) + .then(() => this.boardCache.clearSome(board.id)); + } + + /** + * Reorders the widgets to correspond to the available columns + * @param board + */ + private reorderWidgets(board:Board) { + board.grid.widgets.map((el:GridWidgetResource, index:number) => { + el.startColumn = index + 1; + el.endColumn = index + 2; + return el; + }); + } +} diff --git a/frontend/src/app/modules/boards/board/board.ts b/frontend/src/app/modules/boards/board/board.ts new file mode 100644 index 00000000000..6256d7e3b69 --- /dev/null +++ b/frontend/src/app/modules/boards/board/board.ts @@ -0,0 +1,50 @@ +import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource"; +import {GridResource} from "core-app/modules/hal/resources/grid-resource"; + +export type BoardDisplayMode = 'table'|'cards'; + +export class Board { + constructor(public grid:GridResource) { + } + + public get id() { + return this.grid.id; + } + + public get name() { + return this.grid.name; + } + + public get editable() { + return !!this.grid.updateImmediately; + } + + public get displayMode():BoardDisplayMode { + const mode = this.grid.options.display_mode; + return (mode === 'table') ? 'table' : 'cards'; + } + + public set displayMode(value:BoardDisplayMode) { + this.grid.options.display_mode = value; + } + + public set name(name:string) { + this.grid.name = name; + } + + public addQuery(widget:GridWidgetResource) { + this.grid.widgets.push(widget); + } + + public removeQuery(widget:GridWidgetResource) { + this.grid.widgets = this.grid.widgets.filter(el => el.options.query_id !== widget.options.query_id); + } + + public get queries():GridWidgetResource[] { + return this.grid.widgets; + } + + public get createdAt() { + return this.grid.createdAt; + } +} diff --git a/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.modal.html b/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.modal.html new file mode 100644 index 00000000000..d3a2d4f3dd8 --- /dev/null +++ b/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.modal.html @@ -0,0 +1,48 @@ +
    +
    + + +

    + +
    + + +
    + +
    + +
    +
    diff --git a/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.modal.ts b/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.modal.ts new file mode 100644 index 00000000000..bd28cdfceeb --- /dev/null +++ b/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.modal.ts @@ -0,0 +1,127 @@ +import { + ApplicationRef, + ChangeDetectorRef, + Component, + ComponentFactoryResolver, + ElementRef, + Inject, + Injector, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import {OpModalLocalsMap} from 'core-components/op-modals/op-modal.types'; +import {OpModalComponent} from 'core-components/op-modals/op-modal.component'; +import { + ActiveTabInterface, + TabComponent, + TabInterface, + TabPortalOutlet +} from 'core-components/wp-table/configuration-modal/tab-portal-outlet'; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {OpModalLocalsToken} from "core-components/op-modals/op-modal.service"; +import {BoardConfigurationService} from "core-app/modules/boards/board/configuration-modal/board-configuration.service"; +import {BoardService} from "core-app/modules/boards/board/board.service"; +import {Board} from "core-app/modules/boards/board/board"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; + + +@Component({ + templateUrl: './board-configuration.modal.html' +}) +export class BoardConfigurationModal extends OpModalComponent implements OnInit, OnDestroy { + + /* Close on escape? */ + public closeOnEscape = false; + + /* Close on outside click */ + public closeOnOutsideClick = false; + + public text = { + title: this.I18n.t('js.boards.configuration_modal.title'), + closePopup: this.I18n.t('js.close_popup_title'), + + applyButton: this.I18n.t('js.modals.button_apply'), + cancelButton: this.I18n.t('js.modals.button_cancel'), + }; + + // Get the view child we'll use as the portal host + @ViewChild('tabContentOutlet') tabContentOutlet:ElementRef; + // And a reference to the actual portal host interface + public tabPortalHost:TabPortalOutlet; + + constructor(@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap, + readonly I18n:I18nService, + readonly boardService:BoardService, + readonly boardCache:BoardCacheService, + readonly boardConfigurationService:BoardConfigurationService, + readonly injector:Injector, + readonly appRef:ApplicationRef, + readonly componentFactoryResolver:ComponentFactoryResolver, + readonly cdRef:ChangeDetectorRef, + readonly elementRef:ElementRef) { + super(locals, cdRef, elementRef); + } + + ngOnInit() { + this.$element = jQuery(this.elementRef.nativeElement); + + this.tabPortalHost = new TabPortalOutlet( + this.boardConfigurationService.tabs, + this.tabContentOutlet.nativeElement, + this.componentFactoryResolver, + this.appRef, + this.injector + ); + + setTimeout(() => { + const initialTab = this.availableTabs[0].name; + this.switchTo(initialTab); + }); + } + + ngOnDestroy() { + this.tabPortalHost.dispose(); + } + + public get availableTabs():TabInterface[] { + return this.tabPortalHost.availableTabs; + } + + public get currentTab():ActiveTabInterface|null { + return this.tabPortalHost.currentTab; + } + + public switchTo(name:string) { + this.tabPortalHost.switchTo(name); + } + + public saveChanges():void { + this.tabPortalHost.activeComponents.forEach((component:TabComponent) => { + component.onSave(); + }); + + const board = this.locals.board as Board; + this.boardService + .save(board) + .then(board => { + this.boardCache.update(board); + this.service.close(); + }); + + } + + /** + * Called when the user attempts to close the modal window. + * The service will close this modal if this method returns true + * @returns {boolean} + */ + public onClose():boolean { + this.afterFocusOn.focus(); + return true; + } + + protected get afterFocusOn():JQuery { + return this.$element; + } +} diff --git a/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.service.ts b/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.service.ts new file mode 100644 index 00000000000..dcd3bc9779a --- /dev/null +++ b/frontend/src/app/modules/boards/board/configuration-modal/board-configuration.service.ts @@ -0,0 +1,23 @@ +import {Injectable} from '@angular/core'; +import {I18nService} from 'core-app/modules/common/i18n/i18n.service'; +import {TabInterface} from "core-components/wp-table/configuration-modal/tab-portal-outlet"; +import {BoardConfigurationDisplaySettingsTab} from "core-app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component"; + +@Injectable() +export class BoardConfigurationService { + + protected _tabs:TabInterface[] = [ + { + name: 'display', + title: this.I18n.t('js.work_packages.table_configuration.display_settings'), + componentClass: BoardConfigurationDisplaySettingsTab, + } + ]; + + constructor(readonly I18n:I18nService) { + } + + public get tabs() { + return this._tabs; + } +} diff --git a/frontend/src/app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component.html b/frontend/src/app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component.html new file mode 100644 index 00000000000..30a8ad8311c --- /dev/null +++ b/frontend/src/app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component.html @@ -0,0 +1,31 @@ +
    +
    +

    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    diff --git a/frontend/src/app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component.ts b/frontend/src/app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component.ts new file mode 100644 index 00000000000..f55dd17e6e0 --- /dev/null +++ b/frontend/src/app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component.ts @@ -0,0 +1,39 @@ +import {Component, Inject, Injector} from '@angular/core'; +import {I18nService} from 'core-app/modules/common/i18n/i18n.service'; +import {TabComponent} from 'core-components/wp-table/configuration-modal/tab-portal-outlet'; +import {OpModalLocalsMap} from "core-components/op-modals/op-modal.types"; +import {Board, BoardDisplayMode} from "core-app/modules/boards/board/board"; +import {OpModalLocalsToken} from "core-components/op-modals/op-modal.service"; + +@Component({ + templateUrl: './display-settings-tab.component.html' +}) +export class BoardConfigurationDisplaySettingsTab implements TabComponent { + + // Current board resource + public board:Board; + + // Display mode + public displayMode:BoardDisplayMode = 'cards'; + + public text = { + choose_mode: this.I18n.t('js.work_packages.table_configuration.choose_display_mode'), + card_mode: this.I18n.t('js.boards.configuration_modal.display_settings.card_mode'), + table_mode: this.I18n.t('js.boards.configuration_modal.display_settings.table_mode'), + + }; + + constructor(readonly injector:Injector, + @Inject(OpModalLocalsToken) public locals:OpModalLocalsMap, + readonly I18n:I18nService) { + } + + public onSave() { + this.board.displayMode = this.displayMode; + } + + ngOnInit() { + this.board = this.locals.board; + this.displayMode = this.board.displayMode; + } +} diff --git a/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.component.ts b/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.component.ts new file mode 100644 index 00000000000..1ed9dff5e07 --- /dev/null +++ b/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.component.ts @@ -0,0 +1,125 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +import {AfterContentInit, Component, Input, ViewChild, ViewEncapsulation} from '@angular/core'; +import {I18nService} from 'core-app/modules/common/i18n/i18n.service'; +import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource'; +import {Observable, of, Subject} from "rxjs"; +import {catchError, debounceTime, distinctUntilChanged, map, switchMap, tap} from "rxjs/operators"; +import {WorkPackageInlineCreateService} from "core-components/wp-inline-create/wp-inline-create.service"; +import {WorkPackageNotificationService} from "core-components/wp-edit/wp-notification.service"; +import {NgSelectComponent} from "@ng-select/ng-select"; +import {WorkPackageInlineCreateComponent} from "core-components/wp-inline-create/wp-inline-create.component"; +import {TableState} from "core-components/wp-table/table-state/table-state"; +import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; +import {WorkPackageTableRefreshService} from "core-components/wp-table/wp-table-refresh-request.service"; +import {WorkPackageCollectionResource} from "core-app/modules/hal/resources/wp-collection-resource"; +import {CurrentProjectService} from "core-components/projects/current-project.service"; +import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder"; +import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; +import {ReorderQueryService} from "core-app/modules/boards/drag-and-drop/reorder-query.service"; + +@Component({ + selector: 'board-inline-add-autocompleter', + templateUrl: './board-inline-add-autocompleter.html', + + // Allow styling the embedded ng-select + encapsulation: ViewEncapsulation.None, + styleUrls: ['./board-inline-add-autocompleter.sass'] +}) +export class BoardInlineAddAutocompleterComponent implements AfterContentInit { + readonly text = { + placeholder: this.I18n.t('js.relations_autocomplete.placeholder') + }; + + @Input() appendToContainer:string = '.boards-list--item'; + @ViewChild(NgSelectComponent) public ngSelectComponent:NgSelectComponent; + + // Whether we're currently loading + public isLoading = false; + + // Search input from ng-select + public searchInput$ = new Subject(); + + // Search results mapped to input + public results$:Observable = this.searchInput$.pipe( + debounceTime(250), + distinctUntilChanged(), + tap(() => this.isLoading = true), + switchMap(queryString => this.autocompleteWorkPackages(queryString)) + ); + + constructor(private readonly parent:WorkPackageInlineCreateComponent, + private readonly tableState:TableState, + private readonly pathHelper:PathHelperService, + private readonly wpTableRefresh:WorkPackageTableRefreshService, + private readonly wpInlineCreateService:WorkPackageInlineCreateService, + private readonly wpNotificationsService:WorkPackageNotificationService, + private readonly CurrentProject:CurrentProjectService, + private readonly halResourceService:HalResourceService, + private readonly reorderQueryService:ReorderQueryService, + private readonly I18n:I18nService) { + } + + ngAfterContentInit():void { + this.ngSelectComponent && this.ngSelectComponent.open(); + } + + cancel() { + this.parent.resetRow(); + } + + public addWorkPackageToQuery(wpId:string) { + this.reorderQueryService + .add(this.tableState, wpId) + .then(() => this.wpTableRefresh.request('Row added')); + } + + private autocompleteWorkPackages(query:string):Observable { + const path = this.pathHelper.api.v3.withOptionalProject(this.CurrentProject.id).work_packages; + const filters:ApiV3FilterBuilder = new ApiV3FilterBuilder(); + const rows:WorkPackageResource[] = this.tableState.rows.getValueOr([]); + + filters.add('subjectOrId', '**', [query]); + + if (rows.length > 0) { + filters.add('id', '!', rows.map((wp:WorkPackageResource) => wp.id)); + } + + return this.halResourceService + .get(path.filtered(filters)) + .pipe( + map(collection => collection.elements), + catchError((error:unknown) => { + this.wpNotificationsService.handleRawError(error); + return of([]); + }), + tap(() => this.isLoading = false) + ); + } +} diff --git a/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.html b/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.html new file mode 100644 index 00000000000..25d9aed5e5f --- /dev/null +++ b/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.html @@ -0,0 +1,16 @@ + + + {{item.type.name }} #{{ item.id }} {{ item.subject }} + + + {{item.type.name }} #{{ item.id }} {{ item.subject }} + + diff --git a/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.sass b/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.sass new file mode 100644 index 00000000000..df19e42f258 --- /dev/null +++ b/frontend/src/app/modules/boards/board/inline-add/board-inline-add-autocompleter.sass @@ -0,0 +1,5 @@ +ng-select.wp-inline-create--reference-autocompleter + border: none + + .ng-clear-wrapper + display: none diff --git a/frontend/src/app/modules/boards/board/toolbar-menu/boards-toolbar-menu.directive.ts b/frontend/src/app/modules/boards/board/toolbar-menu/boards-toolbar-menu.directive.ts new file mode 100644 index 00000000000..094865bde14 --- /dev/null +++ b/frontend/src/app/modules/boards/board/toolbar-menu/boards-toolbar-menu.directive.ts @@ -0,0 +1,128 @@ +//-- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +//++ + +import {Directive, ElementRef, Input, OnDestroy} from '@angular/core'; +import {I18nService} from 'core-app/modules/common/i18n/i18n.service'; +import {OpContextMenuTrigger} from 'core-components/op-context-menu/handlers/op-context-menu-trigger.directive'; +import {OPContextMenuService} from 'core-components/op-context-menu/op-context-menu.service'; +import {OpModalService} from "core-components/op-modals/op-modal.service"; +import {Board} from "core-app/modules/boards/board/board"; +import {BoardConfigurationModal} from "core-app/modules/boards/board/configuration-modal/board-configuration.modal"; +import {BoardService} from "core-app/modules/boards/board/board.service"; +import {StateService} from "@uirouter/core"; +import {NotificationsService} from "core-app/modules/common/notifications/notifications.service"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; +import { + selectableTitleIdentifier, + triggerEditingEvent +} from "core-app/modules/common/editable-toolbar-title/editable-toolbar-title.component"; + +@Directive({ + selector: '[boardsToolbarMenu]' +}) +export class BoardsToolbarMenuDirective extends OpContextMenuTrigger implements OnDestroy { + @Input('boardsToolbarMenu-resource') public board:Board; + + public text = { + deleteSuccessful: this.I18n.t('js.notice_successful_delete'), + }; + + constructor(readonly elementRef:ElementRef, + readonly opContextMenu:OPContextMenuService, + readonly opModalService:OpModalService, + readonly boardService:BoardService, + readonly BoardCache:BoardCacheService, + readonly Notifications:NotificationsService, + readonly State:StateService, + readonly I18n:I18nService) { + + super(elementRef, opContextMenu); + } + + ngOnDestroy():void { + // Nothing to do + } + + public get locals() { + return { + contextMenuId: 'boardsToolbarMenu', + items: this.items + }; + } + + protected open(evt:JQueryEventObject) { + this.buildItems(); + this.opContextMenu.show(this, evt); + } + + private buildItems() { + this.items = [ + { + // Configuration modal + linkText: this.I18n.t('js.toolbar.settings.configure_view'), + icon: 'icon-settings', + onClick: ($event:JQueryEventObject) => { + this.opContextMenu.close(); + this.opModalService.show(BoardConfigurationModal, { board: this.board }); + + return true; + } + }, + { + // Rename query shortcut + linkText: this.I18n.t('js.toolbar.settings.page_settings'), + icon: 'icon-edit', + onClick: ($event:JQueryEventObject) => { + if (!!this.board.grid.updateImmediately) { + jQuery(`#${selectableTitleIdentifier}`).trigger(triggerEditingEvent); + } + + return true; + } + }, + { + // Delete query + linkText: this.I18n.t('js.toolbar.settings.delete'), + icon: 'icon-delete', + onClick: ($event:JQueryEventObject) => { + if (this.board.grid.delete && + window.confirm(this.I18n.t('js.text_query_destroy_confirmation'))) { + this.boardService + .delete(this.board) + .then(() => { + this.BoardCache.clearSome(this.board.id); + this.State.go('^', { flash_message: { type: 'success', message: this.text.deleteSuccessful }}); + }); + } + + return true; + } + } + ]; + } +} diff --git a/frontend/src/app/modules/boards/boards-root/boards-root.component.ts b/frontend/src/app/modules/boards/boards-root/boards-root.component.ts new file mode 100644 index 00000000000..19ed121debb --- /dev/null +++ b/frontend/src/app/modules/boards/boards-root/boards-root.component.ts @@ -0,0 +1,12 @@ +import {Component} from "@angular/core"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; + +@Component({ + selector: 'boards-entry', + template: '', + providers: [ + BoardCacheService + ] +}) +export class BoardsRootComponent { +} diff --git a/frontend/src/app/modules/boards/boards-sidebar/boards-menu.component.html b/frontend/src/app/modules/boards/boards-sidebar/boards-menu.component.html new file mode 100644 index 00000000000..194520f3688 --- /dev/null +++ b/frontend/src/app/modules/boards/boards-sidebar/boards-menu.component.html @@ -0,0 +1,11 @@ +
    + +
    diff --git a/frontend/src/app/modules/boards/boards-sidebar/boards-menu.component.ts b/frontend/src/app/modules/boards/boards-sidebar/boards-menu.component.ts new file mode 100644 index 00000000000..c27097687c9 --- /dev/null +++ b/frontend/src/app/modules/boards/boards-sidebar/boards-menu.component.ts @@ -0,0 +1,25 @@ +import {Component} from "@angular/core"; +import {Observable} from "rxjs"; +import {BoardService} from "core-app/modules/boards/board/board.service"; +import {Board} from "core-app/modules/boards/board/board"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; +import {DynamicBootstrapper} from "core-app/globals/dynamic-bootstrapper"; +import {AngularTrackingHelpers} from "core-components/angular/tracking-functions"; + +@Component({ + selector: 'boards-menu', + templateUrl: './boards-menu.component.html' +}) + +export class BoardsMenuComponent { + trackById = AngularTrackingHelpers.compareByAttribute('id'); + + public boards$:Observable = this.boardCache.observeAll(); + + constructor(private readonly boardService:BoardService, + private readonly boardCache:BoardCacheService) { + + this.boardService.loadAllBoards(); + } +} +DynamicBootstrapper.register({selector: 'boards-menu', cls: BoardsMenuComponent}); diff --git a/frontend/src/app/modules/boards/drag-and-drop/drag-and-drop.service.ts b/frontend/src/app/modules/boards/drag-and-drop/drag-and-drop.service.ts new file mode 100644 index 00000000000..6ab9eb050d7 --- /dev/null +++ b/frontend/src/app/modules/boards/drag-and-drop/drag-and-drop.service.ts @@ -0,0 +1,94 @@ +import {Inject, Injectable, OnDestroy} from "@angular/core"; +import {DOCUMENT} from "@angular/common"; +import {keyCodes} from "core-app/modules/common/keyCodes.enum"; + +export interface DragMember { + container:HTMLElement; + moves:(element:HTMLElement, fromContainer:HTMLElement, handle:HTMLElement, sibling:HTMLElement|null) => boolean; + onMoved:(row:HTMLTableRowElement, target:any, source:HTMLTableRowElement, sibling:HTMLTableRowElement|null) => void; + onAdded:(row:HTMLTableRowElement, target:any, source:HTMLTableRowElement, sibling:HTMLTableRowElement|null) => void; + onRemoved:(row:HTMLTableRowElement, target:any, source:HTMLTableRowElement, sibling:HTMLTableRowElement|null) => void; +} + +@Injectable() +export class DragAndDropService implements OnDestroy { + + public drake:dragula.Drake|null = null; + + public members:DragMember[] = []; + + private escapeListener = (evt:KeyboardEvent) => { + if (this.drake && evt.key === 'Escape') { + this.drake.cancel(true); + } + }; + + constructor(@Inject(DOCUMENT) private document:Document) { + this.document.documentElement.addEventListener('keydown', this.escapeListener); + } + + ngOnDestroy():void { + this.document.documentElement.removeEventListener('keydown', this.escapeListener); + } + + public remove(container:HTMLElement) { + if (this.initialized) { + _.remove(this.drake!.containers, (el) => el === container); + _.remove(this.members, (el) => el.container === container); + } + } + + public member(container:HTMLElement):DragMember|undefined { + return _.find(this.members, el => el.container === container); + } + + public get initialized() { + return this.drake !== null; + } + + public register(...members:DragMember[]) { + this.members.push(...members); + const containers = members.map(m => m.container); + if (this.drake === null) { + this.initializeDrake(containers); + } else { + this.drake.containers.push(...containers); + } + } + + protected initializeDrake(containers:any) { + this.drake = dragula(containers, { + moves: (el:any, container:any, handle:any, sibling:any) => { + let result = false; + this.members.forEach(member => { + if (member.container === container) { + result = member.moves(el, container, handle, sibling); + return; + } + }); + + return result; + }, + accepts: () => true, + invalid: () => false, + direction: 'vertical', // Y axis is considered when determining where an element would be dropped + copy: false, // elements are moved by default, not copied + revertOnSpill: true, // spilling will put the element back where it was dragged from, if this is true + removeOnSpill: false, // spilling will `.remove` the element, if this is true + mirrorContainer: document.body, // set the element that gets mirror elements appended + ignoreInputTextSelection: true // allows users to select input text, see details below + }); + + this.drake.on('drop', (row:HTMLTableRowElement, target:HTMLElement, source:HTMLTableRowElement, sibling:HTMLTableRowElement|null) => { + const to = this.member(target); + const from = this.member(source); + + if (to && to === from) { + return to.onMoved(row, target, source, sibling); + } + + to && to.onAdded(row, target, source, sibling); + from && from.onRemoved(row, target, source, sibling); + }); + } +} diff --git a/frontend/src/app/modules/boards/drag-and-drop/reorder-query.service.ts b/frontend/src/app/modules/boards/drag-and-drop/reorder-query.service.ts new file mode 100644 index 00000000000..7f741399d2f --- /dev/null +++ b/frontend/src/app/modules/boards/drag-and-drop/reorder-query.service.ts @@ -0,0 +1,70 @@ +import {Injectable} from "@angular/core"; +import {TableState} from "core-components/wp-table/table-state/table-state"; +import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; +import {QueryResource} from "core-app/modules/hal/resources/query-resource"; + +@Injectable() +export class ReorderQueryService { + + constructor(readonly pathHelper:PathHelperService) { + } + + /** + * Move an item in the list + */ + public move(tableState:TableState, wpId:string, toIndex:number):Promise { + const order = this.getCurrentOrder(tableState); + + // Find index of the work package + let fromIndex = order.findIndex((id) => id === wpId); + + order.splice(fromIndex, 1); + order.splice(toIndex, 0, wpId); + + return this.updateQuery(tableState.query.value, order); + } + + /** + * Pull an item from the rendered list + */ + public remove(tableState:TableState, wpId:string):Promise { + const order = this.getCurrentOrder(tableState); + _.remove(order, id => id === wpId); + + return this.updateQuery(tableState.query.value, order); + } + + /** + * Add an item to the list + * @param tableState + * @param toIndex index to add to or -1 to push to the end. + */ + public add(tableState:TableState, wpId:string, toIndex:number = -1) { + const order = this.getCurrentOrder(tableState); + + if (toIndex === -1) { + order.push(wpId); + } else { + order.splice(toIndex, 0, wpId); + } + + return this.updateQuery(tableState.query.value, order); + } + + protected getCurrentOrder(tableState:TableState):string[] { + return tableState + .renderedWorkPackages + .mapOr((rows) => rows.map(row => row.workPackageId!.toString()), []); + } + + private updateQuery(query:QueryResource|undefined, orderedIds:string[]):Promise { + if (query && !!query.updateImmediately) { + const orderedWorkPackages = orderedIds + .map(id => this.pathHelper.api.v3.work_packages.id(id).toString()); + + return query.updateImmediately({orderedWorkPackages: orderedWorkPackages}); + } else { + return Promise.reject("Query not writable"); + } + } +} diff --git a/frontend/src/app/modules/boards/index-page/boards-index-page.component.html b/frontend/src/app/modules/boards/index-page/boards-index-page.component.html new file mode 100644 index 00000000000..f140f2b0944 --- /dev/null +++ b/frontend/src/app/modules/boards/index-page/boards-index-page.component.html @@ -0,0 +1,90 @@ +
    +
    +

    +

    +
    + +
    + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + + + + + +
    + + + + + + + {{ text.delete }} + +
    +
    +
    +
    diff --git a/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts b/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts new file mode 100644 index 00000000000..ef3aed377f1 --- /dev/null +++ b/frontend/src/app/modules/boards/index-page/boards-index-page.component.ts @@ -0,0 +1,62 @@ +import {Component} from "@angular/core"; +import {Observable} from "rxjs"; +import {StateService} from "@uirouter/core"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {BoardService} from "core-app/modules/boards/board/board.service"; +import {Board} from "core-app/modules/boards/board/board"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; +import {NotificationsService} from "core-app/modules/common/notifications/notifications.service"; + +@Component({ + templateUrl: './boards-index-page.component.html' +}) +export class BoardsIndexPageComponent { + + public text = { + name: this.I18n.t('js.modals.label_name'), + board: this.I18n.t('js.label_board'), + boards: this.I18n.t('js.label_board_plural'), + createdAt: this.I18n.t('js.label_created_on'), + delete: this.I18n.t('js.button_delete'), + areYouSure: this.I18n.t('js.text_are_you_sure'), + deleteSuccessful: this.I18n.t('js.notice_successful_delete'), + noResults: this.I18n.t('js.notice_no_results_to_display') + }; + + public boards$:Observable = this.boardCache.observeAll(); + + constructor(private readonly boardService:BoardService, + private readonly boardCache:BoardCacheService, + private readonly I18n:I18nService, + private readonly notifications:NotificationsService, + private readonly state:StateService) { + this.boardService.loadAllBoards(); + } + + get canManage() { + return this.boardService.canManage; + } + + newBoard() { + this.boardService + .create() + .then((board) => { + this.boardCache.update(board); + this.state.go('boards.show', { board_id: board.id, isNew: true }); + }); + } + + destroyBoard(board:Board) { + if (!window.confirm(this.text.areYouSure)) { + return; + } + + this.boardService + .delete(board) + .then(() => { + this.boardCache.clearSome(board.id); + this.notifications.addSuccess(this.text.deleteSuccessful); + }) + .catch((error) => this.notifications.addError("Deletion failed: " + error)); + } +} diff --git a/frontend/src/app/modules/boards/openproject-boards.module.ts b/frontend/src/app/modules/boards/openproject-boards.module.ts new file mode 100644 index 00000000000..f5f7a813ed1 --- /dev/null +++ b/frontend/src/app/modules/boards/openproject-boards.module.ts @@ -0,0 +1,105 @@ +// -- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +// ++ + +import {NgModule} from '@angular/core'; +import {OpenprojectCommonModule} from "core-app/modules/common/openproject-common.module"; +import {OpenprojectWorkPackagesModule} from "core-app/modules/work_packages/openproject-work-packages.module"; +import {Ng2StateDeclaration, UIRouterModule} from "@uirouter/angular"; +import {BoardComponent} from "core-app/modules/boards/board/board.component"; +import {BoardListComponent} from "core-app/modules/boards/board/board-list/board-list.component"; +import {BoardsRootComponent} from "core-app/modules/boards/boards-root/boards-root.component"; +import {BoardListsService} from "core-app/modules/boards/board/board-list/board-lists.service"; +import {BoardService} from "core-app/modules/boards/board/board.service"; +import {BoardInlineAddAutocompleterComponent} from "core-app/modules/boards/board/inline-add/board-inline-add-autocompleter.component"; +import {BoardCacheService} from "core-app/modules/boards/board/board-cache.service"; +import {BoardConfigurationDisplaySettingsTab} from "core-app/modules/boards/board/configuration-modal/tabs/display-settings-tab.component"; +import {BoardsToolbarMenuDirective} from "core-app/modules/boards/board/toolbar-menu/boards-toolbar-menu.directive"; +import {BoardConfigurationService} from "core-app/modules/boards/board/configuration-modal/board-configuration.service"; +import {BoardConfigurationModal} from "core-app/modules/boards/board/configuration-modal/board-configuration.modal"; +import {BoardsIndexPageComponent} from "core-app/modules/boards/index-page/boards-index-page.component"; +import {BoardsMenuComponent} from "core-app/modules/boards/boards-sidebar/boards-menu.component"; +import {BoardDmService} from "core-app/modules/boards/board/board-dm.service"; + +export const BOARDS_ROUTES:Ng2StateDeclaration[] = [ + { + name: 'boards', + parent: 'root', + url: '/work_packages/boards', + redirectTo: 'boards.list', + component: BoardsRootComponent + }, + { + name: 'boards.list', + component: BoardsIndexPageComponent + }, + { + name: 'boards.show', + url: '/{board_id}', + params: { + board_id: { type: 'int' }, + isNew: { type: 'bool' } + }, + component: BoardComponent + } +]; + +@NgModule({ + imports: [ + OpenprojectCommonModule, + OpenprojectWorkPackagesModule, + + // Routes for /boards + UIRouterModule.forChild({ states: BOARDS_ROUTES }), + ], + providers: [ + BoardService, + BoardDmService, + BoardListsService, + BoardCacheService, + BoardConfigurationService, + ], + declarations: [ + BoardsIndexPageComponent, + BoardComponent, + BoardListComponent, + BoardsRootComponent, + BoardInlineAddAutocompleterComponent, + BoardsMenuComponent, + BoardConfigurationDisplaySettingsTab, + BoardConfigurationModal, + BoardsToolbarMenuDirective, + ], + entryComponents: [ + BoardInlineAddAutocompleterComponent, + BoardsMenuComponent, + BoardConfigurationModal, + BoardConfigurationDisplaySettingsTab, + ] +}) +export class OpenprojectBoardsModule { } + diff --git a/frontend/src/app/components/wp-query-select/wp-query-selectable-title.component.ts b/frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.component.ts similarity index 60% rename from frontend/src/app/components/wp-query-select/wp-query-selectable-title.component.ts rename to frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.component.ts index 11a98931b58..eee8e35c54a 100644 --- a/frontend/src/app/components/wp-query-select/wp-query-selectable-title.component.ts +++ b/frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.component.ts @@ -25,36 +25,47 @@ // // See doc/COPYRIGHT.rdoc for more details. //++ -import {AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild, ViewEncapsulation} from "@angular/core"; +import { + Component, + ElementRef, + EventEmitter, + Injector, + Input, + OnChanges, + OnInit, + Output, SimpleChanges, + ViewChild +} from "@angular/core"; import {I18nService} from "core-app/modules/common/i18n/i18n.service"; -import {QueryResource} from 'core-app/modules/hal/resources/query-resource'; -import {WorkPackagesListService} from 'core-components/wp-list/wp-list.service'; -import {StateService, TransitionService} from '@uirouter/core'; -import {States} from 'core-components/states.service'; -import {AuthorisationService} from "core-app/modules/common/model-auth/model-auth.service"; import {ContainHelpers} from "core-app/modules/common/focus/contain-helpers"; export const triggerEditingEvent = 'op:selectableTitle:trigger'; -export const selectableTitleIdentifier = 'wp-query-selectable-title'; +export const selectableTitleIdentifier = 'editable-toolbar-title'; @Component({ - selector: 'wp-query-selectable-title', - templateUrl: './wp-query-selectable-title.html', - styleUrls: ['./wp-query-selectable-title.sass'], - // Don't encapsulate styles because we're styling within other components - encapsulation: ViewEncapsulation.None, + selector: 'editable-toolbar-title', + templateUrl: './editable-toolbar-title.html', + styleUrls: ['./editable-toolbar-title.sass'], host: { 'class': 'title-container' } }) -export class WorkPackageQuerySelectableTitleComponent implements OnInit { - @Input() public selectedTitle:string; - @Input() public currentQuery:QueryResource; - @Input() queryEditable:boolean = true; +export class EditableToolbarTitleComponent implements OnInit, OnChanges { + @Input('title') public inputTitle:string; + @Input() public editable:boolean = true; + @Input() public inFlight:boolean = false; + @Input() public showSaveCondition:boolean = false; + @Input() public initialFocus:boolean = false; + + @Output() public onSave = new EventEmitter(); + @Output() public onEmptySubmit = new EventEmitter(); @ViewChild('editableTitleInput') inputField?:ElementRef; - public inFlight:boolean = false; + public selectedTitle:string; public selectableTitleIdentifier = selectableTitleIdentifier; + protected readonly elementRef:ElementRef = this.injector.get(ElementRef); + protected readonly I18n:I18nService = this.injector.get(I18nService); + public text = { click_to_edit: this.I18n.t('js.work_packages.query.click_to_edit_query_name'), press_enter_to_save: this.I18n.t('js.label_press_enter_to_save'), @@ -66,13 +77,7 @@ export class WorkPackageQuerySelectableTitleComponent implements OnInit { duplicate_query_title: this.I18n.t('js.work_packages.query.errors.duplicate_query_title') }; - - constructor(readonly elementRef:ElementRef, - readonly I18n:I18nService, - readonly wpListService:WorkPackagesListService, - readonly authorisationService:AuthorisationService, - readonly $state:StateService, - readonly states:States) { + constructor(protected readonly injector:Injector) { } ngOnInit() { @@ -86,7 +91,7 @@ export class WorkPackageQuerySelectableTitleComponent implements OnInit { this.selectedTitle = val; setTimeout(() => { - let field = jQuery(this.inputField!.nativeElement); + const field:HTMLInputElement = this.inputField!.nativeElement; field.focus(); }, 20); @@ -94,25 +99,30 @@ export class WorkPackageQuerySelectableTitleComponent implements OnInit { }); } + ngOnChanges(changes:SimpleChanges):void { + + if (changes.inputTitle) { + this.selectedTitle = changes.inputTitle.currentValue; + } + } + + public selectInput(event:FocusEvent) { + (event.target as HTMLInputElement).select(); + } + public resetWhenFocusOutside($event:FocusEvent) { ContainHelpers.whenOutside(this.elementRef.nativeElement, () => this.reset()); } public reset() { this.resetInputField(); - this.selectedTitle = this.currentTitle; - } - - public get editable() { - return this.queryEditable && - this.authorisationService.can('query', 'updateImmediately'); + this.selectedTitle = this.inputTitle; } public get showSave() { - return this.editable && this.$state.params.query_props; + return this.editable && this.showSaveCondition; } - // Element looses focus on click outside and is not editable anymore public save($event:Event, force = false) { $event.preventDefault(); @@ -121,37 +131,35 @@ export class WorkPackageQuerySelectableTitleComponent implements OnInit { // If the title is empty, show an error if (this.isEmpty) { - this.updateItemInMenu(); // Throws an error message, when name is empty - this.focusInputOnError(); + this.onEmptyError(); return; } - if (!force && this.currentTitle === this.selectedTitle) { + if (!force && this.inputTitle === this.selectedTitle) { return; // Nothing changed } - this.updateItemInMenu(); + this.emitSave(this.selectedTitle); } - // Check if title of query is empty public get isEmpty():boolean { return this.selectedTitle === ''; } /** - * The current saved title + * Called when saving the changed title */ - private get currentTitle():string { - return this.currentQuery.name; + private emitSave(title:string) { + this.onSave.emit(title); } - // Send new query name to service to update the name in the menu - private updateItemInMenu() { - this.inFlight = true; - this.currentQuery.name = this.selectedTitle; - this.wpListService.save(this.currentQuery) - .then(() => this.inFlight = false) - .catch(() => this.inFlight = false); + /** + * Called when trying to save an empty text + */ + private onEmptyError() { + // this.updateItemInMenu(); // Throws an error message, when name is empty + this.onEmptySubmit.emit(); + this.focusInputOnError(); } private focusInputOnError() { diff --git a/frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.html b/frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.html new file mode 100644 index 00000000000..5f1d6ca131f --- /dev/null +++ b/frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.html @@ -0,0 +1,31 @@ +
    + + + + +
    +

    {{ selectedTitle | slice:0:50 }} +

    diff --git a/frontend/src/app/components/wp-query-select/wp-query-selectable-title.sass b/frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.sass similarity index 59% rename from frontend/src/app/components/wp-query-select/wp-query-selectable-title.sass rename to frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.sass index 420fe2c48b5..6b75fca3a26 100644 --- a/frontend/src/app/components/wp-query-select/wp-query-selectable-title.sass +++ b/frontend/src/app/modules/common/editable-toolbar-title/editable-toolbar-title.sass @@ -1,18 +1,18 @@ -.wp-query-selectable-title--container +.editable-toolbar-title--container display: flex align-items: center -.wp-query-selectable-title--fixed +.editable-toolbar-title--fixed padding: 0 -.wp-query-selectable-title--save +.editable-toolbar-title--save span:before color: #5F5F5F &:hover span:before color: darken(#5F5F5F, 20%) -.wp-query--selectable-title +.editable-toolbar-title--input color: #5F5F5F &.-changed diff --git a/frontend/src/app/modules/common/gon/gon.service.ts b/frontend/src/app/modules/common/gon/gon.service.ts new file mode 100644 index 00000000000..c3e170c3685 --- /dev/null +++ b/frontend/src/app/modules/common/gon/gon.service.ts @@ -0,0 +1,53 @@ +// -- copyright +// OpenProject is a project management system. +// Copyright (C) 2012-2015 the OpenProject Foundation (OPF) +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software +// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +// +// See doc/COPYRIGHT.rdoc for more details. +// ++ + +import {Injectable} from "@angular/core"; + +declare global { + interface Window { + gon:GonType; + } +} + +export interface GonType { + [key:string]:unknown; +} + +@Injectable() +export class GonService { + get(...path:string[]):unknown|null { + return _.get(window.gon, path, null); + } + + /** + * Get the gon object + */ + get gon():GonType { + return window.gon; + } +} diff --git a/frontend/src/app/modules/common/loading-indicator/loading-indicator.service.ts b/frontend/src/app/modules/common/loading-indicator/loading-indicator.service.ts index 82116af4ea2..dd81b63c8a2 100644 --- a/frontend/src/app/modules/common/loading-indicator/loading-indicator.service.ts +++ b/frontend/src/app/modules/common/loading-indicator/loading-indicator.service.ts @@ -27,44 +27,30 @@ // ++ import {Injectable} from "@angular/core"; +import {Observable} from "rxjs"; +import {tap} from "rxjs/operators"; export const indicatorLocationSelector = '.loading-indicator--location'; export const indicatorBackgroundSelector = '.loading-indicator--background'; +export function withLoadingIndicator(indicator:LoadingIndicator, delayStopTime?:number):(source:Observable) => Observable { + return (source$:Observable) => { + indicator.start(); + + return source$.pipe( + tap( + () => indicator.delayedStop(delayStopTime), + () => indicator.stop(), + () => indicator.stop() + ) + ); + }; +} export class LoadingIndicator { - constructor(public indicator:JQuery, public template:string) {} - - public set promise(promise:Promise) { - this.start(); - - const stop = () => { - setTimeout(() => this.stop(), 25); - }; - - promise - .then(stop) - .catch(stop); - } - - public start() { - // If we're currently having an active indicator, remove that one - this.stop(); - this.indicator.prepend(this.template); - } - - public stop() { - this.indicator.find('.loading-indicator--background').remove(); - - } -} - -@Injectable() -export class LoadingIndicatorService { - private indicatorTemplate:string = - `
    + `
    @@ -75,15 +61,49 @@ export class LoadingIndicatorService {
    `; + constructor(public indicator:JQuery) {} + + public set promise(promise:Promise) { + this.start(); + + // Keep bound method around + const stopper = () => this.delayedStop(); + + promise + .then(stopper) + .catch(stopper); + } + + public start() { + // If we're currently having an active indicator, remove that one + this.stop(); + this.indicator.prepend(this.indicatorTemplate); + } + + public delayedStop(time = 25) { + setTimeout(() => this.stop(), time); + } + + public stop() { + this.indicator.find('.loading-indicator--background').remove(); + } +} + +@Injectable() +export class LoadingIndicatorService { + // Provide shortcut to the primarily used indicators public get table() { return this.indicator('table'); } public get wpDetails() { return this.indicator('wpDetails'); } public get modal() { return this.indicator('modal'); } - // Return an indicator by name - public indicator(name:string):LoadingIndicator { - let indicator = this.getIndicatorAt(name); - return new LoadingIndicator(indicator, this.indicatorTemplate); + // Return an indicator by name or element + public indicator(indicator:string|JQuery):LoadingIndicator { + if (typeof indicator === 'string') { + indicator = this.getIndicatorAt(name) as JQuery; + } + + return new LoadingIndicator(indicator); } private getIndicatorAt(name:string):JQuery { diff --git a/frontend/src/app/modules/common/openproject-common.module.ts b/frontend/src/app/modules/common/openproject-common.module.ts index 436f1cfb561..75aa342f85c 100644 --- a/frontend/src/app/modules/common/openproject-common.module.ts +++ b/frontend/src/app/modules/common/openproject-common.module.ts @@ -71,11 +71,14 @@ import {PortalModule} from "@angular/cdk/portal"; import {CommonModule} from "@angular/common"; import {CollapsibleSectionComponent} from "core-app/modules/common/collapsible-section/collapsible-section.component"; import {NoResultsComponent} from "core-app/modules/common/no-results/no-results.component"; +import {DragDropModule} from "@angular/cdk/drag-drop"; import {NgSelectModule} from "@ng-select/ng-select"; import {UserAutocompleterComponent} from "app/modules/common/autocomplete/user-autocompleter.component"; import {ScrollableTabsComponent} from "core-app/modules/common/tabs/scrollable-tabs.component"; import {BrowserDetector} from "core-app/modules/common/browser/browser-detector.service"; +import {EditableToolbarTitleComponent} from "core-app/modules/common/editable-toolbar-title/editable-toolbar-title.component"; import {UserAvatarComponent} from "core-components/user/user-avatar/user-avatar.component"; +import {GonService} from "core-app/modules/common/gon/gon.service"; export function bootstrapModule(injector:Injector) { return () => { @@ -98,6 +101,7 @@ export function bootstrapModule(injector:Injector) { FormsModule, // Angular CDK PortalModule, + DragDropModule, // Our own A11y module OpenprojectAccessibilityModule, NgSelectModule, @@ -109,6 +113,7 @@ export function bootstrapModule(injector:Injector) { CommonModule, FormsModule, PortalModule, + DragDropModule, OpenprojectAccessibilityModule, OpDatePickerComponent, @@ -155,6 +160,8 @@ export function bootstrapModule(injector:Injector) { ScrollableTabsComponent, + EditableToolbarTitleComponent, + // User Avatar UserAvatarComponent, ], @@ -206,6 +213,8 @@ export function bootstrapModule(injector:Injector) { ScrollableTabsComponent, + EditableToolbarTitleComponent, + // User Avatar UserAvatarComponent, ], @@ -240,6 +249,7 @@ export function bootstrapModule(injector:Injector) { HTMLSanitizeService, TimezoneService, BrowserDetector, + GonService, ] }) export class OpenprojectCommonModule { } diff --git a/frontend/src/app/modules/common/path-helper/apiv3/apiv3-paths.ts b/frontend/src/app/modules/common/path-helper/apiv3/apiv3-paths.ts index 3401c36a4cd..71f94998837 100644 --- a/frontend/src/app/modules/common/path-helper/apiv3/apiv3-paths.ts +++ b/frontend/src/app/modules/common/path-helper/apiv3/apiv3-paths.ts @@ -99,7 +99,7 @@ export class ApiV3Paths { * @param {string | number} projectIdentifier * @returns {Apiv3ProjectPaths | this} */ - public withOptionalProject(projectIdentifier?:string|number):Apiv3ProjectPaths|this { + public withOptionalProject(projectIdentifier:string|number|null|undefined):Apiv3ProjectPaths|this { if (_.isNil(projectIdentifier)) { return this; } else { @@ -107,6 +107,18 @@ export class ApiV3Paths { } } + /** + * returns a resource segment from (/base)/api/v3/(resource) + * @param segment + */ + public resource(segment:string) { + if (!segment.startsWith('/')) { + segment = '/' + segment; + } + + return this.apiV3Base + segment; + } + public previewMarkup(context:string) { let base = this.apiV3Base + '/render/markdown'; diff --git a/frontend/src/app/modules/common/path-helper/apiv3/path-resources.ts b/frontend/src/app/modules/common/path-helper/apiv3/path-resources.ts index 1273d6563da..7dc1cc390ca 100644 --- a/frontend/src/app/modules/common/path-helper/apiv3/path-resources.ts +++ b/frontend/src/app/modules/common/path-helper/apiv3/path-resources.ts @@ -1,3 +1,5 @@ +import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-builder"; + export class SimpleResourceCollection { // Base path public readonly path:string; @@ -25,6 +27,10 @@ export class SimpleResourceCollection public toPath():string { return this.path; } + + public filtered(filters:ApiV3FilterBuilder) { + return this.toString() + '/?' + filters.toParams(); + } } export class SimpleResource { diff --git a/frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-project-paths.ts b/frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-project-paths.ts index fb0aa412a3c..c8398915952 100644 --- a/frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-project-paths.ts +++ b/frontend/src/app/modules/common/path-helper/apiv3/projects/apiv3-project-paths.ts @@ -29,6 +29,7 @@ import {SimpleResource} from 'core-app/modules/common/path-helper/apiv3/path-resources'; import {Apiv3QueriesPaths} from 'core-app/modules/common/path-helper/apiv3/queries/apiv3-queries-paths'; import {Apiv3TypesPaths} from "core-app/modules/common/path-helper/apiv3/types/apiv3-types-paths"; +import {ApiV3WorkPackagesPaths} from "core-app/modules/common/path-helper/apiv3/work_packages/apiv3-work-packages-paths"; export class Apiv3ProjectPaths extends SimpleResource { // Base path @@ -42,7 +43,5 @@ export class Apiv3ProjectPaths extends SimpleResource { public readonly types = new Apiv3TypesPaths(this.path); - public readonly work_packages = { - form: new SimpleResource(this.path, 'work_packages/form') - }; + public readonly work_packages = new ApiV3WorkPackagesPaths(this.path); } diff --git a/frontend/src/app/modules/common/path-helper/path-helper.service.ts b/frontend/src/app/modules/common/path-helper/path-helper.service.ts index 699708aa3d0..de68fcdb49d 100644 --- a/frontend/src/app/modules/common/path-helper/path-helper.service.ts +++ b/frontend/src/app/modules/common/path-helper/path-helper.service.ts @@ -44,7 +44,6 @@ export class PathHelperService { public get staticBase() { return this.appBasePath; } - public attachmentDownloadPath(attachmentIdentifier:string, slug:string|undefined) { let path = this.staticBase + '/attachments/' + attachmentIdentifier; @@ -59,8 +58,8 @@ export class PathHelperService { return this.staticBase + '/highlighting/styles'; } - public boardPath(projectIdentifier:string, boardIdentifier:string) { - return this.projectBoardsPath(projectIdentifier) + '/' + boardIdentifier; + public forumPath(projectIdentifier:string, forumIdentifier:string) { + return this.projectForumPath(projectIdentifier) + '/' + forumIdentifier; } public keyboardShortcutsHelpPath() { @@ -91,7 +90,7 @@ export class PathHelperService { return this.projectPath(projectIdentifier) + '/activity'; } - public projectBoardsPath(projectIdentifier:string) { + public projectForumPath(projectIdentifier:string) { return this.projectPath(projectIdentifier) + '/boards'; } @@ -127,6 +126,14 @@ export class PathHelperService { return this.projectWorkPackagesPath(projectId) + '/new'; } + public projectBoardsPath(projectIdentifier:string | null) { + if (projectIdentifier) { + return this.projectWorkPackagesPath(projectIdentifier) + '/boards'; + } else { + return this.workPackagesPath() + '/boards'; + } + } + public timeEntriesPath(workPackageId:string|number) { var suffix = '/time_entries'; @@ -184,4 +191,5 @@ export class PathHelperService { public textFormattingHelp() { return this.staticBase + '/help/text_formatting'; } + } diff --git a/frontend/src/app/modules/fields/edit/editing-portal/wp-editing-portal-service.ts b/frontend/src/app/modules/fields/edit/editing-portal/wp-editing-portal-service.ts index c1b3a5c0f7e..5b18e9f76b0 100644 --- a/frontend/src/app/modules/fields/edit/editing-portal/wp-editing-portal-service.ts +++ b/frontend/src/app/modules/fields/edit/editing-portal/wp-editing-portal-service.ts @@ -82,8 +82,6 @@ export class WorkPackageEditingPortalService { this.injector ); } - - } diff --git a/frontend/src/app/modules/hal/dm-services/abstract-dm.service.ts b/frontend/src/app/modules/hal/dm-services/abstract-dm.service.ts index 857be9f523f..af6b610c73a 100644 --- a/frontend/src/app/modules/hal/dm-services/abstract-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/abstract-dm.service.ts @@ -41,7 +41,7 @@ export abstract class AbstractDmService implements DmServ protected pathHelper:PathHelperService) { } - public list(params:DmListParameter|null):Promise { + public list(params:DmListParameter|null):Promise> { let queryProps = []; if (params && params.sortBy) { diff --git a/frontend/src/app/modules/hal/dm-services/configuration-dm.service.ts b/frontend/src/app/modules/hal/dm-services/configuration-dm.service.ts index ead80784ea7..d2c0fce243a 100644 --- a/frontend/src/app/modules/hal/dm-services/configuration-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/configuration-dm.service.ts @@ -30,6 +30,8 @@ import {Injectable} from '@angular/core'; import {HalResourceService} from 'core-app/modules/hal/services/hal-resource.service'; import {ConfigurationResource} from 'core-app/modules/hal/resources/configuration-resource'; import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service'; +import {shareReplay} from "rxjs/operators"; +import {Observable} from "rxjs"; @Injectable() export class ConfigurationDmService { @@ -37,7 +39,17 @@ export class ConfigurationDmService { protected pathHelper:PathHelperService) { } - public load():Promise { - return this.halResourceService.get(this.pathHelper.api.v3.configuration.toString()).toPromise(); + private $configuration:Observable; + + public load():Observable { + if (this.$configuration) { + return this.$configuration; + } + + return this.$configuration = this.halResourceService + .get(this.pathHelper.api.v3.configuration.toString()) + .pipe( + shareReplay() + ); } } diff --git a/frontend/src/app/modules/hal/dm-services/grid-dm.service.ts b/frontend/src/app/modules/hal/dm-services/grid-dm.service.ts index 43314d472a0..0e7aeaecbfc 100644 --- a/frontend/src/app/modules/hal/dm-services/grid-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/grid-dm.service.ts @@ -88,7 +88,8 @@ export class GridDmService extends AbstractDmService { endRow: widget.endRow, startColumn: widget.startColumn, endColumn: widget.endColumn, - identifier: widget.identifier + identifier: widget.identifier, + options: widget.options }; }); } diff --git a/frontend/src/app/modules/hal/dm-services/payload-dm.service.ts b/frontend/src/app/modules/hal/dm-services/payload-dm.service.ts index 45adfe39ef7..5302393451b 100644 --- a/frontend/src/app/modules/hal/dm-services/payload-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/payload-dm.service.ts @@ -58,7 +58,7 @@ export class PayloadDmService { } _.each(nonLinkProperties, property => { - if (resource.hasOwnProperty(property)) { + if (resource.hasOwnProperty(property) || resource[property]) { if (Array.isArray(resource[property])) { payload[property] = _.map(resource[property], (element:any) => { if (element instanceof HalResource) { diff --git a/frontend/src/app/modules/hal/dm-services/query-dm.service.ts b/frontend/src/app/modules/hal/dm-services/query-dm.service.ts index afd8d072505..1b6aacd1512 100644 --- a/frontend/src/app/modules/hal/dm-services/query-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/query-dm.service.ts @@ -33,10 +33,11 @@ import {WorkPackageCollectionResource} from 'core-app/modules/hal/resources/wp-c import {QueryFormResource} from 'core-app/modules/hal/resources/query-form-resource'; import {CollectionResource} from 'core-app/modules/hal/resources/collection-resource'; import {ApiV3FilterBuilder} from 'core-app/components/api/api-v3/api-v3-filter-builder'; -import {Injectable} from '@angular/core'; +import {Injectable, Query} from '@angular/core'; import {UrlParamsHelperService} from 'core-components/wp-query/url-params-helper'; import {PathHelperService} from 'core-app/modules/common/path-helper/path-helper.service'; import {Observable} from "rxjs"; +import {QueryFiltersService} from "core-components/wp-query/query-filters.service"; export interface PaginationObject { pageSize:number; @@ -48,6 +49,7 @@ export class QueryDmService { constructor(protected halResourceService:HalResourceService, protected pathHelper:PathHelperService, protected UrlParamsHelper:UrlParamsHelperService, + protected QueryFilters:QueryFiltersService, protected PayloadDm:PayloadDmService) { } @@ -122,27 +124,24 @@ export class QueryDmService { } public update(query:QueryResource, form:QueryFormResource) { - return new Promise((resolve, reject) => { - this.extractPayload(query, form) - .then(payload => { - let path:string = this.pathHelper.api.v3.queries.id(query.id).toString(); - this.halResourceService.patch(path, payload) - .toPromise() - .then(resolve) - .catch(reject); - }) - .catch(reject); - }); + const payload = this.extractPayload(query, form); + return this.patch(query.id, payload); + } + + public patch(id:string|number, payload:{[key:string]:unknown}) { + let path:string = this.pathHelper.api.v3.queries.id(id).toString(); + return this.halResourceService + .patch(path, payload) + .toPromise(); } public create(query:QueryResource, form:QueryFormResource):Promise { - return this.extractPayload(query, form).then(payload => { - let path:string = this.pathHelper.api.v3.queries.toString(); + const payload:any = this.extractPayload(query, form); + let path:string = this.pathHelper.api.v3.queries.toString(); - return this.halResourceService - .post(path, payload) - .toPromise(); - }); + return this.halResourceService + .post(path, payload) + .toPromise(); } public delete(query:QueryResource) { @@ -157,7 +156,7 @@ export class QueryDmService { } } - public all(projectIdentifier:string|null|undefined):Promise> { + public all(projectIdentifier:string|null|undefined):Observable> { let filters = new ApiV3FilterBuilder(); if (projectIdentifier) { @@ -174,17 +173,12 @@ export class QueryDmService { let urlQuery = { filters: filters.toJson() }; return this.halResourceService - .get>(this.pathHelper.api.v3.queries.toString(), urlQuery) - .toPromise(); + .get>(this.pathHelper.api.v3.queries.toString(), urlQuery); } - private extractPayload(query:QueryResource, form:QueryFormResource):Promise { + private extractPayload(query:QueryResource, form:QueryFormResource):QueryResource { // Extracting requires having the filter schemas loaded as the dependencies - // need to be present. This should be handled within the cached information however, so it is fast. - const promises = _.map(query.filters, filter => filter.schema.$load()); - - return Promise - .all(promises) - .then(() => this.PayloadDm.extract(query, form.schema)); + this.QueryFilters.mapSchemasIntoFilters(query, form); + return this.PayloadDm.extract(query, form.schema); } } diff --git a/frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts b/frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts index bfa90f2a0a1..a82e5c33f9d 100644 --- a/frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/query-form-dm.service.ts @@ -57,21 +57,18 @@ export class QueryFormDmService { return query.$links.update(payload); } - public loadWithParams(params:{}, queryId?:number, projectIdentifier?:string):Promise { + public loadWithParams(params:{}, queryId:number|undefined, projectIdentifier:string|undefined|null, payload:any = {}):Promise { // We need a valid payload so that we // can check whether form saving is possible. // The query needs a name to be valid. - let payload:any = {}; - - if (!queryId) { - payload['name'] = '!!!__O__o__O__!!!'; + if (!queryId && !payload.name) { + payload.name = '!!!__O__o__O__!!!'; } if (projectIdentifier) { - payload['_links'] = { - 'project': { - 'href': this.pathHelper.api.v3.projects.id(projectIdentifier).toString() - } + payload._links = payload._links || {}; + payload._links.project = { + 'href': this.pathHelper.api.v3.projects.id(projectIdentifier).toString() }; } @@ -82,4 +79,8 @@ export class QueryFormDmService { .post(href, payload) .toPromise(); } + + public buildQueryResource(form:QueryFormResource):QueryResource { + return this.halResourceService.createHalResourceOfType('Query', form.payload); + } } diff --git a/frontend/src/app/modules/hal/resources/grid-resource.ts b/frontend/src/app/modules/hal/resources/grid-resource.ts index a73c47b7b31..9f7493866ea 100644 --- a/frontend/src/app/modules/hal/resources/grid-resource.ts +++ b/frontend/src/app/modules/hal/resources/grid-resource.ts @@ -28,9 +28,22 @@ import {HalResource} from 'core-app/modules/hal/resources/hal-resource'; import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource"; +import { + WorkPackageBaseResource, + WorkPackageResourceEmbedded, + WorkPackageResourceLinks +} from "core-app/modules/hal/resources/work-package-resource"; + +export interface GridResourceLinks { + update(payload:unknown):Promise; + updateImmediately(payload:unknown):Promise; + delete():Promise; +} export class GridResource extends HalResource { public widgets:GridWidgetResource[]; + public name:string; + public options:{[key:string]:unknown}; public $initialize(source:any) { super.$initialize(source); @@ -47,3 +60,6 @@ export class GridResource extends HalResource { ); } } + +export interface GridResource extends Partial { +} diff --git a/frontend/src/app/modules/hal/resources/grid-widget-resource.ts b/frontend/src/app/modules/hal/resources/grid-widget-resource.ts index 6bf3c4ae03a..bb897e9582f 100644 --- a/frontend/src/app/modules/hal/resources/grid-widget-resource.ts +++ b/frontend/src/app/modules/hal/resources/grid-widget-resource.ts @@ -35,6 +35,8 @@ export class GridWidgetResource extends HalResource { public startColumn:number; public endColumn:number; + public options:{[key:string]:unknown}; + public get height() { return this.endRow - this.startRow; } diff --git a/frontend/src/app/modules/hal/resources/hal-resource.ts b/frontend/src/app/modules/hal/resources/hal-resource.ts index b819940303f..40aed510412 100644 --- a/frontend/src/app/modules/hal/resources/hal-resource.ts +++ b/frontend/src/app/modules/hal/resources/hal-resource.ts @@ -140,9 +140,6 @@ export class HalResource { /** * Alias for $href. - * Please use $href instead. - * - * @deprecated */ public get href():string|null { return this.$link.href; diff --git a/frontend/src/app/modules/hal/resources/query-filter-instance-schema-resource.ts b/frontend/src/app/modules/hal/resources/query-filter-instance-schema-resource.ts index 34e3720cfb2..3c58f961c09 100644 --- a/frontend/src/app/modules/hal/resources/query-filter-instance-schema-resource.ts +++ b/frontend/src/app/modules/hal/resources/query-filter-instance-schema-resource.ts @@ -36,6 +36,7 @@ import {SchemaDependencyResource} from 'core-app/modules/hal/resources/schema-de import {QueryOperatorResource} from 'core-app/modules/hal/resources/query-operator-resource'; import {QueryFilterInstanceResource} from 'core-app/modules/hal/resources/query-filter-instance-resource'; import {HalResource} from 'core-app/modules/hal/resources/hal-resource'; +import Collection = api.v3.Collection; export interface QueryFilterInstanceSchemaResourceLinks { filter:QueryFilterResource; @@ -46,7 +47,7 @@ export class QueryFilterInstanceSchemaResource extends SchemaResource { public $links:QueryFilterInstanceSchemaResourceLinks; public operator:SchemaAttributeObject; - public filter:SchemaAttributeObject; + public filter:SchemaAttributeObject; public dependency:SchemaDependencyResource; public values:SchemaAttributeObject|null; @@ -58,6 +59,14 @@ export class QueryFilterInstanceSchemaResource extends SchemaResource { return this.operator.allowedValues; } + public get allowedFilterValue():QueryFilterResource { + if (this.filter.allowedValues instanceof CollectionResource) { + return this.filter.allowedValues.elements[0]; + } + + return this.filter.allowedValues[0]; + } + public $initialize(source:any) { super.$initialize(source); diff --git a/frontend/src/app/modules/hal/resources/query-resource.ts b/frontend/src/app/modules/hal/resources/query-resource.ts index b30ed237968..8a32ebfc49b 100644 --- a/frontend/src/app/modules/hal/resources/query-resource.ts +++ b/frontend/src/app/modules/hal/resources/query-resource.ts @@ -88,3 +88,11 @@ export class QueryResource extends HalResource { ); } } + +export interface QueryResourceLinks { + updateImmediately?(attributes:any):Promise; +} + +export interface QueryResource extends QueryResourceLinks { +} + diff --git a/frontend/src/app/modules/hal/resources/schema-resource.ts b/frontend/src/app/modules/hal/resources/schema-resource.ts index ea87c19abca..5b869d1cded 100644 --- a/frontend/src/app/modules/hal/resources/schema-resource.ts +++ b/frontend/src/app/modules/hal/resources/schema-resource.ts @@ -41,11 +41,11 @@ export class SchemaResource extends HalResource { } } -export class SchemaAttributeObject { +export class SchemaAttributeObject { public type:string; public name:string; public required:boolean; public hasDefault:boolean; public writable:boolean; - public allowedValues:HalResource[] | CollectionResource; + public allowedValues:T[] | CollectionResource; } diff --git a/frontend/src/app/modules/hal/services/hal-resource.service.ts b/frontend/src/app/modules/hal/services/hal-resource.service.ts index 01a8be0fe84..77b0db90eac 100644 --- a/frontend/src/app/modules/hal/services/hal-resource.service.ts +++ b/frontend/src/app/modules/hal/services/hal-resource.service.ts @@ -252,6 +252,20 @@ export class HalResourceService { return resource; } + /** + * Create a resource class of the given class + * @param resourceClass + * @param source + * @param loaded + */ + public createHalResourceOfClass(resourceClass:HalResourceClass, source:any, loaded:boolean = false) { + const initializer = (halResource:T) => initializeHalProperties(this, halResource); + const type = source._type || 'HalResource'; + let resource = new resourceClass(this.injector, source, loaded, initializer, type); + + return resource; + } + /** * Create a linked HalResource from the given link. * diff --git a/frontend/src/app/modules/router/openproject.routes.ts b/frontend/src/app/modules/router/openproject.routes.ts index 4637ccb2963..04d2fc23acf 100644 --- a/frontend/src/app/modules/router/openproject.routes.ts +++ b/frontend/src/app/modules/router/openproject.routes.ts @@ -27,7 +27,7 @@ // ++ import {StateService, Transition, TransitionService, UIRouter, UrlService} from '@uirouter/core'; -import {NotificationsService} from "core-app/modules/common/notifications/notifications.service"; +import {INotification, NotificationsService} from "core-app/modules/common/notifications/notifications.service"; import {CurrentProjectService} from "core-components/projects/current-project.service"; import {Injector} from "@angular/core"; import {FirstRouteService} from "core-app/modules/router/first-route-service"; @@ -45,6 +45,9 @@ export const OPENPROJECT_ROUTES = [ // squash: true avoids duplicate slashes when the parameter is not provided projectPath: {type: 'path', value: null, squash: true}, projects: {type: 'path', value: null, squash: true}, + + // Allow passing of flash messages after routes load + flash_message: { dynamic: true, value: null, inherit: false } } }, // We could lazily load work packages module already, @@ -137,7 +140,7 @@ export function initializeUiRouterListeners(injector:Injector) { // Only move to the URL if we're not coming from an initial URL load // (cases like /work_packages/invalid/activity which render a 403 without frontend, // but trigger the ui-router state) - if (!(transition.options().source === 'url' && firstRoute.isEmpty)) { + if (!(transition.options().source === 'url' || firstRoute.isEmpty)) { const target = stateService.href(toState, toParams); window.location.href = target; return false; @@ -154,6 +157,11 @@ export function initializeUiRouterListeners(injector:Injector) { notificationsService.clear(); } + // Add new notifications if passed to params + if (toParams.flash_message) { + notificationsService.add(toParams.flash_message as INotification); + } + const projectIdentifier = toParams.projectPath || currentProject.identifier; if (!toParams.projects && projectIdentifier) { diff --git a/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts b/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts index 9389e4f4e16..194deaa9032 100644 --- a/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts +++ b/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts @@ -28,7 +28,6 @@ import {OpenprojectCommonModule} from 'core-app/modules/common/openproject-common.module'; import {WorkPackageFormAttributeGroupComponent} from 'core-components/wp-form-group/wp-attribute-group.component'; -import {OpenprojectHalModule} from 'core-app/modules/hal/openproject-hal.module'; import {OpenprojectFieldsModule} from 'core-app/modules/fields/openproject-fields.module'; import {ChartsModule} from 'ng2-charts'; import {DynamicModule} from 'ng-dynamic-component'; @@ -53,7 +52,6 @@ import {OpSettingsMenuDirective} from 'core-components/op-context-menu/handlers/ import {WorkPackageStatusDropdownDirective} from 'core-components/op-context-menu/handlers/wp-status-dropdown-menu.directive'; import {WorkPackageCreateSettingsMenuDirective} from 'core-components/op-context-menu/handlers/wp-create-settings-menu.directive'; import {WorkPackageSingleContextMenuDirective} from 'core-components/op-context-menu/wp-context-menu/wp-single-context-menu'; -import {WorkPackageQuerySelectableTitleComponent} from 'core-components/wp-query-select/wp-query-selectable-title.component'; import {WorkPackageQuerySelectDropdownComponent} from 'core-components/wp-query-select/wp-query-select-dropdown.component'; import {WorkPackageTimelineHeaderController} from 'core-components/wp-table/timeline/header/wp-timeline-header.directive'; import {WorkPackageTableTimelineRelations} from 'core-components/wp-table/timeline/global-elements/wp-timeline-relations.directive'; @@ -91,7 +89,6 @@ import {WorkPackageRelationRowComponent} from 'core-components/wp-relations/wp-r import {WorkPackageRelationsCreateComponent} from 'core-components/wp-relations/wp-relations-create/wp-relations-create.component'; import {WorkPackageRelationsHierarchyComponent} from 'core-components/wp-relations/wp-relations-hierarchy/wp-relations-hierarchy.directive'; import {WorkPackageCreateButtonComponent} from 'core-components/wp-buttons/wp-create-button/wp-create-button.component'; -import {FullCalendarModule} from 'ng-fullcalendar'; import {WorkPackageBreadcrumbParentComponent} from 'core-components/work-packages/wp-breadcrumb/wp-breadcrumb-parent.component'; import {WorkPackageFilterButtonComponent} from 'core-components/wp-buttons/wp-filter-button/wp-filter-button.component'; import {WorkPackageFilterContainerComponent} from 'core-components/filters/filter-container/filter-container.directive'; @@ -187,6 +184,9 @@ import {WorkPackagesFullViewComponent} from "core-app/modules/work_packages/rout import {AttachmentsUploadComponent} from 'core-app/modules/attachments/attachments-upload/attachments-upload.component'; import {AttachmentListComponent} from 'core-app/modules/attachments/attachment-list/attachment-list.component'; import {WorkPackageFilterByTextInputComponent} from "core-components/filters/quick-filter-by-text-input/quick-filter-by-text-input.component"; +import {QueryFiltersService} from "core-components/wp-query/query-filters.service"; +import {ReorderQueryService} from "core-app/modules/boards/drag-and-drop/reorder-query.service"; +import {WorkPackageCardViewComponent} from "core-components/wp-card-view/wp-card-view.component"; @NgModule({ imports: [ @@ -228,6 +228,7 @@ import {WorkPackageFilterByTextInputComponent} from "core-components/filters/qui WorkPackageTableSortByService, WorkPackageTableColumnsService, WorkPackageTableFiltersService, + QueryFiltersService, WorkPackageTableSumService, WorkPackageTableHighlightingService, WorkPackageStatesInitializationService, @@ -270,6 +271,7 @@ import {WorkPackageFilterByTextInputComponent} from "core-components/filters/qui QueryFormDmService, TableState, + ReorderQueryService, WpTableConfigurationService, ], @@ -331,7 +333,6 @@ import {WorkPackageFilterByTextInputComponent} from "core-components/filters/qui WorkPackageStatusDropdownDirective, WorkPackageCreateSettingsMenuDirective, WorkPackageSingleContextMenuDirective, - WorkPackageQuerySelectableTitleComponent, WorkPackageQuerySelectDropdownComponent, // Timeline @@ -421,6 +422,9 @@ import {WorkPackageFilterByTextInputComponent} from "core-components/filters/qui // editor module to avoid circular dependencies EmbeddedTablesMacroComponent, WpButtonMacroModal, + + // Card view + WorkPackageCardViewComponent, ], entryComponents: [ // Split view @@ -492,11 +496,15 @@ import {WorkPackageFilterByTextInputComponent} from "core-components/filters/qui // editor module to avoid circular dependencies EmbeddedTablesMacroComponent, WpButtonMacroModal, + + // Card view + WorkPackageCardViewComponent, ], exports: [ WorkPackagesTableController, WorkPackageTablePaginationComponent, WorkPackageEmbeddedTableComponent, + WorkPackageCardViewComponent, WorkPackageFilterButtonComponent, WorkPackageFilterContainerComponent, ] diff --git a/frontend/src/app/modules/work_packages/routing/wp-list/wp-list.component.ts b/frontend/src/app/modules/work_packages/routing/wp-list/wp-list.component.ts index ed9a59472bc..2766e983995 100644 --- a/frontend/src/app/modules/work_packages/routing/wp-list/wp-list.component.ts +++ b/frontend/src/app/modules/work_packages/routing/wp-list/wp-list.component.ts @@ -43,9 +43,15 @@ export class WorkPackagesListComponent extends WorkPackagesSetComponent implemen 'button_settings': this.I18n.t('js.button_settings') }; + /** Whether the title can be edited */ titleEditingEnabled:boolean; + + /** Current query title to render */ selectedTitle?:string; currentQuery:QueryResource; + + /** Whether we're saving the query */ + querySaving:boolean; unRegisterTitleListener:Function; private readonly titleService:OpTitleService = this.injector.get(OpTitleService); @@ -90,10 +96,18 @@ export class WorkPackagesListComponent extends WorkPackagesSetComponent implemen return this.authorisationService.can(model, permission); } + public updateQueryName(val:string) { + this.querySaving = true; + this.currentQuery.name = val; + this.wpListService.save(this.currentQuery) + .then(() => this.querySaving = false) + .catch(() => this.querySaving = false); + } + updateTitle(query:QueryResource) { if (query.id) { this.selectedTitle = query.name; - this.titleEditingEnabled = true; + this.titleEditingEnabled = this.authorisationService.can('query', 'updateImmediately'); } else { this.selectedTitle = this.wpStaticQueries.getStaticName(query); this.titleEditingEnabled = false; diff --git a/frontend/src/app/modules/work_packages/routing/wp-list/wp.list.component.html b/frontend/src/app/modules/work_packages/routing/wp-list/wp.list.component.html index aca1c4b467b..7953111b26d 100644 --- a/frontend/src/app/modules/work_packages/routing/wp-list/wp.list.component.html +++ b/frontend/src/app/modules/work_packages/routing/wp-list/wp.list.component.html @@ -1,10 +1,13 @@
    - - + +
      diff --git a/frontend/src/assets/.gitkeep b/frontend/src/assets/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/frontend/src/assets/sass/_helpers.sass b/frontend/src/assets/sass/_helpers.sass new file mode 100644 index 00000000000..d7378a2d07c --- /dev/null +++ b/frontend/src/assets/sass/_helpers.sass @@ -0,0 +1 @@ +@import "../../../../app/assets/stylesheets/openproject/_mixins" diff --git a/frontend/src/typings/shims.d.ts b/frontend/src/typings/shims.d.ts index 3c29f8c8655..9fa9c485db4 100644 --- a/frontend/src/typings/shims.d.ts +++ b/frontend/src/typings/shims.d.ts @@ -14,6 +14,7 @@ /// /// /// +/// import {Injector} from '@angular/core'; @@ -22,6 +23,7 @@ import * as TLodash from 'lodash'; import * as TMoment from 'moment'; import * as TSinon from 'sinon'; import {GlobalI18n} from "core-app/modules/common/i18n/i18n.service"; +import {Dragula} from "dragula"; declare global { const _:typeof TLodash; @@ -29,6 +31,7 @@ declare global { const moment:typeof TMoment; const bowser:any; const I18n:GlobalI18n; + const dragula:Dragula; declare const require:any; declare const describe:any; diff --git a/lib/api/v3/queries/query_representer.rb b/lib/api/v3/queries/query_representer.rb index 7dc5922c0c1..c86a4833146 100644 --- a/lib/api/v3/queries/query_representer.rb +++ b/lib/api/v3/queries/query_representer.rb @@ -44,10 +44,10 @@ module API id = id_from_href "projects", fragment['href'] id = if id.to_i.nonzero? - id # return numerical ID - else - Project.where(identifier: id).pluck(:id).first # lookup Project by identifier - end + id # return numerical ID + else + Project.where(identifier: id).pluck(:id).first # lookup Project by identifier + end represented.project_id = id if id }, @@ -60,14 +60,14 @@ module API link :results do path = if represented.project - api_v3_paths.work_packages_by_project(represented.project.id) - else - api_v3_paths.work_packages - end + api_v3_paths.work_packages_by_project(represented.project.id) + else + api_v3_paths.work_packages + end url_query = ::API::V3::Queries::QueryParamsRepresenter - .new(represented) - .to_url_query(merge_params: params.slice(:offset, :pageSize)) + .new(represented) + .to_url_query(merge_params: params.slice(:offset, :pageSize)) { href: [path, url_query].join('?') } @@ -93,10 +93,10 @@ module API link :schema do href = if represented.project - api_v3_paths.query_project_schema(represented.project.identifier) - else - api_v3_paths.query_schema - end + api_v3_paths.query_project_schema(represented.project.identifier) + else + api_v3_paths.query_schema + end { href: href } @@ -104,10 +104,10 @@ module API link :update do href = if represented.new_record? - api_v3_paths.create_query_form - else - api_v3_paths.query_form(represented.id) - end + api_v3_paths.create_query_form + else + api_v3_paths.query_form(represented.id) + end { href: href, @@ -117,7 +117,7 @@ module API link :updateImmediately do next unless represented.new_record? && allowed_to?(:create) || - represented.persisted? && allowed_to?(:update) + represented.persisted? && allowed_to?(:update) { href: api_v3_paths.query(represented.id), method: :patch @@ -126,7 +126,7 @@ module API link :delete do next if represented.new_record? || - !allowed_to?(:destroy) + !allowed_to?(:destroy) { href: api_v3_paths.query(represented.id), @@ -241,6 +241,18 @@ module API end } + property :ordered_work_packages, + skip_render: true, + exec_context: :decorator, + getter: nil, + setter: ->(fragment:, **) { + ordered_work_packages = Array(fragment).map do |link| + id_from_href "work_packages", link + end + + represented.ordered_work_packages = ordered_work_packages if fragment + } + property :starred, writeable: true diff --git a/lib/api/v3/queries/schemas/manual_sort_filter_dependency_representer.rb b/lib/api/v3/queries/schemas/manual_sort_filter_dependency_representer.rb new file mode 100644 index 00000000000..96482ba50d6 --- /dev/null +++ b/lib/api/v3/queries/schemas/manual_sort_filter_dependency_representer.rb @@ -0,0 +1,39 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +module API + module V3 + module Queries + module Schemas + class ManualSortFilterDependencyRepresenter < ByWorkPackageFilterDependencyRepresenter; end + end + end + end +end diff --git a/lib/open_project/client_preference_extractor.rb b/lib/open_project/client_preference_extractor.rb index d23a04620f3..0dcc609c52f 100644 --- a/lib/open_project/client_preference_extractor.rb +++ b/lib/open_project/client_preference_extractor.rb @@ -47,6 +47,7 @@ module OpenProject pref = user.pref.clone pref[:comments_sorting] = user.pref.comments_sorting + pref[:auto_hide_popups] = user.pref.auto_hide_popups? map_timezone_to_tz!(pref) end diff --git a/modules/auth_plugins/openproject-auth_plugins.gemspec b/modules/auth_plugins/openproject-auth_plugins.gemspec index 5ab39d48c7f..58a10064ccb 100644 --- a/modules/auth_plugins/openproject-auth_plugins.gemspec +++ b/modules/auth_plugins/openproject-auth_plugins.gemspec @@ -1,5 +1,6 @@ # encoding: UTF-8 $:.push File.expand_path('../lib', __FILE__) +$:.push File.expand_path("../../lib", __dir__) require 'open_project/auth_plugins/version' diff --git a/modules/avatars/app/views/settings/_openproject_avatars.html.erb b/modules/avatars/app/views/settings/_openproject_avatars.html.erb index 745d5e9041e..d400279c59f 100644 --- a/modules/avatars/app/views/settings/_openproject_avatars.html.erb +++ b/modules/avatars/app/views/settings/_openproject_avatars.html.erb @@ -7,7 +7,6 @@
      <%= styled_label_tag 'settings-enable-gravatars', t('avatars.settings.enable_gravatars') %> <%= hidden_field_tag 'settings[enable_gravatars]', 0 %> - <%= hidden_field_tag 'settings[gravatar_default]', '404' %>
      <%= styled_check_box_tag 'settings[enable_gravatars]', 1, manager.gravatar_enabled?, container_class: '-xslim', id: 'settings-enable-gravatars' %>
      diff --git a/modules/avatars/lib/open_project/avatars/engine.rb b/modules/avatars/lib/open_project/avatars/engine.rb index 87aaf94ce96..a7291b5070b 100644 --- a/modules/avatars/lib/open_project/avatars/engine.rb +++ b/modules/avatars/lib/open_project/avatars/engine.rb @@ -28,7 +28,6 @@ module OpenProject::Avatars settings: { default: { enable_gravatars: true, - gravatar_default: '404', enable_local_avatars: true }, partial: 'settings/openproject_avatars' diff --git a/modules/avatars/lib/open_project/avatars/patches/avatar_helper_patch.rb b/modules/avatars/lib/open_project/avatars/patches/avatar_helper_patch.rb index 608e9872823..1343aa5e64c 100644 --- a/modules/avatars/lib/open_project/avatars/patches/avatar_helper_patch.rb +++ b/modules/avatars/lib/open_project/avatars/patches/avatar_helper_patch.rb @@ -142,7 +142,7 @@ AvatarHelper.class_eval do content_tag 'user-avatar', '', 'data-class-list': tag_options[:class], - 'data-user-id': user.id.to_s, + 'data-user-name': user.name, 'data-use-fallback': 'true' end diff --git a/modules/avatars/spec/features/shared_avatar_examples.rb b/modules/avatars/spec/features/shared_avatar_examples.rb index 22b8c982a3b..101b4a11429 100644 --- a/modules/avatars/spec/features/shared_avatar_examples.rb +++ b/modules/avatars/spec/features/shared_avatar_examples.rb @@ -4,13 +4,11 @@ shared_examples 'avatar management' do let(:image_base_path) { File.expand_path(File.dirname(__FILE__) + '/../fixtures/') } let(:enable_gravatars) { false } - let(:gravatar_default) { '' } let(:enable_local_avatars) { false } let(:plugin_settings) do { 'enable_gravatars' => enable_gravatars, - 'enable_local_avatars' => enable_local_avatars, - 'gravatar_default' => gravatar_default + 'enable_local_avatars' => enable_local_avatars } end diff --git a/modules/avatars/spec/helpers/avatar_helper_spec.rb b/modules/avatars/spec/helpers/avatar_helper_spec.rb index 6b876e9f635..69d26e28b07 100644 --- a/modules/avatars/spec/helpers/avatar_helper_spec.rb +++ b/modules/avatars/spec/helpers/avatar_helper_spec.rb @@ -6,13 +6,11 @@ describe AvatarHelper, type: :helper, with_settings: { protocol: 'http' } do let(:avatar_stub) { FactoryBot.build_stubbed(:avatar_attachment) } let(:enable_gravatars) { false } - let(:gravatar_default) { '' } let(:enable_local_avatars) { false } let(:plugin_settings) do { 'enable_gravatars' => enable_gravatars, - 'enable_local_avatars' => enable_local_avatars, - 'gravatar_default' => gravatar_default + 'enable_local_avatars' => enable_local_avatars } end @@ -38,7 +36,7 @@ describe AvatarHelper, type: :helper, with_settings: { protocol: 'http' } do def default_expected_user_avatar_tag(user) tag_options = { 'data-use-fallback': "true", - 'data-user-id': user.id, + 'data-user-name': user.name, 'data-class-list': 'avatar avatar-default' } content_tag 'user-avatar', '', tag_options diff --git a/modules/boards/app/controllers/boards/base_controller.rb b/modules/boards/app/controllers/boards/base_controller.rb new file mode 100644 index 00000000000..84fc740ec82 --- /dev/null +++ b/modules/boards/app/controllers/boards/base_controller.rb @@ -0,0 +1,4 @@ +module ::Boards + class BaseController < ::ApplicationController + end +end diff --git a/modules/boards/app/controllers/boards/boards_controller.rb b/modules/boards/app/controllers/boards/boards_controller.rb new file mode 100644 index 00000000000..2b18fc7ee88 --- /dev/null +++ b/modules/boards/app/controllers/boards/boards_controller.rb @@ -0,0 +1,37 @@ +module ::Boards + class BoardsController < BaseController + include OpenProject::ClientPreferenceExtractor + + before_action :find_optional_project + before_action :authorize + + # The boards permission alone does not suffice + # to view work packages + before_action :authorize_work_package_permission + + # Pass some visibility settings via gon that are not + # available through the global grids API + before_action :pass_gon + + menu_item :board_view + + def index + render layout: 'angular' + end + + private + + def pass_gon + gon.settings = client_preferences + gon.permission_flags = { + manage_board_views: current_user.allowed_to_in_project?(:manage_board_views, @project) + } + end + + def authorize_work_package_permission + unless current_user.allowed_to?(:view_work_packages, @project, global: @project.nil?) + deny_access + end + end + end +end diff --git a/modules/boards/app/models/boards/grid.rb b/modules/boards/app/models/boards/grid.rb new file mode 100644 index 00000000000..03d2ac062da --- /dev/null +++ b/modules/boards/app/models/boards/grid.rb @@ -0,0 +1,60 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require_dependency 'grids/grid' + +module Boards + class Grid < ::Grids::Grid + belongs_to :project + validates_presence_of :name + + before_destroy :delete_queries + + def user_deletable? + true + end + + def contained_queries + ::Query.where(id: contained_query_ids) + end + + private + + def delete_queries + contained_queries.delete_all + end + + def contained_query_ids + widgets + .map { |w| w.options['query_id'] } + .compact + end + end +end diff --git a/modules/boards/app/seeders/role_seeder.rb b/modules/boards/app/seeders/role_seeder.rb new file mode 100644 index 00000000000..cc97a107bdd --- /dev/null +++ b/modules/boards/app/seeders/role_seeder.rb @@ -0,0 +1,24 @@ +module BasicData + module Boards + module RoleSeeder + def member + super.tap do |member| + member[:permissions].concat %i( + show_board_views + manage_board_views + ) + end + end + + def reader + super.tap do |reader| + reader[:permissions].concat %i( + show_board_views + ) + end + end + end + + BasicData::RoleSeeder.prepend BasicData::Boards::RoleSeeder + end +end diff --git a/modules/boards/app/views/boards/boards/_menu_board.html b/modules/boards/app/views/boards/boards/_menu_board.html new file mode 100644 index 00000000000..48ddc1b6a3a --- /dev/null +++ b/modules/boards/app/views/boards/boards/_menu_board.html @@ -0,0 +1 @@ + diff --git a/modules/boards/app/views/boards/boards/index.html.erb b/modules/boards/app/views/boards/boards/index.html.erb new file mode 100644 index 00000000000..d314de3539e --- /dev/null +++ b/modules/boards/app/views/boards/boards/index.html.erb @@ -0,0 +1 @@ +<% html_title(t('boards.label_boards')) -%> diff --git a/modules/boards/config/locales/en.yml b/modules/boards/config/locales/en.yml new file mode 100644 index 00000000000..dc69281cbe5 --- /dev/null +++ b/modules/boards/config/locales/en.yml @@ -0,0 +1,8 @@ +# English strings go here +en: + permission_view_boards: "View boards" + permission_manage_board_views: "Manage boards" + + project_module_board_view: "Boards" + boards: + label_boards: "Boards" diff --git a/modules/boards/config/locales/js-en.yml b/modules/boards/config/locales/js-en.yml new file mode 100644 index 00000000000..be80dbb7ffb --- /dev/null +++ b/modules/boards/config/locales/js-en.yml @@ -0,0 +1,10 @@ +# English strings go here +en: + js: + boards: + label_unnamed_board: 'Unnamed board' + configuration_modal: + title: 'Configure this board' + display_settings: + card_mode: "Display as cards" + table_mode: "Display as table" diff --git a/modules/boards/config/routes.rb b/modules/boards/config/routes.rb new file mode 100644 index 00000000000..7df70e15076 --- /dev/null +++ b/modules/boards/config/routes.rb @@ -0,0 +1,9 @@ +OpenProject::Application.routes.draw do + scope '', as: :work_package_boards do + get '/work_packages/boards(/*state)', to: 'boards/boards#index' + end + + scope 'projects/:project_id', as: 'project' do + get '/work_packages/boards(/*state)', to: 'boards/boards#index', as: :work_package_boards + end +end diff --git a/modules/boards/lib/open_project/boards.rb b/modules/boards/lib/open_project/boards.rb new file mode 100644 index 00000000000..f74b586822f --- /dev/null +++ b/modules/boards/lib/open_project/boards.rb @@ -0,0 +1,5 @@ +module OpenProject + module Boards + require "open_project/boards/engine" + end +end diff --git a/modules/boards/lib/open_project/boards/engine.rb b/modules/boards/lib/open_project/boards/engine.rb new file mode 100644 index 00000000000..515dac1467e --- /dev/null +++ b/modules/boards/lib/open_project/boards/engine.rb @@ -0,0 +1,57 @@ +# OpenProject Boards module +# +# Copyright (C) 2018 OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module OpenProject::Boards + class Engine < ::Rails::Engine + engine_name :openproject_boards + + include OpenProject::Plugins::ActsAsOpEngine + + register 'openproject-boards', + author_url: 'https://community.openproject.com', + settings: {}, + name: 'OpenProject Boards' do + + project_module :board_view do + permission :show_board_views, 'boards/boards': %i[index] + permission :manage_board_views, 'boards/boards': %i[index] + end + + menu :project_menu, + :board_view, + { controller: '/boards/boards', action: :index }, + caption: :'boards.label_boards', + after: :work_packages, + param: :project_id, + icon: 'icon2 icon-backlogs' + + menu :project_menu, + :board_menu, + { controller: '/boards/boards', action: :index }, + param: :project_id, + parent: :board_view, + partial: 'boards/boards/menu_board', + last: true, + caption: :'boards.label_boards' + end + + config.to_prepare do + OpenProject::Boards::GridRegistration.register! + end + end +end diff --git a/modules/boards/lib/open_project/boards/grid_registration.rb b/modules/boards/lib/open_project/boards/grid_registration.rb new file mode 100644 index 00000000000..481a1fd6b49 --- /dev/null +++ b/modules/boards/lib/open_project/boards/grid_registration.rb @@ -0,0 +1,62 @@ +module OpenProject + module Boards + class GridRegistration < ::Grids::Configuration::Registration + grid_class 'Boards::Grid' + to_scope :project_work_package_boards_path + + widgets 'work_package_query' + + defaults( + row_count: 1, + column_count: 4, + widgets: [] + ) + + class << self + def from_scope(scope) + recognized = Rails.application.routes.recognize_path(scope) + + if recognized[:controller] == 'boards/boards' + recognized.slice(:project_id, :id, :user_id)&.merge(class: ::Boards::Grid) + end + rescue ActionController::RoutingError + nil + end + + def all_scopes + view_allowed = Project.allowed_to(User.current, :show_board_views) + manage_allowed = Project.allowed_to(User.current, :manage_board_views) + + board_projects = Project + .where(id: view_allowed) + .or(Project.where(id: manage_allowed)) + + paths = board_projects.map { |p| url_helpers.project_work_package_boards_path(p) } + + paths if paths.any? + end + + alias_method :super_visible, :visible + + def visible(user = User.current) + in_project_with_permission(user, :show_board_views) + .or(in_project_with_permission(user, :manage_board_views)) + end + + def writable?(model, user) + super && + Project.allowed_to(user, :manage_board_views).exists?(model.project_id) + end + + private + + def in_project_with_permission(user, permission) + super_visible + .where(project_id: Project.allowed_to(user, permission)) + end + end + + private_class_method :super_visible + end + end +end diff --git a/modules/boards/lib/open_project/boards/version.rb b/modules/boards/lib/open_project/boards/version.rb new file mode 100644 index 00000000000..2789a652d29 --- /dev/null +++ b/modules/boards/lib/open_project/boards/version.rb @@ -0,0 +1,7 @@ +require 'open_project/version' + +module OpenProject + module Boards + VERSION = ::OpenProject::VERSION.to_semver + end +end diff --git a/modules/boards/lib/openproject-boards.rb b/modules/boards/lib/openproject-boards.rb new file mode 100644 index 00000000000..cde0d2155d0 --- /dev/null +++ b/modules/boards/lib/openproject-boards.rb @@ -0,0 +1 @@ +require 'open_project/boards' diff --git a/modules/boards/openproject-boards.gemspec b/modules/boards/openproject-boards.gemspec new file mode 100644 index 00000000000..76f45701193 --- /dev/null +++ b/modules/boards/openproject-boards.gemspec @@ -0,0 +1,19 @@ +# encoding: UTF-8 + +$:.push File.expand_path('../lib', __FILE__) +$:.push File.expand_path("../../lib", __dir__) + +require 'open_project/boards/version' + +Gem::Specification.new do |s| + s.name = 'openproject-boards' + s.version = OpenProject::Boards::VERSION + s.authors = 'OpenProject GmbH' + s.email = 'info@openproject.com' + s.homepage = 'https://community.openproject.org' + s.summary = 'OpenProject Boards' + s.description = 'Provides board views' + s.license = 'GPLv3' + + s.files = Dir['{app,config,db,lib}/**/*'] +end diff --git a/modules/boards/spec/contracts/grids/create_contract_spec.rb b/modules/boards/spec/contracts/grids/create_contract_spec.rb new file mode 100644 index 00000000000..94c223da2c0 --- /dev/null +++ b/modules/boards/spec/contracts/grids/create_contract_spec.rb @@ -0,0 +1,56 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2017 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe Grids::CreateContract, 'for Boards::Grid' do + let(:project) { FactoryBot.build_stubbed(:project) } + let(:user) { FactoryBot.build_stubbed(:user) } + let(:grid) do + FactoryBot.create(:board_grid, project: project) + end + include_context 'model contract' + + let(:instance) { described_class.new(grid, user) } + + describe 'user_id' do + it_behaves_like 'is not writable' do + let(:attribute) { :user_id } + let(:value) { 5 } + end + end + + describe 'project_id' do + it_behaves_like 'is writable' do + let(:attribute) { :project_id } + let(:value) { 5 } + end + end +end diff --git a/modules/boards/spec/factories/board_factory.rb b/modules/boards/spec/factories/board_factory.rb new file mode 100644 index 00000000000..d2123a3895e --- /dev/null +++ b/modules/boards/spec/factories/board_factory.rb @@ -0,0 +1,36 @@ +FactoryBot.define do + factory :board_grid, class: Boards::Grid do + project + name { 'My board' } + row_count { 1 } + column_count { 4 } + end + + factory :board_grid_with_query, class: Boards::Grid do + project + sequence(:name) { |n| "Board with query #{n}" } + row_count { 1 } + column_count { 4 } + + transient do + query { nil } + end + + callback(:after_build) do |board, evaluator| # this is also done after :create + query = evaluator.query || begin + Query.new_default(name: 'List 1', is_public: true, project: board.project).tap do |q| + q.add_filter(:manual_sort, 'ow', []) + q.save! + end + end + + + board.widgets << FactoryBot.create(:grid_widget, + start_row: 1, + end_row: 2, + start_column: 1, + end_column: 1, + options: { query_id: query.id }) + end + end +end diff --git a/modules/boards/spec/features/board_management_spec.rb b/modules/boards/spec/features/board_management_spec.rb new file mode 100644 index 00000000000..1602917e8d6 --- /dev/null +++ b/modules/boards/spec/features/board_management_spec.rb @@ -0,0 +1,157 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require_relative './support/board_index_page' +require_relative './support/board_page' + +describe 'Board management spec', type: :feature, js: true do + let(:user) do + FactoryBot.create(:user, + member_in_project: project, + member_through_role: role) + end + let(:project) { FactoryBot.create(:project, enabled_module_names: %i[work_package_tracking board_view]) } + let(:role) { FactoryBot.create(:role, permissions: permissions) } + + let(:board_index) { Pages::BoardIndex.new(project) } + + before do + project + login_as(user) + end + + context 'with full boards permissions' do + let(:permissions) { %i[show_board_views manage_board_views add_work_packages view_work_packages manage_public_queries] } + let(:board_view) { FactoryBot.create :board_grid_with_query, project: project } + + let!(:priority) { FactoryBot.create :default_priority } + let!(:status) { FactoryBot.create :default_status } + + it 'allows management of boards' do + board_view + board_index.visit! + + board_page = board_index.open_board board_view + board_page.expect_query 'List 1', editable: true + board_page.expect_editable true + board_page.back_to_index + + board_index.expect_board board_view.name + + # Create new board + board_page = board_index.create_board + board_page.rename_board 'Board foo' + + board_page.rename_list 'New list', 'First' + board_page.board(reload: true) do |board| + expect(board.name).to eq 'Board foo' + queries = board.contained_queries + expect(queries.count).to eq(1) + expect(queries.first.name).to eq 'First' + end + + # Create new list + board_page.add_list 'Second' + + # Add item + board_page.add_card 'First', 'Task 1' + + # Expect added to query + queries = board_page.board(reload: true).contained_queries + first = queries.find_by(name: 'First') + second = queries.find_by(name: 'Second') + expect(first.ordered_work_packages.count).to eq(1) + expect(second.ordered_work_packages).to be_empty + + # Expect work package to be saved in query first + subjects = WorkPackage.where(id: first.ordered_work_packages).pluck(:subject) + expect(subjects).to match_array ['Task 1'] + + # Move item to Second list + board_page.move_card(0, from: 'First', to: 'Second') + board_page.expect_card('First', 'Task 1', present: false) + board_page.expect_card('Second', 'Task 1', present: true) + + # Expect work package to be saved in query second + expect(first.reload.ordered_work_packages).to be_empty + expect(second.reload.ordered_work_packages.count).to eq(1) + + subjects = WorkPackage.where(id: second.ordered_work_packages).pluck(:subject) + expect(subjects).to match_array ['Task 1'] + + # Remove query + board_page.remove_list 'Second' + queries = board_page.board(reload: true).contained_queries + expect(queries.count).to eq(1) + expect(queries.first.name).to eq 'First' + expect(queries.first.ordered_work_packages).to be_empty + + # Remove entire board + board_page.delete_board + board_index.expect_board 'Board foo', present: false + end + end + + context 'with view boards + work package permission' do + let(:permissions) { %i[show_board_views view_work_packages] } + let(:board_view) { FactoryBot.create :board_grid_with_query, project: project } + + it 'allows viewing boards index and boards' do + board_view + board_index.visit! + + board_page = board_index.open_board board_view + board_page.expect_query 'List 1', editable: false + board_page.expect_editable false + board_page.back_to_index + + board_index.expect_board board_view.name + end + end + + context 'with view permission only' do + let(:permissions) { %i[show_board_views] } + + it 'does not allow viewing of boards' do + board_index.visit! + expect(page).to have_selector('#errorExplanation', text: I18n.t(:notice_not_authorized)) + + board_index.expect_editable false + end + end + + context 'with no permission only' do + let(:permissions) { %i[] } + + it 'does not allow viewing of boards' do + board_index.visit! + expect(page).to have_selector('#errorExplanation', text: I18n.t(:notice_not_authorized)) + end + end +end diff --git a/modules/boards/spec/features/support/board_index_page.rb b/modules/boards/spec/features/support/board_index_page.rb new file mode 100644 index 00000000000..e50af2c282a --- /dev/null +++ b/modules/boards/spec/features/support/board_index_page.rb @@ -0,0 +1,70 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'support/pages/page' +require_relative './board_page' + +module Pages + class BoardIndex < Page + attr_reader :project + + def initialize(project = nil) + @project = project + end + + def visit! + if project + visit project_work_package_boards_path(project_id: project.id) + else + visit work_package_boards_path + end + end + + def expect_editable(editable) + # Editable / draggable check + expect(page).to have_conditional_selector(editable, '.buttons a.icon-delete') + # Create button + expect(page).to have_conditional_selector(editable, '.toolbar-item a', text: 'Board') + end + + def expect_board(name, present: true) + expect(page).to have_conditional_selector(present, 'td.name', text: name) + end + + def create_board + page.find('.toolbar-item a', text: 'Board').click + expect(page).to have_selector('.boards-list--item', wait: 10) + ::Pages::Board.new ::Boards::Grid.last + end + + def open_board(board) + page.find('td.name a', text: board.name).click + ::Pages::Board.new board + end + end +end diff --git a/modules/boards/spec/features/support/board_page.rb b/modules/boards/spec/features/support/board_page.rb new file mode 100644 index 00000000000..f3ee0967fdd --- /dev/null +++ b/modules/boards/spec/features/support/board_page.rb @@ -0,0 +1,191 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'support/pages/page' +require_relative './board_page' + +module Pages + class Board < Page + + def initialize(board) + @board = board + end + + def board(reload: false) + @board.reload if reload + + yield @board if block_given? + + @board + end + + def card_view? + board.options['display_mode'] == 'cards' + end + + def list_count + page.all('.board-list--container').count + end + + def within_list(name, &block) + page.within(list_selector(name), &block) + end + + def list_selector(name) + ".board-list--container[data-query-name='#{name}']" + end + + def add_card(list_name, card_title) + within_list(list_name) do + page.find('.wp-inline-create--add-link').click + subject = page.find('#wp-new-inline-edit--field-subject') + subject.set card_title + subject.send_keys :enter + end + + expect_card(list_name, card_title) + end + + ## + # Expect the given titled card in the list name to be present (expect=true) or not (expect=false) + def expect_card(list_name, card_title, present: true) + within_list(list_name) do + expect(page).to have_conditional_selector(present, '.work-package--card--subject', text: card_title) + end + end + + def move_card(index, from:, to:) + source = page.all("#{list_selector(from)} .work-package--card")[index] + target = page.find list_selector(to) + + scroll_to_element(source) + page + .driver + .browser + .action + .move_to(source.native) + .click_and_hold(source.native) + .perform + + scroll_to_element(target) + page + .driver + .browser + .action + .move_to(target.native) + .release + .perform + end + + def add_list(name) + count = list_count + page.find('.boards-list--add-item').click + expect(page).to have_selector('.board-list--container', count: count + 1) + + rename_list 'New list', name + end + + def remove_list(name) + list = page.find list_selector(name) + list.hover + + page.find('.board-list--delete-icon a').click + accept_alert_dialog! + expect_and_dismiss_notification message: I18n.t('js.notice_successful_update') + + expect(page).to have_no_selector list_selector(name) + end + + def visit! + if board.project + visit project_work_package_boards_path(project_id: board.project.id, state: board.id) + else + visit work_package_boards_path(state: board.id) + end + end + + def delete_board + page.find('.board--settings-dropdown').click + page.find('.menu-item', text: 'Delete').click + + accept_alert_dialog! + expect_and_dismiss_notification message: I18n.t('js.notice_successful_delete') + end + + def back_to_index + find('.board--back-button').click + end + + def expect_editable(editable) + # Editable / draggable check + expect(page).to have_conditional_selector(editable, '.board--container.-editable') + + # Settings dropdown + expect(page).to have_conditional_selector(editable, '.board--settings-dropdown') + + # Add new list + expect(page).to have_conditional_selector(editable, '.boards-list--add-item') + + if editable + expect(page).to have_selector('.wp-inline-create--add-link', count: list_count) + else + expect(page).to have_no_selector('.wp-inline-create--add-link') + end + end + + def rename_board(new_name) + page.within('.board--header-container') do + input = page.find('.editable-toolbar-title--input').click + input.set new_name + input.send_keys :enter + end + + expect_and_dismiss_notification message: I18n.t('js.notice_successful_update') + + page.within('.board--header-container') do + expect(page).to have_field('editable-toolbar-title', with: new_name) + end + end + + def rename_list(from, to) + input = page.find_field('editable-toolbar-title', with: from).click + input.set to + input.send_keys :enter + + expect_and_dismiss_notification message: I18n.t('js.notice_successful_update') + end + + def expect_query(name, editable: true) + if editable + expect(page).to have_field('editable-toolbar-title', with: name) + else + expect(page).to have_selector('.editable-toolbar-title--fixed', text: name) + end + end + end +end diff --git a/modules/boards/spec/lib/open_project/boards/grid_registration_spec.rb b/modules/boards/spec/lib/open_project/boards/grid_registration_spec.rb new file mode 100644 index 00000000000..670b6852595 --- /dev/null +++ b/modules/boards/spec/lib/open_project/boards/grid_registration_spec.rb @@ -0,0 +1,37 @@ +describe OpenProject::Boards::GridRegistration do + let(:project) { FactoryBot.create(:project) } + let(:permissions) { [:show_board_views] } + let(:board) { FactoryBot.create(:board_grid, project: project) } + let(:user) do + FactoryBot.create(:user, + member_in_project: project, + member_with_permissions: permissions) + end + + describe '.visible' do + context 'when having the view_boards permission' do + it 'returns the board' do + expect(described_class.visible(user)) + .to match_array(board) + end + end + + context 'when having the manage_board_views permission' do + let(:permissions) { [:manage_board_views] } + + it 'returns the board' do + expect(described_class.visible(user)) + .to match_array(board) + end + end + + context 'when having neither of the permissions' do + let(:permissions) { [] } + + it 'returns the board' do + expect(described_class.visible(user)) + .to be_empty + end + end + end +end diff --git a/modules/boards/spec/models/boards/grid_spec.rb b/modules/boards/spec/models/boards/grid_spec.rb new file mode 100644 index 00000000000..16af0b31481 --- /dev/null +++ b/modules/boards/spec/models/boards/grid_spec.rb @@ -0,0 +1,52 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe Boards::Grid, type: :model do + let(:instance) { described_class.new } + let(:project) { FactoryBot.build_stubbed(:project) } + + context 'attributes' do + it '#project' do + instance.project = project + expect(instance.project) + .to eql project + end + + it '#name' do + instance.name = nil + + expect(instance).not_to be_valid + expect(instance.errors[:name]).to be_present + + instance.name = 'foo' + expect(instance).to be_valid + end + end +end diff --git a/modules/boards/spec/queries/grids/query_integration_spec.rb b/modules/boards/spec/queries/grids/query_integration_spec.rb new file mode 100644 index 00000000000..a211356ef24 --- /dev/null +++ b/modules/boards/spec/queries/grids/query_integration_spec.rb @@ -0,0 +1,83 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe Grids::Query, type: :model do + include OpenProject::StaticRouting::UrlHelpers + + shared_let(:project) { FactoryBot.create(:project) } + shared_let(:other_project) { FactoryBot.create(:project) } + shared_let(:show_board_views_role) { FactoryBot.create(:role, permissions: [:show_board_views]) } + shared_let(:other_role) { FactoryBot.create(:role, permissions: []) } + shared_let(:current_user) do + FactoryBot.create(:user).tap do |user| + FactoryBot.create(:member, user: user, project: project, roles: [show_board_views_role]) + FactoryBot.create(:member, user: user, project: other_project, roles: [other_role]) + end + end + let!(:board_grid) do + FactoryBot.create(:board_grid, project: project) + end + let!(:other_board_grid) do + FactoryBot.create(:board_grid, project: other_project) + end + let(:instance) { described_class.new } + + before do + login_as(current_user) + end + + context 'without a filter' do + describe '#results' do + it 'is the same as getting all the boards visible to the user' do + expect(instance.results).to match_array [board_grid] + end + end + end + + context 'with a scope filter' do + context 'filtering for a projects/:project_id/boards' do + before do + instance.where('scope', '=', [project_work_package_boards_path(project)]) + end + + describe '#results' do + it 'yields boards assigned to the project' do + expect(instance.results).to match_array [board_grid] + end + end + + describe '#valid?' do + it 'is true' do + expect(instance).to be_valid + end + end + end + end +end diff --git a/modules/boards/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb b/modules/boards/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb new file mode 100644 index 00000000000..356b4407f72 --- /dev/null +++ b/modules/boards/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb @@ -0,0 +1,175 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe "POST /api/v3/grids/form for Board Grids", type: :request, content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) do + FactoryBot.create(:project) + end + + let(:current_user) { allowed_user } + + shared_let(:current_user) do + FactoryBot.create(:user, member_in_project: project, member_with_permissions: [:manage_board_views]) + end + + shared_let(:prohibited_user) do + FactoryBot.create(:user, member_in_project: project, member_with_permissions: [:show_board_views]) + end + + let(:path) { api_v3_paths.create_grid_form } + let(:params) { {} } + subject(:response) { last_response } + + before do + login_as(current_user) + end + + describe '#post' do + before do + post path, params.to_json, 'CONTENT_TYPE' => 'application/json' + end + + context 'with a valid boards scope' do + let(:params) do + { + name: 'foo', + '_links': { + 'scope': { + 'href': project_work_package_boards_path(project) + } + } + } + end + + it 'contains default data in the payload' do + expected = { + "rowCount": 1, + "columnCount": 4, + "widgets": [], + "name": 'foo', + "options": {}, + "_links": { + "scope": { + 'href': project_work_package_boards_path(project), + "type": "text/html" + } + } + } + + expect(subject.body) + .to be_json_eql(expected.to_json) + .at_path('_embedded/payload') + end + + it 'has no validationErrors' do + expect(subject.body) + .to be_json_eql({}.to_json) + .at_path('_embedded/validationErrors') + end + + it 'has a commit link' do + expect(subject.body) + .to be_json_eql(api_v3_paths.grids.to_json) + .at_path('_links/commit/href') + end + end + + context 'with boards scope for which the user does not have the necessary permissions' do + let(:current_user) { prohibited_user } + let(:params) do + { + '_links': { + 'scope': { + 'href': project_work_package_boards_path(project) + } + } + } + end + + it 'has a validationError on widget' do + expect(subject.body) + .to be_json_eql("Scope is not set to one of the allowed values.".to_json) + .at_path('_embedded/validationErrors/scope/message') + end + end + + context 'with an invalid boards scope' do + let(:params) do + { + '_links': { + 'scope': { + 'href': project_work_package_boards_path(project_id: project.id + 1) + } + } + } + end + + it 'has a validationError on widget' do + expect(subject.body) + .to be_json_eql("Scope is not set to one of the allowed values.".to_json) + .at_path('_embedded/validationErrors/scope/message') + end + end + + context 'with an unsupported widget identifier' do + let(:params) do + { + name: 'foo', + "_links": { + "scope": { + 'href': project_work_package_boards_path(project), + "type": "text/html" + } + }, + "widgets": [ + { + "_type": "GridWidget", + "identifier": "bogus_identifier", + "startRow": 1, + "endRow": 2, + "startColumn": 1, + "endColumn": 2 + } + ] + } + end + + it 'has a validationError on widget' do + expect(subject.body) + .to be_json_eql("Widgets is not set to one of the allowed values.".to_json) + .at_path('_embedded/validationErrors/widgets/message') + end + end + end +end diff --git a/modules/boards/spec/requests/api/v3/grids/grids_resource_spec.rb b/modules/boards/spec/requests/api/v3/grids/grids_resource_spec.rb new file mode 100644 index 00000000000..8e746a3e577 --- /dev/null +++ b/modules/boards/spec/requests/api/v3/grids/grids_resource_spec.rb @@ -0,0 +1,504 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Grids resource for Board Grids', type: :request, content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:manage_board_views_project) { FactoryBot.create(:project) } + shared_let(:show_board_views_project) { FactoryBot.create(:project) } + shared_let(:other_project) { FactoryBot.create(:project) } + shared_let(:show_board_views_role) { FactoryBot.create(:role, permissions: [:show_board_views]) } + shared_let(:manage_board_views_role) { FactoryBot.create(:role, permissions: [:manage_board_views]) } + shared_let(:other_role) { FactoryBot.create(:role, permissions: []) } + shared_let(:current_user) do + FactoryBot.create(:user).tap do |user| + FactoryBot.create(:member, user: user, project: manage_board_views_project, roles: [manage_board_views_role]) + FactoryBot.create(:member, user: user, project: show_board_views_project, roles: [show_board_views_role]) + FactoryBot.create(:member, user: user, project: other_project, roles: [other_role]) + end + end + + let(:manage_board_views_grid) do + FactoryBot.create(:board_grid, project: manage_board_views_project) + end + let(:show_board_views_grid) do + FactoryBot.create(:board_grid, project: show_board_views_project) + end + let(:other_board_grid) do + FactoryBot.create(:board_grid, project: other_project) + end + + before do + login_as(current_user) + end + + subject(:response) { last_response } + + describe '#get INDEX' do + let(:path) { api_v3_paths.grids } + + let(:stored_grids) do + manage_board_views_grid + other_board_grid + end + + before do + stored_grids + + get path + end + + it 'responds with 200 OK' do + expect(subject.status).to eq(200) + end + + it 'sends a collection of grids but only those visible to the current user' do + expect(subject.body) + .to be_json_eql('Collection'.to_json) + .at_path('_type') + + expect(subject.body) + .to be_json_eql('Grid'.to_json) + .at_path('_embedded/elements/0/_type') + + expect(subject.body) + .to be_json_eql(1.to_json) + .at_path('total') + end + + context 'with a filter on the scope attribute for all boards of a project' do + # The user would be able to see both boards + shared_let(:other_role) { FactoryBot.create(:role, permissions: [:show_board_views]) } + + let(:path) do + filter = [{ 'scope' => + { + 'operator' => '=', + 'values' => [project_work_package_boards_path(manage_board_views_project)] + } }] + + "#{api_v3_paths.grids}?#{{ filters: filter.to_json }.to_query}" + end + + it 'responds with 200 OK' do + expect(subject.status).to eq(200) + end + + it 'sends only the board of the project' do + expect(subject.body) + .to be_json_eql('Collection'.to_json) + .at_path('_type') + + expect(subject.body) + .to be_json_eql('Grid'.to_json) + .at_path('_embedded/elements/0/_type') + + expect(subject.body) + .to be_json_eql(1.to_json) + .at_path('total') + + expect(subject.body) + .to be_json_eql(manage_board_views_grid.id.to_json) + .at_path('_embedded/elements/0/id') + end + end + end + + describe '#get' do + let(:path) { api_v3_paths.grid(manage_board_views_grid.id) } + + let(:stored_grids) do + manage_board_views_grid + end + + before do + stored_grids + + get path + end + + it 'responds with 200 OK' do + expect(subject.status).to eq(200) + end + + it 'sends a grid block' do + expect(subject.body) + .to be_json_eql('Grid'.to_json) + .at_path('_type') + end + + it 'has a name' do + expect(subject.body) + .to be_json_eql('My board'.to_json) + .at_path('name') + end + + it 'identifies the url the grid is stored for' do + expect(subject.body) + .to be_json_eql(project_work_package_boards_path(manage_board_views_project).to_json) + .at_path('_links/scope/href') + end + + context 'with the scope not existing' do + let(:path) { api_v3_paths.grid(5) } + + it 'responds with 404 NOT FOUND' do + expect(subject.status).to eql 404 + end + end + + context 'when lacking permission to see the grid' do + let(:stored_grids) do + manage_board_views_grid + other_board_grid + end + + let(:path) { api_v3_paths.grid(other_board_grid.id) } + + it 'responds with 404 NOT FOUND' do + expect(subject.status).to eql 404 + end + end + end + + describe '#patch' do + let(:path) { api_v3_paths.grid(manage_board_views_grid.id) } + + let(:params) do + { + "rowCount": 10, + "columnCount": 15, + "widgets": [{ + "identifier": "work_package_query", + "startRow": 4, + "endRow": 8, + "startColumn": 2, + "endColumn": 5 + }] + }.with_indifferent_access + end + + let(:stored_grids) do + manage_board_views_grid + end + + before do + stored_grids + + patch path, params.to_json, 'CONTENT_TYPE' => 'application/json' + end + + it 'responds with 200 OK' do + expect(subject.status).to eq(200) + end + + it 'returns the altered grid block' do + expect(subject.body) + .to be_json_eql('Grid'.to_json) + .at_path('_type') + expect(subject.body) + .to be_json_eql(params['rowCount'].to_json) + .at_path('rowCount') + expect(subject.body) + .to be_json_eql(params['widgets'][0]['identifier'].to_json) + .at_path('widgets/0/identifier') + end + + it 'perists the changes' do + expect(manage_board_views_grid.reload.row_count) + .to eql params['rowCount'] + end + + context 'with invalid params' do + let(:params) do + { + "rowCount": -5, + "columnCount": 15, + "widgets": [{ + "identifier": "work_package_query", + "startRow": 4, + "endRow": 8, + "startColumn": 2, + "endColumn": 5 + }] + }.with_indifferent_access + end + + it 'responds with 422 and mentions the error' do + expect(subject.status).to eq 422 + + expect(subject.body) + .to be_json_eql('Error'.to_json) + .at_path('_type') + + expect(subject.body) + .to be_json_eql("Widgets is outside of the grid.".to_json) + .at_path('_embedded/errors/0/message') + + expect(subject.body) + .to be_json_eql("Number of rows must be greater than 0.".to_json) + .at_path('_embedded/errors/1/message') + end + + it 'does not persist the changes to widgets' do + expect(manage_board_views_grid.reload.widgets.count) + .to eql OpenProject::Boards::GridRegistration.defaults[:widgets].size + end + end + + context 'with a scope param' do + let(:params) do + { + "_links": { + "scope": { + "href": '' + } + } + }.with_indifferent_access + end + + it 'responds with 422 and mentions the error' do + expect(subject.status).to eq 422 + + expect(subject.body) + .to be_json_eql('Error'.to_json) + .at_path('_type') + + expect(subject.body) + .to be_json_eql("You must not write a read-only attribute.".to_json) + .at_path('message') + + expect(subject.body) + .to be_json_eql("scope".to_json) + .at_path('_embedded/details/attribute') + end + end + + context 'with the grid not existing' do + let(:path) { api_v3_paths.grid(5) } + + it 'responds with 404 NOT FOUND' do + expect(subject.status).to eql 404 + end + end + + context 'without the manage_board_views permission' do + let(:stored_grids) do + show_board_views_grid + end + + let(:path) { api_v3_paths.grid(show_board_views_grid.id) } + + it 'responds with 404 NOT FOUND' do + expect(subject.status).to eql 404 + end + end + end + + describe '#post' do + let(:path) { api_v3_paths.grids } + + let(:params) do + { + "rowCount": 10, + "name": 'foo', + "columnCount": 15, + "widgets": [{ + "identifier": "work_package_query", + "startRow": 4, + "endRow": 8, + "startColumn": 2, + "endColumn": 5 + }], + "_links": { + "scope": { + "href": project_work_package_boards_path(manage_board_views_project) + } + } + }.with_indifferent_access + end + + before do + post path, params.to_json, 'CONTENT_TYPE' => 'application/json' + end + + it 'responds with 201 CREATED' do + expect(subject.status).to eq(201) + end + + it 'returns the created grid block' do + expect(subject.body) + .to be_json_eql('Grid'.to_json) + .at_path('_type') + expect(subject.body) + .to be_json_eql('foo'.to_json) + .at_path('name') + expect(subject.body) + .to be_json_eql(params['rowCount'].to_json) + .at_path('rowCount') + expect(subject.body) + .to be_json_eql(params['widgets'][0]['identifier'].to_json) + .at_path('widgets/0/identifier') + end + + it 'persists the grid' do + expect(Grids::Grid.count) + .to eql(1) + end + + context 'with invalid params' do + let(:params) do + { + "name": 'foo', + "rowCount": -5, + "columnCount": "sdjfksdfsdfdsf", + "widgets": [{ + "identifier": "work_package_query", + "startRow": 4, + "endRow": 8, + "startColumn": 2, + "endColumn": 5 + }], + "_links": { + "scope": { + "href": project_work_package_boards_path(manage_board_views_project) + } + } + }.with_indifferent_access + end + + it 'responds with 422' do + expect(subject.status).to eq(422) + end + + it 'does not create a grid' do + expect(Grids::Grid.count) + .to eql(0) + end + + it 'returns the errors' do + expect(subject.body) + .to be_json_eql('Error'.to_json) + .at_path('_type') + + expect(subject.body) + .to be_json_eql("Widgets is outside of the grid.".to_json) + .at_path('_embedded/errors/0/message') + + expect(subject.body) + .to be_json_eql("Number of rows must be greater than 0.".to_json) + .at_path('_embedded/errors/1/message') + + expect(subject.body) + .to be_json_eql("Number of columns must be greater than 0.".to_json) + .at_path('_embedded/errors/2/message') + end + end + + context 'without a scope link' do + let(:params) do + { + "rowCount": 5, + "name": 'foo', + "columnCount": 5, + "widgets": [{ + "identifier": "work_package_query", + "startRow": 2, + "endRow": 4, + "startColumn": 2, + "endColumn": 5 + }] + }.with_indifferent_access + end + + it 'responds with 422' do + expect(subject.status).to eq(422) + end + + it 'does not create a grid' do + expect(Grids::Grid.count) + .to eql(0) + end + + it 'returns the errors' do + expect(subject.body) + .to be_json_eql('Error'.to_json) + .at_path('_type') + + expect(subject.body) + .to be_json_eql("Scope is not set to one of the allowed values.".to_json) + .at_path('message') + end + end + + context 'without the permission to create boards in the project' do + let(:params) do + { + "name": 'foo', + "rowCount": 5, + "columnCount": 5, + "widgets": [{ + "identifier": "work_package_query", + "startRow": 2, + "endRow": 4, + "startColumn": 2, + "endColumn": 5 + }], + "_links": { + "scope": { + "href": project_work_package_boards_path(show_board_views_project) + } + } + }.with_indifferent_access + end + + it 'responds with 422' do + expect(subject.status).to eq(422) + end + + it 'does not create a grid' do + expect(Grids::Grid.count) + .to eql(0) + end + + it 'returns the errors' do + expect(subject.body) + .to be_json_eql('Error'.to_json) + .at_path('_type') + + expect(subject.body) + .to be_json_eql("Scope is not set to one of the allowed values.".to_json) + .at_path('message') + end + end + end +end diff --git a/modules/boards/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb b/modules/boards/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb new file mode 100644 index 00000000000..cf82a0fd2e9 --- /dev/null +++ b/modules/boards/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb @@ -0,0 +1,169 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe "PATCH /api/v3/grids/:id/form for Board Grids", type: :request, content_type: :json do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + shared_let(:project) do + FactoryBot.create(:project) + end + shared_let(:allowed_user) do + FactoryBot.create(:user, member_in_project: project, member_with_permissions: [:manage_board_views]) + end + shared_let(:prohibited_user) do + FactoryBot.create(:user, member_in_project: project, member_with_permissions: [:show_board_views]) + end + + let(:grid) do + FactoryBot.create(:board_grid, project: project) + end + let(:path) { api_v3_paths.grid_form(grid.id) } + let(:params) { {} } + subject(:response) { last_response } + + let(:current_user) { allowed_user } + before do + login_as(current_user) + end + + describe '#post' do + before do + post path, params.to_json, 'CONTENT_TYPE' => 'application/json' + end + + it 'returns 200 OK' do + expect(subject.status) + .to eql 200 + end + + it 'is of type form' do + expect(subject.body) + .to be_json_eql("Form".to_json) + .at_path('_type') + end + + it 'contains a Schema disallowing setting scope' do + expect(subject.body) + .to be_json_eql("Schema".to_json) + .at_path('_embedded/schema/_type') + + expect(subject.body) + .to be_json_eql(false.to_json) + .at_path('_embedded/schema/scope/writable') + end + + it 'contains the current data in the payload' do + expected = { + name: 'My board', + rowCount: 1, + columnCount: 4, + widgets: [], + options: {}, + "_links": { + "scope": { + "href": project_work_package_boards_path(project), + "type": "text/html" + } + } + } + + expect(subject.body) + .to be_json_eql(expected.to_json) + .at_path('_embedded/payload') + end + + it 'has a commit link' do + expect(subject.body) + .to be_json_eql(api_v3_paths.grid(grid.id).to_json) + .at_path('_links/commit/href') + end + + context 'with some value for the scope value' do + let(:params) do + { + '_links': { + 'scope': { + 'href': '/some/path' + } + } + } + end + + it 'has a validation error on scope as the value is not writeable' do + expect(subject.body) + .to be_json_eql("You must not write a read-only attribute.".to_json) + .at_path('_embedded/validationErrors/scope/message') + end + end + + context 'with an unsupported widget identifier' do + let(:params) do + { + "widgets": [ + { + "_type": "GridWidget", + "identifier": "bogus_identifier", + "startRow": 1, + "endRow": 2, + "startColumn": 1, + "endColumn": 2 + } + ] + } + end + + it 'has a validationError on widget' do + expect(subject.body) + .to be_json_eql("Widgets is not set to one of the allowed values.".to_json) + .at_path('_embedded/validationErrors/widgets/message') + end + end + + context 'for a non existing grid' do + let(:path) { api_v3_paths.grid_form(grid.id + 5) } + + it 'returns 404 NOT FOUND' do + expect(subject.status) + .to eql 404 + end + end + + context 'for a grid for which the user does not have permission' do + let(:current_user) { prohibited_user } + + it 'returns 404 NOT FOUND' do + expect(subject.status) + .to eql 404 + end + end + end +end diff --git a/modules/boards/spec/routing/boards_routing_spec.rb b/modules/boards/spec/routing/boards_routing_spec.rb new file mode 100644 index 00000000000..67058083588 --- /dev/null +++ b/modules/boards/spec/routing/boards_routing_spec.rb @@ -0,0 +1,43 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe 'Boards routing', type: :routing do + it { + is_expected + .to route(:get, '/projects/foobar/work_packages/boards/state') + .to(controller: 'boards/boards', action: 'index', project_id: 'foobar', state: 'state') + } + + it { + is_expected + .to route(:get, '/work_packages/boards/state') + .to(controller: 'boards/boards', action: 'index', state: 'state') + } +end diff --git a/modules/global_roles/app/views/roles/_form.html.erb b/modules/global_roles/app/views/roles/_form.html.erb index 4e0b576e185..281b00518e1 100644 --- a/modules/global_roles/app/views/roles/_form.html.erb +++ b/modules/global_roles/app/views/roles/_form.html.erb @@ -62,3 +62,4 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. <%= render partial: "permissions", locals: {permissions: member_permissions, role: role, globalRole: "false" }%>
      <% end %> + diff --git a/modules/grids/app/contracts/grids/base_contract.rb b/modules/grids/app/contracts/grids/base_contract.rb index 986ad5aed58..1058c5b4c0a 100644 --- a/modules/grids/app/contracts/grids/base_contract.rb +++ b/modules/grids/app/contracts/grids/base_contract.rb @@ -41,10 +41,10 @@ module Grids validate_positive_integer(:column_count) end - attribute_alias :type, :page + attribute_alias :type, :scope def validate - validate_registered_subclass + validate_allowed validate_registered_widgets validate_widget_collisions validate_widgets_within @@ -55,6 +55,10 @@ module Grids attribute :widgets + attribute :name + + attribute :options + def self.model Grid end @@ -63,12 +67,16 @@ module Grids nil end + def edit_allowed? + Grids::Configuration.writable?(model, user) + end + private - def validate_registered_subclass - unless Grids::Configuration.registered_grid?(model.class) - # page because that is what is exposed to the outside - errors.add(:page, :inclusion) + def validate_allowed + unless edit_allowed? + # scope because that is what is exposed to the outside + errors.add(:scope, :inclusion) end end diff --git a/modules/grids/app/contracts/grids/create_contract.rb b/modules/grids/app/contracts/grids/create_contract.rb index 4e69b71f48f..6b9c3a8df4d 100644 --- a/modules/grids/app/contracts/grids/create_contract.rb +++ b/modules/grids/app/contracts/grids/create_contract.rb @@ -33,21 +33,24 @@ require 'grids/base_contract' module Grids class CreateContract < BaseContract attribute :user_id, - writeable: -> { model.class.reflect_on_association(:user) } + writeable: -> { !!model.class.reflect_on_association(:user) } + + attribute :project_id, + writeable: -> { !!model.class.reflect_on_association(:project) } attribute :type def assignable_values(column, _user) case column - when :page - Grids::Configuration.registered_pages + when :scope + Grids::Configuration.all_scopes else super end end def writable?(attribute) - attribute == :page || super + attribute == :scope || super end end end diff --git a/modules/grids/app/contracts/grids/delete_contract.rb b/modules/grids/app/contracts/grids/delete_contract.rb new file mode 100644 index 00000000000..3354b120adb --- /dev/null +++ b/modules/grids/app/contracts/grids/delete_contract.rb @@ -0,0 +1,57 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'grids/base_contract' + +module Grids + class DeleteContract < BaseContract + + def validate + validate_delete_allowed + + super + end + + ## + # Check whether this grid can be deleted. + # The base contract already checks whether we can manage it. + def validate_delete_allowed + unless model.user_deletable? + errors.add(:scope, :unremovable) + end + end + + protected + + def validate_model? + false + end + end +end diff --git a/modules/grids/app/controllers/api/v3/grids/create_form_api.rb b/modules/grids/app/controllers/api/v3/grids/create_form_api.rb index 2b107c82df6..9f9299cc3f6 100644 --- a/modules/grids/app/controllers/api/v3/grids/create_form_api.rb +++ b/modules/grids/app/controllers/api/v3/grids/create_form_api.rb @@ -41,8 +41,7 @@ module API .call(request_body) .result - grid_class = ::Grids::Configuration.grid_for_page(params.delete(:page)) - grid = grid_class.new_default(current_user) + grid = ::Grids::Factory.build(params.delete(:scope), current_user) call = ::Grids::SetAttributesService .new(user: current_user, diff --git a/modules/grids/app/controllers/api/v3/grids/grid_representer.rb b/modules/grids/app/controllers/api/v3/grids/grid_representer.rb index 3e9b10f25f4..5cdea15261d 100644 --- a/modules/grids/app/controllers/api/v3/grids/grid_representer.rb +++ b/modules/grids/app/controllers/api/v3/grids/grid_representer.rb @@ -32,9 +32,9 @@ module API class GridRepresenter < ::API::Decorators::Single include API::Decorators::LinkedResource - resource_link :page, + resource_link :scope, getter: ->(*) { - path = ::Grids::Configuration.grid_for_class(represented.class) + path = scope_path next unless path @@ -44,12 +44,13 @@ module API } }, setter: ->(fragment:, **) { - represented.page = fragment['href'] + represented.scope = fragment['href'] } self_link title_getter: ->(*) { nil } link :updateImmediately do + next unless write_allowed? { href: api_v3_paths.grid(represented.id), method: :patch @@ -57,18 +58,32 @@ module API end link :update do + next unless write_allowed? { href: api_v3_paths.grid_form(represented.id), method: :post } end + link :delete do + next unless delete_allowed? + + { + href: api_v3_paths.grid(represented.id), + method: :delete + } + end + property :id + property :name, render_nil: false + property :row_count property :column_count + property :options + property :widgets, exec_context: :decorator, getter: ->(*) do @@ -89,6 +104,7 @@ module API writeable: false, getter: ->(*) { next unless represented.created_at + datetime_formatter.format_datetime(represented.created_at) } @@ -97,12 +113,48 @@ module API writeable: false, getter: ->(*) { next unless represented.updated_at + datetime_formatter.format_datetime(represented.updated_at) } def _type 'Grid' end + + private + + def delete_allowed? + represented.user_deletable? && write_allowed? + end + + def write_allowed? + !represented.new_record? && + ::Grids::Configuration.writable?(represented, current_user) + end + + def scope_path + path = ::Grids::Configuration.to_scope(represented.class, + scope_path_attributes) + + # Remove all query params + # Those are added when the path does not actually require + # project or user + path&.gsub(/(\?.+)|(\.\d+)\z/, '') + end + + def scope_path_attributes + path_attributes = [] + + if represented.respond_to?(:project) + path_attributes << represented.project + end + + if represented.respond_to?(:user) + path_attributes << represented.user + end + + path_attributes.compact + end end end end diff --git a/modules/grids/app/controllers/api/v3/grids/grids_api.rb b/modules/grids/app/controllers/api/v3/grids/grids_api.rb index 9adc3959ff2..c1d5c6f3e66 100644 --- a/modules/grids/app/controllers/api/v3/grids/grids_api.rb +++ b/modules/grids/app/controllers/api/v3/grids/grids_api.rb @@ -74,14 +74,19 @@ module API mount ::API::V3::Grids::Schemas::GridSchemaAPI route_param :id do + helpers do + def raise_if_lacking_manage_permission + unless ::Grids::UpdateContract.new(@grid, current_user).edit_allowed? + raise ActiveRecord::RecordNotFound + end + end + end + before do @grid = ::Grids::Query .new(user: current_user) .results - .where(id: params['id']) - .first - - raise ActiveRecord::RecordNotFound unless @grid + .find(params['id']) end get do @@ -90,11 +95,17 @@ module API end patch do + raise_if_lacking_manage_permission + params = API::V3::ParseResourceParamsService .new(current_user, representer: GridRepresenter) .call(request_body) .result + if params[:scope] + params[:type] = ::Grids::Configuration.class_from_scope(params.delete(:scope)).to_s + end + call = ::Grids::UpdateService .new(user: current_user, grid: @grid) @@ -109,6 +120,20 @@ module API end end + delete do + raise_if_lacking_manage_permission + + call = ::Grids::DeleteService + .new(user: current_user, grid: @grid) + .call + + if call.success? + status 204 + else + fail ::API::Errors::ErrorBase.create_and_merge_errors(call.errors) + end + end + mount UpdateFormAPI end end diff --git a/modules/grids/app/controllers/api/v3/grids/schemas/grid_schema_representer.rb b/modules/grids/app/controllers/api/v3/grids/schemas/grid_schema_representer.rb index 1efb1ad145a..8c2909d7de6 100644 --- a/modules/grids/app/controllers/api/v3/grids/schemas/grid_schema_representer.rb +++ b/modules/grids/app/controllers/api/v3/grids/schemas/grid_schema_representer.rb @@ -60,7 +60,15 @@ module API type: 'Integer', visibility: false - schema_with_allowed_collection :page, + schema :name, + type: 'String', + visibility: false + + schema :options, + type: 'JSON', + visibility: false + + schema_with_allowed_collection :scope, type: 'Href', required: true, has_default: false, diff --git a/modules/grids/app/controllers/api/v3/grids/update_form_api.rb b/modules/grids/app/controllers/api/v3/grids/update_form_api.rb index 3f3f9358733..6ccd9f91bde 100644 --- a/modules/grids/app/controllers/api/v3/grids/update_form_api.rb +++ b/modules/grids/app/controllers/api/v3/grids/update_form_api.rb @@ -36,13 +36,15 @@ module API end post do + raise_if_lacking_manage_permission + params = API::V3::ParseResourceParamsService .new(current_user, representer: GridPayloadRepresenter) .call(request_body) .result - if params[:page] - params[:type] = ::Grids::Configuration.grid_for_page(params.delete(:page)).to_s + if params[:scope] + params[:type] = ::Grids::Configuration.class_from_scope(params.delete(:scope)).to_s end call = ::Grids::SetAttributesService diff --git a/modules/grids/app/controllers/api/v3/grids/widget_representer.rb b/modules/grids/app/controllers/api/v3/grids/widget_representer.rb index 1dfed469f44..f9e66a7bf78 100644 --- a/modules/grids/app/controllers/api/v3/grids/widget_representer.rb +++ b/modules/grids/app/controllers/api/v3/grids/widget_representer.rb @@ -36,6 +36,8 @@ module API property :start_column property :end_column + property :options + def _type 'GridWidget' end diff --git a/modules/grids/app/models/grids/grid.rb b/modules/grids/app/models/grids/grid.rb index 8a66317e2b8..49ea33945af 100644 --- a/modules/grids/app/models/grids/grid.rb +++ b/modules/grids/app/models/grids/grid.rb @@ -32,16 +32,14 @@ module Grids class Grid < ActiveRecord::Base self.table_name = :grids + serialize :options, Hash + has_many :widgets, class_name: 'Widget', autosave: true - def self.new_default(_user) - new( - row_count: 4, - column_count: 5, - widgets: [] - ) + def user_deletable? + false end end end diff --git a/modules/grids/app/models/grids/my_page.rb b/modules/grids/app/models/grids/my_page.rb index 733086e4353..33cc4519aa4 100644 --- a/modules/grids/app/models/grids/my_page.rb +++ b/modules/grids/app/models/grids/my_page.rb @@ -31,33 +31,5 @@ module Grids class MyPage < Grid belongs_to :user - - def self.new_default(user) - new( - user: user, - row_count: 7, - column_count: 4, - widgets: [ - Grids::Widget.new( - identifier: 'work_packages_assigned', - start_row: 1, - end_row: 7, - start_column: 1, - end_column: 3 - ), - Grids::Widget.new( - identifier: 'work_packages_created', - start_row: 1, - end_row: 7, - start_column: 3, - end_column: 5 - ) - ] - ) - end - - def self.visible_scope - where(user_id: User.current.id) - end end end diff --git a/modules/grids/app/queries/grids/filters/page_filter.rb b/modules/grids/app/queries/grids/filters/page_filter.rb index da188cc48c5..3597eff98bd 100644 --- a/modules/grids/app/queries/grids/filters/page_filter.rb +++ b/modules/grids/app/queries/grids/filters/page_filter.rb @@ -32,31 +32,70 @@ module Grids module Filters class PageFilter < Filters::GridFilter def allowed_values - ::Grids::Configuration.registered_pages + raise NotImplementedError, 'There would be too many candidates' + end + + def allowed_values_subset + values + .map { |page| [page, ::Grids::Configuration.attributes_from_scope(page)] } + .map do |page, config| + next unless config && config[:class] + + if config[:id] && config[:class].visible.exists?(config[:id]) || config[:class].visible.any? + page + end + end.compact end def type - :string + :list end def self.key :page end + # TODO: add condition methods for user_id and id def where - actual_values = values - .map do |page| - ::Grids::Configuration.grid_for_page(page).name - end + values + .map { |page| ::Grids::Configuration.attributes_from_scope(page) } + .map do |actual_value| + conditions = [class_condition(actual_value[:class]), + project_id_condition(actual_value[:project_id])] - operator_strategy.sql_for_field(actual_values, + "(#{conditions.compact.join(' AND ')})" + end.join(' OR ') + end + + private + + def class_condition(klass) + return nil unless klass + + operator_strategy.sql_for_field([klass.name], self.class.model.table_name, 'type') end + def project_id_condition(project_id) + return nil unless project_id + + unless project_id.match?(/\A\d+\z/) + project_id = Project.find(project_id).id + end + + operator_strategy.sql_for_field([project_id], + self.class.model.table_name, + 'project_id') + end + def available_operators [::Queries::Operators::Equals] end + + def type_strategy + @type_strategy ||= Queries::Filters::Strategies::HugeList.new(self) + end end end end diff --git a/modules/grids/app/queries/grids/filters/scope_filter.rb b/modules/grids/app/queries/grids/filters/scope_filter.rb new file mode 100644 index 00000000000..518377f80c7 --- /dev/null +++ b/modules/grids/app/queries/grids/filters/scope_filter.rb @@ -0,0 +1,99 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +module Grids + module Filters + class ScopeFilter < Filters::GridFilter + def allowed_values + raise NotImplementedError, 'There would be too many candidates' + end + + def allowed_values_subset + grid_configs_of_values + .map do |value, config| + next unless config && config[:class] + + if config && config[:class] + value + end + end.compact + end + + def type + :list + end + + def where + grid_configs_of_values + .map do |_value, config| + conditions = [class_condition(config[:class]), + project_id_condition(config[:project_id])] + + "(#{conditions.compact.join(' AND ')})" + end.join(' OR ') + end + + private + + def grid_configs_of_values + values + .map { |value| [value, ::Grids::Configuration.attributes_from_scope(value)] } + end + + def class_condition(klass) + return nil unless klass + + operator_strategy.sql_for_field([klass.name], + self.class.model.table_name, + 'type') + end + + def project_id_condition(project_id) + return nil unless project_id + + unless project_id.match?(/\A\d+\z/) + project_id = Project.find(project_id).id + end + + operator_strategy.sql_for_field([project_id], + self.class.model.table_name, + 'project_id') + end + + def type_strategy + @type_strategy ||= Queries::Filters::Strategies::HugeList.new(self) + end + + def available_operators + [::Queries::Operators::Equals] + end + end + end +end diff --git a/modules/grids/app/queries/grids/query.rb b/modules/grids/app/queries/grids/query.rb index 58d76cb1557..99bb8c08ad5 100644 --- a/modules/grids/app/queries/grids/query.rb +++ b/modules/grids/app/queries/grids/query.rb @@ -33,12 +33,12 @@ module Grids end def default_scope - grid_classes = ::Grids::Configuration.registered_grids + configs = ::Grids::Configuration.all - or_scope = grid_classes.pop.visible_scope + or_scope = configs.pop.visible(User.current) - while grid_classes.any? - or_scope = or_scope.or(grid_classes.pop.visible_scope) + while configs.any? + or_scope = or_scope.or(configs.pop.visible(User.current)) end # Have to use the subselect as AR will otherwise remove diff --git a/modules/grids/app/services/grids/create_service.rb b/modules/grids/app/services/grids/create_service.rb index 44dfec4b2ad..42554285438 100644 --- a/modules/grids/app/services/grids/create_service.rb +++ b/modules/grids/app/services/grids/create_service.rb @@ -48,7 +48,7 @@ class Grids::CreateService protected def create(attributes) - grid = new_grid(attributes.delete(:page)) + grid = new_grid(attributes.delete(:scope)) set_attributes_call = set_attributes(attributes, grid) @@ -69,8 +69,7 @@ class Grids::CreateService .call(attributes) end - def new_grid(page) - grid_class = ::Grids::Configuration.grid_for_page(page) - grid_class.new_default(user) + def new_grid(scope) + ::Grids::Factory.build(scope, user) end end diff --git a/modules/grids/app/services/grids/delete_service.rb b/modules/grids/app/services/grids/delete_service.rb new file mode 100644 index 00000000000..e6ea8878aaf --- /dev/null +++ b/modules/grids/app/services/grids/delete_service.rb @@ -0,0 +1,60 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +class Grids::DeleteService + include ::Shared::ServiceContext + include ::Concerns::Contracted + + attr_accessor :user, + :grid, + :contract_class + + def initialize(user:, grid:, contract_class: Grids::DeleteContract) + self.user = user + self.grid = grid + self.contract_class = contract_class + end + + def call + in_context(false) do + delete_grid + end + end + + protected + + def delete_grid + result, errors = validate_and_yield(grid, user) do + grid.destroy + end + + ServiceResult.new(success: result, errors: errors) + end +end diff --git a/modules/grids/app/services/grids/update_service.rb b/modules/grids/app/services/grids/update_service.rb index 10ce21f1e74..346027eb176 100644 --- a/modules/grids/app/services/grids/update_service.rb +++ b/modules/grids/app/services/grids/update_service.rb @@ -50,7 +50,7 @@ class Grids::UpdateService protected def create(attributes) - set_type_for_error_message(attributes.delete(:page)) + set_type_for_error_message(attributes.delete(:scope)) set_attributes_call = set_attributes(attributes, grid) @@ -71,11 +71,11 @@ class Grids::UpdateService .call(attributes) end - # Changing the page/type after the grid has been created is prohibited. + # Changing the scope/type after the grid has been created is prohibited. # But we set the value so that an error message can be displayed - def set_type_for_error_message(page) - if page - grid_class = ::Grids::Configuration.grid_for_page(page) + def set_type_for_error_message(scope) + if scope + grid_class = ::Grids::Configuration.class_from_scope(scope) grid.type = grid_class.name end end diff --git a/modules/grids/config/locales/en.yml b/modules/grids/config/locales/en.yml index 4f4ce90af46..0268df9c20b 100644 --- a/modules/grids/config/locales/en.yml +++ b/modules/grids/config/locales/en.yml @@ -2,7 +2,7 @@ en: activerecord: attributes: grids/grid: - page: "Page" + scope: "Scope" row_count: "Number of rows" column_count: "Number of columns" widgets: "Widgets" diff --git a/modules/grids/lib/grids/configuration.rb b/modules/grids/lib/grids/configuration.rb index 31890f6d7a0..705cbb97bb7 100644 --- a/modules/grids/lib/grids/configuration.rb +++ b/modules/grids/lib/grids/configuration.rb @@ -31,30 +31,56 @@ class Grids::Configuration class << self def register_grid(grid, - page) - @grid_register ||= {} - - @grid_register[grid] = page + klass) + grid_register[grid] = klass end def registered_grids - registered_grid_by_klass.keys + if @registered_grid_classes && @registered_grid_classes.length == grid_register.length + @registered_grid_classes + else + @registered_grid_classes = grid_register.keys.map(&:constantize) + end end - def registered_pages - registered_grid_by_page.keys + def all_scopes + all.map(&:all_scopes).flatten.compact end - def grid_for_page(page) - registered_grid_by_page[page] || Grids::Grid + def all + grid_register.values end - def grid_for_class(klass) - registered_grid_by_klass[klass] + def attributes_from_scope(page) + config = all.find do |config| + config.from_scope(page) + end + + if config + config.from_scope(page) + else + { class: ::Grids::Grid } + end + end + + def defaults(klass) + grid_register[klass.name]&.defaults + end + + def class_from_scope(page) + attributes_from_scope(page)[:class] + end + + def to_scope(klass, path_parts) + config = grid_register[klass.name] + + return nil unless config + + url_helpers.send(config.to_scope, path_parts) end def registered_grid?(klass) - registered_grid_by_klass.key?(klass) + registered_grids.include? klass end def register_widget(identifier, grid_classes) @@ -69,28 +95,14 @@ class Grids::Configuration (grid_classes || []).include?(grid) end - protected - - def registered_grid_by_page - if @registered_grid_by_page && @registered_grid_by_page.length == @grid_register.length - @registered_grid_by_page - else - @registered_grid_by_page = @grid_register.map do |klass, path| - [url_helpers.send(path), - klass.constantize] - end.to_h - end + def writable?(grid, user) + grid_register[grid.class.to_s]&.writable?(grid, user) end - def registered_grid_by_klass - if @registered_grid_by_klass && @registered_grid_by_klass.length == @grid_register.length - @registered_grid_by_klass - else - @registered_grid_by_klass = @grid_register.map do |klass, path| - [klass.constantize, - url_helpers.send(path)] - end.to_h - end + protected + + def grid_register + @grid_register ||= {} end def registered_widget_by_identifier @@ -107,4 +119,90 @@ class Grids::Configuration @url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new end end + + class Registration + class << self + def grid_class(name_string = nil) + if name_string + @grid_class = name_string + end + + @grid_class + end + + def to_scope(path = nil) + if path + @to_scope = path + end + + @to_scope + end + + def widgets(*widgets) + if widgets.any? + @widgets = widgets + end + + @widgets + end + + def defaults(hash = nil) + # This is called during code load, which + # may not have the table available. + return unless Grids::Widget.table_exists? + + if hash + @defaults = hash + end + + params = @defaults.dup + params[:widgets] = (params[:widgets] || []).map do |widget| + Grids::Widget.new(widget) + end + + params + end + + def from_scope(_scope) + raise NotImplementedError + end + + def all_scopes + Array(url_helpers.send(@to_scope)) + end + + def visible(_user = User.current) + ::Grids::Grid + .where(type: grid_class) + end + + def writable?(_grid, _user) + true + end + + def register! + unless @grid_class + raise 'Need to define the grid class first. Use grid_class to do so.' + end + unless @widgets + raise 'Need to define at least one widget first. Use widgets to do so.' + end + unless @to_scope + raise 'Need to define a scope. Use to_scope to do so' + end + + Grids::Configuration.register_grid(@grid_class, self) + + widgets.each do |widget| + Grids::Configuration.register_widget(widget, @grid_class) + end + end + + private + + def url_helpers + @url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new + end + end + end end diff --git a/modules/grids/lib/grids/engine.rb b/modules/grids/lib/grids/engine.rb index cbf4775565d..d0653721bb8 100644 --- a/modules/grids/lib/grids/engine.rb +++ b/modules/grids/lib/grids/engine.rb @@ -7,19 +7,11 @@ module Grids config.to_prepare do query = Grids::Query - Queries::Register.filter query, Grids::Filters::PageFilter + Queries::Register.filter query, Grids::Filters::ScopeFilter end config.to_prepare do - Grids::Configuration.register_grid('Grids::MyPage', 'my_page_path') - Grids::Configuration.register_widget('work_packages_assigned', 'Grids::MyPage') - Grids::Configuration.register_widget('work_packages_accountable', 'Grids::MyPage') - Grids::Configuration.register_widget('work_packages_watched', 'Grids::MyPage') - Grids::Configuration.register_widget('work_packages_created', 'Grids::MyPage') - Grids::Configuration.register_widget('work_packages_calendar', 'Grids::MyPage') - Grids::Configuration.register_widget('time_entries_current_user', 'Grids::MyPage') - Grids::Configuration.register_widget('documents', 'Grids::MyPage') - Grids::Configuration.register_widget('news', 'Grids::MyPage') + Grids::MyPageGridRegistration.register! end end end diff --git a/modules/grids/lib/grids/factory.rb b/modules/grids/lib/grids/factory.rb new file mode 100644 index 00000000000..307d50f51d7 --- /dev/null +++ b/modules/grids/lib/grids/factory.rb @@ -0,0 +1,72 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +module Grids + class Factory + class << self + def build(scope, user) + attributes = ::Grids::Configuration.attributes_from_scope(scope) + + grid_class = attributes[:class] + grid_project = project_from_id(attributes[:project_id]) + + new_default(grid_class, grid_project, user) + end + + private + + def new_default(klass, project, user) + params = class_defaults(klass) + + if klass.reflect_on_association(:project) + params[:project] = project + end + + if klass.reflect_on_association(:user) + params[:user] = user + end + + klass.new(params) + end + + def class_defaults(klass) + params = ::Grids::Configuration.defaults(klass) + + params || { row_count: 4, column_count: 5, widgets: [] } + end + + def project_from_id(id) + Project.find(id) if id + rescue ActiveRecord::RecordNotFound + nil + end + end + end +end diff --git a/modules/grids/lib/grids/my_page_grid_registration.rb b/modules/grids/lib/grids/my_page_grid_registration.rb new file mode 100644 index 00000000000..f71639413a6 --- /dev/null +++ b/modules/grids/lib/grids/my_page_grid_registration.rb @@ -0,0 +1,49 @@ +module Grids + class MyPageGridRegistration < ::Grids::Configuration::Registration + grid_class 'Grids::MyPage' + to_scope :my_page_path + + widgets 'work_packages_assigned', + 'work_packages_accountable', + 'work_packages_watched', + 'work_packages_created', + 'work_packages_calendar', + 'time_entries_current_user', + 'documents', + 'news' + + defaults( + row_count: 7, + column_count: 4, + widgets: [ + { + identifier: 'work_packages_assigned', + start_row: 1, + end_row: 7, + start_column: 1, + end_column: 3 + }, + { + identifier: 'work_packages_created', + start_row: 1, + end_row: 7, + start_column: 3, + end_column: 5 + } + ] + ) + + class << self + def from_scope(scope) + if scope == url_helpers.my_page_path + { class: Grids::MyPage } + end + end + + def visible(user = User.current) + super + .where(user_id: user.id) + end + end + end +end diff --git a/modules/grids/spec/contracts/grids/create_contract_spec.rb b/modules/grids/spec/contracts/grids/create_contract_spec.rb index e46b329014e..7e765048e8e 100644 --- a/modules/grids/spec/contracts/grids/create_contract_spec.rb +++ b/modules/grids/spec/contracts/grids/create_contract_spec.rb @@ -33,6 +33,7 @@ require_relative './shared_examples' describe Grids::CreateContract do include_context 'grid contract' + include_context 'model contract' it_behaves_like 'shared grid contract attributes' @@ -44,18 +45,15 @@ describe Grids::CreateContract do end describe 'user_id' do - let(:grid) do - FactoryBot.build_stubbed(:grid, default_values) - end + let(:grid) { FactoryBot.build_stubbed(:grid, default_values) } + it_behaves_like 'is not writable' do let(:attribute) { :user_id } let(:value) { 5 } end context 'for a Grids::MyPage' do - let(:grid) do - FactoryBot.build_stubbed(:my_page, default_values) - end + let(:grid) { FactoryBot.build_stubbed(:my_page, default_values) } it_behaves_like 'is writable' do let(:attribute) { :user_id } @@ -64,14 +62,40 @@ describe Grids::CreateContract do end end - describe '#assignable_values' do - context 'for page' do - it 'returns the array of supported pages' do - expect(instance.assignable_values(:page, user)) - .to match_array [OpenProject::StaticRouting::StaticUrlHelpers.new.my_page_path] - end + describe 'project_id' do + let(:grid) { FactoryBot.build_stubbed(:grid, default_values) } - it 'returns the nil for something else' do + it_behaves_like 'is not writable' do + let(:attribute) { :project_id } + let(:value) { 5 } + end + + context 'for a Grids::MyPage' do + let(:grid) { FactoryBot.build_stubbed(:my_page, default_values) } + + it_behaves_like 'is not writable' do + let(:attribute) { :project_id } + let(:value) { 5 } + end + end + end + + describe '#assignable_values' do + context 'for scope' do + it 'calls the grid configuration for the available values' do + scopes = double('scopes') + + allow(Grids::Configuration) + .to receive(:all_scopes) + .and_return(scopes) + + expect(instance.assignable_values(:scope, user)) + .to eql scopes + end + end + + context 'for something else' do + it 'returns nil' do expect(instance.assignable_values(:something, user)) .to be_nil end diff --git a/modules/grids/spec/contracts/grids/shared_examples.rb b/modules/grids/spec/contracts/grids/shared_examples.rb index 26c002b7128..4e9324db817 100644 --- a/modules/grids/spec/contracts/grids/shared_examples.rb +++ b/modules/grids/spec/contracts/grids/shared_examples.rb @@ -34,41 +34,14 @@ shared_context 'grid contract' do let(:default_values) do { row_count: 6, - column_count: 7 + column_count: 7, + widgets: [] } end let(:grid) do FactoryBot.build_stubbed(:my_page, default_values) end - shared_examples_for 'is not writable' do - before do - grid.attributes = { attribute => value } - end - - it 'is not writable' do - expect(instance.validate) - .to be_falsey - end - - it 'explains the not writable error' do - instance.validate - expect(instance.errors.details[attribute]) - .to match_array [{ error: :error_readonly }] - end - end - - shared_examples_for 'is writable' do - before do - grid.attributes = { attribute => value } - end - - it 'is writable' do - expect(instance.validate) - .to be_truthy - end - end - shared_examples_for 'validates positive integer' do context 'when the value is negative' do let(:value) { -1 } @@ -95,6 +68,9 @@ shared_context 'grid contract' do end shared_examples_for 'shared grid contract attributes' do + include_context 'model contract' + let(:model) { grid } + describe 'row_count' do it_behaves_like 'is writable' do let(:attribute) { :row_count } @@ -402,7 +378,7 @@ shared_examples_for 'shared grid contract attributes' do end it 'is invalid for the grid superclass itself' do - expect(instance.errors.details[:page]) + expect(instance.errors.details[:scope]) .to match_array [{ error: :inclusion }] end end diff --git a/modules/grids/spec/contracts/grids/update_contract_spec.rb b/modules/grids/spec/contracts/grids/update_contract_spec.rb index 309a93f7f9d..1202f02a1af 100644 --- a/modules/grids/spec/contracts/grids/update_contract_spec.rb +++ b/modules/grids/spec/contracts/grids/update_contract_spec.rb @@ -32,6 +32,7 @@ require 'spec_helper' require_relative './shared_examples' describe Grids::UpdateContract do + include_context 'model contract' include_context 'grid contract' it_behaves_like 'shared grid contract attributes' @@ -48,16 +49,25 @@ describe Grids::UpdateContract do it 'explains the not writable error' do instance.validate - # page because that is what type is called on the outside for grids - expect(instance.errors.details[:page]) + # scope because that is what type is called on the outside for grids + expect(instance.errors.details[:scope]) .to match_array [{ error: :error_readonly }] end end describe 'user_id' do it_behaves_like 'is not writable' do + let(:model) { grid } let(:attribute) { :user_id } let(:value) { 5 } end end + + describe 'project_id' do + it_behaves_like 'is not writable' do + let(:model) { grid } + let(:attribute) { :project_id } + let(:value) { 5 } + end + end end diff --git a/modules/grids/spec/factories/grid_factory.rb b/modules/grids/spec/factories/grid_factory.rb index 94b94ae1adb..d0749efaff4 100644 --- a/modules/grids/spec/factories/grid_factory.rb +++ b/modules/grids/spec/factories/grid_factory.rb @@ -3,5 +3,26 @@ FactoryBot.define do end factory :my_page, class: Grids::MyPage do + user + row_count { 7 } + column_count { 4 } + widgets do + [ + Grids::Widget.new( + identifier: 'work_packages_assigned', + start_row: 1, + end_row: 7, + start_column: 1, + end_column: 3 + ), + Grids::Widget.new( + identifier: 'work_packages_created', + start_row: 1, + end_row: 7, + start_column: 3, + end_column: 5 + ) + ] + end end end diff --git a/modules/grids/spec/lib/api/v3/grids/grid_payload_representer_parsing_spec.rb b/modules/grids/spec/lib/api/v3/grids/grid_payload_representer_parsing_spec.rb index 686bc58fabe..308d6b88ede 100644 --- a/modules/grids/spec/lib/api/v3/grids/grid_payload_representer_parsing_spec.rb +++ b/modules/grids/spec/lib/api/v3/grids/grid_payload_representer_parsing_spec.rb @@ -70,7 +70,7 @@ describe ::API::V3::Grids::GridPayloadRepresenter, 'parsing' do } ], "_links" => { - "page" => { + "scope" => { "href" => my_page_path } } @@ -78,10 +78,10 @@ describe ::API::V3::Grids::GridPayloadRepresenter, 'parsing' do end describe '_links' do - context 'page' do + context 'scope' do it 'updates page' do grid = representer.from_hash(hash) - expect(grid.page) + expect(grid.scope) .to eql(my_page_path) end end diff --git a/modules/grids/spec/lib/api/v3/grids/grid_representer_rendering_spec.rb b/modules/grids/spec/lib/api/v3/grids/grid_representer_rendering_spec.rb index 1ee411e8e97..b17313eabe3 100644 --- a/modules/grids/spec/lib/api/v3/grids/grid_representer_rendering_spec.rb +++ b/modules/grids/spec/lib/api/v3/grids/grid_representer_rendering_spec.rb @@ -81,7 +81,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do it 'identifies the url the grid is stored for' do is_expected .to be_json_eql(my_page_path.to_json) - .at_path('_links/page/href') + .at_path('_links/scope/href') end it 'has an id' do @@ -121,6 +121,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do { "_type": "GridWidget", "identifier": 'work_packages_assigned', + "options": {}, "startRow": 4, "endRow": 5, "startColumn": 1, @@ -129,6 +130,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do { "_type": "GridWidget", "identifier": 'work_packages_created', + "options": {}, "startRow": 1, "endRow": 2, "startColumn": 1, @@ -137,6 +139,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do { "_type": "GridWidget", "identifier": 'work_packages_watched', + "options": {}, "startRow": 2, "endRow": 4, "startColumn": 4, @@ -174,9 +177,9 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do end end - context 'page link' do + context 'scope link' do it_behaves_like 'has an untitled link' do - let(:link) { 'page' } + let(:link) { 'scope' } let(:href) { my_page_path } let(:type) { "text/html" } diff --git a/modules/grids/spec/lib/api/v3/grids/schemas/grid_schema_representer_spec.rb b/modules/grids/spec/lib/api/v3/grids/schemas/grid_schema_representer_spec.rb index 07d0678161f..4fcf094ac68 100644 --- a/modules/grids/spec/lib/api/v3/grids/schemas/grid_schema_representer_spec.rb +++ b/modules/grids/spec/lib/api/v3/grids/schemas/grid_schema_representer_spec.rb @@ -36,7 +36,7 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do let(:self_link) { '/a/self/link' } let(:embedded) { true } let(:new_record) { true } - let(:allowed_pages) { %w(/some/path /some/other/path) } + let(:allowed_scopes) { %w(/some/path /some/other/path) } let(:allowed_widgets) do [ OpenStruct.new( @@ -55,7 +55,7 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do writable = %w(row_count column_count widgets) if new_record - writable << 'page' + writable << 'scope' end writable.include?(attribute.to_s) @@ -63,8 +63,8 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do allow(contract) .to receive(:assignable_values) - .with(:page, current_user) - .and_return(allowed_pages) + .with(:scope, current_user) + .and_return(allowed_scopes) allow(contract) .to receive(:assignable_values) @@ -186,13 +186,13 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do end end - describe 'page' do - let(:path) { 'page' } + describe 'scope' do + let(:path) { 'scope' } context 'when having a new record' do it_behaves_like 'has basic schema properties' do let(:type) { 'Href' } - let(:name) { Grids::Grid.human_attribute_name('page') } + let(:name) { Grids::Grid.human_attribute_name('scope') } let(:required) { true } let(:writable) { true } end @@ -201,12 +201,12 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do let(:embedded) { true } it_behaves_like 'links to allowed values directly' do - let(:hrefs) { allowed_pages } + let(:hrefs) { allowed_scopes } end it 'does not embed' do expect(generated) - .not_to have_json_path('page/embedded') + .not_to have_json_path('scope/embedded') end end @@ -217,18 +217,18 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do it 'does not embed' do expect(generated) - .not_to have_json_path('page/embedded') + .not_to have_json_path('scope/embedded') end end end context 'when not having a new record' do let(:new_record) { false } - let(:allowed_pages) { nil } + let(:allowed_scopes) { nil } it_behaves_like 'has basic schema properties' do let(:type) { 'Href' } - let(:name) { Grids::Grid.human_attribute_name('page') } + let(:name) { Grids::Grid.human_attribute_name('scope') } let(:required) { true } let(:writable) { false } end @@ -240,7 +240,7 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do it 'does not embed' do expect(generated) - .not_to have_json_path('page/embedded') + .not_to have_json_path('scope/embedded') end end @@ -251,7 +251,7 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do it 'does not embed' do expect(generated) - .not_to have_json_path('page/embedded') + .not_to have_json_path('scope/embedded') end end end diff --git a/modules/grids/spec/models/grids/my_page_spec.rb b/modules/grids/spec/models/grids/my_page_spec.rb index 21d169d5b63..c1b7117a38a 100644 --- a/modules/grids/spec/models/grids/my_page_spec.rb +++ b/modules/grids/spec/models/grids/my_page_spec.rb @@ -32,6 +32,7 @@ require_relative './shared_model' describe Grids::MyPage, type: :model do let(:instance) { described_class.new } + let(:user) { FactoryBot.build_stubbed(:user) } it_behaves_like 'grid attributes' diff --git a/modules/grids/spec/models/grids/shared_model.rb b/modules/grids/spec/models/grids/shared_model.rb index 310e43ffa9c..3e453343020 100644 --- a/modules/grids/spec/models/grids/shared_model.rb +++ b/modules/grids/spec/models/grids/shared_model.rb @@ -40,6 +40,29 @@ shared_examples_for 'grid attributes' do .to eql 5 end + it '#name' do + instance.name = 'custom 123' + expect(instance.name) + .to eql 'custom 123' + + # can be empty + instance.name = nil + expect(instance).to be_valid + end + + it '#options' do + value = { + some: 'value', + and: { + also: 1 + } + } + + instance.options = value + expect(instance.options) + .to eql value + end + it '#widgets' do widgets = [ Grids::Widget.new(start_row: 2), diff --git a/modules/grids/spec/queries/grids/filters/page_filter_spec.rb b/modules/grids/spec/queries/grids/filters/scope_filter_spec.rb similarity index 84% rename from modules/grids/spec/queries/grids/filters/page_filter_spec.rb rename to modules/grids/spec/queries/grids/filters/scope_filter_spec.rb index 7147dab5f99..0a98eb06838 100644 --- a/modules/grids/spec/queries/grids/filters/page_filter_spec.rb +++ b/modules/grids/spec/queries/grids/filters/scope_filter_spec.rb @@ -30,7 +30,7 @@ require 'spec_helper' -describe Grids::Filters::PageFilter, type: :model do +describe Grids::Filters::ScopeFilter, type: :model do include_context 'filter tests' let(:values) { ['/my/page'] } let(:user) { FactoryBot.build_stubbed(:user) } @@ -41,17 +41,10 @@ describe Grids::Filters::PageFilter, type: :model do end it_behaves_like 'basic query filter' do - let(:class_key) { :page } - let(:type) { :string } + let(:class_key) { :scope } + let(:type) { :list } let(:model) { Grids::Grid.where(user_id: user.id) } let(:values) { ['/my/page'] } - - describe '#allowed_values' do - it 'is /my/page' do - expect(instance.allowed_values) - .to match_array values - end - end end describe '#scope' do @@ -60,7 +53,7 @@ describe Grids::Filters::PageFilter, type: :model do context 'for /my/page do' do it 'is the same as handwriting the query' do - expected = model.where("grids.type IN ('Grids::MyPage')") + expected = model.where("(grids.type IN ('Grids::MyPage'))") expect(instance.scope.to_sql).to eql expected.to_sql end diff --git a/modules/grids/spec/queries/grids/query_spec.rb b/modules/grids/spec/queries/grids/query_integration_spec.rb similarity index 77% rename from modules/grids/spec/queries/grids/query_spec.rb rename to modules/grids/spec/queries/grids/query_integration_spec.rb index 6674d21e5b0..35277360882 100644 --- a/modules/grids/spec/queries/grids/query_spec.rb +++ b/modules/grids/spec/queries/grids/query_integration_spec.rb @@ -29,8 +29,14 @@ require 'spec_helper' describe Grids::Query, type: :model do - let(:user) { FactoryBot.build_stubbed(:user) } - let(:base_scope) { Grids::Grid.where(id: Grids::MyPage.where(user_id: user.id)) } + let(:user) { FactoryBot.create(:user) } + let(:other_user) { FactoryBot.create(:user) } + let!(:my_page_grid) do + FactoryBot.create(:my_page, user: user) + end + let!(:other_my_page_grid) do + FactoryBot.create(:my_page, user: other_user) + end let(:instance) { described_class.new } before do @@ -40,22 +46,19 @@ describe Grids::Query, type: :model do context 'without a filter' do describe '#results' do it 'is the same as getting all the grids visible to the user' do - expect(instance.results.to_sql).to eql base_scope.to_sql + expect(instance.results).to match_array [my_page_grid] end end end - context 'with a page filter' do + context 'with a scope filter' do before do - instance.where('page', '=', ['/my/page']) + instance.where('scope', '=', ['/my/page']) end describe '#results' do it 'is the same as handwriting the query' do - expected = base_scope - .where(['grids.type IN (?)', ['Grids::MyPage']]) - - expect(instance.results.to_sql).to eql expected.to_sql + expect(instance.results).to match_array [my_page_grid] end end @@ -65,7 +68,7 @@ describe Grids::Query, type: :model do end it 'is invalid if the filter is invalid' do - instance.where('page', '!', ['/some/other/page']) + instance.where('scope', '!', ['/some/other/page']) expect(instance).to be_invalid end end diff --git a/modules/grids/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb b/modules/grids/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb index 89fb6da5bc2..0252eb017f4 100644 --- a/modules/grids/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb +++ b/modules/grids/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb @@ -68,7 +68,7 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do expect(subject.body) .to be_json_eql(my_page_path.to_json) - .at_path('_embedded/schema/page/_links/allowedValues/0/href') + .at_path('_embedded/schema/scope/_links/allowedValues/0/href') end it 'contains default data in the payload' do @@ -76,6 +76,7 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do "rowCount": 4, "columnCount": 5, "widgets": [], + "options": {}, "_links": {} } @@ -84,10 +85,10 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do .at_path('_embedded/payload') end - it 'has a validation error on page' do + it 'has a validation error on scope' do expect(subject.body) - .to be_json_eql("Page is not set to one of the allowed values.".to_json) - .at_path('_embedded/validationErrors/page/message') + .to be_json_eql("Scope is not set to one of the allowed values.".to_json) + .at_path('_embedded/validationErrors/scope/message') end it 'does not have a commit link' do @@ -95,11 +96,11 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do .not_to have_json_path('_links/commit') end - context 'with /my/page for the page value' do + context 'with /my/page for the scope value' do let(:params) do { '_links': { - 'page': { + 'scope': { 'href': my_page_path } } @@ -110,10 +111,12 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do expected = { "rowCount": 7, "columnCount": 4, + "options": {}, "widgets": [ { "_type": "GridWidget", identifier: 'work_packages_assigned', + "options": {}, startRow: 1, endRow: 7, startColumn: 1, @@ -122,6 +125,7 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do { "_type": "GridWidget", identifier: 'work_packages_created', + "options": {}, startRow: 1, endRow: 7, startColumn: 3, @@ -129,7 +133,7 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do } ], "_links": { - "page": { + "scope": { "href": "/my/page", "type": "text/html" } @@ -158,7 +162,7 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do let(:params) do { '_links': { - 'page': { + 'scope': { 'href': my_page_path } }, @@ -181,5 +185,45 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do .at_path('_embedded/validationErrors/widgets/message') end end + + context 'with name set' do + let(:params) do + { + name: 'My custom grid 1', + '_links': { + 'scope': { + 'href': my_page_path + } + } + } + end + + it 'feeds it back' do + expect(subject.body) + .to be_json_eql("My custom grid 1".to_json) + .at_path('_embedded/payload/name') + end + end + + context 'with options set' do + let(:params) do + { + options: { + foo: 'bar' + }, + '_links': { + 'scope': { + 'href': my_page_path + } + } + } + end + + it 'feeds them back' do + expect(subject.body) + .to be_json_eql("bar".to_json) + .at_path('_embedded/payload/options/foo') + end + end end end diff --git a/modules/grids/spec/requests/api/v3/grids/grids_resource_spec.rb b/modules/grids/spec/requests/api/v3/grids/grids_resource_spec.rb index 1217008c588..427df745137 100644 --- a/modules/grids/spec/requests/api/v3/grids/grids_resource_spec.rb +++ b/modules/grids/spec/requests/api/v3/grids/grids_resource_spec.rb @@ -37,17 +37,11 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do FactoryBot.create(:user) end - let(:my_page_grid) do - grid = Grids::MyPage.new_default(current_user) - grid.save! - grid - end + let(:my_page_grid) { FactoryBot.create(:my_page, user: current_user) } let(:other_user) do FactoryBot.create(:user) end - let(:other_my_page_grid) do - Grids::MyPage.new_default(other_user).save - end + let(:other_my_page_grid) { FactoryBot.create(:my_page, user: other_user) } before do login_as(current_user) @@ -87,7 +81,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do .at_path('total') end - context 'with a filter on the page attribute' do + context 'with a filter on the scope attribute' do shared_let(:other_grid) do grid = Grids::Grid.new(row_count: 20, column_count: 20) @@ -107,7 +101,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do end let(:path) do - filter = [{ 'page' => + filter = [{ 'scope' => { 'operator' => '=', 'values' => [my_page_path] @@ -162,7 +156,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do it 'identifies the url the grid is stored for' do expect(subject.body) .to be_json_eql(my_page_path.to_json) - .at_path('_links/page/href') + .at_path('_links/scope/href') end context 'with the page not existing' do @@ -179,7 +173,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do other_my_page_grid end - let(:path) { api_v3_paths.grid(other_my_page_grid) } + let(:path) { api_v3_paths.grid(other_my_page_grid.id) } it 'responds with 404 NOT FOUND' do expect(subject.status).to eql 404 @@ -193,6 +187,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do let(:params) do { "rowCount": 10, + "name": 'foo', "columnCount": 15, "widgets": [{ "identifier": "work_packages_assigned", @@ -222,6 +217,9 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do expect(subject.body) .to be_json_eql('Grid'.to_json) .at_path('_type') + expect(subject.body) + .to be_json_eql('foo'.to_json) + .at_path('name') expect(subject.body) .to be_json_eql(params['rowCount'].to_json) .at_path('rowCount') @@ -268,15 +266,15 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do it 'does not persist the changes to widgets' do expect(my_page_grid.reload.widgets.count) - .to eql Grids::MyPage.new_default(current_user).widgets.size + .to eql Grids::MyPageGridRegistration.defaults[:widgets].size end end - context 'with a page param' do + context 'with a scope param' do let(:params) do { "_links": { - "page": { + "scope": { "href": '' } } @@ -295,7 +293,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do .at_path('message') expect(subject.body) - .to be_json_eql("page".to_json) + .to be_json_eql("scope".to_json) .at_path('_embedded/details/attribute') end end @@ -314,7 +312,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do other_my_page_grid end - let(:path) { api_v3_paths.grid(other_my_page_grid) } + let(:path) { api_v3_paths.grid(other_my_page_grid.id) } it 'responds with 404 NOT FOUND' do expect(subject.status).to eql 404 @@ -337,7 +335,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do "endColumn": 5 }], "_links": { - "page": { + "scope": { "href": my_page_path } } @@ -382,7 +380,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do "endColumn": 5 }], "_links": { - "page": { + "scope": { "href": my_page_path } } @@ -447,7 +445,7 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do .at_path('_type') expect(subject.body) - .to be_json_eql("Page is not set to one of the allowed values.".to_json) + .to be_json_eql("Scope is not set to one of the allowed values.".to_json) .at_path('message') end end diff --git a/modules/grids/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb b/modules/grids/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb index 93160ba5dab..5d4fba3b4c0 100644 --- a/modules/grids/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb +++ b/modules/grids/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb @@ -38,9 +38,7 @@ describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do end let(:grid) do - grid = Grids::MyPage.new_default(current_user) - grid.save! - grid + FactoryBot.create(:my_page, user: current_user) end let(:path) { api_v3_paths.grid_form(grid.id) } let(:params) { {} } @@ -66,24 +64,26 @@ describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do .at_path('_type') end - it 'contains a Schema disallowing setting page' do + it 'contains a Schema disallowing setting scope' do expect(subject.body) .to be_json_eql("Schema".to_json) .at_path('_embedded/schema/_type') expect(subject.body) .to be_json_eql(false.to_json) - .at_path('_embedded/schema/page/writable') + .at_path('_embedded/schema/scope/writable') end it 'contains the current data in the payload' do expected = { - "rowCount": 7, - "columnCount": 4, - "widgets": [ + rowCount: 7, + columnCount: 4, + options: {}, + widgets: [ { "_type": "GridWidget", identifier: 'work_packages_assigned', + options: {}, startRow: 1, endRow: 7, startColumn: 1, @@ -92,6 +92,7 @@ describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do { "_type": "GridWidget", identifier: 'work_packages_created', + options: {}, startRow: 1, endRow: 7, startColumn: 3, @@ -99,7 +100,7 @@ describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do } ], "_links": { - "page": { + "scope": { "href": "/my/page", "type": "text/html" } @@ -117,21 +118,21 @@ describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do .at_path('_links/commit/href') end - context 'with some value for the page value' do + context 'with some value for the scope value' do let(:params) do { '_links': { - 'page': { + 'scope': { 'href': '/some/path' } } } end - it 'has a validation error on page as the value is not writeable' do + it 'has a validation error on scope as the value is not writeable' do expect(subject.body) .to be_json_eql("You must not write a read-only attribute.".to_json) - .at_path('_embedded/validationErrors/page/message') + .at_path('_embedded/validationErrors/scope/message') end end @@ -169,11 +170,7 @@ describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do context 'for another user\'s grid' do let(:other_user) { FactoryBot.create(:user) } - let(:other_grid) do - grid = Grids::MyPage.new_default(other_user) - grid.save! - grid - end + let(:other_grid) { FactoryBot.create(:my_page, user: other_user) } let(:path) { api_v3_paths.grid_form(other_grid.id) } diff --git a/modules/grids/spec/services/grids/create_service_spec.rb b/modules/grids/spec/services/grids/create_service_spec.rb index 4ad8affe0a4..8b25eac30ec 100644 --- a/modules/grids/spec/services/grids/create_service_spec.rb +++ b/modules/grids/spec/services/grids/create_service_spec.rb @@ -32,6 +32,14 @@ require 'spec_helper' describe Grids::CreateService, type: :model do let(:user) { FactoryBot.build_stubbed(:user) } + let(:project) do + FactoryBot.build_stubbed(:project).tap do |p| + allow(Project) + .to receive(:find) + .with(p.id) + .and_return(p) + end + end let(:contract_class) do double('contract_class') end @@ -40,7 +48,8 @@ describe Grids::CreateService, type: :model do described_class.new(user: user, contract_class: contract_class) end - let(:call_attributes) { { page: OpenProject::StaticRouting::StaticUrlHelpers.new.my_page_path } } + let(:scope) { "some/scope/url" } + let(:call_attributes) { { scope: scope } } let(:grid_class) { Grids::MyPage } let(:set_attributes_success) do true @@ -54,11 +63,11 @@ describe Grids::CreateService, type: :model do errors: set_attributes_errors end let!(:grid) do - grid = FactoryBot.build_stubbed(grid_class.name.demodulize.underscore.to_sym) + grid = FactoryBot.build(grid_class.name.demodulize.underscore.to_sym) - allow(grid_class) - .to receive(:new_default) - .with(user) + allow(Grids::Factory) + .to receive(:build) + .with(scope, user) .and_return(grid) allow(grid) @@ -81,13 +90,38 @@ describe Grids::CreateService, type: :model do .to receive(:call) .and_return(set_attributes_result) end + let!(:grid_configuration) do + allow(Grids::Configuration) + .to receive(:attributes_from_scope) + .with(scope) + .and_return(class: grid_class, project_id: project.id) + end describe 'call' do - shared_examples_for 'service call' do - subject { instance.call(attributes: call_attributes) } + subject { instance.call(attributes: call_attributes) } - it 'is successful' do - expect(subject.success?).to be_truthy + it 'is successful' do + expect(subject.success?).to be_truthy + end + + it 'returns the result of the SetAttributesService' do + expect(subject) + .to eql set_attributes_result + end + + it 'persists the grid' do + expect(grid) + .to receive(:save) + .and_return(grid_valid) + + subject + end + + context 'when the SetAttributeService is unsuccessful' do + let(:set_attributes_success) { false } + + it 'is unsuccessful' do + expect(subject.success?).to be_falsey end it 'returns the result of the SetAttributesService' do @@ -95,76 +129,37 @@ describe Grids::CreateService, type: :model do .to eql set_attributes_result end - it 'persists the grid' do + it 'does not persist the changes' do expect(grid) - .to receive(:save) - .and_return(grid_valid) + .to_not receive(:save) subject end - context 'when the SetAttributeService is unsuccessful' do - let(:set_attributes_success) { false } + it "exposes the contract's errors" do + subject - it 'is unsuccessful' do - expect(subject.success?).to be_falsey - end - - it 'returns the result of the SetAttributesService' do - expect(subject) - .to eql set_attributes_result - end - - it 'does not persist the changes' do - expect(grid) - .to_not receive(:save) - - subject - end - - it "exposes the contract's errors" do - subject - - expect(subject.errors).to eql set_attributes_errors - end - end - - context 'when the grid is invalid' do - let(:grid_valid) { false } - - it 'is unsuccessful' do - expect(subject.success?).to be_falsey - end - - it "exposes the grid's errors" do - subject - - expect(subject.errors).to eql grid.errors - end + expect(subject.errors).to eql set_attributes_errors end end - context 'without parameters' do - let(:call_attributes) { {} } - let(:grid_class) { Grids::Grid } + context 'when the grid is invalid' do + let(:grid_valid) { false } - it_behaves_like 'service call' do - it 'creates a Grid' do - expect(subject.result) - .to be_a grid_class - end + it 'is unsuccessful' do + expect(subject.success?).to be_falsey + end + + it "exposes the grid's errors" do + subject + + expect(subject.errors).to eql grid.errors end end - context 'with my page grid parameters' do - let(:call_attributes) { { page: OpenProject::StaticRouting::StaticUrlHelpers.new.my_page_path } } - - it_behaves_like 'service call' do - it 'creates a Grids::MyPage' do - expect(subject.result) - .to be_a Grids::MyPage - end - end + it 'creates a Grid' do + expect(subject.result) + .to be_a grid_class end end end diff --git a/modules/grids/spec/services/grids/set_attributes_service_spec.rb b/modules/grids/spec/services/grids/set_attributes_service_spec.rb index ffcdaeb26b5..d420c299587 100644 --- a/modules/grids/spec/services/grids/set_attributes_service_spec.rb +++ b/modules/grids/spec/services/grids/set_attributes_service_spec.rb @@ -58,7 +58,7 @@ describe Grids::SetAttributesService, type: :model do let(:call_attributes) { {} } let(:grid_class) { Grids::MyPage } let(:grid) do - FactoryBot.build_stubbed(grid_class.name.demodulize.underscore.to_sym) + FactoryBot.build_stubbed(grid_class.name.demodulize.underscore.to_sym, widgets: []) end describe 'call' do @@ -126,7 +126,7 @@ describe Grids::SetAttributesService, type: :model do .to be_new_record end - it 'applies the provided valuees' do + it 'applies the provided values' do expect(grid.widgets[0].attributes.except('id')) .to eql widgets[0].attributes.except('id').merge('grid_id' => grid.id) end diff --git a/spec/controllers/work_packages_controller_spec.rb b/spec/controllers/work_packages_controller_spec.rb index 8c613ae18e3..33fe8a32e28 100644 --- a/spec/controllers/work_packages_controller_spec.rb +++ b/spec/controllers/work_packages_controller_spec.rb @@ -127,7 +127,7 @@ describe WorkPackagesController, type: :controller do .with({ controller: 'work_packages', action: 'index' }, project, - global: true) + global: project.nil?) .and_return(true) end diff --git a/spec/features/custom_fields/multi_user_custom_field_spec.rb b/spec/features/custom_fields/multi_user_custom_field_spec.rb index 171a605ee24..bcc0616437c 100644 --- a/spec/features/custom_fields/multi_user_custom_field_spec.rb +++ b/spec/features/custom_fields/multi_user_custom_field_spec.rb @@ -1,5 +1,5 @@ require "spec_helper" -require "support/pages/abstract_work_package" +require "support/pages/work_packages/abstract_work_package" describe "multi select custom values", js: true do let(:type) { FactoryBot.create :type } diff --git a/spec/features/custom_fields/multi_value_custom_field_spec.rb b/spec/features/custom_fields/multi_value_custom_field_spec.rb index 106c3355eb6..15158039d14 100644 --- a/spec/features/custom_fields/multi_value_custom_field_spec.rb +++ b/spec/features/custom_fields/multi_value_custom_field_spec.rb @@ -1,5 +1,5 @@ require "spec_helper" -require "support/pages/abstract_work_package" +require "support/pages/work_packages/abstract_work_package" describe "multi select custom values", js: true do let(:type) { FactoryBot.create :type } diff --git a/spec/features/menu_items/query_menu_item_spec.rb b/spec/features/menu_items/query_menu_item_spec.rb index 3d313ab6964..f9e3edbec98 100644 --- a/spec/features/menu_items/query_menu_item_spec.rb +++ b/spec/features/menu_items/query_menu_item_spec.rb @@ -37,6 +37,7 @@ RSpec.feature 'Query menu items', js: true do let(:work_packages_page) { WorkPackagesPage.new(project) } let(:wp_table) { ::Pages::WorkPackagesTable.new(project) } let(:notification) { PageObjects::Notifications.new(page) } + let(:query_title) { ::Components::WorkPackages::QueryTitle.new } let(:status) { FactoryBot.create :status } def visit_index_page(query) @@ -95,8 +96,8 @@ RSpec.feature 'Query menu items', js: true do before do visit_index_page(query_b) - expect(page).to have_field('wp-query-selectable-title', with: 'zzzz') - input = find('.wp-query--selectable-title') + query_title.expect_title 'zzzz' + input = query_title.input_field input.set new_name input.send_keys :return end diff --git a/spec/features/work_packages/indem_sums_spec.rb b/spec/features/work_packages/index_sums_spec.rb similarity index 100% rename from spec/features/work_packages/indem_sums_spec.rb rename to spec/features/work_packages/index_sums_spec.rb diff --git a/spec/features/work_packages/table/queries/filter_spec.rb b/spec/features/work_packages/table/queries/filter_spec.rb index a698461b2ce..a10acff85db 100644 --- a/spec/features/work_packages/table/queries/filter_spec.rb +++ b/spec/features/work_packages/table/queries/filter_spec.rb @@ -274,12 +274,17 @@ describe 'filter work packages', js: true do wp_with_attachment_b ExtractFulltextJob.new(attachment_b.id).perform wp_without_attachment - - wp_table.visit! end - if OpenProject::Database::allows_tsv? + context 'with full text search capabilities' do + before do + skip("Database does not support full text search.") unless OpenProject::Database::allows_tsv? + end + it 'allows filtering and retrieving and altering the saved filter' do + wp_table.visit! + wp_table.expect_work_package_listed wp_with_attachment_a, wp_with_attachment_b + filters.open # content contains with multiple hits @@ -353,7 +358,6 @@ describe 'filter work packages', js: true do wp_table.expect_work_package_not_listed wp_with_attachment_a end end - end context 'DB does not offer TSVector support' do diff --git a/spec/features/work_packages/table/queries/query_name_inline_edit_spec.rb b/spec/features/work_packages/table/queries/query_name_inline_edit_spec.rb index 1fe6e78ff8d..b8c60087871 100644 --- a/spec/features/work_packages/table/queries/query_name_inline_edit_spec.rb +++ b/spec/features/work_packages/table/queries/query_name_inline_edit_spec.rb @@ -105,7 +105,7 @@ describe 'Query name inline edit', js: true do # Rename query through context menu wp_table.click_setting_item 'Rename view ...' - expect(page).to have_focus_on('#wp-query-selectable-title') + expect(page).to have_focus_on('.editable-toolbar-title--input') page.driver.browser.switch_to.active_element.send_keys('Some other name') page.driver.browser.switch_to.active_element.send_keys(:return) diff --git a/spec/lib/api/v3/queries/query_representer_parsing_spec.rb b/spec/lib/api/v3/queries/query_representer_parsing_spec.rb index 7e4f1766a1a..d92384f367d 100644 --- a/spec/lib/api/v3/queries/query_representer_parsing_spec.rb +++ b/spec/lib/api/v3/queries/query_representer_parsing_spec.rb @@ -100,4 +100,24 @@ describe ::API::V3::Queries::QueryRepresenter do expect(subject.highlighted_attributes).to eq(%i{type}) end end + + describe 'parsing ordered work packages' do + let(:request_body) do + { + 'orderedWorkPackages' => %w[ + /api/v3/work_packages/50 + /api/v3/work_packages/38 + /api/v3/work_packages/102 + ] + } + end + + it 'should set ordered_work_packages' do + expect(query) + .to receive(:ordered_work_packages=) + .with %w[50 38 102] + + subject + end + end end diff --git a/spec/models/queries/work_packages/filter/manual_sort_filter_spec.rb b/spec/models/queries/work_packages/filter/manual_sort_filter_spec.rb new file mode 100644 index 00000000000..55d5bb8df82 --- /dev/null +++ b/spec/models/queries/work_packages/filter/manual_sort_filter_spec.rb @@ -0,0 +1,49 @@ +#-- encoding: UTF-8 + +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe Queries::WorkPackages::Filter::ManualSortFilter, type: :model do + let!(:in_order) { FactoryBot.create(:work_package) } + let!(:in_order2) { FactoryBot.create(:work_package) } + let!(:out_order) { FactoryBot.create(:work_package) } + let(:query_double) { double(Query, ordered_work_packages: [in_order2.id, in_order.id]) } + + let(:instance) do + described_class.create!(name: :manual_sort, context: query_double, operator: 'ow', values: []) + end + + describe '#where' do + it 'filters based on the manual sort order' do + expect(WorkPackage.where(instance.where)) + .to match_array [in_order2, in_order] + end + end +end diff --git a/spec/models/queries/work_packages/manual_sorting_spec.rb b/spec/models/queries/work_packages/manual_sorting_spec.rb new file mode 100644 index 00000000000..ec9c64cec0f --- /dev/null +++ b/spec/models/queries/work_packages/manual_sorting_spec.rb @@ -0,0 +1,60 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe Query, "manual sorting ", type: :model do + shared_let(:user) { FactoryBot.create :admin } + shared_let(:project) { FactoryBot.create :project } + shared_let(:query) { FactoryBot.create :query, user: user, project: project } + shared_let(:wp_1) { FactoryBot.create :work_package, project: project } + shared_let(:wp_2) { FactoryBot.create :work_package, project: project } + + describe '#ordered_work_packages' do + it 'keeps the current set of ordered work packages' do + expect(query.ordered_work_packages).to eq [] + + expect(::OrderedWorkPackage.where(query_id: query.id).count).to eq 0 + query.ordered_work_packages = [wp_1.id, wp_2.id] + expect(::OrderedWorkPackage.where(query_id: query.id).count).to eq 0 + + expect(query.save).to eq true + expect(::OrderedWorkPackage.where(query_id: query.id).count).to eq 2 + + query.reload + expect(query.ordered_work_packages).to eq [wp_1.id, wp_2.id] + + query.ordered_work_packages = [wp_1.id] + expect(query.save).to eq true + expect(::OrderedWorkPackage.where(query_id: query.id).count).to eq 1 + + query.reload + expect(query.ordered_work_packages).to eq [wp_1.id] + end + end +end diff --git a/spec/support/components/work_packages/query_title.rb b/spec/support/components/work_packages/query_title.rb index d73662c54eb..710f47591ff 100644 --- a/spec/support/components/work_packages/query_title.rb +++ b/spec/support/components/work_packages/query_title.rb @@ -34,24 +34,32 @@ module Components def expect_changed - expect(page).to have_selector '.wp-query-selectable-title--save' - expect(page).to have_selector '.wp-query--selectable-title.-changed' + expect(page).to have_selector '.editable-toolbar-title--save' + expect(page).to have_selector '.editable-toolbar-title--input.-changed' end def expect_not_changed - expect(page).to have_no_selector '.wp-query-selectable-title--save' - expect(page).to have_no_selector '.wp-query--selectable-title.-changed' + expect(page).to have_no_selector '.editable-toolbar-title--save' + expect(page).to have_no_selector '.editable-toolbar-title--input.-changed' + end + + def input_field + find('.editable-toolbar-title--input') + end + + def expect_title(name) + expect(page).to have_field('editable-toolbar-title', with: name) end def press_save_button - find('.wp-query-selectable-title--save').click + find('.editable-toolbar-title--save').click end def rename(name, save: true) - fill_in 'wp-query-selectable-title', with: name + fill_in 'editable-toolbar-title', with: name if save - find('.wp-query--selectable-title').send_keys :return + input_field.send_keys :return end end end diff --git a/spec/support/components/work_packages/table_configuration_modal.rb b/spec/support/components/work_packages/table_configuration_modal.rb index 3652eed20cd..1a64847065b 100644 --- a/spec/support/components/work_packages/table_configuration_modal.rb +++ b/spec/support/components/work_packages/table_configuration_modal.rb @@ -87,8 +87,20 @@ module Components expect(page).to have_selector("#{selector} .tab-show.-disabled", text: name) end + def selected_tab(name) + page.find("#{selector} .tab-show.selected", text: name) + page.find("#{selector} .tab-content[data-tab-name='#{name}']") + end + def switch_to(target) - find("#{selector} .tab-show", text: target).click + # Switching too fast may result in the click handler not yet firing + # so wait a bit initially + sleep 1 + + retry_block do + find("#{selector} .tab-show", text: target, wait: 10).click + selected_tab(target) + end end def selector diff --git a/spec/support/contracts/shared.rb b/spec/support/contracts/shared.rb new file mode 100644 index 00000000000..3655a8d9f45 --- /dev/null +++ b/spec/support/contracts/shared.rb @@ -0,0 +1,26 @@ +shared_context 'model contract' do + shared_examples_for 'is not writable' do + before do + instance.model.attributes = { attribute => value } + end + + it 'explains the not writable error' do + instance.validate + expect(instance.errors.details[attribute]) + .to match_array [{ error: :error_readonly }] + end + end + + shared_examples_for 'is writable' do + before do + instance.model.attributes = { attribute => value } + end + + it 'is writable' do + instance.validate + + expect(instance.errors.details[attribute]) + .not_to include(error: :error_readonly) + end + end +end diff --git a/spec/support/matchers/has_conditional_selector.rb b/spec/support/matchers/has_conditional_selector.rb new file mode 100644 index 00000000000..311bda4fbdc --- /dev/null +++ b/spec/support/matchers/has_conditional_selector.rb @@ -0,0 +1,43 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +# Extending Capybara to allow a flagged check for has_selector to avoid +# lots of if/else + +module Capybara + class Session + def has_conditional_selector?(condition, *args) + if condition + has_selector? *args + else + has_no_selector? *args + end + end + end +end diff --git a/spec/support/pages/abstract_work_package.rb b/spec/support/pages/work_packages/abstract_work_package.rb similarity index 100% rename from spec/support/pages/abstract_work_package.rb rename to spec/support/pages/work_packages/abstract_work_package.rb diff --git a/spec/support/pages/abstract_work_package_create.rb b/spec/support/pages/work_packages/abstract_work_package_create.rb similarity index 97% rename from spec/support/pages/abstract_work_package_create.rb rename to spec/support/pages/work_packages/abstract_work_package_create.rb index 1c9f3bfc197..cb61f2e6d08 100644 --- a/spec/support/pages/abstract_work_package_create.rb +++ b/spec/support/pages/work_packages/abstract_work_package_create.rb @@ -27,7 +27,7 @@ #++ require 'support/pages/page' -require 'support/pages/abstract_work_package' +require 'support/pages/work_packages/abstract_work_package' module Pages class AbstractWorkPackageCreate < AbstractWorkPackage diff --git a/spec/support/pages/embedded_work_packages_table.rb b/spec/support/pages/work_packages/embedded_work_packages_table.rb similarity index 96% rename from spec/support/pages/embedded_work_packages_table.rb rename to spec/support/pages/work_packages/embedded_work_packages_table.rb index 74468222afa..625774994a2 100644 --- a/spec/support/pages/embedded_work_packages_table.rb +++ b/spec/support/pages/work_packages/embedded_work_packages_table.rb @@ -27,7 +27,7 @@ #++ require 'support/pages/page' -require 'support/pages/work_packages_table' +require 'support/pages/work_packages/work_packages_table' module Pages class EmbeddedWorkPackagesTable < WorkPackagesTable diff --git a/spec/support/pages/full_work_package.rb b/spec/support/pages/work_packages/full_work_package.rb similarity index 96% rename from spec/support/pages/full_work_package.rb rename to spec/support/pages/work_packages/full_work_package.rb index c75b22c4051..af492de2eec 100644 --- a/spec/support/pages/full_work_package.rb +++ b/spec/support/pages/work_packages/full_work_package.rb @@ -26,7 +26,7 @@ # See docs/COPYRIGHT.rdoc for more details. #++ -require 'support/pages/abstract_work_package' +require 'support/pages/work_packages/abstract_work_package' module Pages class FullWorkPackage < Pages::AbstractWorkPackage diff --git a/spec/support/pages/full_work_package_create.rb b/spec/support/pages/work_packages/full_work_package_create.rb similarity index 96% rename from spec/support/pages/full_work_package_create.rb rename to spec/support/pages/work_packages/full_work_package_create.rb index 620395d468e..dd542556a35 100644 --- a/spec/support/pages/full_work_package_create.rb +++ b/spec/support/pages/work_packages/full_work_package_create.rb @@ -27,7 +27,7 @@ #++ require 'support/pages/page' -require 'support/pages/abstract_work_package_create' +require 'support/pages/work_packages/abstract_work_package_create' module Pages class FullWorkPackageCreate < AbstractWorkPackageCreate diff --git a/spec/support/pages/split_work_package.rb b/spec/support/pages/work_packages/split_work_package.rb similarity index 94% rename from spec/support/pages/split_work_package.rb rename to spec/support/pages/work_packages/split_work_package.rb index 2492a07695b..8d1f2fcc2a8 100644 --- a/spec/support/pages/split_work_package.rb +++ b/spec/support/pages/work_packages/split_work_package.rb @@ -26,8 +26,8 @@ # See docs/COPYRIGHT.rdoc for more details. #++ -require 'support/pages/abstract_work_package' -require 'support/pages/split_work_package_create' +require 'support/pages/work_packages/abstract_work_package' +require 'support/pages/work_packages/split_work_package_create' module Pages class SplitWorkPackage < Pages::AbstractWorkPackage diff --git a/spec/support/pages/split_work_package_create.rb b/spec/support/pages/work_packages/split_work_package_create.rb similarity index 96% rename from spec/support/pages/split_work_package_create.rb rename to spec/support/pages/work_packages/split_work_package_create.rb index 41cf80c8c92..d5f48f49cd9 100644 --- a/spec/support/pages/split_work_package_create.rb +++ b/spec/support/pages/work_packages/split_work_package_create.rb @@ -27,7 +27,7 @@ #++ require 'support/pages/page' -require 'support/pages/abstract_work_package_create' +require 'support/pages/work_packages/abstract_work_package_create' module Pages class SplitWorkPackageCreate < AbstractWorkPackageCreate diff --git a/spec/support/pages/work_packages/work_package_card.rb b/spec/support/pages/work_packages/work_package_card.rb new file mode 100644 index 00000000000..ee5e584a5ea --- /dev/null +++ b/spec/support/pages/work_packages/work_package_card.rb @@ -0,0 +1,41 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2017 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See docs/COPYRIGHT.rdoc for more details. +#++ + +require 'support/pages/page' + +module Pages + class WorkPackageCard < Page + attr_reader :project, :work_package + + def initialize(work_package, project = nil) + @work_package = work_package + @project = project + end + + end +end diff --git a/spec/support/pages/work_packages_table.rb b/spec/support/pages/work_packages/work_packages_table.rb similarity index 98% rename from spec/support/pages/work_packages_table.rb rename to spec/support/pages/work_packages/work_packages_table.rb index 7ef66fdf397..414d74f2e09 100644 --- a/spec/support/pages/work_packages_table.rb +++ b/spec/support/pages/work_packages/work_packages_table.rb @@ -91,7 +91,7 @@ module Pages def expect_title(name, editable: true) if editable - expect(page).to have_field('wp-query-selectable-title', with: name, wait: 10) + expect(page).to have_field('editable-toolbar-title', with: name, wait: 10) else expect(page) .to have_selector('.toolbar-container', text: name, wait: 10) diff --git a/spec/support/pages/work_packages_timeline.rb b/spec/support/pages/work_packages/work_packages_timeline.rb similarity index 98% rename from spec/support/pages/work_packages_timeline.rb rename to spec/support/pages/work_packages/work_packages_timeline.rb index 7bf915fb138..2558c5715e4 100644 --- a/spec/support/pages/work_packages_timeline.rb +++ b/spec/support/pages/work_packages/work_packages_timeline.rb @@ -27,7 +27,7 @@ #++ require 'support/pages/page' -require 'support/pages/work_packages_table' +require 'support/pages/work_packages/work_packages_table' module Pages class WorkPackagesTimeline < WorkPackagesTable diff --git a/tslint.json b/tslint.json index b124a014117..03a9df2db76 100644 --- a/tslint.json +++ b/tslint.json @@ -55,7 +55,7 @@ ], "no-var-keyword": false, "no-arg": true, - "no-bitwise": true, + "no-bitwise": false, "no-console": [ true, "debug",