Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding cypress test for new attachment API + fixes #2821

Merged
merged 6 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/features/attachments/AttachmentsStorePlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ export class AttachmentsStorePlugin extends NodeDataPlugin<AttachmentsStorePlugi
const { mutateAsync: uploadAttachment } = useAttachmentsUploadMutation();

const applicationMetadata = useApplicationMetadata();
const supportsNewAttatchmentAPI = appSupportsNewAttatchmentAPI(applicationMetadata);
const supportsNewAttachmentAPI = appSupportsNewAttachmentAPI(applicationMetadata);

const setAttachmentsInDataModel = useSetAttachmentInDataModel();
const { lock, unlock } = FD.useLocking('__attachment__upload__');
Expand All @@ -302,7 +302,7 @@ export class AttachmentsStorePlugin extends NodeDataPlugin<AttachmentsStorePlugi
};
upload(fullAction);

if (supportsNewAttatchmentAPI) {
if (supportsNewAttachmentAPI) {
await lock();
const results: AttachmentUploadResult[] = [];

Expand Down Expand Up @@ -362,7 +362,7 @@ export class AttachmentsStorePlugin extends NodeDataPlugin<AttachmentsStorePlugi
},
[
upload,
supportsNewAttatchmentAPI,
supportsNewAttachmentAPI,
lock,
setAttachmentsInDataModel,
uploadFinished,
Expand Down Expand Up @@ -586,6 +586,16 @@ export class AttachmentsStorePlugin extends NodeDataPlugin<AttachmentsStorePlugi
}
}

/**
* When an attachment is uploaded, it may be added to the data model via a list or a simpleBinding. In repeating groups,
* this is required to ensure we know which row the attachment belongs to.
*
* If the attachment is deleted from the instance data outside of these functions (i.e. by a backend hook), these
* components will make sure to remove the attachment ID from the data model:
Comment on lines +593 to +594
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I did not think of this case 🥇

*
* @see MaintainListDataModelBinding
* @see MaintainSimpleDataModelBinding
*/
function useSetAttachmentInDataModel() {
const setLeafValue = FD.useSetLeafValue();
const appendToListUnique = FD.useAppendToListUnique();
Expand Down Expand Up @@ -728,6 +738,6 @@ function useAttachmentsRemoveMutation() {
});
}

