Merge pull request #7479 from opf/feature/custom-text-widget_on_dashboard

Feature/custom text widget on dashboard

[ci skip]
pull/7491/head
Oliver Günther 5 years ago committed by GitHub
commit 74a9ffbe78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 31
      app/assets/stylesheets/content/_grid.sass
  2. 3
      config/locales/js-en.yml
  3. 2
      frontend/src/app/components/wp-card-view/wp-card-view.component.ts
  4. 4
      frontend/src/app/components/wp-copy/wp-copy.controller.ts
  5. 118
      frontend/src/app/components/wp-edit-form/work-package-changeset.ts
  6. 19
      frontend/src/app/components/wp-edit-form/work-package-edit-field-handler.ts
  7. 6
      frontend/src/app/components/wp-edit-form/work-package-editing-service.ts
  8. 2
      frontend/src/app/components/wp-edit-form/work-package-filter-values.ts
  9. 2
      frontend/src/app/components/wp-inline-create/wp-inline-create.component.ts
  10. 2
      frontend/src/app/components/wp-new/wp-create.controller.ts
  11. 8
      frontend/src/app/components/wp-new/wp-create.service.ts
  12. 8
      frontend/src/app/components/wp-table/timeline/cells/timeline-cell-renderer.ts
  13. 4
      frontend/src/app/components/wp-table/timeline/cells/timeline-milestone-cell-renderer.ts
  14. 6
      frontend/src/app/modules/boards/board/board-list/board-list.component.ts
  15. 2
      frontend/src/app/modules/boards/board/board-list/board-lists.service.ts
  16. 6
      frontend/src/app/modules/boards/board/board.component.ts
  17. 2
      frontend/src/app/modules/boards/board/board.ts
  18. 73
      frontend/src/app/modules/fields/changeset/edit-changeset.ts
  19. 56
      frontend/src/app/modules/fields/edit/edit-field.component.ts
  20. 9
      frontend/src/app/modules/fields/edit/editing-portal/edit-field-handler.ts
  21. 9
      frontend/src/app/modules/fields/edit/editing-portal/edit-form-portal.component.ts
  22. 5
      frontend/src/app/modules/fields/edit/editing-portal/edit-form-portal.injector.ts
  23. 5
      frontend/src/app/modules/fields/edit/editing-portal/wp-editing-portal-service.ts
  24. 31
      frontend/src/app/modules/fields/edit/field-types/formattable-edit-field.component.ts
  25. 4
      frontend/src/app/modules/fields/openproject-fields.module.ts
  26. 66
      frontend/src/app/modules/grids/grid/area.service.ts
  27. 6
      frontend/src/app/modules/grids/grid/grid.component.html
  28. 17
      frontend/src/app/modules/grids/grid/grid.component.ts
  29. 10
      frontend/src/app/modules/grids/grid/remove-widget.service.ts
  30. 24
      frontend/src/app/modules/grids/openproject-grids.module.ts
  31. 22
      frontend/src/app/modules/grids/widgets/abstract-widget.component.ts
  32. 5
      frontend/src/app/modules/grids/widgets/custom-text/custom-text-changeset.ts
  33. 118
      frontend/src/app/modules/grids/widgets/custom-text/custom-text-edit-field.service.ts
  34. 37
      frontend/src/app/modules/grids/widgets/custom-text/custom-text.component.html
  35. 95
      frontend/src/app/modules/grids/widgets/custom-text/custom-text.component.ts
  36. 5
      frontend/src/app/modules/grids/widgets/documents/documents.component.ts
  37. 5
      frontend/src/app/modules/grids/widgets/news/news.component.ts
  38. 5
      frontend/src/app/modules/grids/widgets/project-description/project-description.component.ts
  39. 5
      frontend/src/app/modules/grids/widgets/time-entries-current-user/time-entries-current-user.component.ts
  40. 6
      frontend/src/app/modules/grids/widgets/widget-changeset.ts
  41. 5
      frontend/src/app/modules/grids/widgets/wp-calendar/wp-calendar.component.ts
  42. 23
      frontend/src/app/modules/grids/widgets/wp-graph/wp-graph.component.ts
  43. 2
      frontend/src/app/modules/grids/widgets/wp-table/wp-table-qs.component.html
  44. 5
      frontend/src/app/modules/grids/widgets/wp-table/wp-table-qs.component.ts
  45. 10
      frontend/src/app/modules/grids/widgets/wp-table/wp-table.component.ts
  46. 5
      frontend/src/app/modules/hal/dm-services/grid-dm.service.ts
  47. 2
      modules/boards/app/models/boards/grid.rb
  48. 47
      modules/boards/app/representers/api/v3/boards/widgets/board_options_representer.rb
  49. 4
      modules/boards/lib/open_project/boards/grid_registration.rb
  50. 3
      modules/boards/spec/factories/board_factory.rb
  51. 34
      modules/dashboards/lib/dashboards/grid_registration.rb
  52. 97
      modules/dashboards/spec/features/custom_text_spec.rb
  53. 2
      modules/dashboards/spec/features/project_description_spec.rb
  54. 2
      modules/dashboards/spec/features/work_package_graph_overview_spec.rb
  55. 4
      modules/dashboards/spec/features/work_package_graph_spec.rb
  56. 4
      modules/dashboards/spec/features/work_package_table_spec.rb
  57. 196
      modules/dashboards/spec/requests/api/v3/grids/grids_resource_spec.rb
  58. 23
      modules/grids/app/controllers/api/v3/grids/grids_api.rb
  59. 0
      modules/grids/app/representers/api/v3/grids/create_form_representer.rb
  60. 0
      modules/grids/app/representers/api/v3/grids/form_representer.rb
  61. 0
      modules/grids/app/representers/api/v3/grids/grid_collection_representer.rb
  62. 4
      modules/grids/app/representers/api/v3/grids/grid_payload_representer.rb
  63. 4
      modules/grids/app/representers/api/v3/grids/grid_representer.rb
  64. 4
      modules/grids/app/representers/api/v3/grids/schemas/grid_schema_representer.rb
  65. 0
      modules/grids/app/representers/api/v3/grids/update_form_representer.rb
  66. 17
      modules/grids/app/representers/api/v3/grids/widgets/chart_options_representer.rb
  67. 46
      modules/grids/app/representers/api/v3/grids/widgets/custom_text_options_representer.rb
  68. 42
      modules/grids/app/representers/api/v3/grids/widgets/default_options_representer.rb
  69. 49
      modules/grids/app/representers/api/v3/grids/widgets/query_options_representer.rb
  70. 58
      modules/grids/app/representers/api/v3/grids/widgets/widget_representer.rb
  71. 10
      modules/grids/app/services/grids/set_attributes_service.rb
  72. 2
      modules/grids/lib/grids/configuration.rb
  73. 8
      modules/grids/lib/grids/configuration/widget_strategy.rb
  74. 2
      modules/grids/spec/lib/api/v3/grids/grid_payload_representer_parsing_spec.rb
  75. 3
      modules/grids/spec/lib/api/v3/grids/grid_representer_rendering_spec.rb
  76. 4
      modules/grids/spec/lib/api/v3/grids/schemas/grid_schema_representer_spec.rb
  77. 3
      modules/grids/spec/services/grids/set_attributes_service_spec.rb
  78. 30
      modules/my_page/lib/my_page/grid_registration.rb
  79. 4
      modules/my_page/spec/features/my/accountable_spec.rb
  80. 2
      modules/my_page/spec/features/my/my_page_spec.rb
  81. 4
      modules/my_page/spec/features/my/work_package_table_spec.rb
  82. 2
      spec/support/work_packages/work_package_editor_field.rb

@ -1,6 +1,8 @@
$grid--gap-width: 20px
$grid--header-width: 20px
@import openproject/mixins
@mixin grid--commons
display: grid
grid-column-gap: $grid--gap-width
@ -105,6 +107,35 @@ $grid--header-width: 20px
overflow-y: hidden
display: block
// styles specific to the custom-text widget
&.-custom-text
a.inplace-editing--trigger-link
color: inherit
text-decoration: none
.op-ckeditor--wrapper
margin-bottom: 0
.ck-editor__top
position: sticky
top: 0
.inplace-edit--controls
position: initial
i
float: initial
padding: 0
edit-field-controls
display: flex
position: sticky
bottom: 0
justify-content: flex-end
.textarea-wrapper
margin-bottom: 0
.grid--widget-limited-text
max-height: 5rem
position: relative

@ -213,6 +213,8 @@ en:
choose_widget: 'Choose widget'
remove: 'Remove widget'
widgets:
custom_text:
title: 'Custom text'
documents:
title: 'Documents'
no_results: 'No documents yet.'
@ -241,6 +243,7 @@ en:
title: 'Calendar'
work_packages_overview:
title: 'Work packages overview'
placeholder: 'Click to edit ...'
homescreen:
blocks:

