Skip to content

Commit e0b4cc2

Browse files
authored
Merge pull request #24 from platform-mesh/feat/edit-entity-btn
feat: edit entity btn
2 parents 0f1751d + 9f2e8be commit e0b4cc2

13 files changed

+409
-30
lines changed

projects/lib/services/resource/resource.service.spec.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import { ApolloFactory } from './apollo-factory';
2+
import { ResourceService } from './resource.service';
13
import { TestBed } from '@angular/core/testing';
24
import { LuigiCoreService } from '@openmfp/portal-ui-lib';
35
import { mock } from 'jest-mock-extended';
46
import { of, throwError } from 'rxjs';
5-
import { ApolloFactory } from './apollo-factory';
6-
import { ResourceService } from './resource.service';
77

88
describe('ResourceService', () => {
99
let service: ResourceService;
@@ -564,6 +564,122 @@ describe('ResourceService', () => {
564564
});
565565
});
566566

567+
describe('update', () => {
568+
it('should strip __typename recursively from update payload', (done) => {
569+
const dirtyResource: any = {
570+
metadata: { name: 'test-name', __typename: 'Meta' },
571+
spec: {
572+
__typename: 'Spec',
573+
items: [
574+
{ key: 'a', __typename: 'Item' },
575+
{ key: 'b', nested: { foo: 'bar', __typename: 'Nested' } },
576+
],
577+
map: {
578+
one: { val: 1, __typename: 'Val' },
579+
two: [{ x: 1, __typename: 'X' }, { y: 2 }],
580+
},
581+
},
582+
};
583+
584+
mockApollo.mutate.mockReturnValue(
585+
of({ data: { __typename: 'TestKind' } }),
586+
);
587+
588+
service
589+
.update(dirtyResource, resourceDefinition, namespacedNodeContext)
590+
.subscribe(() => {
591+
const mutateCall = mockApollo.mutate.mock.calls[0][0];
592+
const passedObject = mutateCall.variables.object;
593+
expect(passedObject).toEqual({
594+
metadata: { name: 'test-name' },
595+
spec: {
596+
items: [{ key: 'a' }, { key: 'b', nested: { foo: 'bar' } }],
597+
map: {
598+
one: { val: 1 },
599+
two: [{ x: 1 }, { y: 2 }],
600+
},
601+
},
602+
});
603+
done();
604+
});
605+
});
606+
it('should update resource', (done) => {
607+
mockApollo.mutate.mockReturnValue(
608+
of({ data: { __typename: 'TestKind' } }),
609+
);
610+
service
611+
.update(resource, resourceDefinition, namespacedNodeContext)
612+
.subscribe(() => {
613+
expect(mockApollo.mutate).toHaveBeenCalled();
614+
done();
615+
});
616+
});
617+
618+
it('should update namespaced resource', (done) => {
619+
mockApollo.mutate.mockReturnValue(
620+
of({ data: { __typename: 'TestKind' } }),
621+
);
622+
623+
service
624+
.update(resource, resourceDefinition, namespacedNodeContext)
625+
.subscribe(() => {
626+
expect(mockApollo.mutate).toHaveBeenCalledWith({
627+
mutation: expect.anything(),
628+
fetchPolicy: 'no-cache',
629+
variables: {
630+
name: resource.metadata.name,
631+
object: resource,
632+
namespace: namespacedNodeContext.namespaceId,
633+
},
634+
});
635+
done();
636+
});
637+
});
638+
639+
it('should update cluster resource', (done) => {
640+
mockApollo.mutate.mockReturnValue(
641+
of({ data: { __typename: 'TestKind' } }),
642+
);
643+
644+
service
645+
.update(resource, resourceDefinition, clusterScopeNodeContext)
646+
.subscribe(() => {
647+
expect(mockApollo.mutate).toHaveBeenCalledWith({
648+
mutation: expect.anything(),
649+
fetchPolicy: 'no-cache',
650+
variables: {
651+
name: resource.metadata.name,
652+
object: resource,
653+
},
654+
});
655+
done();
656+
});
657+
});
658+
659+
it('should handle update error', (done) => {
660+
const error = new Error('fail');
661+
mockApollo.mutate.mockReturnValue(throwError(() => error));
662+
console.error = jest.fn();
663+
664+
service
665+
.update(resource, resourceDefinition, clusterScopeNodeContext)
666+
.subscribe({
667+
error: () => {
668+
expect(console.error).toHaveBeenCalledWith(
669+
'Error executing GraphQL query.',
670+
error,
671+
);
672+
expect(mockLuigiCoreService.showAlert).toHaveBeenCalledWith({
673+
text: 'fail',
674+
type: 'error',
675+
});
676+
done();
677+
},
678+
});
679+
});
680+
});
681+
682+
567683
describe('readAccountInfo', () => {
568684
it('should read account info', (done) => {
569685
const ca = 'cert-data';

projects/lib/services/resource/resource.service.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import {
1212
getValueByPath,
1313
replaceDotsAndHyphensWithUnderscores,
14+
stripTypename,
1415
} from '@platform-mesh/portal-ui-lib/utils';
1516
import { gql } from 'apollo-angular';
1617
import * as gqlBuilder from 'gql-query-builder';
@@ -278,6 +279,58 @@ export class ResourceService {
278279
);
279280
}
280281

282+
update(
283+
resource: Resource,
284+
resourceDefinition: ResourceDefinition,
285+
nodeContext: ResourceNodeContext,
286+
) {
287+
const isNamespacedResource = this.isNamespacedResource(nodeContext);
288+
const group = replaceDotsAndHyphensWithUnderscores(
289+
resourceDefinition.group,
290+
);
291+
const kind = resourceDefinition.kind;
292+
const namespace = nodeContext.namespaceId;
293+
294+
const cleanResource = stripTypename(resource);
295+
296+
const mutation = gqlBuilder.mutation({
297+
operation: group,
298+
fields: [
299+
{
300+
operation: `update${kind}`,
301+
variables: {
302+
...(isNamespacedResource && {
303+
namespace: { type: 'String', value: namespace },
304+
}),
305+
name: { type: 'String!', value: resource.metadata.name },
306+
object: {
307+
type: `${kind}Input!`,
308+
value: cleanResource,
309+
},
310+
},
311+
fields: ['__typename'],
312+
},
313+
],
314+
});
315+
316+
return this.apolloFactory
317+
.apollo(nodeContext)
318+
.mutate({
319+
mutation: gql`
320+
${mutation.query}
321+
`,
322+
fetchPolicy: 'no-cache',
323+
variables: mutation.variables,
324+
})
325+
.pipe(
326+
catchError((error) => {
327+
this.alertErrors(error);
328+
console.error('Error executing GraphQL query.', error);
329+
return error;
330+
}),
331+
);
332+
}
333+
281334
readAccountInfo(nodeContext: ResourceNodeContext): Observable<AccountInfo> {
282335
return this.apolloFactory
283336
.apollo(nodeContext)

projects/lib/utils/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './get-value-by-path';
33
export * from './group-name-sanitizer';
44
export * from './is-local-setup';
55
export * from './resource-field-by-path';
6+
export * from './resource-sanitizer';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { stripTypename } from '@platform-mesh/portal-ui-lib/utils';
2+
3+
describe('stripTypename (utils)', () => {
4+
it('should return primitives as-is', () => {
5+
expect(stripTypename(42 as any)).toBe(42);
6+
expect(stripTypename('x' as any)).toBe('x');
7+
expect(stripTypename(null as any)).toBe(null);
8+
expect(stripTypename(undefined as any)).toBe(undefined);
9+
});
10+
11+
it('should clean nested objects and arrays', () => {
12+
const input = {
13+
__typename: 'Root',
14+
a: { __typename: 'A', b: 1 },
15+
arr: [{ __typename: 'X', v: 1 }, [{ __typename: 'Y', z: 2 }, 3]],
16+
} as any;
17+
18+
const output = stripTypename(input);
19+
expect(output).toEqual({ a: { b: 1 }, arr: [{ v: 1 }, [{ z: 2 }, 3]] });
20+
});
21+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export function stripTypename<T>(value: T): T {
2+
if (Array.isArray(value)) {
3+
return (value as unknown as any[]).map((v) => stripTypename(v)) as T;
4+
}
5+
if (value && typeof value === 'object') {
6+
const { __typename, ...rest } = value as Record<string, unknown> & {
7+
__typename?: string;
8+
};
9+
for (const k of Object.keys(rest)) {
10+
// @ts-ignore - rest is an indexable object
11+
rest[k] = stripTypename((rest as any)[k]);
12+
}
13+
return rest as T;
14+
}
15+
return value;
16+
}

projects/wc/src/app/components/generic-ui/list-view/create-resource-modal/create-resource-modal.component.html

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
1-
<ui5-dialog #dialog header-text="Create">
1+
<ui5-dialog #dialog>
2+
<ui5-bar slot="header" design="Header">
3+
<ui5-title slot="startContent">
4+
@if (isEditMode()) {
5+
Edit
6+
} @else {
7+
Create
8+
}
9+
</ui5-title>
10+
</ui5-bar>
211
<section class="form" [formGroup]="form">
312
@for (field of fields(); track field.property) {
413
@let fieldProperty = sanitizePropertyName(field.property);
@@ -15,6 +24,7 @@
1524
(blur)="onFieldBlur(fieldProperty)"
1625
[required]="field.required"
1726
[valueState]="getValueState(fieldProperty)"
27+
[disabled]="isEditMode() && isCreateFieldOnly(field)"
1828
>
1929
@for (value of [''].concat(field.values); track value) {
2030
<ui5-option
@@ -43,6 +53,7 @@
4353
(input)="setFormControlValue($event, fieldProperty)"
4454
[required]="field.required"
4555
[valueState]="getValueState(fieldProperty)"
56+
[disabled]="isEditMode() && isCreateFieldOnly(field)"
4657
></ui5-input>
4758
}
4859
</div>

projects/wc/src/app/components/generic-ui/list-view/create-resource-modal/create-resource-modal.component.spec.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CreateResourceModalComponent } from './create-resource-modal.component';
2-
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
2+
import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core';
33
import { ComponentFixture, TestBed } from '@angular/core/testing';
44
import { ReactiveFormsModule } from '@angular/forms';
55
import { FieldDefinition } from '@openmfp/portal-ui-lib';
@@ -17,7 +17,8 @@ describe('CreateResourceModalComponent', () => {
1717
beforeEach(async () => {
1818
await TestBed.configureTestingModule({
1919
imports: [ReactiveFormsModule, CreateResourceModalComponent],
20-
schemas: [CUSTOM_ELEMENTS_SCHEMA],
20+
schemas: [CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA],
21+
teardown: { destroyAfterEach: true },
2122
})
2223
.overrideComponent(CreateResourceModalComponent, {
2324
set: { template: '' },
@@ -61,6 +62,44 @@ describe('CreateResourceModalComponent', () => {
6162
expect(mockDialog.open).toBeTruthy();
6263
});
6364

65+
it('should prefill and disable name/namespace in edit mode, emit updateResource', () => {
66+
(component as any).fields = () =>
67+
[
68+
{ property: 'metadata.name', required: true, label: 'Name' },
69+
{ property: 'metadata.namespace', required: false, label: 'Namespace' },
70+
{ property: 'spec.description', required: false, label: 'Description' },
71+
] as any;
72+
73+
component.form = (component as any).fb.group(
74+
(component as any).createControls(),
75+
);
76+
77+
const resource: any = {
78+
metadata: { name: 'res1', namespace: 'ns1' },
79+
spec: { description: 'hello' },
80+
};
81+
82+
const updateSpy = spyOn(component.updateResource, 'emit');
83+
84+
component.open(resource);
85+
expect(mockDialog.open).toBeTruthy();
86+
87+
expect(component.form.controls['metadata_name'].value).toBe('res1');
88+
expect(component.form.controls['metadata_namespace'].value).toBe('ns1');
89+
expect(component.form.controls['spec_description'].value).toBe('hello');
90+
91+
component.form.controls['spec_description'].setValue('updated');
92+
component.create();
93+
94+
expect(updateSpy).toHaveBeenCalledWith({
95+
metadata: { name: 'res1', namespace: 'ns1' },
96+
spec: { description: 'updated' },
97+
});
98+
99+
expect(component.form.controls['metadata_name'].disabled).toBeFalsy();
100+
expect(component.form.controls['metadata_namespace'].disabled).toBeFalsy();
101+
});
102+
64103
it('should close dialog and reset form when close method is called', () => {
65104
spyOn(component.form, 'reset');
66105

@@ -168,4 +207,22 @@ describe('CreateResourceModalComponent', () => {
168207
);
169208
});
170209
});
210+
211+
it('should detect name/namespace and not other fields in isCreateFieldOnly function', () => {
212+
const nameField: any = { property: 'metadata.name' };
213+
const nsField: any = { property: 'metadata.namespace' };
214+
const otherField: any = { property: 'spec.description' };
215+
216+
expect(component.isCreateFieldOnly(nameField)).toBeTruthy();
217+
expect(component.isCreateFieldOnly(nsField)).toBeTruthy();
218+
expect(component.isCreateFieldOnly(otherField)).toBeFalsy();
219+
});
220+
221+
it('should open dialog using open function', () => {
222+
mockDialog.open = false;
223+
224+
component.open();
225+
226+
expect(mockDialog.open).toBeTruthy();
227+
});
171228
});

0 commit comments

Comments
 (0)