From 10e7c8c95133412c5eac3ab5c940746bf43a1c27 Mon Sep 17 00:00:00 2001 From: Max Peterson Date: Mon, 14 Jun 2021 18:41:56 +0100 Subject: [PATCH] feat: implement array property upload functionality --- src/features/upload-file/components/edit.tsx | 6 +- src/features/upload-file/components/file.tsx | 3 +- .../factories/strip-payload-factory.ts | 30 +- .../factories/update-record-factory.spec.ts | 19 +- .../factories/update-record-factory.ts | 259 ++++++++++-------- .../upload-file/types/property-custom.type.ts | 1 + .../upload-file/types/upload-options.type.ts | 6 + .../upload-file/upload-file.feature.ts | 6 +- .../upload-file/utils/build-custom.ts | 37 +++ .../utils/fill-record-with-path.ts | 55 ++-- .../upload-file/utils/validate-properties.ts | 6 +- 11 files changed, 278 insertions(+), 150 deletions(-) create mode 100644 src/features/upload-file/utils/build-custom.ts diff --git a/src/features/upload-file/components/edit.tsx b/src/features/upload-file/components/edit.tsx index 367a748..4c9b07c 100644 --- a/src/features/upload-file/components/edit.tsx +++ b/src/features/upload-file/components/edit.tsx @@ -1,18 +1,18 @@ import React, { FC, useState, useEffect } from 'react' import { EditPropertyProps, flat } from 'admin-bro' import { DropZone, FormGroup, Label, DropZoneItem } from '@admin-bro/design-system' -import PropertyCustom from '../types/property-custom.type' +import buildCustom from '../utils/build-custom' const Edit: FC = ({ property, record, onChange }) => { const { params } = record - const { custom } = property as unknown as { custom: PropertyCustom } + const custom = buildCustom(property) const path = flat.get(params, custom.filePathProperty) const key = flat.get(params, custom.keyProperty) const file = flat.get(params, custom.fileProperty) const [originalKey, setOriginalKey] = useState(key) - const [filesToUpload, setFilesToUpload] = useState>([]) + const [filesToUpload, setFilesToUpload] = useState>(file || []) useEffect(() => { // it means means that someone hit save and new file has been uploaded diff --git a/src/features/upload-file/components/file.tsx b/src/features/upload-file/components/file.tsx index 9698774..24ba2c0 100644 --- a/src/features/upload-file/components/file.tsx +++ b/src/features/upload-file/components/file.tsx @@ -5,6 +5,7 @@ import { Icon, Button, Box } from '@admin-bro/design-system' import { ShowPropertyProps, flat } from 'admin-bro' import { ImageMimeTypes, AudioMimeTypes } from '../types/mime-types.type' import PropertyCustom from '../types/property-custom.type' +import buildCustom from '../utils/build-custom' type Props = ShowPropertyProps & { width?: number | string; @@ -47,7 +48,7 @@ const SingleFile: FC = (props) => { } const File: FC = ({ width, record, property }) => { - const { custom } = property as unknown as { custom: PropertyCustom } + const custom = buildCustom(property) const path = flat.get(record?.params, custom.filePathProperty) diff --git a/src/features/upload-file/factories/strip-payload-factory.ts b/src/features/upload-file/factories/strip-payload-factory.ts index b817757..c54349b 100644 --- a/src/features/upload-file/factories/strip-payload-factory.ts +++ b/src/features/upload-file/factories/strip-payload-factory.ts @@ -26,18 +26,40 @@ export const stripPayloadFactory = ( request: ActionRequest, context: ActionContext, ): Promise => { - const { properties } = uploadOptionsWithDefault + const { properties, parentArray } = uploadOptionsWithDefault if (request?.payload) { let data: ContextNamespace = context[CONTEXT_NAMESPACE] || {} + if (parentArray) { + const parent = flat.get(request.payload, parentArray) + if (parent) { + for (let index = 0; index < parent.length; index += 1) { + data = { + ...data, + ...[properties.file, properties.filesToDelete].reduce((memo, prop) => { + const fullProp = [parentArray, index, prop].join(flat.DELIMITER) + return { + ...memo, + [fullProp]: flat.get(request.payload, fullProp), + } + }, {}), + } + } + } + } else { + data = { + ...data, + [properties.file]: flat.get(request.payload, properties.file), + [properties.filesToDelete]: flat.get(request.payload, properties.filesToDelete), + } + } + data = { ...data, - [properties.file]: flat.get(request.payload, properties.file), - [properties.filesToDelete]: flat.get(request.payload, properties.filesToDelete), __invocations: [ ...(data.__invocations || []), - { properties }, + { properties, parentArray }, ], } diff --git a/src/features/upload-file/factories/update-record-factory.spec.ts b/src/features/upload-file/factories/update-record-factory.spec.ts index 1903549..f3b550d 100644 --- a/src/features/upload-file/factories/update-record-factory.spec.ts +++ b/src/features/upload-file/factories/update-record-factory.spec.ts @@ -45,7 +45,8 @@ describe('updateRecordFactory', () => { recordStub = createStubInstance(BaseRecord, { id: sinon.stub().returns('1'), isValid: sinon.stub().returns(true), - update: sinon.stub>().returnsThis(), + save: sinon.stub<[], Promise>().returnsThis(), + storeParams: sinon.stub(), }) recordStub.params = {} }) @@ -92,13 +93,14 @@ describe('updateRecordFactory', () => { it('updates all fields in the record', async () => { await updateRecord(response, request, actionContext) - expect(recordStub.update).to.have.been.calledWith(sinon.match({ + expect(recordStub.storeParams).to.have.been.calledWith(sinon.match({ [uploadOptions.properties.key]: expectedKey, [uploadOptions.properties.bucket as string]: provider.bucket, [uploadOptions.properties.size as string]: File.size.toString(), [uploadOptions.properties.mimeType as string]: File.type, [uploadOptions.properties.filename as string]: File.name, })) + expect(recordStub.save).to.have.been.calledWith() }) it('does not delete any old file if there were not file before', async () => { @@ -138,13 +140,14 @@ describe('updateRecordFactory', () => { expect(provider.delete).to.have.been.calledWith(expectedKey, storedBucket) - expect(recordStub.update).to.have.been.calledWith(sinon.match({ + expect(recordStub.storeParams).to.have.been.calledWith(sinon.match({ [uploadOptions.properties.key]: null, [uploadOptions.properties.bucket as string]: null, [uploadOptions.properties.size as string]: null, [uploadOptions.properties.mimeType as string]: null, [uploadOptions.properties.filename as string]: null, })) + expect(recordStub.save).to.have.been.calledWith() }) }) @@ -188,11 +191,12 @@ describe('updateRecordFactory', () => { [`${uploadOptions.properties.filename}.${index}` as string]: Files[index].name, }) - expect(recordStub.update).to.have.been.calledWith(sinon.match({ + expect(recordStub.storeParams).to.have.been.calledWith(sinon.match({ ...values(0), ...values(1), ...values(2), })) + expect(recordStub.save).to.have.been.calledWith() }) }) @@ -226,7 +230,9 @@ describe('updateRecordFactory', () => { }, record: new BaseRecord(oldParams, {} as BaseResource), } as unknown as ActionContext - sinon.stub(BaseRecord.prototype, 'update') + sinon.stub(BaseRecord.prototype, 'save') + sinon.stub(BaseRecord.prototype, 'storeParams') + sinon.stub(BaseRecord.prototype, 'toJSON') updateRecord = updateRecordFactory(uploadOptions, provider) }) @@ -234,11 +240,12 @@ describe('updateRecordFactory', () => { it('removes files from the database', async () => { await updateRecord(response, request, actionContext) - expect(BaseRecord.prototype.update).to.have.been.calledWith({ + expect(BaseRecord.prototype.storeParams).to.have.been.calledWith({ 'media.key.0': 'key1', 'media.bucket.0': 'bucket1', 'media.type.0': 'mime1', }) + expect(BaseRecord.prototype.save).to.have.been.calledWith() }) it('removes files from the adapter store', async () => { diff --git a/src/features/upload-file/factories/update-record-factory.ts b/src/features/upload-file/factories/update-record-factory.ts index 8c7e3d8..3e3edb9 100644 --- a/src/features/upload-file/factories/update-record-factory.ts +++ b/src/features/upload-file/factories/update-record-factory.ts @@ -5,6 +5,7 @@ import { ActionContext, UploadedFile, After, + BaseRecord, } from 'admin-bro' import { buildRemotePath } from '../utils/build-remote-path' @@ -17,151 +18,175 @@ export const updateRecordFactory = ( uploadOptionsWithDefault: UploadOptionsWithDefault, provider: BaseProvider, ): After => { - const { properties, uploadPath, multiple } = uploadOptionsWithDefault + const { properties: origProperties, uploadPath, multiple, parentArray } = uploadOptionsWithDefault - const updateRecord = async ( - response: RecordActionResponse, - request: ActionRequest, + const processProperties = async ( + record: BaseRecord, context: ActionContext, - ): Promise => { - const { record } = context - + properties: UploadOptionsWithDefault['properties'], + ) => { const { [properties.file]: files, [properties.filesToDelete]: filesToDelete, } = getNamespaceFromContext(context) - const { method } = request + if (multiple && filesToDelete && filesToDelete.length) { + const filesData = (filesToDelete as Array).map((index) => ({ + key: record.get(properties.key)[index] as string, + bucket: record.get(properties.bucket)[index] as string | undefined, + })) + + await Promise.all(filesData.map(async (fileData) => ( + provider.delete(fileData.key, fileData.bucket || provider.bucket, context) + ))) + + const newParams = DB_PROPERTIES.reduce((params, propertyName: string) => { + if (properties[propertyName]) { + const filtered = record.get(properties[propertyName]).filter((el, i) => ( + !filesToDelete.includes(i.toString()) + )) + return flat.set(params, properties[propertyName], filtered) + } + return params + }, {}) - if (method !== 'post') { - return response + record.storeParams(newParams) } + if (multiple && files) { + const uploadedFiles = files as Array - if (record && record.isValid()) { - if (multiple && filesToDelete && filesToDelete.length) { - const filesData = (filesToDelete as Array).map((index) => ({ - key: record.get(properties.key)[index] as string, - bucket: record.get(properties.bucket)[index] as string | undefined, - })) - - await Promise.all(filesData.map(async (fileData) => ( - provider.delete(fileData.key, fileData.bucket || provider.bucket, context) - ))) - - const newParams = DB_PROPERTIES.reduce((params, propertyName: string) => { - if (properties[propertyName]) { - const filtered = record.get(properties[propertyName]).filter((el, i) => ( - !filesToDelete.includes(i.toString()) - )) - return flat.set(params, properties[propertyName], filtered) - } - return params - }, {}) - - await record.update(newParams) + const keys = await Promise.all(uploadedFiles.map(async (uploadedFile) => { + const key = buildRemotePath(record, uploadedFile, uploadPath) + await provider.upload(uploadedFile, key, context) + return key + })) + + let params = flat.set({}, properties.key, [ + ...(record.get(properties.key) || []), + ...keys, + ]) + if (properties.bucket) { + params = flat.set(params, properties.bucket, [ + ...(record.get(properties.bucket) || []), + ...uploadedFiles.map(() => provider.bucket), + ]) } - if (multiple && files) { - const uploadedFiles = files as Array - - const keys = await Promise.all(uploadedFiles.map(async (uploadedFile) => { - const key = buildRemotePath(record, uploadedFile, uploadPath) - await provider.upload(uploadedFile, key, context) - return key - })) - - let params = flat.set({}, properties.key, [ - ...(record.get(properties.key) || []), - ...keys, + if (properties.size) { + params = flat.set(params, properties.size, [ + ...(record.get(properties.size) || []), + ...uploadedFiles.map((file) => file.size), ]) - if (properties.bucket) { - params = flat.set(params, properties.bucket, [ - ...(record.get(properties.bucket) || []), - ...uploadedFiles.map(() => provider.bucket), - ]) - } - if (properties.size) { - params = flat.set(params, properties.size, [ - ...(record.get(properties.size) || []), - ...uploadedFiles.map((file) => file.size), - ]) - } - if (properties.mimeType) { - params = flat.set(params, properties.mimeType, [ - ...(record.get(properties.mimeType) || []), - ...uploadedFiles.map((file) => file.type), - ]) - } - if (properties.filename) { - params = flat.set(params, properties.filename, [ - ...(record.get(properties.filename) || []), - ...uploadedFiles.map((file) => file.name), - ]) - } + } + if (properties.mimeType) { + params = flat.set(params, properties.mimeType, [ + ...(record.get(properties.mimeType) || []), + ...uploadedFiles.map((file) => file.type), + ]) + } + if (properties.filename) { + params = flat.set(params, properties.filename, [ + ...(record.get(properties.filename) || []), + ...uploadedFiles.map((file) => file.name), + ]) + } - await record.update(params) + record.storeParams(params) + return + } - return { - ...response, - record: record.toJSON(context.currentAdmin), - } + if (!multiple && files && files.length) { + const uploadedFile: UploadedFile = files[0] + + const oldRecordParams = { ...record.params } + const key = buildRemotePath(record, uploadedFile, uploadPath) + + await provider.upload(uploadedFile, key, context) + + const params = { + [properties.key]: key, + ...properties.bucket && { [properties.bucket]: provider.bucket }, + ...properties.size && { [properties.size]: uploadedFile.size?.toString() }, + ...properties.mimeType && { [properties.mimeType]: uploadedFile.type }, + ...properties.filename && { [properties.filename]: uploadedFile.name as string }, } - if (!multiple && files && files.length) { - const uploadedFile: UploadedFile = files[0] + record.storeParams(params) - const oldRecordParams = { ...record.params } - const key = buildRemotePath(record, uploadedFile, uploadPath) + const oldKey = oldRecordParams[properties.key] && oldRecordParams[properties.key] + const oldBucket = ( + properties.bucket && oldRecordParams[properties.bucket] + ) || provider.bucket - await provider.upload(uploadedFile, key, context) + if (oldKey && oldBucket && (oldKey !== key || oldBucket !== provider.bucket)) { + await provider.delete(oldKey, oldBucket, context) + } + + return + } + // someone wants to remove one file + if (!multiple && files === null) { + const bucket = (properties.bucket && record.get(properties.bucket)) || provider.bucket + const key = record.get(properties.key) as string | undefined + + // and file exists + if (key && bucket) { const params = { - [properties.key]: key, - ...properties.bucket && { [properties.bucket]: provider.bucket }, - ...properties.size && { [properties.size]: uploadedFile.size?.toString() }, - ...properties.mimeType && { [properties.mimeType]: uploadedFile.type }, - ...properties.filename && { [properties.filename]: uploadedFile.name as string }, + [properties.key]: null, + ...properties.bucket && { [properties.bucket]: null }, + ...properties.size && { [properties.size]: null }, + ...properties.mimeType && { [properties.mimeType]: null }, + ...properties.filename && { [properties.filename]: null }, } - await record.update(params) + record.storeParams(params) + await provider.delete(key, bucket, context) + } + } + } - const oldKey = oldRecordParams[properties.key] && oldRecordParams[properties.key] - const oldBucket = ( - properties.bucket && oldRecordParams[properties.bucket] - ) || provider.bucket + const updateRecord = async ( + response: RecordActionResponse, + request: ActionRequest, + context: ActionContext, + ): Promise => { + const { method } = request + const { record } = context - if (oldKey && oldBucket && (oldKey !== key || oldBucket !== provider.bucket)) { - await provider.delete(oldKey, oldBucket, context) - } + if (method !== 'post') { + return response + } - return { - ...response, - record: record.toJSON(context.currentAdmin), + if (record && record.isValid()) { + const data = getNamespaceFromContext(context) + + if (parentArray) { + const items: Record[] = flat.get(data, parentArray) + if (items) { + // Call processProperties with the properties mapped for each aray item. + await Promise.all(items.map(async (_item, index) => { + const basePath = [parentArray, index].join(flat.DELIMITER) + const mappedProperties = Object.keys(origProperties).reduce((memo, prop) => ({ + ...memo, + [prop]: [basePath, origProperties[prop]].join(flat.DELIMITER), + }), { ...origProperties }) + return processProperties( + record, + context, + mappedProperties, + ) + })) } } - // someone wants to remove one file - if (!multiple && files === null) { - const bucket = (properties.bucket && record.get(properties.bucket)) || provider.bucket - const key = record.get(properties.key) as string | undefined - - // and file exists - if (key && bucket) { - const params = { - [properties.key]: null, - ...properties.bucket && { [properties.bucket]: null }, - ...properties.size && { [properties.size]: null }, - ...properties.mimeType && { [properties.mimeType]: null }, - ...properties.filename && { [properties.filename]: null }, - } - - await record.update(params) - await provider.delete(key, bucket, context) - - return { - ...response, - record: record.toJSON(context.currentAdmin), - } - } + await processProperties(record, context, origProperties) + + // Save any updates made by record.storeParams + await record.save() + + return { + ...response, + record: record.toJSON(context.currentAdmin), } } diff --git a/src/features/upload-file/types/property-custom.type.ts b/src/features/upload-file/types/property-custom.type.ts index 3b336d6..59e53e7 100644 --- a/src/features/upload-file/types/property-custom.type.ts +++ b/src/features/upload-file/types/property-custom.type.ts @@ -17,6 +17,7 @@ type PropertyCustom = { maxSize?: number, provider: string, multiple: boolean, + parentArray?: string, } export default PropertyCustom diff --git a/src/features/upload-file/types/upload-options.type.ts b/src/features/upload-file/types/upload-options.type.ts index 1750a00..9277a2e 100644 --- a/src/features/upload-file/types/upload-options.type.ts +++ b/src/features/upload-file/types/upload-options.type.ts @@ -73,6 +73,11 @@ export type UploadOptions = { */ multiple?: boolean, + /** + * Array property that the properties are nested within. + */ + parentArray?: string, + /** Validation rules */ validation?: { /** @@ -96,6 +101,7 @@ export type UploadOptionsWithDefault = { export type FeatureInvocation = { properties: Partial + parentArray?: string } /** diff --git a/src/features/upload-file/upload-file.feature.ts b/src/features/upload-file/upload-file.feature.ts index 0140610..013d8d1 100644 --- a/src/features/upload-file/upload-file.feature.ts +++ b/src/features/upload-file/upload-file.feature.ts @@ -24,7 +24,7 @@ const DEFAULT_FILE_PATH_PROPERTY = 'filePath' const DEFAULT_FILES_TO_DELETE_PROPERTY = 'filesToDelete' const uploadFileFeature = (config: UploadOptions): FeatureType => { - const { provider: providerOptions, validation, multiple } = config + const { provider: providerOptions, validation, multiple, parentArray } = config const configWithDefault: UploadOptionsWithDefault = { ...config, @@ -81,11 +81,13 @@ const uploadFileFeature = (config: UploadOptions): FeatureType => { mimeTypes: validation?.mimeTypes, maxSize: validation?.maxSize, multiple: !!multiple, + parentArray, } + const fileProperty = parentArray ? `${parentArray}.${properties.file}` : properties.file const uploadFeature = buildFeature({ properties: { - [properties.file]: { + [fileProperty]: { custom, isVisible: { show: true, edit: true, list: true, filter: false }, components: { diff --git a/src/features/upload-file/utils/build-custom.ts b/src/features/upload-file/utils/build-custom.ts new file mode 100644 index 0000000..c17303e --- /dev/null +++ b/src/features/upload-file/utils/build-custom.ts @@ -0,0 +1,37 @@ +import { flat, PropertyJSON } from 'admin-bro' +import PropertyCustom from '../types/property-custom.type' + +export default (property: PropertyJSON): PropertyCustom => { + const { custom } = property as unknown as { custom: PropertyCustom } + + if (custom.parentArray) { + // Array OR mixed property + // TODO look at adding mixed property support + // TODO Handle the case were `parentArray` is nested within another array. + const parts = flat.pathToParts(property.path) + const parentArrayIndex = parts.indexOf(custom.parentArray) + if (parentArrayIndex === -1) { + return custom + } + const parentPath = parts[parentArrayIndex + 1] + const updates = [ + 'fileProperty', + 'filePathProperty', + 'filesToDeleteProperty', + 'keyProperty', + ].reduce((memo, prop) => { + const val = custom[prop] + return { + ...memo, + [prop]: val && [parentPath, val].join(flat.DELIMITER), + } + }, {}) + + return { + ...custom, + ...updates, + } + } + + return custom +} diff --git a/src/features/upload-file/utils/fill-record-with-path.ts b/src/features/upload-file/utils/fill-record-with-path.ts index 23b1d97..d756bda 100644 --- a/src/features/upload-file/utils/fill-record-with-path.ts +++ b/src/features/upload-file/utils/fill-record-with-path.ts @@ -8,26 +8,49 @@ export const fillRecordWithPath = async ( uploadOptionsWithDefault: UploadOptionsWithDefault, provider: BaseProvider, ): Promise => { - const { properties, multiple } = uploadOptionsWithDefault + const { properties, multiple, parentArray } = uploadOptionsWithDefault - const key = flat.get(record?.params, properties.key) - const storedBucket = properties.bucket && flat.get(record?.params, properties.bucket) + const getPath = async ( + keyProperty: string, + bucketProperty?: string, + ): Promise => { + const key = flat.get(record?.params, keyProperty) + const storedBucket = bucketProperty && flat.get(record?.params, bucketProperty) - let filePath: string | Array | undefined - if (multiple && key && key.length) { - filePath = await Promise.all(key.map(async (singleKey, index) => ( - provider.path( - singleKey, storedBucket?.[index] ?? provider.bucket, context, + if (multiple && key && key.length) { + return Promise.all(key.map(async (singleKey, index) => ( + provider.path( + singleKey, storedBucket?.[index] ?? provider.bucket, context, + ) + ))) + } + + if (!multiple && key) { + return provider.path( + key, storedBucket ?? provider.bucket, context, ) - ))) - } else if (!multiple && key) { - filePath = await provider.path( - key, storedBucket ?? provider.bucket, context, - ) + } + + return undefined } - return { - ...record, - params: flat.set(record.params, properties.filePath, filePath), + const { key, bucket, filePath } = properties + let { params } = record + + if (parentArray) { + const items: Record[] = flat.get(params, parentArray) + if (items) { + await Promise.all(items.map(async (_item, index) => { + const path = await getPath( + [parentArray, index, key].join(flat.DELIMITER), + bucket && [parentArray, index, bucket].join(flat.DELIMITER), + ) + params = flat.set(params, [parentArray, index, filePath].join(flat.DELIMITER), path) + })) + } + } else { + params = flat.set(params, filePath, await getPath(key, bucket)) } + + return { ...record, params } } diff --git a/src/features/upload-file/utils/validate-properties.ts b/src/features/upload-file/utils/validate-properties.ts index 15ed486..eb027f6 100644 --- a/src/features/upload-file/utils/validate-properties.ts +++ b/src/features/upload-file/utils/validate-properties.ts @@ -5,6 +5,10 @@ const invocationPrefix = (index) => ( `__invocation__${index}__` ) +const propertyPrefix = (invocation: FeatureInvocation) => ( + invocation.parentArray ? `${invocation.parentArray}.` : '' +) + /** * Checks if values for properties given by the user are different * @@ -51,7 +55,7 @@ export const validatePropertiesGlobally = ( ...Object.keys(invocation.properties).reduce((props, key) => ( { ...props, - [`${invocationPrefix(index)}${key}`]: invocation.properties[key], + [`${invocationPrefix(index)}${key}`]: `${propertyPrefix(invocation)}${invocation.properties[key]}`, } ), {}), }),