@ -295,7 +295,7 @@ export class WorkPackageCardViewComponent implements OnInit {
this.wpCreate
.createOrContinueWorkPackage(this.currentProject.identifier)
.then((changeset:WorkPackageChangeset) => {
this.activeInlineCreateWp = changeset.workPackage;
this.activeInlineCreateWp = changeset.resource;
this.workPackages = this.workPackages;
this.cdRef.detectChanges();
});

@ -84,9 +84,9 @@ export class WorkPackageCopyController extends WorkPackageCreateController {
return this.wpCreate
.copyWorkPackage(sourceChangeset)
.then((copyChangeset) => {
this.__initialized_at = copyChangeset.workPackage.__initialized_at;
this.__initialized_at = copyChangeset.resource.__initialized_at;
this.wpCacheService.updateWorkPackage(copyChangeset.workPackage);
this.wpCacheService.updateWorkPackage(copyChangeset.resource);
this.wpEditing.updateValue('new', copyChangeset);
return copyChangeset;

@ -30,7 +30,6 @@ import {debugLog} from '../../helpers/debug_output';
import {SchemaCacheService} from '../schemas/schema-cache.service';
import {WorkPackageCacheService} from '../work-packages/work-package-cache.service';
import {WorkPackageNotificationService} from '../wp-edit/wp-notification.service';
import {Injector} from '@angular/core';
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {FormResource} from 'core-app/modules/hal/resources/form-resource';
import {HalResourceService} from 'core-app/modules/hal/services/hal-resource.service';
@ -45,9 +44,9 @@ import {
} from "core-components/wp-edit-form/work-package-editing.service.interface";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {SchemaResource} from 'core-app/modules/hal/resources/schema-resource';
import {EditChangeset} from 'core-app/modules/fields/changeset/edit-changeset';
export class WorkPackageChangeset {
export class WorkPackageChangeset extends EditChangeset<WorkPackageResource> {
// Injections
public wpNotificationsService:WorkPackageNotificationService = this.injector.get(WorkPackageNotificationService);
public schemaCacheService:SchemaCacheService = this.injector.get(SchemaCacheService);
@ -58,20 +57,13 @@ export class WorkPackageChangeset {
public halResourceService:HalResourceService = this.injector.get(HalResourceService);
// The changeset to be applied to the work package
private changes:{ [attribute:string]:any } = {};
// private changes:{ [attribute:string]:any } = {};
public inFlight:boolean = false;
private wpFormPromise:Promise<FormResource>|null;
public form:FormResource|null;
// The current editing resource
public resource:WorkPackageResource|null;
public editingResource:WorkPackageResource|null;
constructor(readonly injector:Injector,
public workPackage:WorkPackageResource,
form?:FormResource) {
this.form = form || null;
}
private wpFormPromise:Promise<FormResource>|null;
public reset(key:string) {
delete this.changes[key];
@ -102,52 +94,17 @@ export class WorkPackageChangeset {
this.form = null;
}
public get empty() {
return _.isEmpty(this.changes);
}
/**
* Get attributes
* @returns {string[]}
*/
public get changedAttributes() {
return _.keys(this.changes);
}
/**
* Retrieve the editing value for the given attribute
*
* @param {string} key The attribute to read
* @return {any} Either the value from the overriden change, or the default value
*/
public value(key:string) {
if (this.isOverridden(key)) {
return this.changes[key];
} else {
return this.workPackage[key];
}
}
public setValue(key:string, val:any) {
this.changes[key] = val;
super.setValue(key, val);
// Update the form for fields that may alter the form itself
// when the work package is new. Otherwise, the save request afterwards
// will update the form automatically.
if (this.workPackage.isNew && (key === 'project' || key === 'type')) {
if (this.resource.isNew && (key === 'project' || key === 'type')) {
this.updateForm();
}
}
/**
* Return whether a change value exist for the given attribute key.
* @param {string} key
* @return {boolean}
*/
public isOverridden(key:string) {
return this.changes.hasOwnProperty(key);
}
public getForm():Promise<FormResource> {
if (!this.form) {
return this.updateForm();
@ -163,12 +120,12 @@ export class WorkPackageChangeset {
let payload = this.buildPayloadFromChanges();
if (!this.wpFormPromise) {
this.wpFormPromise = this.workPackage.$links
this.wpFormPromise = this.resource.$links
.update(payload)
.then((form:FormResource) => {
this.form = form;
if (!this.workPackage.isNew) {
this.schemaCacheService.state(this.workPackage).putValue(form.schema);
if (!this.resource.isNew) {
this.schemaCacheService.state(this.resource).putValue(form.schema);
}
this.rebuildDefaults(form.payload);
@ -191,7 +148,7 @@ export class WorkPackageChangeset {
public save():Promise<WorkPackageResource> {
this.inFlight = true;
const wasNew = this.workPackage.isNew;
const wasNew = this.resource.isNew;
let promise = new Promise<WorkPackageResource>((resolve, reject) => {
this.updateForm()
@ -204,32 +161,32 @@ export class WorkPackageChangeset {
return reject(errors);
}
this.workPackage.$links.updateImmediately(payload)
this.resource.$links.updateImmediately(payload)
.then((savedWp:WorkPackageResource) => {
// Ensure the schema is loaded before updating
this.schemaCacheService.ensureLoaded(savedWp).then(() => {
// Clear any previous activities
this.wpActivity.clear(this.workPackage.id!);
this.wpActivity.clear(this.resource.id!);
// Initialize any potentially new HAL values
savedWp.retainFrom(this.workPackage);
savedWp.retainFrom(this.resource);
this.inFlight = false;
this.workPackage = savedWp;
this.wpCacheService.updateWorkPackage(this.workPackage, true);
this.resource = savedWp;
this.wpCacheService.updateWorkPackage(this.resource, true);
if (wasNew) {
this.workPackage.overriddenSchema = undefined;
this.wpCreate.newWorkPackageCreated(this.workPackage);
this.resource.overriddenSchema = undefined;
this.wpCreate.newWorkPackageCreated(this.resource);
}
// If there is a parent, its view has to be updated as well
if (this.workPackage.parent) {
this.wpCacheService.loadWorkPackage(this.workPackage.parent.id.toString(), true);
if (this.resource.parent) {
this.wpCacheService.loadWorkPackage(this.resource.parent.id.toString(), true);
}
this.resource = null;
this.editingResource = null;
this.clear();
resolve(this.workPackage);
resolve(this.resource);
});
})
.catch(error => {
@ -252,7 +209,7 @@ export class WorkPackageChangeset {
* Will only apply for new work packages.
*/
private rebuildDefaults(payload:HalResource) {
if (!this.workPackage.isNew) {
if (!this.resource.isNew) {
return;
}
@ -270,7 +227,7 @@ export class WorkPackageChangeset {
*/
private mergeWithPayload(plainPayload:any) {
// Fall back to the last known state of the work package should the form not be loaded.
let reference = this.workPackage.$source;
let reference = this.resource.$source;
if (this.form) {
reference = this.form.payload.$source;
}
@ -300,19 +257,19 @@ export class WorkPackageChangeset {
private buildPayloadFromChanges() {
let payload;
if (this.workPackage.isNew) {
if (this.resource.isNew) {
// If the work package is new, we need to pass the entire form payload
// to let all default values be transmitted (type, status, etc.)
if (this.form) {
payload = this.form.payload.$source;
} else {
payload = this.workPackage.$source;
payload = this.resource.$source;
}
// Add attachments to be assigned.
// They will already be created on the server but now
// we need to claim them for the newly created work package.
payload['_links']['attachments'] = this.workPackage
payload['_links']['attachments'] = this.resource
.attachments
.elements
.map((a:HalResource) => { return { href: a.href }; });
@ -325,7 +282,7 @@ export class WorkPackageChangeset {
}
private get minimalPayload() {
return {lockVersion: this.workPackage.lockVersion, _links: {}};
return {lockVersion: this.resource.lockVersion, _links: {}};
}
/**
@ -371,9 +328,9 @@ export class WorkPackageChangeset {
* If loaded, return the form schema, which provides better information on writable status
* and contains available values.
*/
public get schema():SchemaResource {
return (this.form || this.workPackage).schema;
}
//public get schema():SchemaResource {
// return (this.form || this.resource).schema;
//}
/**
* Check whether the given attribute is writable.
@ -390,28 +347,27 @@ export class WorkPackageChangeset {
return fieldSchema.name || attribute;
}
public getSchemaName(attribute:string):string {
if (this.schema.hasOwnProperty('date') && (attribute === 'startDate' || attribute === 'dueDate')) {
return 'date';
} else {
return attribute;
return super.getSchemaName(attribute);
}
}
private buildResource() {
if (this.empty) {
this.resource = null;
this.wpEditing.updateValue(this.workPackage.id!, this);
this.editingResource = null;
this.wpEditing.updateValue(this.resource.id!, this);
return;
}
let payload:any = { ... this.workPackage.$source };
let payload:any = { ... this.resource.$source };
const resource = this.halResourceService.createHalResourceOfType('WorkPackage', this.mergeWithPayload(payload));
resource.overriddenSchema = this.schema;
this.resource = (resource as WorkPackageResource);
this.wpEditing.updateValue(this.workPackage.id!, this);
this.editingResource = (resource as WorkPackageResource);
this.wpEditing.updateValue(this.resource.id!, this);
}
}

@ -40,6 +40,7 @@ import {debugLog} from "core-app/helpers/debug_output";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {Subject} from 'rxjs';
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
export class WorkPackageEditFieldHandler extends EditFieldHandler {
// Injections
@ -64,7 +65,9 @@ export class WorkPackageEditFieldHandler extends EditFieldHandler {
public fieldName:string,
public schema:IFieldSchema,
public element:HTMLElement,
protected pathHelper:PathHelperService,
protected withErrors?:string[]) {
super();
this.editContext = form.editContext;
@ -243,4 +246,20 @@ export class WorkPackageEditFieldHandler extends EditFieldHandler {
{messages: this.errors.join(' ')});
}
}
public get formattableEditorType() {
if (this.fieldName === 'description') {
return 'full';
} else {
return 'constrained';
}
}
public previewContext(resource:WorkPackageResource) {
if (resource.isNew && resource.project) {
return resource.project.href;
} else if (!resource.isNew) {
return this.pathHelper.api.v3.work_packages.id(resource.id!).path;
}
}
}

@ -75,7 +75,7 @@ export class WorkPackageEditingService extends StateCacheService<WorkPackageChan
}
const changeset = state.value!;
changeset.workPackage = workPackage;
changeset.resource = workPackage;
return changeset;
}
@ -99,8 +99,8 @@ export class WorkPackageEditingService extends StateCacheService<WorkPackageChan
($) => $
.pipe(
map(([wp, changeset]) => {
if (wp && changeset && changeset.resource) {
return changeset.resource;
if (wp && changeset && changeset.editingResource) {
return changeset.editingResource;
} else {
return wp;
}

@ -44,7 +44,7 @@ export class WorkPackageFilterValues {
if (newValue) {
this.changeset.setValue(field, newValue);
this.changeset.workPackage[field] = newValue;
this.changeset.resource[field] = newValue;
}
}

@ -221,7 +221,7 @@ export class WorkPackageInlineCreateComponent implements OnInit, AfterViewInit,
.createOrContinueWorkPackage(this.projectIdentifier)
.then((changeset:WorkPackageChangeset) => {
const wp = this.currentWorkPackage = changeset.workPackage;
const wp = this.currentWorkPackage = changeset.resource;
this.editingSubscription = this
.wpCreate

@ -91,7 +91,7 @@ export class WorkPackageCreateController implements OnInit, OnDestroy {
.createdWorkPackage()
.then((changeset:WorkPackageChangeset) => {
this.changeset = changeset;
this.newWorkPackage = changeset.workPackage;
this.newWorkPackage = changeset.resource;
this.cdRef.detectChanges();
this.setTitle();

@ -92,7 +92,7 @@ export class WorkPackageCreateService implements IWorkPackageCreateService {
}
public copyWorkPackage(copyFrom:WorkPackageChangeset) {
let request = copyFrom.workPackage.$source;
let request = copyFrom.resource.$source;
// Ideally we would make an empty request before to get the create schema (cannot use the update schema of the source changeset)
// to get all the writable attributes and only send those.
@ -148,7 +148,7 @@ export class WorkPackageCreateService implements IWorkPackageCreateService {
return changesetPromise.then((changeset) => {
this.wpEditing.updateValue('new', changeset);
this.wpCacheService.updateWorkPackage(changeset.workPackage);
this.wpCacheService.updateWorkPackage(changeset.resource);
return changeset;
});
@ -157,7 +157,7 @@ export class WorkPackageCreateService implements IWorkPackageCreateService {
protected continueExistingEdit(type?:number) {
const changeset = this.wpEditing.state('new').value;
if (changeset !== undefined) {
const changeType = changeset.workPackage.type;
const changeType = changeset.resource.type;
const hasChanges = !changeset.empty;
const typeEmpty = !changeType && !type;
@ -191,7 +191,7 @@ export class WorkPackageCreateService implements IWorkPackageCreateService {
except = ['type'];
}
this.applyDefaults(changeset, changeset.workPackage, except);
this.applyDefaults(changeset, changeset.resource, except);
return changeset;
});

@ -111,8 +111,8 @@ export class TimelineCellRenderer {
delta:number,
direction:'left' | 'right' | 'both' | 'create' | 'dragright'):CellDateMovement {
const initialStartDate = changeset.workPackage.startDate;
const initialDueDate = changeset.workPackage.dueDate;
const initialStartDate = changeset.resource.startDate;
const initialDueDate = changeset.resource.dueDate;
const now = moment().format('YYYY-MM-DD');
@ -421,7 +421,7 @@ export class TimelineCellRenderer {
labels:WorkPackageCellLabels,
changeset:WorkPackageChangeset) {
const labelConfiguration = this.wpTableTimeline.getNormalizedLabels(changeset.workPackage);
const labelConfiguration = this.wpTableTimeline.getNormalizedLabels(changeset.resource);
if (!activeDragNDrop) {
// normal display
@ -460,7 +460,7 @@ export class TimelineCellRenderer {
}
// Get the rendered field
let [field, span] = this.fieldRenderer.renderFieldValue(changeset.workPackage, attribute, changeset);
let [field, span] = this.fieldRenderer.renderFieldValue(changeset.resource, attribute, changeset);
if (label && field && span) {
span.classList.add('label-content');

@ -78,7 +78,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
delta:number,
direction:'left' | 'right' | 'both' | 'create' | 'dragright') {
const initialDate = changeset.workPackage.date;
const initialDate = changeset.resource.date;
let dates:CellDateMovement = {};
if (initialDate) {
@ -221,7 +221,7 @@ export class TimelineMilestoneCellRenderer extends TimelineCellRenderer {
labels:WorkPackageCellLabels,
changeset:WorkPackageChangeset) {
const labelConfiguration = this.wpTableTimeline.getNormalizedLabels(changeset.workPackage);
const labelConfiguration = this.wpTableTimeline.getNormalizedLabels(changeset.resource);
if (!activeDragNDrop) {
// normal display

@ -129,14 +129,14 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
private readonly wpStatesInitialization:WorkPackageStatesInitializationService,
private readonly authorisationService:AuthorisationService,
private readonly wpInlineCreate:WorkPackageInlineCreateService,
private readonly injector:Injector,
protected readonly injector:Injector,
@Inject(IWorkPackageEditingServiceToken) private readonly wpEditing:WorkPackageEditingService,
private readonly loadingIndicator:LoadingIndicatorService,
private readonly wpCacheService:WorkPackageCacheService,
private readonly boardService:BoardService,
private readonly boardActionRegistry:BoardActionsRegistryService,
private readonly causedUpdates:CausedUpdatesService) {
super(I18n);
super(I18n, injector);
}
ngOnInit():void {
@ -339,7 +339,7 @@ export class BoardListComponent extends AbstractWidgetComponent implements OnIni
}
private loadQuery(visibly = true) {
const queryId:string = (this.resource.options.query_id as number|string).toString();
const queryId:string = (this.resource.options.queryId as number|string).toString();
let observable = this.QueryDm.stream(this.columnsQueryProps, queryId);

@ -75,7 +75,7 @@ export class BoardListsService {
startColumn: count + 1,
endColumn: count + 2,
options: {
query_id: query.id,
queryId: query.id,
filters: filters,
}
};

@ -117,7 +117,7 @@ export class BoardComponent implements OnInit, OnDestroy {
}
);
trackByQueryId = (index:number, widget:GridWidgetResource) => widget.options.query_id;
trackByQueryId = (index:number, widget:GridWidgetResource) => widget.options.queryId;
constructor(public readonly state:StateService,
private readonly I18n:I18nService,
@ -239,7 +239,7 @@ export class BoardComponent implements OnInit, OnDestroy {
this.currentQueryUpdatedMonitoring = this
.QueryUpdated
.monitor(this.board.queries.map((widget) => widget.options.query_id as string))
.monitor(this.board.queries.map((widget) => widget.options.queryId as string))
.pipe(
untilComponentDestroyed(this)
)
@ -252,7 +252,7 @@ export class BoardComponent implements OnInit, OnDestroy {
.lists
.filter((listComponent) => {
const id = query.id!.toString();
const listId = (listComponent.resource.options.query_id as string|number).toString() ;
const listId = (listComponent.resource.options.queryId as string|number).toString() ;
return id === listId;
})

@ -55,7 +55,7 @@ export class Board {
}
public removeQuery(widget:GridWidgetResource) {
this.grid.widgets = this.grid.widgets.filter(el => el.options.query_id !== widget.options.query_id);
this.grid.widgets = this.grid.widgets.filter(el => el.options.queryId !== widget.options.queryId);
}
public get queries():GridWidgetResource[] {

@ -0,0 +1,73 @@
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {FormResource} from "core-app/modules/hal/resources/form-resource";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {Injector} from '@angular/core';
export abstract class EditChangeset<T extends HalResource|{ [key:string]:unknown; }> {
// The changeset to be applied to the resource
public changes:{ [attribute:string]:any } = {};
public form:FormResource|null;
constructor(readonly injector:Injector,
public resource:T,
form?:FormResource) {
this.form = form || null;
}
public get empty() {
return _.isEmpty(this.changes);
}
/**
* Get attributes
* @returns {string[]}
*/
public get changedAttributes() {
return _.keys(this.changes);
}
/**
* Retrieve the editing value for the given attribute
*
* @param {string} key The attribute to read
* @return {any} Either the value from the overriden change, or the default value
*/
public value(key:string) {
if (this.isOverridden(key)) {
return this.changes[key];
} else {
return this.resource[key];
}
}
public setValue(key:string, val:any) {
this.changes[key] = val;
}
public getSchemaName(attribute:string):string {
return attribute;
}
public clear() {
this.changes = {};
}
/**
* Return whether a change value exist for the given attribute key.
* @param {string} key
* @return {boolean}
*/
public isOverridden(key:string) {
return this.changes.hasOwnProperty(key);
}
/**
* Get the best schema currently available, either the default resource schema (must exist).
* If loaded, return the form schema, which provides better information on writable status
* and contains available values.
*/
public get schema():SchemaResource {
return (this.form || this.resource).schema;
}
}

@ -34,7 +34,9 @@ import {
InjectionToken,
Injector,
OnDestroy,
OnInit
OnInit,
Input,
Optional
} from "@angular/core";
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@ -43,6 +45,8 @@ import {WorkPackageEditingService} from "core-components/wp-edit-form/work-packa
import {untilComponentDestroyed} from "ng2-rx-componentdestroyed";
import {Field, IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {EditChangeset} from "core-app/modules/fields/changeset/edit-changeset";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
export const OpEditingPortalSchemaToken = new InjectionToken('wp-editing-portal--schema');
export const OpEditingPortalHandlerToken = new InjectionToken('wp-editing-portal--handler');
@ -53,11 +57,7 @@ export const overflowingContainerAttribute = 'overflowingIdentifier';
export const editModeClassName = '-editing';
@Component({
template: ''
})
export class EditFieldComponent extends Field implements OnInit, OnDestroy {
export abstract class EditFieldComponent extends Field implements OnInit, OnDestroy {
/** Self reference */
public self = this;
@ -66,8 +66,8 @@ export class EditFieldComponent extends Field implements OnInit, OnDestroy {
constructor(readonly I18n:I18nService,
readonly elementRef:ElementRef,
@Inject(IWorkPackageEditingServiceToken) protected wpEditing:WorkPackageEditingService,
@Inject(OpEditingPortalChangesetToken) protected changeset:WorkPackageChangeset,
@Optional() @Inject(IWorkPackageEditingServiceToken) wpEditing:WorkPackageEditingService,
@Inject(OpEditingPortalChangesetToken) protected changeset:EditChangeset<HalResource>,
@Inject(OpEditingPortalSchemaToken) public schema:IFieldSchema,
@Inject(OpEditingPortalHandlerToken) readonly handler:EditFieldHandler,
readonly cdRef:ChangeDetectorRef,
@ -75,25 +75,27 @@ export class EditFieldComponent extends Field implements OnInit, OnDestroy {
super();
this.schema = this.schema || this.changeset.schema[this.name];
this.wpEditing.state(this.changeset.workPackage.id!)
.values$()
.pipe(
untilComponentDestroyed(this)
)
.subscribe((changeset) => {
if (this.changeset.form) {
const fieldSchema = changeset.schema[this.name];
if (!fieldSchema) {
return handler.deactivate(false);
if (wpEditing) {
wpEditing.state(this.changeset.resource.id!)
.values$()
.pipe(
untilComponentDestroyed(this)
)
.subscribe((changeset) => {
if (this.changeset.form) {
const fieldSchema = changeset.schema[this.name];
if (!fieldSchema) {
return handler.deactivate(false);
}
this.changeset = changeset;
this.schema = this.changeset.schema[this.name];
this.initialize();
this.cdRef.markForCheck();
}
this.changeset = changeset;
this.schema = this.changeset.schema[this.name];
this.initialize();
this.cdRef.markForCheck();
}
});
});
}
}
ngOnInit():void {
@ -142,7 +144,7 @@ export class EditFieldComponent extends Field implements OnInit, OnDestroy {
}
public get resource() {
return this.changeset.workPackage;
return this.changeset.resource;
}
/**

@ -27,6 +27,7 @@
// ++
import {Subject} from 'rxjs';
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
export abstract class EditFieldHandler {
/**
@ -133,4 +134,12 @@ export abstract class EditFieldHandler {
* Handle focus loss
*/
public abstract onFocusOut():void;
public get formattableEditorType() {
return 'constrained';
}
public previewContext(resource:HalResource) {
return undefined;
}
}

@ -11,7 +11,6 @@ import {
} from "@angular/core";
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {
EditFieldComponent,
OpEditingPortalChangesetToken,
OpEditingPortalHandlerToken,
OpEditingPortalSchemaToken
@ -20,6 +19,8 @@ import {createLocalInjector} from "core-app/modules/fields/edit/editing-portal/e
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {EditFieldService, IEditFieldType} from "core-app/modules/fields/edit/edit-field.service";
import {EditChangeset} from "core-app/modules/fields/changeset/edit-changeset";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
@Component({
selector: 'edit-form-portal',
@ -27,13 +28,13 @@ import {EditFieldService, IEditFieldType} from "core-app/modules/fields/edit/edi
})
export class EditFormPortalComponent implements OnInit, OnDestroy, AfterViewInit {
@Input() schemaInput:IFieldSchema;
@Input() changesetInput:WorkPackageChangeset;
@Input() changesetInput:EditChangeset<HalResource|{ [key:string]:unknown; }>;
@Input() editFieldHandler:EditFieldHandler;
@Output() public onEditFieldReady = new EventEmitter<void>();
public handler:EditFieldHandler;
public schema:IFieldSchema;
public changeset:WorkPackageChangeset;
public changeset:EditChangeset<HalResource|{ [key:string]:unknown; }>;
public fieldInjector:Injector;
public componentClass:IEditFieldType;
@ -54,7 +55,7 @@ export class EditFormPortalComponent implements OnInit, OnDestroy, AfterViewInit
} else {
this.handler = this.injector.get<EditFieldHandler>(OpEditingPortalHandlerToken);
this.schema = this.injector.get<IFieldSchema>(OpEditingPortalSchemaToken);
this.changeset = this.injector.get<WorkPackageChangeset>(OpEditingPortalChangesetToken);
this.changeset = this.injector.get<EditChangeset<HalResource|{ [key:string]:unknown; }>>(OpEditingPortalChangesetToken);
}
this.componentClass = this.editField.getClassFor(this.handler.fieldName, this.schema.type);

@ -7,14 +7,15 @@ import {
import {PortalInjector} from "@angular/cdk/portal";
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {WorkPackageChangeset} from "core-components/wp-edit-form/work-package-changeset";
import {EditChangeset} from "core-app/modules/fields/changeset/edit-changeset";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
/**
* Creates an injector for the edit field portal to pass data into.
*
* @returns {PortalInjector}
*/
export function createLocalInjector(injector:Injector, changeset:WorkPackageChangeset, fieldHandler:EditFieldHandler, schema:IFieldSchema):Injector {
export function createLocalInjector(injector:Injector, changeset:EditChangeset<HalResource|{ [key:string]:unknown; }>, fieldHandler:EditFieldHandler, schema:IFieldSchema):Injector {
const injectorTokens = new WeakMap();
injectorTokens.set(OpEditingPortalChangesetToken, changeset);

@ -9,12 +9,14 @@ import {EditFormPortalComponent} from "core-app/modules/fields/edit/editing-port
import {createLocalInjector} from "core-app/modules/fields/edit/editing-portal/edit-form-portal.injector";
import {take} from "rxjs/operators";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
@Injectable()
export class WorkPackageEditingPortalService {
constructor(private readonly appRef:ApplicationRef,
private readonly componentFactoryResolver:ComponentFactoryResolver) {
private readonly componentFactoryResolver:ComponentFactoryResolver,
private readonly pathHelper:PathHelperService) {
}
@ -35,6 +37,7 @@ export class WorkPackageEditingPortalService {
fieldName,
schema,
container,
this.pathHelper,
errors
);

@ -30,7 +30,6 @@ import {PathHelperService} from "core-app/modules/common/path-helper/path-helper
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {OpCkeditorComponent} from "core-app/modules/common/ckeditor/op-ckeditor.component";
import {ICKEditorContext, ICKEditorInstance} from "core-app/modules/common/ckeditor/ckeditor-setup.service";
import {untilComponentDestroyed} from 'ng2-rx-componentdestroyed';
export const formattableFieldTemplate = `
<div class="textarea-wrapper">
@ -45,26 +44,24 @@ export const formattableFieldTemplate = `
</div>
<edit-field-controls *ngIf="!(handler.inEditMode || initializationError)"
[fieldController]="field"
(onSave)="handler.handleUserSubmit()"
(onSave)="handleUserSubmit()"
(onCancel)="handler.handleUserCancel()"
[saveTitle]="text.save"
[cancelTitle]="text.cancel">
</edit-field-controls>
</div>
`
`;
@Component({
template: formattableFieldTemplate
})
export class FormattableEditFieldComponent extends EditFieldComponent implements OnInit {
readonly pathHelper:PathHelperService = this.injector.get(PathHelperService);
public readonly field = this;
// Detect when inner component could not be initalized
public initializationError = false;
@ViewChild(OpCkeditorComponent, { static: true }) instance:OpCkeditorComponent;
@ViewChild(OpCkeditorComponent, { static: true }) editor:OpCkeditorComponent;
// Values used in template
public isPreview:boolean = false;
@ -89,7 +86,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
}
public getCurrentValue():Promise<void> {
return this.instance
return this.editor
.getTransformedContent()
.then((val) => {
this.rawValue = val;
@ -118,24 +115,16 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
}
public get editorType() {
if (this.name === 'description') {
return 'full';
} else {
return 'constrained';
}
return this.handler.formattableEditorType;
}
private get previewContext() {
if (this.resource.isNew && this.resource.project) {
return this.resource.project.href;
} else if (!this.resource.isNew) {
return this.pathHelper.api.v3.work_packages.id(this.resource.id!).path;
}
return this.handler.previewContext(this.resource);
}
public reset() {
if (this.instance && this.instance.initialized) {
this.instance.content = this.rawValue;
if (this.editor && this.editor.initialized) {
this.editor.content = this.rawValue;
}
}
@ -148,7 +137,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
}
public set rawValue(val:string) {
this.value = {raw: val};
this.value = { raw: val };
}
public isEmpty():boolean {
@ -160,7 +149,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
}
protected initialize() {
if (this.resource.isNew && this.instance) {
if (this.resource.isNew && this.editor) {
// Reset CKEditor when reloading after type/form changes
this.reset();
}

@ -31,7 +31,6 @@ import {EditFieldService} from "core-app/modules/fields/edit/edit-field.service"
import {DisplayFieldService} from "core-app/modules/fields/display/display-field.service";
import {initializeCoreEditFields} from "core-app/modules/fields/edit/edit-field.initializer";
import {initializeCoreDisplayFields} from "core-app/modules/fields/display/display-field.initializer";
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component";
import {BooleanEditFieldComponent} from "core-app/modules/fields/edit/field-types/boolean-edit-field.component";
import {DateEditFieldComponent} from "core-app/modules/fields/edit/field-types/date-edit-field.component";
import {DurationEditFieldComponent} from "core-app/modules/fields/edit/field-types/duration-edit-field.component";
@ -50,7 +49,6 @@ import {WorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-
import {OpenprojectEditorModule} from 'core-app/modules/editor/openproject-editor.module';
import {UserFieldPortalComponent} from "core-app/modules/fields/display/display-portal/display-user-field-portal/user-field-portal.component";
import {UserFieldPortalService} from "core-app/modules/fields/display/display-portal/display-user-field-portal/user-field-portal-service";
import {PortalCleanupService} from "core-app/modules/fields/display/display-portal/portal-cleanup.service";
import {SelectAutocompleterRegisterService} from "core-app/modules/fields/edit/field-types/select-autocompleter-register.service";
@NgModule({
@ -76,7 +74,6 @@ import {SelectAutocompleterRegisterService} from "core-app/modules/fields/edit/f
declarations: [
EditFormPortalComponent,
UserFieldPortalComponent,
EditFieldComponent,
BooleanEditFieldComponent,
DateEditFieldComponent,
DurationEditFieldComponent,
@ -92,7 +89,6 @@ import {SelectAutocompleterRegisterService} from "core-app/modules/fields/edit/f
entryComponents: [
EditFormPortalComponent,
UserFieldPortalComponent,
EditFieldComponent,
BooleanEditFieldComponent,
DateEditFieldComponent,
DurationEditFieldComponent,

@ -5,7 +5,7 @@ import {GridDmService} from "core-app/modules/hal/dm-services/grid-dm.service";
import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {WidgetChangeset} from "core-app/modules/grids/widgets/widget-changeset";
@Injectable()
export class GridAreaService {
@ -18,22 +18,17 @@ export class GridAreaService {
public gridAreas:GridArea[];
public widgetAreas:GridWidgetArea[];
public gridAreaIds:string[];
public widgetResources:GridWidgetResource[] = [];
public mousedOverArea:GridArea|null;
constructor (private gridDm:GridDmService) {
}
constructor (private gridDm:GridDmService) { }
public set gridResource(value:GridResource) {
this.resource = value;
this.fetchSchema();
this.numRows = this.resource.rowCount;
this.numColumns = this.resource.columnCount;
this.widgetResources = this.resource.widgets;
this.buildAreas(false);
}
@ -46,18 +41,53 @@ export class GridAreaService {
this.gridAreaIds = this.buildGridAreaIds();
this.widgetAreas = this.buildGridWidgetAreas();
this.resource.widgets = this.widgetResources;
this.resource.rowCount = this.numRows;
this.resource.columnCount = this.numColumns;
if (save) {
this.saveGrid();
this.saveGrid(this.resource, this.schema);
}
}
public saveGrid() {
this.gridDm.update(this.resource, this.schema);
public saveWidgetChangeset(changeset:WidgetChangeset) {
let payload = this.gridDm.extractPayload(this.resource, this.schema);
let payloadWidget = payload.widgets.find((w:any) => w.id === changeset.resource.id);
Object.assign(payloadWidget, changeset.changes);
// Adding the id so that the url can be deduced
payload['id'] = this.resource.id;
this.saveGrid(payload);
}
private saveGrid(resource:GridWidgetResource|any, schema?:SchemaResource) {
this
.gridDm
.update(resource, schema)
.then(updatedGrid => {
this.assignAreasWidget(updatedGrid);
});
}
private assignAreasWidget(newGrid:GridResource) {
this.resource.widgets = newGrid.widgets;
let takenIds = this.widgetAreas.map(a => a.widget.id);
this.widgetAreas.forEach(area => {
let newWidget:GridWidgetResource;
// identify the right resource for the area. Typically that means fetching them by id.
// But new areas have unpersisted resources at first. Unpersisted resources have no id.
// In those cases, we find the one resource which is not claimed by any other area.
if (area.widget.id) {
newWidget = newGrid.widgets.find(widget => widget.id === area.widget.id)!;
} else {
newWidget = newGrid.widgets.find(widget => !takenIds.includes(widget.id) && widget.identifier === area.widget.identifier && widget.startRow === area.widget.startRow && widget.startColumn === area.widget.startColumn)!;
}
area.widget = newWidget!;
});
}
private buildGridAreas() {
@ -144,7 +174,7 @@ export class GridAreaService {
this.numColumns--;
// remove widgets that only span the removed column
this.widgetResources = this.widgetResources.filter((widget) => {
this.resource.widgets = this.widgetResources.filter((widget) => {
return !(widget.startColumn === column && widget.endColumn === column + 1);
});
@ -172,7 +202,7 @@ export class GridAreaService {
this.numRows--;
// remove widgets that only span the removed row
this.widgetResources = this.widgetResources.filter((widget) => {
this.resource.widgets = this.widgetResources.filter((widget) => {
return !(widget.startRow === row && widget.endRow === row + 1);
});
@ -222,4 +252,12 @@ export class GridAreaService {
this.schema = form.schema;
});
}
public removeWidget(removedWidget:GridWidgetResource) {
this.resource.widgets = this.widgetResources.filter((widget) => widget.id !== removedWidget.id );
}
public get widgetResources() {
return (this.resource && this.resource.widgets) || [];
}
}

@ -54,9 +54,9 @@
cdkDragHandle></span>
<div *cdkDragPlaceholder></div>
<ndc-dynamic [ndcDynamicComponent]="widgetComponent(area.widget)"
[ndcDynamicInputs]="widgetComponentInput(area.widget)"
[ndcDynamicOutputs]="widgetComponentOutput">
<ndc-dynamic [ndcDynamicComponent]="widgetComponent(area)"
[ndcDynamicInputs]="widgetComponentInput(area)"
[ndcDynamicOutputs]="widgetComponentOutput(area)">
</ndc-dynamic>
</div>
<resizer *ngIf="!drag.currentlyDragging"

@ -2,7 +2,8 @@ import {Component,
ComponentRef,
OnDestroy,
OnInit,
Input} from "@angular/core";
Input,
AfterViewInit} from "@angular/core";
import {GridResource} from "app/modules/hal/resources/grid-resource";
import {GridWidgetResource} from "app/modules/hal/resources/grid-widget-resource";
import {debugLog} from "app/helpers/debug_output";
@ -17,6 +18,8 @@ import {GridAreaService} from "core-app/modules/grids/grid/area.service";
import {GridAddWidgetService} from "core-app/modules/grids/grid/add-widget.service";
import {GridRemoveWidgetService} from "core-app/modules/grids/grid/remove-widget.service";
import {WidgetWpGraphComponent} from "core-app/modules/grids/widgets/wp-graph/wp-graph.component";
import {WidgetChangeset} from "core-app/modules/grids/widgets/widget-changeset";
import {GridWidgetArea} from "core-app/modules/grids/areas/grid-widget-area";
export interface WidgetRegistration {
identifier:string;
@ -62,7 +65,9 @@ export class GridComponent implements OnDestroy, OnInit {
this.uiWidgets.forEach((widget) => widget.destroy());
}
public widgetComponent(widget:GridWidgetResource|null) {
public widgetComponent(area:GridWidgetArea) {
let widget = area.widget;
if (!widget) {
return null;
}
@ -78,11 +83,13 @@ export class GridComponent implements OnDestroy, OnInit {
}
}
public widgetComponentInput(resource:GridWidgetResource) {
return { resource: resource };
public widgetComponentInput(area:GridWidgetArea) {
return { resource: area.widget };
}
public widgetComponentOutput = { resourceChanged: this.layout.saveGrid.bind(this.layout) };
public widgetComponentOutput(area:GridWidgetArea) {
return { resourceChanged: this.layout.saveWidgetChangeset.bind(this.layout) };
}
public get gridColumnStyle() {
return this.sanitization.bypassSecurityTrustStyle(`repeat(${this.layout.numColumns}, 1fr)`);

@ -14,15 +14,7 @@ export class GridRemoveWidgetService {
}
public widget(widget:GridWidgetResource) {
let removedWidget = widget;
this.layout.widgetResources = this.layout.widgetResources.filter((widget) => {
return widget.identifier !== removedWidget.identifier ||
widget.startColumn !== removedWidget.startColumn ||
widget.endColumn !== removedWidget.endColumn ||
widget.startRow !== removedWidget.startRow ||
widget.endRow !== removedWidget.endRow;
});
this.layout.removeWidget(widget);
this.layout.buildAreas();
}

@ -59,6 +59,8 @@ import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {WidgetProjectDescriptionComponent} from "core-app/modules/grids/widgets/project-description/project-description.component";
import {WidgetHeaderComponent} from "core-app/modules/grids/widgets/header/header.component";
import {WidgetWpOverviewComponent} from "core-app/modules/grids/widgets/wp-overview/wp-overview.component";
import {WidgetCustomTextComponent} from "core-app/modules/grids/widgets/custom-text/custom-text.component";
import {OpenprojectFieldsModule} from "core-app/modules/fields/openproject-fields.module";
export const GRID_ROUTES:Ng2StateDeclaration[] = [
{
@ -84,7 +86,9 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
OpenprojectWorkPackageGraphsModule,
OpenprojectCalendarModule,
DynamicModule.withComponents([WidgetDocumentsComponent,
DynamicModule.withComponents([
WidgetCustomTextComponent,
WidgetDocumentsComponent,
WidgetNewsComponent,
WidgetWpTableQuerySpaceComponent,
WidgetWpGraphComponent,
@ -93,6 +97,9 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
WidgetProjectDescriptionComponent,
WidgetTimeEntriesCurrentUserComponent]),
// Support for inline editig fields
OpenprojectFieldsModule,
// Routes for grid pages
UIRouterModule.forChild({ states: GRID_ROUTES }),
],
@ -108,6 +115,9 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
],
declarations: [
GridComponent,
// Widgets
WidgetCustomTextComponent,
WidgetDocumentsComponent,
WidgetNewsComponent,
WidgetWpCalendarComponent,
@ -118,6 +128,7 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
WidgetProjectDescriptionComponent,
WidgetTimeEntriesCurrentUserComponent,
// Widget menus
WidgetMenuComponent,
WidgetWpTableMenuComponent,
WidgetWpGraphMenuComponent,
@ -282,6 +293,17 @@ export function registerWidgets(injector:Injector) {
properties: {
name: i18n.t('js.grid.widgets.project_description.title')
}
},
{
identifier: 'custom_text',
component: WidgetCustomTextComponent,
title: i18n.t(`js.grid.widgets.custom_text.title`),
properties: {
name: i18n.t('js.grid.widgets.custom_text.title'),
text: {
raw: ''
}
}
}
];
});

@ -1,6 +1,7 @@
import {HostBinding, Input, EventEmitter, Output, HostListener} from "@angular/core";
import {HostBinding, Input, EventEmitter, Output, HostListener, Injector} from "@angular/core";
import {GridWidgetResource} from "app/modules/hal/resources/grid-widget-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {WidgetChangeset} from "core-app/modules/grids/widgets/widget-changeset";
export abstract class AbstractWidgetComponent {
@HostBinding('style.grid-column-start') gridColumnStart:number;
@ -10,23 +11,26 @@ export abstract class AbstractWidgetComponent {
@Input() resource:GridWidgetResource;
@Output() resourceChanged = new EventEmitter<GridWidgetResource>();
@Output() resourceChanged = new EventEmitter<WidgetChangeset>();
public get widgetName() {
return this.resource.options.name;
}
public renameWidget(name:string) {
this.resource.options.name = name;
let changeset = this.setChangesetOptions({ name: name });
this.resourceChanged.emit(this.resource);
this.resourceChanged.emit(changeset);
}
constructor(protected i18n:I18nService) { }
constructor(protected i18n:I18nService,
protected injector:Injector) { }
// apparently, static methods cannot be abstract
// https://github.com/microsoft/TypeScript/issues/14600
public static get identifier():string {
return 'need to override';
protected setChangesetOptions(values:{ [key:string]:unknown; }) {
let changeset = new WidgetChangeset(this.injector, this.resource);
changeset.setValue('options', Object.assign({}, this.resource.options, values));
return changeset;
}
}

@ -0,0 +1,5 @@
import {EditChangeset} from "core-app/modules/fields/changeset/edit-changeset";
export class CustomTextChangeset extends EditChangeset<{ [key:string]:unknown; }> {
}

@ -0,0 +1,118 @@
import {EditFieldHandler} from "core-app/modules/fields/edit/editing-portal/edit-field-handler";
import {ElementRef, Injector, Injectable} from "@angular/core";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {BehaviorSubject} from "rxjs";
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
import {CustomTextChangeset} from "core-app/modules/grids/widgets/custom-text/custom-text-changeset";
@Injectable()
export class CustomTextEditFieldService extends EditFieldHandler {
public fieldName = 'text';
public inEdit = false;
public inEditMode = false;
public inFlight = false;
public valueChanged$:BehaviorSubject<string>;
public changeset:CustomTextChangeset;
constructor(protected elementRef:ElementRef,
protected injector:Injector) {
super();
}
errorMessageOnLabel:string;
onFocusOut():void {
// interface
}
public initialize(value:GridWidgetResource) {
this.changeset = new CustomTextChangeset(this.injector, value.options);
this.valueChanged$ = new BehaviorSubject(value.options['text'] as string);
}
public reinitialize(value:GridWidgetResource) {
this.changeset = new CustomTextChangeset(this.injector, value.options);
}
/**
* Handle saving the text
*/
public handleUserSubmit():Promise<any> {
return this.update();
}
public reset(withText:string = '') {
if (withText.length > 0) {
withText += '\n';
}
this.changeset.setValue('text', { raw: withText });
}
public get schema():IFieldSchema {
return {
name: I18n.t('js.grid.widgets.custom_text.title'),
writable: true,
required: false,
type: 'Formattable',
hasDefault: false
};
}
private async update() {
return this
.onSubmit()
.then(() => {
this.valueChanged$.next(this.rawText);
this.deactivate();
});
}
public get rawText() {
return _.get(this.textValue, 'raw', '');
}
public get htmlText() {
return _.get(this.textValue, 'html', '');
}
public get textValue() {
return this.changeset.value('text');
}
public handleUserCancel() {
this.deactivate();
}
public get active() {
return this.inEdit;
}
public activate(withText?:string) {
this.inEdit = true;
}
deactivate():void {
this.changeset.clear();
this.inEdit = false;
}
focus():void {
const trigger = this.elementRef.nativeElement.querySelector('.inplace-editing--trigger-container');
trigger && trigger.focus();
}
handleUserKeydown(event:JQueryEventObject, onlyCancel?:boolean):void {
// interface
}
isChanged():boolean {
return !this.changeset.empty;
}
stopPropagation(evt:JQueryEventObject):boolean {
return false;
}
}

@ -0,0 +1,37 @@
<widget-header
[name]="widgetName"
[icon]="'quote'"
(onRenamed)="renameWidget($event)">
<widget-menu
[resource]="resource">
</widget-menu>
</widget-header>
<div class="grid--widget-content -custom-text">
<div class="wp-edit-field inplace-edit">
<edit-form-portal *ngIf="active"
[schemaInput]="schema"
[changesetInput]="changeset"
[editFieldHandler]="handler">
</edit-form-portal>
<div *ngIf="!active"
class="inplace-edit--read">
<accessible-by-keyboard
class="inplace-editing--trigger-container"
[spanClass]="inplaceEditClasses"
[linkClass]="'inplace-editing--trigger-link'"
(execute)="activate()">
<span class="inplace-edit--read-value -default">
<span
*ngIf="!textEmpty"
[innerHTML]="customText"></span>
<span
*ngIf="textEmpty"
[innerHTML]="placeholderText"></span>
</span>
</accessible-by-keyboard>
</div>
</div>
</div>

@ -0,0 +1,95 @@
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {Component, ChangeDetectionStrategy, Injector, OnInit, OnDestroy, SimpleChanges, ChangeDetectorRef} from '@angular/core';
import {CustomTextEditFieldService} from "core-app/modules/grids/widgets/custom-text/custom-text-edit-field.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {untilComponentDestroyed} from 'ng2-rx-componentdestroyed';
import {filter} from 'rxjs/operators';
@Component({
templateUrl: './custom-text.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
CustomTextEditFieldService
]
})
export class WidgetCustomTextComponent extends AbstractWidgetComponent implements OnInit, OnDestroy {
protected currentRawText:string;
constructor (protected i18n:I18nService,
protected injector:Injector,
public handler:CustomTextEditFieldService,
protected cdr:ChangeDetectorRef) {
super(i18n, injector);
}
ngOnInit():void {
this.memorizeRawText();
this.handler.initialize(this.resource);
this
.handler
.valueChanged$
.pipe(
untilComponentDestroyed(this),
filter(value => value !== this.resource.options['text'])
).subscribe(newText => {
let changeset = this.setChangesetOptions({ text: { raw: newText } });
this.resourceChanged.emit(changeset);
});
}
ngOnDestroy():void {
// comply to interface
}
ngOnChanges(changes:SimpleChanges):void {
if (changes.resource.currentValue.options.text.raw !== this.currentRawText) {
this.memorizeRawText();
this.handler.reinitialize(this.resource);
this.cdr.detectChanges();
}
}
public activate() {
this.handler.activate();
}
public get customText() {
return this.handler.htmlText;
}
public get placeholderText() {
return this.i18n.t('js.grid.widgets.work_packages_overview.placeholder');
}
public get inplaceEditClasses() {
let classes = 'inplace-editing--container wp-edit-field--display-field';
if (this.textEmpty) {
classes += ' -placeholder';
}
return classes;
}
public get schema() {
return this.handler.schema;
}
public get changeset() {
return this.handler.changeset;
}
public get active() {
return this.handler.active;
}
public get textEmpty() {
return !this.customText;
}
private memorizeRawText() {
this.currentRawText = (this.resource.options.text as HalResource).raw;
}
}

@ -1,5 +1,5 @@
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {Component, OnInit, SecurityContext, ChangeDetectionStrategy, ChangeDetectorRef} from '@angular/core';
import {Component, OnInit, SecurityContext, ChangeDetectionStrategy, ChangeDetectorRef, Injector} from '@angular/core';
import {DocumentResource} from "../../../../../../../modules/documents/frontend/module/hal/resources/document-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource";
@ -25,8 +25,9 @@ export class WidgetDocumentsComponent extends AbstractWidgetComponent implements
readonly i18n:I18nService,
readonly timezone:TimezoneService,
readonly domSanitizer:DomSanitizer,
protected readonly injector:Injector,
readonly cdr:ChangeDetectorRef) {
super(i18n);
super(i18n, injector);
}
ngOnInit() {

@ -1,5 +1,5 @@
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {Component, OnInit, ChangeDetectorRef} from '@angular/core';
import {Component, OnInit, ChangeDetectorRef, Injector} from '@angular/core';
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
@ -25,11 +25,12 @@ export class WidgetNewsComponent extends AbstractWidgetComponent implements OnIn
constructor(readonly halResource:HalResourceService,
readonly pathHelper:PathHelperService,
readonly i18n:I18nService,
protected readonly injector:Injector,
readonly timezone:TimezoneService,
readonly userCache:UserCacheService,
readonly newsDm:NewsDmService,
readonly cdr:ChangeDetectorRef) {
super(i18n);
super(i18n, injector);
}
ngOnInit() {

@ -26,7 +26,7 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef} from '@angular/core';
import {Component, OnInit, ChangeDetectionStrategy, ChangeDetectorRef, Injector} from '@angular/core';
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {ProjectDmService} from "core-app/modules/hal/dm-services/project-dm.service";
@ -40,10 +40,11 @@ export class WidgetProjectDescriptionComponent extends AbstractWidgetComponent i
public description:string;
constructor(protected readonly i18n:I18nService,
protected readonly injector:Injector,
protected readonly projectDm:ProjectDmService,
protected readonly currentProject:CurrentProjectService,
protected readonly cdr:ChangeDetectorRef) {
super(i18n);
super(i18n, injector);
}
ngOnInit() {

@ -1,4 +1,4 @@
import {Component, OnInit, ChangeDetectorRef} from "@angular/core";
import {Component, OnInit, ChangeDetectorRef, Injector} from "@angular/core";
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service";
@ -31,12 +31,13 @@ export class WidgetTimeEntriesCurrentUserComponent extends AbstractWidgetCompone
public rows:{ date:string, sum?:string, entry?:TimeEntryResource}[] = [];
constructor(readonly timeEntryDm:TimeEntryDmService,
protected readonly injector:Injector,
readonly timezone:TimezoneService,
readonly i18n:I18nService,
readonly pathHelper:PathHelperService,
readonly confirmDialog:ConfirmDialogService,
protected readonly cdr:ChangeDetectorRef) {
super(i18n);
super(i18n, injector);
}
ngOnInit() {

@ -0,0 +1,6 @@
import {EditChangeset} from "core-app/modules/fields/changeset/edit-changeset";
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
export class WidgetChangeset extends EditChangeset<GridWidgetResource> {
}

@ -26,7 +26,7 @@
// See doc/COPYRIGHT.rdoc for more details.
// ++
import {Component} from '@angular/core';
import {Component, Injector} from '@angular/core';
import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget.component";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {CurrentProjectService} from "core-components/projects/current-project.service";
@ -36,8 +36,9 @@ import {CurrentProjectService} from "core-components/projects/current-project.se
})
export class WidgetWpCalendarComponent extends AbstractWidgetComponent {
constructor(protected readonly i18n:I18nService,
protected readonly injector:Injector,
protected readonly currentProject:CurrentProjectService) {
super(i18n);
super(i18n, injector);
}
public get projectIdentifier() {

@ -1,4 +1,4 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {Component, OnDestroy, OnInit, Injector, ChangeDetectionStrategy, ChangeDetectorRef} from '@angular/core';
import {WorkPackageEmbeddedGraphDataset} from "core-app/modules/work-package-graphs/embedded/wp-embedded-graph.component";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {UrlParamsHelperService} from "core-components/wp-query/url-params-helper";
@ -11,15 +11,18 @@ import {WpGraphConfiguration} from "core-app/modules/work-package-graphs/configu
selector: 'widget-wp-graph',
templateUrl: './wp-graph.component.html',
styleUrls: ['../wp-table/wp-table.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [WpGraphConfigurationService]
})
export class WidgetWpGraphComponent extends AbstractWidgetComponent implements OnInit, OnDestroy {
public datasets:WorkPackageEmbeddedGraphDataset[] = [];
constructor(protected i18n:I18nService,
protected injector:Injector,
protected cdr:ChangeDetectorRef,
protected urlParamsHelper:UrlParamsHelperService,
protected readonly graphConfiguration:WpGraphConfigurationService) {
super(i18n);
super(i18n, injector);
}
ngOnInit() {
@ -38,18 +41,19 @@ export class WidgetWpGraphComponent extends AbstractWidgetComponent implements O
public updateGraph(config:any) {
this.graphConfiguration.persistAndReload()
.then(() => {
this.updateDatasets();
this.repaint();
if (this.resource.options.chartType !== this.graphConfiguration.chartType) {
this.resource.options.chartType = this.graphConfiguration.chartType;
let changeset = this.setChangesetOptions({ chartType: this.graphConfiguration.chartType });
this.resourceChanged.emit(this.resource);
this.resourceChanged.emit(changeset);
}
});
}
protected updateDatasets() {
protected repaint() {
this.datasets = this.graphConfiguration.datasets;
this.cdr.detectChanges();
}
protected initializeConfiguration() {
@ -67,10 +71,11 @@ export class WidgetWpGraphComponent extends AbstractWidgetComponent implements O
this.graphConfiguration.ensureQueryAndLoad()
.then(() => {
if (!this.resource.options.queryId) {
this.resource.options.queryId = this.graphConfiguration.queryParams[0].id;
this.resourceChanged.emit(this.resource);
let changeset = this.setChangesetOptions({ queryId: this.graphConfiguration.queryParams[0].id });
this.resourceChanged.emit(changeset);
}
this.updateDatasets();
this.repaint();
});
}

@ -1,4 +1,4 @@
<ng-container wp-isolated-query-space>
<widget-wp-table [resource]="resource"
(resourceChanged)="onResourceChanged(resource)"> </widget-wp-table>
(resourceChanged)="onResourceChanged($event)"> </widget-wp-table>
</ng-container>

@ -1,13 +1,14 @@
import {Component} from '@angular/core';
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {GridWidgetResource} from "core-app/modules/hal/resources/grid-widget-resource";
import {WidgetChangeset} from "core-app/modules/grids/widgets/widget-changeset";
@Component({
templateUrl: './wp-table-qs.component.html',
styleUrls: ['./wp-table-qs.component.sass'],
})
export class WidgetWpTableQuerySpaceComponent extends AbstractWidgetComponent {
public onResourceChanged(resource:GridWidgetResource) {
this.resourceChanged.emit(resource);
public onResourceChanged(changeset:WidgetChangeset) {
this.resourceChanged.emit(changeset);
}
}

@ -1,4 +1,4 @@
import {Component} from '@angular/core';
import {Component, Injector, ChangeDetectionStrategy} from '@angular/core';
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {QueryFormResource} from "core-app/modules/hal/resources/query-form-resource";
import {QueryResource} from "core-app/modules/hal/resources/query-resource";
@ -17,6 +17,7 @@ import {untilComponentDestroyed} from 'ng2-rx-componentdestroyed';
selector: 'widget-wp-table',
templateUrl: './wp-table.component.html',
styleUrls: ['./wp-table.component.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WidgetWpTableComponent extends AbstractWidgetComponent {
public queryId:string|null;
@ -38,21 +39,22 @@ export class WidgetWpTableComponent extends AbstractWidgetComponent {
};
constructor(protected i18n:I18nService,
protected readonly injector:Injector,
protected urlParamsHelper:UrlParamsHelperService,
protected readonly state:StateService,
protected readonly queryDm:QueryDmService,
protected readonly querySpace:IsolatedQuerySpace,
protected readonly queryFormDm:QueryFormDmService) {
super(i18n);
super(i18n, injector);
}
ngOnInit() {
if (!this.resource.options.queryId) {
this.createInitial()
.then((query) => {
this.resource.options.queryId = query.id;
let changeset = this.setChangesetOptions({ queryId: query.id });
this.resourceChanged.emit(this.resource);
this.resourceChanged.emit(changeset);
this.queryId = query.id;
});

@ -61,10 +61,10 @@ export class GridDmService extends AbstractDmService<GridResource> {
payload).toPromise();
}
public update(resource:GridResource, schema:SchemaResource):Promise<GridResource> {
public update(resource:GridResource, schema:SchemaResource|null = null):Promise<GridResource> {
let payload = this.extractPayload(resource, schema);
return this.halResourceService.patch<GridResource>(this.pathHelper.api.v3.grids.id(resource.idFromLink).toString(),
return this.halResourceService.patch<GridResource>(this.pathHelper.api.v3.grids.id(resource.id!).toString(),
payload).toPromise();
}
@ -84,6 +84,7 @@ export class GridDmService extends AbstractDmService<GridResource> {
if (payload.widgets) {
payload.widgets = resource.widgets.map((widget) => {
return {
id: widget.id,
startRow: widget.startRow,
endRow: widget.endRow,
startColumn: widget.startColumn,

@ -53,7 +53,7 @@ module Boards
def contained_query_ids
widgets
.map { |w| w.options['query_id'] }
.map { |w| w.options['queryId'] || w.options['query_id'] }
.compact
end
end

@ -0,0 +1,47 @@
#-- 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 Boards
module Widgets
class BoardOptionsRepresenter < ::API::V3::Grids::Widgets::DefaultOptionsRepresenter
property :queryId,
getter: ->(represented:, **) {
represented['queryId'] || represented['query_id']
}
property :filters,
getter: ->(represented:, **) {
represented['filters']
}
end
end
end
end
end

@ -6,6 +6,10 @@ module OpenProject
widgets 'work_package_query'
widget_strategy 'work_package_query' do
options_representer '::API::V3::Boards::Widgets::BoardOptionsRepresenter'
end
defaults -> {
{
row_count: 1,

@ -24,8 +24,8 @@ FactoryBot.define do
end
end
board.widgets << FactoryBot.create(:grid_widget,
identifier: 'work_package_query',
start_row: 1,
end_row: 2,
start_column: 1,
@ -52,6 +52,7 @@ FactoryBot.define do
end
board.widgets << FactoryBot.create(:grid_widget,
identifier: 'work_package_query',
start_row: 1,
end_row: 2,
start_column: 1,

@ -7,24 +7,36 @@ module Dashboards
'work_packages_graph',
'project_description',
'work_packages_calendar',
'work_packages_overview'
'work_packages_overview',
'custom_text'
remove_query_lambda = -> {
::Query.find_by(id: options[:queryId])&.destroy
}
save_or_manage_queries_lambda = ->(user, project) {
user.allowed_to?(:save_queries, project) &&
user.allowed_to?(:manage_public_queries, project)
}
widget_strategy 'work_packages_table' do
after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
after_destroy remove_query_lambda
allowed ->(user, project) {
user.allowed_to?(:save_queries, project) &&
user.allowed_to?(:manage_public_queries, project)
}
allowed save_or_manage_queries_lambda
options_representer '::API::V3::Grids::Widgets::QueryOptionsRepresenter'
end
widget_strategy 'work_packages_graph' do
after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
after_destroy remove_query_lambda
allowed ->(user, project) {
user.allowed_to?(:save_queries, project) &&
user.allowed_to?(:manage_public_queries, project)
}
allowed save_or_manage_queries_lambda
options_representer '::API::V3::Grids::Widgets::ChartOptionsRepresenter'
end
widget_strategy 'custom_text' do
options_representer '::API::V3::Grids::Widgets::CustomTextOptionsRepresenter'
end
defaults -> {

@ -0,0 +1,97 @@
#-- 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/pages/dashboard'
describe 'Project description widget on dashboard', type: :feature, js: true do
let!(:project) do
FactoryBot.create :project
end
let(:permissions) do
%i[view_dashboards
manage_dashboards]
end
let(:role) do
FactoryBot.create(:role, permissions: permissions)
end
let(:user) do
FactoryBot.create(:user, member_in_project: project, member_with_permissions: permissions)
end
let(:dashboard_page) do
Pages::Dashboard.new(project)
end
before do
login_as user
dashboard_page.visit!
end
it 'can add the widget and see the description in it' do
dashboard_page.add_column(3, before_or_after: :before)
sleep(0.1)
dashboard_page.add_widget(2, 3, "Custom text")
sleep(0.1)
# As the user lacks the manage_public_queries and save_queries permission, no other widget is present
custom_text_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)')
custom_text_widget.expect_to_span(2, 3, 5, 5)
custom_text_widget.resize_to(6, 5)
custom_text_widget.expect_to_span(2, 3, 7, 6)
within custom_text_widget.area do
find('.inplace-editing--trigger-container').click
field = WorkPackageEditorField.new(page, 'description', selector: '.wp-inline-edit--active-field')
field.set_value('My own little text')
field.save!
expect(page)
.to have_selector('.wp-edit-field--display-field', text: 'My own little text')
find('.inplace-editing--trigger-container').click
field.set_value('My new text')
field.cancel_by_click
expect(page)
.to have_selector('.wp-edit-field--display-field', text: 'My own little text')
end
end
end

@ -63,7 +63,7 @@ describe 'Project description widget on dashboard', type: :feature, js: true do
dashboard_page.add_widget(2, 3, "Project description")
sleep(1)
sleep(0.1)
# As the user lacks the manage_public_queries and save_queries permission, no other widget is present
description_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)')

@ -88,6 +88,8 @@ describe 'Work package overview graph widget on dashboard',
it 'can add the widget' do
dashboard.add_column(3, before_or_after: :before)
sleep(0.1)
dashboard.add_widget(1, 1, "Work packages overview")
sleep(0.1)

@ -100,9 +100,11 @@ describe 'Arbitrary WorkPackage query graph widget dashboard', type: :feature, j
it 'can add the widget and see the work packages of the filtered for types' do
dashboard_page.add_column(3, before_or_after: :before)
sleep(0.1)
dashboard_page.add_widget(2, 3, "Work packages graph")
sleep(1)
sleep(0.1)
filter_area = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(2)')

@ -96,9 +96,11 @@ describe 'Arbitrary WorkPackage query table widget dashboard', type: :feature, j
it 'can add the widget and see the work packages of the filtered for types' do
dashboard_page.add_column(3, before_or_after: :before)
sleep(0.2)
dashboard_page.add_widget(2, 3, "Work packages table")
sleep(1)
sleep(0.2)
filter_area = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(2)')

@ -0,0 +1,196 @@
#-- 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', type: :request, content_type: :json do
include Rack::Test::Methods
include API::V3::Utilities::PathHelper
let(:current_user) do
FactoryBot.create(:user,
member_in_project: project,
member_with_permissions: permissions)
end
let(:permissions) { %i[view_dashboards manage_dashboards] }
let(:project) { FactoryBot.create(:project) }
let(:grid) do
FactoryBot.create(:dashboard,
project: project,
widgets: widgets)
end
let(:widgets) do
[FactoryBot.create(:grid_widget,
identifier: 'custom_text',
start_column: 1,
end_column: 3,
start_row: 1,
end_row: 3,
options: {
text: custom_text
})]
end
let(:custom_text) { "Some text a user wrote" }
before do
login_as(current_user)
end
subject(:response) { last_response }
describe '#get' do
let(:path) { api_v3_paths.grid(grid.id) }
let(:stored_grids) do
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 'identifies the url the grid is stored for' do
expect(subject.body)
.to be_json_eql(project_dashboards_path(project).to_json)
.at_path('_links/scope/href')
end
it 'has a widget that renders custom text' do
expect(subject.body)
.to be_json_eql('custom_text'.to_json)
.at_path('widgets/0/identifier')
expect(subject.body)
.to be_json_eql(custom_text.to_json)
.at_path('widgets/0/options/text/raw')
end
context 'with the grid not existing' do
let(:path) { api_v3_paths.grid(grid.id + 1) }
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(grid.id) }
let(:stored_grids) do
grid
end
let(:widget_params) { [] }
let(:params) do
{
"rowCount": 10,
"name": 'foo',
"columnCount": 15,
"widgets": widget_params
}.with_indifferent_access
end
before do
stored_grids
patch path, params.to_json
end
context 'with an added custom_text widget' do
let(:widget_params) do
[
{
"startColumn": 1,
"startRow": 1,
"endColumn": 3,
"endRow": 3,
"identifier": "custom_text",
"options": {
"name": "Name for custom text widget",
"text": {
"format": "markdown",
"raw": "A custom text text",
"html": "<p>A custom text text</p>"
}
}
}.with_indifferent_access
]
end
let(:widgets) { [] }
it 'responds with 200 OK' do
expect(subject.status).to eq(200)
end
it 'returns the altered grid block with the added widget' 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')
expect(subject.body)
.to be_json_eql(params['widgets'][0]['options']['text']['raw'].to_json)
.at_path('widgets/0/options/text/raw')
expect(subject.body)
.to be_json_eql(params['widgets'][0]['options']['name'].to_json)
.at_path('widgets/0/options/name')
end
it 'perists the changes' do
expect(grid.reload.row_count)
.to eql params['rowCount']
expect(grid.reload.widgets[0].options['text'])
.to eql params['widgets'][0]['options']['text']['raw']
expect(grid.reload.widgets[0].options['name'])
.to eql params['widgets'][0]['options']['name']
end
end
end
end

@ -79,7 +79,28 @@ module API
end
end
patch &::API::V3::Utilities::Endpoints::Update.new(model: ::Grids::Grid).mount
patch &::API::V3::Utilities::Endpoints::Update.new(model: ::Grids::Grid,
params_modifier: ->(params) do
params[:widgets]&.each do |widget|
# Need to parse the widget options again
# as the right representer needs to be used
# which is specific to the @grid.class. The parsing
# before strives to be agnostic.
strategy = ::Grids::Configuration
.widget_strategy(@grid.class,
widget.identifier)
representer = strategy.options_representer.constantize
widget.options = representer
.new(OpenStruct.new, current_user: current_user)
.from_hash(widget.options)
.to_h
.with_indifferent_access
end
params
end)
.mount
delete &::API::V3::Utilities::Endpoints::Delete.new(model: ::Grids::Grid).mount
mount UpdateFormAPI

@ -33,6 +33,10 @@ module API
module Grids
class GridPayloadRepresenter < GridRepresenter
include ::API::Utilities::PayloadRepresenter
def widget_representer_class
Widgets::WidgetPayloadRepresenter
end
end
end
end

@ -89,12 +89,12 @@ module API
exec_context: :decorator,
getter: ->(*) do
represented.widgets.map do |widget|
WidgetRepresenter.new(widget, current_user: current_user)
Widgets::WidgetRepresenter.new(widget, current_user: current_user)
end
end,
setter: ->(fragment:, **) do
represented.widgets = fragment.map do |widget_fragment|
WidgetRepresenter
Widgets::WidgetRepresenter
.new(::Grids::Widget.new, current_user: current_user)
.from_hash(widget_fragment.with_indifferent_access)
end

@ -87,10 +87,10 @@ module API
visibility: false,
values_callback: -> do
represented.assignable_widgets.map do |identifier|
OpenStruct.new(identifier: identifier)
OpenStruct.new(identifier: identifier, grid: represented.model, options: {})
end
end,
value_representer: ::API::V3::Grids::WidgetRepresenter,
value_representer: ::API::V3::Grids::Widgets::WidgetRepresenter,
link_factory: false
def self.represented_class

@ -29,17 +29,12 @@
module API
module V3
module Grids
class WidgetRepresenter < ::API::Decorators::Single
property :identifier
property :start_row
property :end_row
property :start_column
property :end_column
property :options
def _type
'GridWidget'
module Widgets
class ChartOptionsRepresenter < QueryOptionsRepresenter
property :chartType,
getter: ->(represented:, **) {
represented['chartType']
}
end
end
end

@ -0,0 +1,46 @@
#-- 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 Grids
module Widgets
class CustomTextOptionsRepresenter < DefaultOptionsRepresenter
include API::Decorators::FormattableProperty
formattable_property :text,
getter: ->(*) do
::API::Decorators::Formattable.new(represented[:text],
object: represented,
plain: false)
end
end
end
end
end
end

@ -0,0 +1,42 @@
#-- 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 Grids
module Widgets
class DefaultOptionsRepresenter < ::API::Decorators::Single
property :name,
getter: ->(represented:, **) {
represented['name']
}
end
end
end
end
end

@ -0,0 +1,49 @@
#-- 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 Grids
module Widgets
class QueryOptionsRepresenter < DefaultOptionsRepresenter
property :queryId,
getter: ->(represented:, **) {
represented['queryId']
}
# This is required for initialization where the values
# are stored like this so the front end can then initialize it.
property :queryProps,
getter: ->(represented:, **) {
represented['queryProps']
}
end
end
end
end
end

@ -0,0 +1,58 @@
#-- 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 Grids
module Widgets
class WidgetRepresenter < ::API::Decorators::Single
property :id
property :identifier
property :start_row
property :end_row
property :start_column
property :end_column
property :options,
getter: ->(represented:, decorator:, **) {
::Grids::Configuration
.widget_strategy(represented.grid.class, represented.identifier)
.options_representer
.constantize
.new(represented.options.with_indifferent_access,
current_user: decorator.current_user)
}
def _type
'GridWidget'
end
end
end
end
end
end

@ -88,7 +88,7 @@ class Grids::SetAttributesService < ::BaseServices::SetAttributes
to_create = []
widgets.each do |widget|
matching_map_key = first_unclaimed_by_identifier(widget_map, widget)
matching_map_key = match_widget(widget_map, widget)
if matching_map_key
widget_map[matching_map_key] = widget
@ -102,10 +102,14 @@ class Grids::SetAttributesService < ::BaseServices::SetAttributes
widget_map.compact]
end
def first_unclaimed_by_identifier(widget_map, widget)
def match_widget(widget_map, widget)
available_map_keys = widget_map.select { |_, v| v.nil? }.keys
available_map_keys.find { |w| w.identifier == widget.identifier }
if model.persisted?
available_map_keys.find { |w| w.id == widget.id }
else
available_map_keys.find { |w| w.identifier == widget.identifier }
end
end
# Removes prohibited widgets from the grid.

@ -109,7 +109,7 @@ module Grids::Configuration
end
def widget_strategy(grid, identifier)
grid_register[grid.to_s]&.widget_strategy(identifier)
grid_register[grid.to_s]&.widget_strategy(identifier) || Grids::Configuration::WidgetStrategy
end
##

@ -50,6 +50,14 @@ module Grids::Configuration
def allowed?(user, project)
allowed.(user, project)
end
def options_representer(klass = nil)
if klass
@options_representer = klass
end
@options_representer || '::API::V3::Grids::Widgets::DefaultOptionsRepresenter'
end
end
end
end

@ -95,6 +95,7 @@ describe ::API::V3::Grids::GridPayloadRepresenter, 'parsing' do
.to eql(10)
end
end
context 'columnCount' do
it 'updates column_count' do
grid = representer.from_hash(hash)
@ -102,6 +103,7 @@ describe ::API::V3::Grids::GridPayloadRepresenter, 'parsing' do
.to eql(20)
end
end
context 'widgets' do
it 'updates widgets' do
grid = representer.from_hash(hash)

@ -129,6 +129,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do
widgets = [
{
"_type": "GridWidget",
"id": grid.widgets[0].id,
"identifier": 'work_packages_assigned',
"options": {},
"startRow": 4,
@ -138,6 +139,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do
},
{
"_type": "GridWidget",
"id": grid.widgets[1].id,
"identifier": 'work_packages_created',
"options": {},
"startRow": 1,
@ -147,6 +149,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do
},
{
"_type": "GridWidget",
"id": grid.widgets[2].id,
"identifier": 'work_packages_watched',
"options": {},
"startRow": 2,

@ -63,6 +63,10 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do
.to receive(:assignable_widgets)
.and_return(allowed_widgets)
allow(contract)
.to receive(:model)
.and_return(double('model'))
contract
end
let(:representer) do

@ -333,6 +333,7 @@ describe Grids::SetAttributesService, type: :model do
let(:widgets) do
[
FactoryBot.build_stubbed(:grid_widget,
id: existing_widgets[0].id,
identifier: 'work_packages_assigned',
start_row: 3,
end_row: 5,
@ -374,7 +375,7 @@ describe Grids::SetAttributesService, type: :model do
end
end
context 'with updates to an existing widget' do
context 'with additions and updates to existing widgets' do
let(:widgets) do
[
FactoryBot.build_stubbed(:grid_widget,

@ -13,35 +13,19 @@ module MyPage
'documents',
'news'
widget_strategy 'work_packages_table' do
wp_table_strategy_proc = Proc.new do
after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
allowed ->(user, _project) { user.allowed_to_globally?(:save_queries) }
end
widget_strategy 'work_packages_assigned' do
after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
allowed ->(user, _project) { user.allowed_to_globally?(:save_queries) }
end
widget_strategy 'work_packages_accountable' do
after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
allowed ->(user, _project) { user.allowed_to_globally?(:save_queries) }
end
widget_strategy 'work_packages_watched' do
after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
allowed ->(user, _project) { user.allowed_to_globally?(:save_queries) }
options_representer '::API::V3::Grids::Widgets::QueryOptionsRepresenter'
end
widget_strategy 'work_packages_created' do
after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
allowed ->(user, _project) { user.allowed_to_globally?(:save_queries) }
end
widget_strategy 'work_packages_table', &wp_table_strategy_proc
widget_strategy 'work_packages_assigned', &wp_table_strategy_proc
widget_strategy 'work_packages_accountable', &wp_table_strategy_proc
widget_strategy 'work_packages_watched', &wp_table_strategy_proc
widget_strategy 'work_packages_created', &wp_table_strategy_proc
defaults -> {
{

@ -81,9 +81,11 @@ describe 'Accountable widget on my page', type: :feature, js: true do
it 'can add the widget and see the work packages the user is accountable for' do
my_page.add_column(3, before_or_after: :before)
sleep(1)
my_page.add_widget(2, 3, "Work packages I am accountable for")
sleep(0.2)
sleep(1)
accountable_area = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(3)')
created_area = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(2)')

@ -102,6 +102,8 @@ describe 'My page', type: :feature, js: true do
watched_area.resize_to(3, 6)
sleep(0.1)
# Reloading kept the user's values
visit home_path
my_page.visit!

@ -30,7 +30,7 @@ require 'spec_helper'
require_relative '../../support/pages/my/page'
describe 'Arbitrary WorkPackage query table widget on my page', type: :feature, js: true do
describe 'Arbitrary WorkPackage query table widget on my page', type: :feature, js: true, with_mail: false do
let!(:type) { FactoryBot.create :type }
let!(:other_type) { FactoryBot.create :type }
let!(:priority) { FactoryBot.create :default_priority }
@ -77,6 +77,8 @@ describe 'Arbitrary WorkPackage query table widget on my page', type: :feature,
it 'can add the widget and see the work packages of the filtered for types' do
my_page.add_column(3, before_or_after: :before)
sleep(1)
my_page.add_widget(2, 3, "Work packages table")
sleep(1)

@ -1,7 +1,6 @@
require_relative './work_package_field'
class WorkPackageEditorField < WorkPackageField
def ckeditor
@ckeditor ||= ::Components::WysiwygEditor.new @selector
end
@ -30,7 +29,6 @@ class WorkPackageEditorField < WorkPackageField
ckeditor.set_markdown text
end
def clear
ckeditor.clear
end

Loading…
Cancel
Save