export function appSupportsNewAttatchmentAPI({ altinnNugetVersion }: ApplicationMetadata) {
export function appSupportsNewAttachmentAPI({ altinnNugetVersion }: ApplicationMetadata) {
return !altinnNugetVersion || isAtLeastVersion({ actualVersion: altinnNugetVersion, minimumVersion: '8.5.0.153' });
}
89 changes: 85 additions & 4 deletions src/features/attachments/StoreAttachmentsInNode.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React from 'react';
import React, { useEffect } from 'react';

import deepEqual from 'fast-deep-equal';

import { useTaskStore } from 'src/core/contexts/taskStoreContext';
import { useApplicationMetadata } from 'src/features/applicationMetadata/ApplicationMetadataProvider';
import { isAttachmentUploaded } from 'src/features/attachments/index';
import { DEFAULT_DEBOUNCE_TIMEOUT } from 'src/features/formData/types';
import { useDataModelBindings } from 'src/features/formData/useDataModelBindings';
import { useLaxInstanceDataElements } from 'src/features/instance/InstanceContext';
import { useLaxProcessData } from 'src/features/instance/ProcessContext';
import { useMemoDeepEqual } from 'src/hooks/useStateDeepEqual';
Expand All @@ -14,11 +17,14 @@ import { NodesInternal } from 'src/utils/layout/NodesContext';
import { useNodeFormData } from 'src/utils/layout/useNodeItem';
import type { ApplicationMetadata } from 'src/features/applicationMetadata/types';
import type { IAttachment } from 'src/features/attachments/index';
import type { IDataModelBindingsList, IDataModelBindingsSimple } from 'src/layout/common.generated';
import type { CompWithBehavior } from 'src/layout/layout';
import type { IData } from 'src/types/shared';
import type { IComponentFormData } from 'src/utils/formComponentUtils';
import type { LayoutNode } from 'src/utils/layout/LayoutNode';

type AttachmentRecord = Record<string, IAttachment>;

export function StoreAttachmentsInNode() {
return (
<GeneratorCondition
Expand All @@ -32,15 +38,29 @@ export function StoreAttachmentsInNode() {

function StoreAttachmentsInNodeWorker() {
const node = GeneratorInternal.useParent() as LayoutNode<CompWithBehavior<'canHaveAttachments'>>;
const item = GeneratorInternal.useIntermediateItem();
const attachments = useNodeAttachments();

const hasBeenSet = NodesInternal.useNodeData(node, (data) => deepEqual(data.attachments, attachments));
NodesStateQueue.useSetNodeProp({ node, prop: 'attachments', value: attachments }, !hasBeenSet);

return null;
// When the backend deletes an attachment, we might need to update the data model and remove the attachment ID from
// there (if the backend didn't do so already). This is done by these `Maintain*DataModelBinding` components.
const dataModelBindings = item?.dataModelBindings as IDataModelBindingsSimple | IDataModelBindingsList | undefined;
return dataModelBindings && 'list' in dataModelBindings && dataModelBindings.list ? (
<MaintainListDataModelBinding
bindings={dataModelBindings}
attachments={attachments}
/>
) : dataModelBindings && 'simpleBinding' in dataModelBindings && dataModelBindings.simpleBinding ? (
<MaintainSimpleDataModelBinding
bindings={dataModelBindings}
attachments={attachments}
/>
) : null;
}

function useNodeAttachments(): Record<string, IAttachment> {
function useNodeAttachments(): AttachmentRecord {
const node = GeneratorInternal.useParent() as LayoutNode<CompWithBehavior<'canHaveAttachments'>>;
const nodeData = useNodeFormData(node);

Expand Down Expand Up @@ -76,7 +96,7 @@ function useNodeAttachments(): Record<string, IAttachment> {

// Find any not-yet uploaded attachments and add them back to the result
for (const [id, attachment] of Object.entries(prev ?? {})) {
if (!result[id]) {
if (!result[id] && !isAttachmentUploaded(attachment)) {
result[id] = attachment;
}
}
Expand Down Expand Up @@ -140,3 +160,64 @@ function mapAttachments(

return attachments;
}

interface MaintainBindingsProps {
attachments: AttachmentRecord;
}

interface MaintainListDataModelBindingProps extends MaintainBindingsProps {
bindings: IDataModelBindingsList;
}

interface MaintainSimpleDataModelBindingProps extends MaintainBindingsProps {
bindings: IDataModelBindingsSimple;
}

/**
* @see useSetAttachmentInDataModel
*/
function MaintainListDataModelBinding({ bindings, attachments }: MaintainListDataModelBindingProps) {
const { formData, setValue } = useDataModelBindings(bindings, DEFAULT_DEBOUNCE_TIMEOUT, 'raw');

useEffect(() => {
const newList = Object.values(attachments)
.filter(isAttachmentUploaded)
.map((attachment) => attachment.data.id);

if (!deepEqual(formData.list, newList)) {
setValue('list', newList);
}
}, [attachments, formData.list, setValue]);

return null;
}

/**
* @see useSetAttachmentInDataModel
*/
function MaintainSimpleDataModelBinding({ bindings, attachments }: MaintainSimpleDataModelBindingProps) {
const node = GeneratorInternal.useParent() as LayoutNode<CompWithBehavior<'canHaveAttachments'>>;
const { formData, setValue } = useDataModelBindings(bindings, DEFAULT_DEBOUNCE_TIMEOUT, 'raw');

useEffect(() => {
if (Object.keys(attachments).length > 1) {
window.logErrorOnce(
`Node ${node.id} has more than one attachment, but only one is supported with \`dataModelBindings.simpleBinding\``,
);
return;
}

const firstAttachment = Object.values(attachments)[0];
if (!firstAttachment && formData.simpleBinding) {
setValue('simpleBinding', undefined);
} else if (
firstAttachment &&
isAttachmentUploaded(firstAttachment) &&
formData.simpleBinding !== firstAttachment.data.id
) {
setValue('simpleBinding', firstAttachment.data.id);
}
}, [attachments, formData.simpleBinding, node.id, setValue]);

return null;
}
112 changes: 112 additions & 0 deletions test/e2e/integration/subform-test/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { AppFrontend } from 'test/e2e/pageobjects/app-frontend';

const appFrontend = new AppFrontend();

describe('Attachments', () => {
beforeEach(() => {
cy.startAppInstance(appFrontend.apps.subformTest, { authenticationLevel: '1' });
});

const mkFile = (fileName: string) => ({
fileName,
mimeType: 'image/png',
lastModified: Date.now(),
contents: Cypress.Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVR4AWNgAAAAAgABc3UBGAAAAABJRU5ErkJggg==',
'base64',
),
});

const anyUuid = /[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}/.source;

it('should be possible to hook into attachment uploads on the backend and update the data model', () => {
// This should use the new API where the URL ends with `/data/<data-type>`, not the old API which
// ends with `/data?dataType=<data-type>`.
cy.intercept('POST', '**/instances/**/data/attachments*').as('upload');

function assertAttachments(...names: string[]) {
cy.get('#form-content-debug-attachmentNames-backend').should('have.text', names.join(', '));
if (names.length === 0) {
cy.get('#form-content-debug-attachmentIds-frontend').should('have.text', '');
} else {
cy.get('#form-content-debug-attachmentIds-frontend')
.invoke('text')
.should(
'match',
names.length === 1
? new RegExp(`^${anyUuid}$`)
: new RegExp(`^(${anyUuid}, ){${names.length - 1}}${anyUuid}$`),
);
}
}

cy.get('#Input-Name').type('debug'); // This will trigger the debug components to show up:
cy.get('#Input-Age').type('55');
cy.get('[data-componentid="debug-attachmentIds-frontend"]').should('be.visible');
cy.get('[data-componentid="debug-attachmentNames-backend"]').should('be.visible');
assertAttachments();

// Upload 5 files in quick succession
cy.get('#altinn-drop-zone-attachments input[type=file]').selectFile(
[mkFile('test1.png'), mkFile('test2.png'), mkFile('test3.png'), mkFile('test4.png'), mkFile('test5.png')],
{ force: true },
);

cy.get('@upload.all').should('have.length', 5);
assertAttachments('test1.png', 'test2.png', 'test3.png', 'test4.png', 'test5.png');

cy.get('[data-validation="Input-Name"]').should('have.text', 'You cannot have exactly 5 attachments');

cy.get('#file-upload-table tr').eq(1).findByRole('button', { name: 'Slett vedlegg' }).click();
assertAttachments('test2.png', 'test3.png', 'test4.png', 'test5.png');
cy.get('[data-validation="Input-Name"]').should('not.exist');

// The backend can also remove attachments (by giving it a command via the name field)
cy.get('#Input-Name').type(',delete,test2.png');
assertAttachments('test3.png', 'test4.png', 'test5.png');

cy.get('#Input-Name').type(',delete,test3.png');
assertAttachments('test4.png', 'test5.png');

cy.get('#Input-Name').type(',delete,test4.png');
assertAttachments('test5.png');

cy.get('#Input-Name').type(',delete,test5.png');
assertAttachments();

cy.get('#altinn-drop-zone-attachments input[type=file]').selectFile(
[mkFile('whatever.png'), mkFile('idontcare.png')],
{ force: true },
);

cy.get('#altinn-fileuploader-attachments')
.findByRole('alert')
.should('contain.text', 'Filen idontcare.png kunne ikke lastes opp')
.and('contain.text', 'You cannot upload a file named idontcare.png');

assertAttachments('whatever.png');

// To test the last validation, since it's not bound to the attachment component (yet), we have to fill out the form
// and assert that the validation message shows up when we try to submit the instance.
cy.get('#subform-subform-mopeder-add-button').click();
cy.get('#moped-regno').type('AB1234');
cy.get('#moped-merke').type('Harley-Davidson');
cy.get('#moped-modell').type('Sportster');
cy.get('#moped-produksjonsaar').type('2021');
cy.get('#custom-button-subform-moped-exitButton').clickAndGone();

cy.findByRole('button', { name: 'Neste' }).click();
cy.findByRole('button', { name: 'Send inn' }).click();
cy.get(appFrontend.errorReport).should('be.visible');
cy.get(appFrontend.errorReport).should('contain.text', 'You cannot upload a file named whatever.png');

cy.get('#custom-button-hovedskjema-backButton').click();
cy.get('#Input-Name').type(',delete,whatever.png');
assertAttachments();

cy.findByRole('button', { name: 'Neste' }).click();
cy.findByRole('button', { name: 'Send inn' }).click();

cy.get('#ReceiptContainer').should('contain.text', 'Skjemaet er sendt inn');
});
});
Loading