From 7d16a4e37567028839d426709858ed82acbfd8ef Mon Sep 17 00:00:00 2001 From: wojtek-krysiak Date: Tue, 25 Aug 2020 23:20:07 +0200 Subject: [PATCH 01/13] fix: propert handle null valuescloses #304 --- example-app/bin/setup-db.js | 1 + .../integration/tools/create-tool.spec.js | 11 +++ .../integration/tools/edit-tool.spec.js | 28 ++++++ src/backend/utils/request-parser.spec.ts | 6 -- src/backend/utils/request-parser.ts | 15 ++-- src/frontend/components/actions/edit.tsx | 2 +- src/frontend/components/actions/new.tsx | 2 +- .../actions/record-to-form-data.spec.ts | 27 ------ .../components/actions/record-to-form-data.ts | 32 ------- .../app/records-table/record-in-list.tsx | 2 +- .../components/routes/record-action.tsx | 2 +- src/frontend/hooks/index.ts | 6 +- src/frontend/hooks/update-record.ts | 56 ------------ .../use-record}/merge-record-response.ts | 4 +- .../use-record/record-to-form-data.spec.ts | 34 ++++++++ .../hooks/use-record/record-to-form-data.ts | 40 +++++++++ .../{ => use-record}/update-record.spec.ts | 20 ++++- .../hooks/use-record/update-record.ts | 78 +++++++++++++++++ .../hooks/{ => use-record}/use-record.tsx | 37 +++++--- src/frontend/hooks/use-resource-edit.ts | 87 ------------------- src/frontend/hooks/use-resource-new.ts | 86 ------------------ src/frontend/utils/record-in-store.ts | 0 22 files changed, 252 insertions(+), 324 deletions(-) create mode 100644 example-app/cypress/integration/tools/edit-tool.spec.js delete mode 100644 src/frontend/components/actions/record-to-form-data.spec.ts delete mode 100644 src/frontend/components/actions/record-to-form-data.ts delete mode 100644 src/frontend/hooks/update-record.ts rename src/frontend/{utils => hooks/use-record}/merge-record-response.ts (84%) create mode 100644 src/frontend/hooks/use-record/record-to-form-data.spec.ts create mode 100644 src/frontend/hooks/use-record/record-to-form-data.ts rename src/frontend/hooks/{ => use-record}/update-record.spec.ts (86%) create mode 100644 src/frontend/hooks/use-record/update-record.ts rename src/frontend/hooks/{ => use-record}/use-record.tsx (82%) delete mode 100644 src/frontend/hooks/use-resource-edit.ts delete mode 100644 src/frontend/hooks/use-resource-new.ts create mode 100644 src/frontend/utils/record-in-store.ts diff --git a/example-app/bin/setup-db.js b/example-app/bin/setup-db.js index 4740e0bd7..39f58e2a9 100644 --- a/example-app/bin/setup-db.js +++ b/example-app/bin/setup-db.js @@ -35,6 +35,7 @@ const setupDb = async () => { await Tool.create([...new Array(100)].map((el, index) => ({ name: `tool ${index}`, + description: null, }))) } diff --git a/example-app/cypress/integration/tools/create-tool.spec.js b/example-app/cypress/integration/tools/create-tool.spec.js index c79a43797..30eac2df1 100644 --- a/example-app/cypress/integration/tools/create-tool.spec.js +++ b/example-app/cypress/integration/tools/create-tool.spec.js @@ -12,11 +12,22 @@ context('resources/Tool/actions/new', () => { }) it('creates new tool by hitting enter instead of clicking submit', () => { + cy.server() + cy.route({ method: 'POST', url: 'admin/api/resources/Tool/actions/new' }) + .as('apiNew') + const name = 'My Name' cy.get('[data-testid="property-edit-name"]').type(`${name}{enter}`) + cy.wait('@apiNew').then((xhr) => { + const { record } = xhr.response.body + expect(record.params.description).to.be.undefined + expect(record.params.name).to.eq(name) + }) + cy.location('pathname').should('eq', '/admin/resources/Tool') + // Remove the created record, because it brakes the pagination test otherwise. cy.get('td[data-property-name="name"]').contains(name).parents('tr') .find('[data-testid="actions-dropdown"]') .trigger('mouseover') diff --git a/example-app/cypress/integration/tools/edit-tool.spec.js b/example-app/cypress/integration/tools/edit-tool.spec.js new file mode 100644 index 000000000..3e65226d3 --- /dev/null +++ b/example-app/cypress/integration/tools/edit-tool.spec.js @@ -0,0 +1,28 @@ +/// +/// + +context('resources/Tool/actions/edit', () => { + before(() => { + cy.login() + }) + + beforeEach(() => { + Cypress.Cookies.preserveOnce(Cypress.env('COOKIE_NAME')) + cy.visit('resources/Tool') + }) + + it('leaves null value in description when it was not alter', () => { + cy.get('[data-testid="property-list-name"]').last().click() + cy.get('[data-testid="action-edit"]').click() + + cy.server() + cy.route({ method: 'POST', url: 'admin/api/resources/Tool/records/*/edit' }) + .as('apiEdit') + cy.get('button[type="submit"]').click() + + cy.wait('@apiEdit').then((xhr) => { + const { record } = xhr.response.body + expect(record.params.description).to.eq(null) + }) + }) +}) diff --git a/src/backend/utils/request-parser.spec.ts b/src/backend/utils/request-parser.spec.ts index 5b24d18cc..977cfee68 100644 --- a/src/backend/utils/request-parser.spec.ts +++ b/src/backend/utils/request-parser.spec.ts @@ -19,12 +19,6 @@ describe('RequestParser', function () { return newProperty }, } as BaseResource - - it('converts empty string to an empty array', function () { - const request = { ...baseRequest, payload: { arrayed: '' } } - - expect(requestParser(request, resource).payload?.arrayed).to.deep.eq([]) - }) }) describe('boolean values', function () { diff --git a/src/backend/utils/request-parser.ts b/src/backend/utils/request-parser.ts index ab39a28c3..226958061 100644 --- a/src/backend/utils/request-parser.ts +++ b/src/backend/utils/request-parser.ts @@ -1,9 +1,10 @@ import { ActionRequest } from '../actions/action.interface' import BaseResource from '../adapters/base-resource' +import { FORM_VALUE_NULL, FORM_VALUE_EMPTY_OBJECT, FORM_VALUE_EMPTY_ARRAY } from '../../frontend/hooks/use-record/record-to-form-data' /** * Takes the original ActionRequest and convert string values to a corresponding - * types. + * types. It * * @param {ActionRequest} originalRequest * @param {BaseResource} resource @@ -14,9 +15,14 @@ import BaseResource from '../adapters/base-resource' const RequestParser = (originalRequest: ActionRequest, resource: BaseResource): ActionRequest => { const { payload: originalPayload } = originalRequest - const payload = Object.entries(originalPayload || {}).reduce((memo, [path, value]) => { + const payload = Object.entries(originalPayload || {}).reduce((memo, [path, formValue]) => { const property = resource.property(path) + let value = formValue + if (formValue === FORM_VALUE_NULL) { value = null } + if (formValue === FORM_VALUE_EMPTY_OBJECT) { value = {} } + if (formValue === FORM_VALUE_EMPTY_ARRAY) { value = [] } + if (property) { if (property.type() === 'boolean') { if (value === 'true') { return { ...memo, [path]: true } } @@ -24,7 +30,7 @@ const RequestParser = (originalRequest: ActionRequest, resource: BaseResource): if (value === '') { return { ...memo, [path]: false } } } if (['date', 'datetime'].includes(property.type())) { - if (value === '') { return { ...memo, [path]: null } } + if (value === '' || value === null) { return { ...memo, [path]: null } } } if (property.type() === 'string') { const availableValues = property.availableValues() @@ -32,9 +38,6 @@ const RequestParser = (originalRequest: ActionRequest, resource: BaseResource): return { ...memo, [path]: null } } } - if (property.isArray() && value === '') { - return { ...memo, [path]: [] } - } } return { diff --git a/src/frontend/components/actions/edit.tsx b/src/frontend/components/actions/edit.tsx index c22dadd96..b2ad7b028 100644 --- a/src/frontend/components/actions/edit.tsx +++ b/src/frontend/components/actions/edit.tsx @@ -5,7 +5,7 @@ import { DrawerContent, Box, DrawerFooter, Button, Icon } from '@admin-bro/desig import PropertyType from '../property-type' import { ActionProps } from './action.props' import ActionHeader from '../app/action-header' -import useRecord from '../../hooks/use-record' +import useRecord from '../../hooks/use-record/use-record' import RecordJSON from '../../../backend/decorators/record-json.interface' import { appendForceRefresh } from './utils/append-force-refresh' import { useTranslation } from '../../hooks/use-translation' diff --git a/src/frontend/components/actions/new.tsx b/src/frontend/components/actions/new.tsx index 6c685cec8..62d15f06f 100644 --- a/src/frontend/components/actions/new.tsx +++ b/src/frontend/components/actions/new.tsx @@ -7,7 +7,7 @@ import PropertyType from '../property-type' import { ActionProps } from './action.props' import ActionHeader from '../app/action-header' import RecordJSON from '../../../backend/decorators/record-json.interface' -import useRecord from '../../hooks/use-record' +import useRecord from '../../hooks/use-record/use-record' import { appendForceRefresh } from './utils/append-force-refresh' import { useTranslation } from '../../hooks/use-translation' diff --git a/src/frontend/components/actions/record-to-form-data.spec.ts b/src/frontend/components/actions/record-to-form-data.spec.ts deleted file mode 100644 index b5f71ef44..000000000 --- a/src/frontend/components/actions/record-to-form-data.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import factory from 'factory-girl' -import { expect } from 'chai' - -import recordToFormData from './record-to-form-data' -import '../spec/record-json.factory' -import RecordJSON from '../../../backend/decorators/record-json.interface' - -describe('recordToFormData', function () { - it('converts objects to empty string', async function () { - const record = await factory.build('RecordJSON', { params: { - someEmptyObject: {}, - } }) - expect(recordToFormData(record).get('someEmptyObject')).to.equal('') - }) - - it('removes duplicated root keys for nested arrays', async function () { - const params = { - 'Item.0': '', - 'Item.0.imageVariants.0.imageURL': 'some-value', - } - const record = await factory.build('RecordJSON', { params }) - expect(recordToFormData(record).get('Item.0')).to.be.null - expect(recordToFormData(record).get('Item.0.imageVariants.0.imageURL')).to.equal( - params['Item.0.imageVariants.0.imageURL'], - ) - }) -}) diff --git a/src/frontend/components/actions/record-to-form-data.ts b/src/frontend/components/actions/record-to-form-data.ts deleted file mode 100644 index 4fa76c7a5..000000000 --- a/src/frontend/components/actions/record-to-form-data.ts +++ /dev/null @@ -1,32 +0,0 @@ -import flat from 'flat' - -import RecordJSON from '../../../backend/decorators/record-json.interface' - -const { flatten, unflatten } = flat - -/** - * Changes RecordJSON that it can be send as a FormData to the backend. - * - * @param {RecordJSON} record - * @return {FormData} - */ -export default function recordToFormData(record: RecordJSON): FormData { - const formData = new FormData() - - // First let make sure that all the fields in the record.params are properly flatten. - // That is why we unflatten all properties and create regular object, where flat - // overwrite prevents from having 2 keys referencing the same property. And - // the result is flatten again. - const normalizedParams = flatten(unflatten(record.params, { overwrite: true })) - Object.entries(normalizedParams).forEach(([key, value]) => { - // flatten does not change empty objects "{}" - so in order to prevent having them changed to - // "[object Object]" we have to set them to empty strings. File objects has to go through. - // eslint-disable-next-line no-undef - if (value === null || (typeof value === 'object' && (value as object).constructor !== File)) { - formData.set(key, '') - } else { - formData.set(key, value as string) - } - }) - return formData -} diff --git a/src/frontend/components/app/records-table/record-in-list.tsx b/src/frontend/components/app/records-table/record-in-list.tsx index 019a4a4a0..58926f3e3 100644 --- a/src/frontend/components/app/records-table/record-in-list.tsx +++ b/src/frontend/components/app/records-table/record-in-list.tsx @@ -12,7 +12,7 @@ import RecordJSON from '../../../../backend/decorators/record-json.interface' import ViewHelpers from '../../../../backend/utils/view-helpers' import { display } from './records-table-header' import { ActionResponse, RecordActionResponse } from '../../../../backend/actions/action.interface' -import mergeRecordResponse from '../../../utils/merge-record-response' +import mergeRecordResponse from '../../../hooks/use-record/merge-record-response' type Props = { resource: ResourceJSON; diff --git a/src/frontend/components/routes/record-action.tsx b/src/frontend/components/routes/record-action.tsx index d4a1b71a5..70df02642 100644 --- a/src/frontend/components/routes/record-action.tsx +++ b/src/frontend/components/routes/record-action.tsx @@ -16,7 +16,7 @@ import { ActionHeader } from '../app' import { useNotice, useTranslation } from '../../hooks' import DrawerPortal from '../app/drawer-portal' import { ActionResponse, RecordActionResponse } from '../../../backend/actions/action.interface' -import mergeRecordResponse from '../../utils/merge-record-response' +import mergeRecordResponse from '../../hooks/use-record/merge-record-response' const api = new ApiClient() diff --git a/src/frontend/hooks/index.ts b/src/frontend/hooks/index.ts index c1389c96c..b7581c51b 100644 --- a/src/frontend/hooks/index.ts +++ b/src/frontend/hooks/index.ts @@ -1,10 +1,8 @@ -export { default as useResourceEdit } from './use-resource-edit' -export { default as useResourceNew } from './use-resource-new' export * from './use-selected-records' export * from './use-notice' export * from './use-translation' -export * from './use-record' +export * from './use-record/use-record' export * from './use-records' export * from './use-current-admin' -export { default as updateRecord } from './update-record' +export { default as updateRecord } from './use-record/update-record' diff --git a/src/frontend/hooks/update-record.ts b/src/frontend/hooks/update-record.ts deleted file mode 100644 index bc0c6d9a4..000000000 --- a/src/frontend/hooks/update-record.ts +++ /dev/null @@ -1,56 +0,0 @@ -import flat from 'flat' - -import RecordJSON from '../../backend/decorators/record-json.interface' - -/** - * Returns a function which takes a record and returns an updated record. - * - * @param {string} property property that must be updated, supports nesting - * with dots - * @param {any} value value that must be set, undefined or null if - * deleting, will be flattened - * @param {RecordJSON} refRecord if value is reference ID, this must be a record - * it's referencing to - * @private - */ -const updateRecord = ( - property: string, - value: any, - refRecord?: RecordJSON, -) => (previousRecord: RecordJSON): RecordJSON => { - let populatedModified = false - const populatedCopy = { ...previousRecord.populated } - const paramsCopy = { ...previousRecord.params } - - // clear previous value - Object.keys(paramsCopy) - .filter(key => key === property || key.startsWith(`${property}.`)) - .forEach(k => delete paramsCopy[k]) - if (property in populatedCopy) { - delete populatedCopy[property] - populatedModified = true - } - - // set new value - if (typeof value !== 'undefined') { - if (typeof value === 'object' && !(value instanceof File) && value !== null) { - const flattened = flat.flatten(value) as any - Object.keys(flattened).forEach((key) => { - paramsCopy[`${property}.${key}`] = flattened[key] - }) - } else { - paramsCopy[property] = value - } - if (refRecord) { - populatedCopy[property] = refRecord - populatedModified = true - } - } - return { - ...previousRecord, - params: paramsCopy, - populated: populatedModified ? populatedCopy : previousRecord.populated, - } -} - -export default updateRecord diff --git a/src/frontend/utils/merge-record-response.ts b/src/frontend/hooks/use-record/merge-record-response.ts similarity index 84% rename from src/frontend/utils/merge-record-response.ts rename to src/frontend/hooks/use-record/merge-record-response.ts index ab6533e75..7b85637a2 100644 --- a/src/frontend/utils/merge-record-response.ts +++ b/src/frontend/hooks/use-record/merge-record-response.ts @@ -1,5 +1,5 @@ -import RecordJSON from '../../backend/decorators/record-json.interface' -import { RecordActionResponse } from '../../backend/actions/action.interface' +import RecordJSON from '../../../backend/decorators/record-json.interface' +import { RecordActionResponse } from '../../../backend/actions/action.interface' /** * Handlers of all [Actions]{@link Action} of type `record` returns record. diff --git a/src/frontend/hooks/use-record/record-to-form-data.spec.ts b/src/frontend/hooks/use-record/record-to-form-data.spec.ts new file mode 100644 index 000000000..a39e69599 --- /dev/null +++ b/src/frontend/hooks/use-record/record-to-form-data.spec.ts @@ -0,0 +1,34 @@ +import factory from 'factory-girl' +import { expect } from 'chai' + +import recordToFormData, { FORM_VALUE_EMPTY_OBJECT, FORM_VALUE_NULL, FORM_VALUE_EMPTY_ARRAY } from './record-to-form-data' +import '../../components/spec/record-json.factory' +import RecordJSON from '../../../backend/decorators/record-json.interface' + +describe('recordToFormData', function () { + const propertyKey = 'someProperty' + + it('converts objects to const', async function () { + const record = await factory.build('RecordJSON', { params: { + [propertyKey]: {}, + } }) + + expect(recordToFormData(record).get(propertyKey)).to.equal(FORM_VALUE_EMPTY_OBJECT) + }) + + it('converts nulls to const', async function () { + const record = await factory.build('RecordJSON', { params: { + [propertyKey]: null, + } }) + + expect(recordToFormData(record).get(propertyKey)).to.equal(FORM_VALUE_NULL) + }) + + it('converts empty array to const', async function () { + const record = await factory.build('RecordJSON', { params: { + [propertyKey]: [], + } }) + + expect(recordToFormData(record).get(propertyKey)).to.equal(FORM_VALUE_EMPTY_ARRAY) + }) +}) diff --git a/src/frontend/hooks/use-record/record-to-form-data.ts b/src/frontend/hooks/use-record/record-to-form-data.ts new file mode 100644 index 000000000..82b2538ae --- /dev/null +++ b/src/frontend/hooks/use-record/record-to-form-data.ts @@ -0,0 +1,40 @@ +import RecordJSON from '../../../backend/decorators/record-json.interface' + +export const FORM_VALUE_NULL = '__FORM_VALUE_NULL__' +export const FORM_VALUE_EMPTY_OBJECT = '__FORM_VALUE_EMPTY_OBJECT__' +export const FORM_VALUE_EMPTY_ARRAY = '__FORM_VALUE_EMPTY_ARRAY__' + +/** + * Changes RecordJSON that it can be send as a FormData to the backend. + * + * FormData is required because we are sending files via the wire. But it has limitations. + * Namely it can only transport files and strings. That is why we have to convert some + * standard types like NULL to constants so they can be property converted back by the backend. + * And thus properly handled. + * + * + * @param {RecordJSON} record + * @return {FormData} + */ +export default function recordToFormData(record: RecordJSON): FormData { + const formData = new FormData() + + Object.entries(record.params).forEach(([key, value]) => { + // {@link updateRecord} does not change empty objects "{}" - so in order to prevent having + // them changed to "[object Object]" we have to set them to empty strings. + if (value === null) { + return formData.set(key, FORM_VALUE_NULL) + } + // File objects has to go through because they are handled by FormData + if (typeof value === 'object' && (value as object).constructor !== File) { + if (Array.isArray(value)) { + return formData.set(key, FORM_VALUE_EMPTY_ARRAY) + } + return formData.set(key, FORM_VALUE_EMPTY_OBJECT) + } + + // Rest goes as a standard value + return formData.set(key, value as string) + }) + return formData +} diff --git a/src/frontend/hooks/update-record.spec.ts b/src/frontend/hooks/use-record/update-record.spec.ts similarity index 86% rename from src/frontend/hooks/update-record.spec.ts rename to src/frontend/hooks/use-record/update-record.spec.ts index a33c506a5..1359bb62d 100644 --- a/src/frontend/hooks/update-record.spec.ts +++ b/src/frontend/hooks/use-record/update-record.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import { unflatten } from 'flat' import updateRecord from './update-record' -import RecordJSON from '../../backend/decorators/record-json.interface' +import RecordJSON from '../../../backend/decorators/record-json.interface' describe('updateRecord', function () { const newPropertyName = 'newProperty' @@ -119,4 +119,22 @@ describe('updateRecord', function () { expect(updatedRecord.params[propertyName]).to.be.null }) + + it('leaves {} when it was given', function () { + const value = {} + + const update = updateRecord(propertyName, value) + const updatedRecord = update(previousRecord) + + expect(updatedRecord.params[propertyName]).to.deep.eq({}) + }) + + it('leaves [] when it was given', function () { + const value = [] + + const update = updateRecord(propertyName, value) + const updatedRecord = update(previousRecord) + + expect(updatedRecord.params[propertyName]).to.deep.eq([]) + }) }) diff --git a/src/frontend/hooks/use-record/update-record.ts b/src/frontend/hooks/use-record/update-record.ts new file mode 100644 index 000000000..c74a5ab5a --- /dev/null +++ b/src/frontend/hooks/use-record/update-record.ts @@ -0,0 +1,78 @@ +import flat from 'flat' +import RecordJSON from '../../../backend/decorators/record-json.interface' + +/** + * HOF returning a function which takes a record and returns an updated record. + * This way we can pass this to setState in react, which takes old state + * (in our case previousRecord) as an argument. + * + * Function is used when to the {@link OnPropertyChange} callback, user passes + * key (property name) and the value (followed by an optional selectedRecord). + * + * The responsibility of the function is to: + * - clear old values under passed key: so when user passes property === `some.key` + * function removes `some.key.1`, `some.key.2` etc + * - sets new value under the passed key for primitive types + * - in case of objects - it flattens them first and then sets all the resulted values + * under the path provided in the property argument + * - it fills value in RecordJSON#populated when selectedRecord is given + * - finally it invalidates populated for given property + * + * + * @param {string} property property that must be updated, supports nesting + * with dots + * @param {any} value value that must be set, undefined or null if + * deleting, will be flattened + * @param {RecordJSON} selectedRecord if value is reference ID, this must be a record + * it's referencing to + * @private + */ +const updateRecord = ( + property: string, + value: any, + selectedRecord?: RecordJSON, +) => (previousRecord: RecordJSON): RecordJSON => { + let populatedModified = false + const populatedCopy = { ...previousRecord.populated } + const paramsCopy = { ...previousRecord.params } + + // clear previous value + Object.keys(paramsCopy) + .filter(key => key === property || key.startsWith(`${property}.`)) + .forEach(k => delete paramsCopy[k]) + if (property in populatedCopy) { + delete populatedCopy[property] + populatedModified = true + } + + // set new value + if (typeof value !== 'undefined') { + if (typeof value === 'object' && !(value instanceof File) && value !== null) { + const flattened = flat.flatten(value) as any + if (Object.keys(flattened).length) { + Object.keys(flattened).forEach((key) => { + paramsCopy[`${property}.${key}`] = flattened[key] + }) + } else if (Array.isArray(value)) { + paramsCopy[property] = [] + } else { + paramsCopy[property] = {} + } + } else { + paramsCopy[property] = value + } + } + + if (selectedRecord) { + populatedCopy[property] = selectedRecord + populatedModified = true + } + + return { + ...previousRecord, + params: paramsCopy, + populated: populatedModified ? populatedCopy : previousRecord.populated, + } +} + +export default updateRecord diff --git a/src/frontend/hooks/use-record.tsx b/src/frontend/hooks/use-record/use-record.tsx similarity index 82% rename from src/frontend/hooks/use-record.tsx rename to src/frontend/hooks/use-record/use-record.tsx index f5f3b9bc1..c973b393d 100644 --- a/src/frontend/hooks/use-record.tsx +++ b/src/frontend/hooks/use-record/use-record.tsx @@ -1,15 +1,27 @@ import { useState, useCallback, useEffect } from 'react' import { AxiosResponse } from 'axios' -import ApiClient from '../utils/api-client' -import RecordJSON from '../../backend/decorators/record-json.interface' -import recordToFormData from '../components/actions/record-to-form-data' -import useNotice from './use-notice' -import { RecordActionResponse } from '../../backend/actions/action.interface' -import mergeRecordResponse from '../utils/merge-record-response' +import ApiClient from '../../utils/api-client' +import RecordJSON from '../../../backend/decorators/record-json.interface' +import recordToFormData from './record-to-form-data' +import useNotice from '../use-notice' +import { RecordActionResponse } from '../../../backend/actions/action.interface' +import mergeRecordResponse from './merge-record-response' import updateRecord from './update-record' +import { OnPropertyChange } from '../../components/property-type/base-property-props' const api = new ApiClient() +const isEntireRecordGiven = ( + propertyOrRecord: RecordJSON | string, + value?: string, +): boolean => !!(typeof value === 'undefined' + // user can pass property and omit value. This makes sense when + // third argument of the function (selectedRecord) is passed to onChange + // callback + && !(typeof propertyOrRecord === 'string') + // we assume that only params has to be given + && propertyOrRecord.params) + /** * Result of useRecord hook * @@ -25,7 +37,7 @@ export type UseRecordResult = { * Function compatible with onChange method supported by all the components wrapped by * {@link BasePropertyComponent}. */ - handleChange: (propertyOrRecord: string | RecordJSON, value?: any) => void; + handleChange: OnPropertyChange; /** * Triggers submission of the record. Returns a promise. * If custom params are given as an argument - they are merged @@ -127,12 +139,8 @@ export const useRecord = ( value?: any, incomingRecord?: RecordJSON, ): void => { - if ( - typeof value === 'undefined' - && !(typeof propertyOrRecord === 'string') - && propertyOrRecord.params - ) { - setRecord(propertyOrRecord) + if (isEntireRecordGiven(propertyOrRecord, value)) { + setRecord(propertyOrRecord as RecordJSON) } else { setRecord(updateRecord(propertyOrRecord as string, value, incomingRecord)) } @@ -142,14 +150,17 @@ export const useRecord = ( customParams: Record = {}, ): Promise> => { setLoading(true) + const formData = recordToFormData(record) Object.entries(customParams).forEach(([key, value]) => formData.set(key, value)) + const params = { resourceId, onUploadProgress: (e): void => setProgress(Math.round((e.loaded * 100) / e.total)), data: formData, headers: { 'Content-Type': 'multipart/form-data' }, } + const promise = record.id ? api.recordAction({ ...params, diff --git a/src/frontend/hooks/use-resource-edit.ts b/src/frontend/hooks/use-resource-edit.ts deleted file mode 100644 index 65e7e11cf..000000000 --- a/src/frontend/hooks/use-resource-edit.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useState } from 'react' -import { useHistory } from 'react-router' -import ApiClient from '../utils/api-client' -import RecordJSON from '../../backend/decorators/record-json.interface' -import { NoticeMessage } from '../store/with-notice' -import recordToFormData from '../components/actions/record-to-form-data' -import { appendForceRefresh } from '../components/actions/utils/append-force-refresh' - -const api = new ApiClient() - -interface ResourceEdit { - record: RecordJSON; - handleChange: (propertyOrRecord: string | RecordJSON, value?: any) => void; - handleSubmit: (event: any) => boolean; - loading: boolean; -} - -const useResourceEdit = ( - initialRecord: RecordJSON | undefined, - resourceId: string, - onNotice: (notice: NoticeMessage) => void, -): ResourceEdit => { - const [record, setRecord] = useState({ - ...initialRecord, - params: initialRecord?.params ?? {}, - errors: initialRecord?.errors ?? {}, - populated: initialRecord?.populated ?? {}, - } as any) - const [loading, setLoading] = useState(false) - const history = useHistory() - - const handleChange = ( - propertyOrRecord: RecordJSON | string, - value?: any, - ): void => { - if ( - typeof value === 'undefined' - && !(typeof propertyOrRecord === 'string') - && propertyOrRecord.params - ) { - setRecord(propertyOrRecord) - } else { - setRecord(prev => ({ - ...prev, - params: { ...prev.params, [propertyOrRecord as string]: value }, - })) - } - } - - const handleSubmit = (event): boolean => { - const formData = recordToFormData(record) - setLoading(true) - api - .recordAction({ - resourceId, - actionName: 'edit', - recordId: record.id, - data: formData, - headers: { 'Content-Type': 'multipart/form-data' }, - }) - .then((response) => { - if (response.data.notice) { - onNotice(response.data.notice) - } - if (response.data.redirectUrl) { - history.push(appendForceRefresh(response.data.redirectUrl)) - } else { - setRecord(prev => ({ ...prev, errors: response.data.record.errors })) - setLoading(false) - } - }) - .catch(() => { - setLoading(false) - onNotice({ - message: - 'There was an error updating record, Check out console to see more information.', - type: 'error', - }) - }) - event.preventDefault() - return false - } - - return { record, handleChange, handleSubmit, loading } -} - -export default useResourceEdit diff --git a/src/frontend/hooks/use-resource-new.ts b/src/frontend/hooks/use-resource-new.ts deleted file mode 100644 index 2144faf89..000000000 --- a/src/frontend/hooks/use-resource-new.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useState } from 'react' -import { useHistory } from 'react-router' -import ApiClient from '../utils/api-client' -import RecordJSON from '../../backend/decorators/record-json.interface' -import { NoticeMessage } from '../store/with-notice' -import recordToFormData from '../components/actions/record-to-form-data' -import { appendForceRefresh } from '../components/actions/utils/append-force-refresh' - -const api = new ApiClient() - -interface ResourceNew { - record: RecordJSON; - handleChange: (propertyOrRecord: string | RecordJSON, value?: any) => void; - handleSubmit: (event: any) => boolean; - loading: boolean; -} - -const useResourceNew = ( - initialRecord: RecordJSON | undefined, - resourceId: string, - onNotice: (notice: NoticeMessage) => void, -): ResourceNew => { - const [record, setRecord] = useState({ - ...initialRecord, - params: initialRecord?.params ?? {}, - errors: initialRecord?.errors ?? {}, - populated: initialRecord?.populated ?? {}, - } as any) - const [loading, setLoading] = useState(false) - const history = useHistory() - - const handleChange = ( - propertyOrRecord: RecordJSON | string, - value?: any, - ): void => { - if ( - typeof value === 'undefined' - && !(typeof propertyOrRecord === 'string') - && propertyOrRecord.params - ) { - setRecord(propertyOrRecord) - } else { - setRecord(prev => ({ - ...prev, - params: { ...prev.params, [propertyOrRecord as string]: value }, - })) - } - } - - const handleSubmit = (event): boolean => { - const formData = recordToFormData(record) - setLoading(true) - api - .resourceAction({ - resourceId, - actionName: 'new', - data: formData, - headers: { 'Content-Type': 'multipart/form-data' }, - }) - .then((response) => { - if (response.data.notice) { - onNotice(response.data.notice) - } - if (response.data.redirectUrl) { - history.push(appendForceRefresh(response.data.redirectUrl)) - } else { - setRecord(prev => ({ ...prev, errors: response.data.record.errors })) - setLoading(false) - } - }) - .catch(() => { - setLoading(false) - onNotice({ - message: - 'There was an error updating record, Check out console to see more information.', - type: 'error', - }) - }) - event.preventDefault() - return false - } - - return { record, handleChange, handleSubmit, loading } -} - -export default useResourceNew diff --git a/src/frontend/utils/record-in-store.ts b/src/frontend/utils/record-in-store.ts new file mode 100644 index 000000000..e69de29bb From 19e8db9666d93bce3b06427277262fbe664f54da Mon Sep 17 00:00:00 2001 From: wojtek-krysiak Date: Wed, 26 Aug 2020 09:46:21 +0200 Subject: [PATCH 02/13] feat: allow to clear field ([19e8db9](https://github.com/SoftwareBrothers/admin-bro/commit/19e8db9666d93bce3b06427277262fbe664f54da)), closes [#161](https://github.com/SoftwareBrothers/admin-bro/issues/161) * unify flatten unflatten logic ([b8435de](https://github.com/SoftwareBrothers/admin-bro/commit/b8435decd5656b8d9186c13aa38e37d66b5b3f62)), closes [#352](https://github.com/SoftwareBrothers/admin-bro/issues/352) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f502115de..a7a6eb0c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "admin-bro", - "version": "3.0.1", + "version": "3.1.0-beta.1", "description": "Admin panel for apps written in node.js", "main": "index.js", "types": "index.d.ts", From f0b43192ccdca4f6438f0c7ad2ec4f01e7d823de Mon Sep 17 00:00:00 2001 From: wojtek-krysiak Date: Thu, 27 Aug 2020 10:19:52 +0200 Subject: [PATCH 06/13] fix: disabled fields are stripped from the payloadrelates to #430 --- spec/backend/helpers/resource-stub.ts | 41 ++- src/admin-bro.ts | 4 +- src/backend/decorators/property-decorator.ts | 13 +- .../decorators/resource-decorator.spec.js | 237 ------------- .../decorators/resource-decorator.spec.ts | 323 ++++++++++++++++++ src/backend/decorators/resource-decorator.ts | 92 ++++- src/backend/utils/filter.ts | 2 +- src/backend/utils/request-parser.spec.ts | 44 ++- src/backend/utils/request-parser.ts | 5 +- 9 files changed, 493 insertions(+), 268 deletions(-) delete mode 100644 src/backend/decorators/resource-decorator.spec.js create mode 100644 src/backend/decorators/resource-decorator.spec.ts diff --git a/spec/backend/helpers/resource-stub.ts b/spec/backend/helpers/resource-stub.ts index 7c51ac8ac..ce2c332d6 100644 --- a/spec/backend/helpers/resource-stub.ts +++ b/spec/backend/helpers/resource-stub.ts @@ -4,11 +4,46 @@ import BaseProperty from '../../../src/backend/adapters/base-property' import BaseResource from '../../../src/backend/adapters/base-resource' import ResourceDecorator from '../../../src/backend/decorators/resource-decorator' +/** + * returns properties with following absolute paths: + * - normal: number + * - nested: mixed + * - nested.normal: string + * - nested.nested: mixed + * - nested.nested.normalInner: string + * - arrayed: string (array) + * - arrayedMixed: mixed (array) + * - arrayedMixed.arrayParam: string + * + * @private + */ +const buildProperties = (): Array => { + const normalProperty = new BaseProperty({ path: 'normal', type: 'number' }) as any + const nestedProperty = new BaseProperty({ path: 'nested', type: 'mixed' }) as any + const nested2Property = new BaseProperty({ path: 'nested', type: 'mixed' }) as any + const arrayProperty = new BaseProperty({ path: 'arrayed', type: 'string' }) as any + const arrayMixedProperty = new BaseProperty({ path: 'arrayedMixed', type: 'mixed' }) as any + arrayProperty.isArray = (): boolean => true + arrayMixedProperty.isArray = (): boolean => true + + nestedProperty.subProperties = (): Array => [ + new BaseProperty({ path: 'normal', type: 'string' }), + nested2Property, + ] + nested2Property.subProperties = (): Array => [ + new BaseProperty({ path: 'normalInner', type: 'string' }), + ] + arrayMixedProperty.subProperties = (): Array => [ + new BaseProperty({ path: 'arrayParam', type: 'string' }), + ] + + return [normalProperty, nestedProperty, arrayProperty, arrayMixedProperty] +} + + export const expectedResult = { id: 'someID', - properties: [...Array(10)].map((a, i) => new BaseProperty({ - path: `property.${i}`, type: 'string', - })), + properties: buildProperties(), resourceName: 'resourceName', databaseName: 'databaseName', databaseType: 'mongodb', diff --git a/src/admin-bro.ts b/src/admin-bro.ts index edd409caf..c2d495afc 100644 --- a/src/admin-bro.ts +++ b/src/admin-bro.ts @@ -27,7 +27,7 @@ import { OverridableComponent } from './frontend/utils/overridable-component' const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8')) export const VERSION = pkg.version -const defaults: AdminBroOptionsWithDefault = { +export const defaultOptions: AdminBroOptionsWithDefault = { rootPath: DEFAULT_PATHS.rootPath, logoutPath: DEFAULT_PATHS.logoutPath, loginPath: DEFAULT_PATHS.loginPath, @@ -160,7 +160,7 @@ class AdminBro { * @type {AdminBroOptions} * @description Options given by a user */ - this.options = _.merge({}, defaults, options) + this.options = _.merge({}, defaultOptions, options) this.initI18n() diff --git a/src/backend/decorators/property-decorator.ts b/src/backend/decorators/property-decorator.ts index adad36107..357f690f4 100644 --- a/src/backend/decorators/property-decorator.ts +++ b/src/backend/decorators/property-decorator.ts @@ -22,7 +22,7 @@ class PropertyDecorator { * This path serves as a key in {@link PropertyOptions} to identify which * property has to be updated */ - private path: string + public path: string private _admin: AdminBro @@ -202,6 +202,15 @@ class PropertyDecorator { return !!this.overrideFromOptions(AvailablePropertyOptions.isTitle) } + /** + * If property should be disabled in the UI + * + * @return {boolean} + */ + isDisabled(): boolean { + return !!this.options.isDisabled + } + /** * Returns JSON representation of a property * @@ -219,7 +228,7 @@ class PropertyDecorator { isRequired: this.isRequired(), availableValues: this.availableValues(), name: this.name(), - isDisabled: !!this.options.isDisabled, + isDisabled: this.isDisabled(), label: this.label(), type: this.type(), reference: this.property.reference(), diff --git a/src/backend/decorators/resource-decorator.spec.js b/src/backend/decorators/resource-decorator.spec.js deleted file mode 100644 index f8d845f09..000000000 --- a/src/backend/decorators/resource-decorator.spec.js +++ /dev/null @@ -1,237 +0,0 @@ -import sinon from 'sinon' - -import ResourceDecorator from './resource-decorator' -import PropertyDecorator from './property-decorator' -import ConfigurationError from '../utils/configuration-error' -import AdminBro from '../../admin-bro' -import resourceStub, { expectedResult } from '../../../spec/backend/helpers/resource-stub' - -describe('ResourceDecorator', function () { - beforeEach(function () { - this.translatedLabel = 'translated label' - this.currentAdmin = { - email: 'some@email.com', - name: 'someName', - otherValue: 'someOther-value', - } - this.stubbedRecord = this.sinon.stub() - this.stubbedResource = resourceStub(this.sinon) - this.stubbedResource._decorated = { - id: () => 'resourceId', - } - this.stubbedAdmin = this.sinon.createStubInstance(AdminBro) - this.stubbedAdmin.translateLabel = sinon.stub().returns(this.translatedLabel) - this.stubbedAdmin.translateProperty = sinon.stub().returns('translated property') - this.stubbedAdmin.translateAction = sinon.stub().returns('translated action') - this.stubbedAdmin.translateMessage = sinon.stub().returns('translate message') - this.stubbedAdmin.options = { rootPath: '/admin' } - this.args = { resource: this.stubbedResource, admin: this.stubbedAdmin } - }) - - describe('#getResourceName', function () { - it('returns resource when name is not specified in options', function () { - expect( - new ResourceDecorator({ ...this.args, options: {} }).getResourceName(), - ).to.equal(this.translatedLabel) - }) - }) - - describe('#getParent', function () { - it('returns database name with its icon when no options were specified', function () { - expect( - new ResourceDecorator({ ...this.args, options: {} }).getParent(), - ).to.deep.equal({ - name: expectedResult.databaseName, - icon: `icon-${expectedResult.databaseType}`, - }) - }) - it('returns custom name with icon when options were specified', function () { - const options = { - parent: { name: 'someName', icon: 'i-icon-some' }, - } - expect( - new ResourceDecorator({ ...this.args, options }).getParent(), - ).to.deep.equal(options.parent) - }) - }) - - describe('#decorateProperties', function () { - beforeEach(function () { - this.PropertyDecoratorSpy = this.sinon.spy(PropertyDecorator) - this.defaultProperties = expectedResult.properties - this.originalPropertyName = this.defaultProperties[1].name() - this.defaultPropertyName = this.defaultProperties[0].name() - this.defaultPropertyOptions = { enable: false, isSortable: false } - this.customPropertyOptions = { enable: true, sortable: false } - this.options = { - properties: { - [this.defaultPropertyName]: this.defaultPropertyOptions, - newProperty: this.customPropertyOptions, - }, - } - this.decorator = new ResourceDecorator({ ...this.args, options: this.options }) - }) - - it('decorates all default properties - default and newProperty', function () { - expect( - Object.keys(this.decorator.properties), - ).to.have.lengthOf(this.defaultProperties.length + 1) - }) - - it('returns default property with options', function () { - expect(this.decorator.properties[this.defaultPropertyName].options).to.deep.equal( - this.defaultPropertyOptions, - ) - }) - - it('returns custom property with options', function () { - expect(this.decorator.properties.newProperty.options).to.deep.equal( - this.customPropertyOptions, - ) - }) - - it('does not pass options where there were not given', function () { - expect(this.decorator.properties[this.originalPropertyName].options).to.deep.equal({}) - }) - }) - - describe('#getProperties', function () { - context('all properties are visible', function () { - beforeEach(function () { - this.sinon.stub(PropertyDecorator.prototype, 'isVisible').returns(true) - }) - - it('returns first n items when limit is given', function () { - const max = 3 - this.decorator = new ResourceDecorator(this.args) - expect( - this.decorator.getProperties({ where: 'list', max }), - ).to.have.lengthOf(max) - }) - - it('returns all properties when limit is not given', function () { - this.decorator = new ResourceDecorator(this.args) - expect( - this.decorator.getProperties({ where: 'list' }), - ).to.have.lengthOf(expectedResult.properties.length) - }) - - it('returns only showProperties from options if they were given', function () { - const path = expectedResult.properties[0].path() - this.decorator = new ResourceDecorator({ - ...this.args, - options: { showProperties: [path] }, - }) - expect( - this.decorator.getProperties({ where: 'show' }), - ).to.have.lengthOf(1) - }) - }) - }) - - describe('#resourceActions', function () { - context('no action were specified in custom settings', function () { - beforeEach(function () { - const options = {} - this.decorator = new ResourceDecorator({ ...this.args, options }) - }) - - it('returns 2 default resource actions', function () { - const actions = this.decorator.resourceActions(this.currentAdmin) - expect(actions).to.have.lengthOf(2) - const [action] = actions - expect(action).to.have.property('name', 'new') - }) - }) - }) - - describe('#getPropertyByKey', function () { - beforeEach(function () { - this.decorator = new ResourceDecorator({ ...this.args }) - }) - - it('returns property by giving its key', function () { - this.propertyPath = expectedResult.properties[0].path() - expect( - this.decorator.getPropertyByKey(this.propertyPath), - ).to.be.an.instanceof(PropertyDecorator) - }) - - it('throws error when there is no property by given key', function () { - expect(() => { - this.decorator.getPropertyByKey('some-unknown-name') - }).to.throw(ConfigurationError) - }) - }) - - describe('#recordAction', function () { - it('returns default actions', function () { - const options = {} - const actions = new ResourceDecorator({ - ...this.args, options, - }).recordActions(this.stubbedRecord, this.currentAdmin) - expect(actions).to.have.lengthOf(3) - }) - - it('shows custom actions specified by the user', function () { - const options = { actions: { customAction: { actionType: ['record'] } } } - const actions = new ResourceDecorator({ - ...this.args, - options, - }).recordActions(this.stubbedRecord, this.currentAdmin) - expect(actions).to.have.lengthOf(4) - }) - - it('hides the given action if user set isVisible to false', function () { - const options = { actions: { show: { isVisible: false } } } - const actions = new ResourceDecorator({ - ...this.args, - options, - }).recordActions(this.stubbedRecord, this.currentAdmin) - expect(actions).to.have.lengthOf(2) - }) - - it('passes properties to isVisible when it is a function', function () { - const options = { actions: { show: { isVisible: (data) => { - // it passes current admin to the isVisible function - expect(data.currentAdmin).to.deep.equal(this.currentAdmin) - expect(data.resource.id).to.equal(this.stubbedResource.id) - expect(data.action.name).to.equal('show') - expect(data.record).to.equal('someRecord') - return false - } } } } - const actions = new ResourceDecorator({ - ...this.args, - options, - }).recordActions('someRecord', this.currentAdmin) - expect(actions).to.have.lengthOf(2) - }) - }) - - describe('#toJSON', function () { - it('returns JSON representation of a resource', function () { - const json = new ResourceDecorator(this.args).toJSON(this.currentAdmin) - expect(json).to.have.keys( - 'id', - 'name', - 'parent', - 'href', - 'actions', - 'titleProperty', - 'resourceActions', - 'listProperties', - 'editProperties', - 'showProperties', - 'filterProperties', - ) - }) - - it('passes current admin to the resourceActions', function () { - const resourceActionsSpy = this.sinon.spy(ResourceDecorator.prototype, 'resourceActions') - - new ResourceDecorator(this.args).toJSON(this.currentAdmin) - - expect(resourceActionsSpy).to.have.been.calledWith(this.currentAdmin) - }) - }) -}) diff --git a/src/backend/decorators/resource-decorator.spec.ts b/src/backend/decorators/resource-decorator.spec.ts new file mode 100644 index 000000000..4ee7b0280 --- /dev/null +++ b/src/backend/decorators/resource-decorator.spec.ts @@ -0,0 +1,323 @@ +import sinon from 'sinon' +import { expect } from 'chai' + +import ResourceDecorator from './resource-decorator' +import PropertyDecorator from './property-decorator' +import ConfigurationError from '../utils/configuration-error' +import AdminBro, { defaultOptions } from '../../admin-bro' +import resourceStub, { expectedResult } from '../../../spec/backend/helpers/resource-stub' +import BaseResource from '../adapters/base-resource' +import BaseRecord from '../adapters/base-record' +import BaseProperty from '../adapters/base-property' + +const translatedLabel = 'translated label' +const currentAdmin = { + email: 'some@email.com', + name: 'someName', + otherValue: 'someOther-value', +} + +const stubAdminBro = (): AdminBro => { + const stubbedAdmin = sinon.createStubInstance(AdminBro) + return Object.assign(stubbedAdmin, { + translateLabel: sinon.stub().returns(translatedLabel), + translateProperty: sinon.stub().returns('translated property'), + translateAction: sinon.stub().returns('translated action'), + translateMessage: sinon.stub().returns('translate message'), + options: { ...defaultOptions, rootPath: '/admin' }, + }) +} + +describe('ResourceDecorator', function () { + let stubbedAdmin: AdminBro + let stubbedRecord: any + let stubbedResource: BaseResource + let args + + beforeEach(function () { + stubbedRecord = sinon.stub() + stubbedResource = resourceStub() + stubbedResource._decorated = { + id: () => 'resourceId', + } as ResourceDecorator + stubbedAdmin = stubAdminBro() + args = { + resource: stubbedResource, admin: stubbedAdmin, + } + }) + + afterEach(function () { + sinon.restore() + }) + + describe('#getResourceName', function () { + it('returns resource when name is not specified in options', function () { + expect( + new ResourceDecorator({ ...args, options: {} }).getResourceName(), + ).to.equal(translatedLabel) + }) + }) + + describe('#getParent', function () { + it('returns database name with its icon when no options were specified', function () { + expect( + new ResourceDecorator({ ...args, options: {} }).getParent(), + ).to.deep.equal({ + name: expectedResult.databaseName, + icon: `icon-${expectedResult.databaseType}`, + }) + }) + it('returns custom name with icon when options were specified', function () { + const options = { + parent: { name: 'someName', icon: 'i-icon-some' }, + } + expect( + new ResourceDecorator({ ...args, options }).getParent(), + ).to.deep.equal(options.parent) + }) + }) + + describe('#decorateProperties', function () { + beforeEach(function () { + this.PropertyDecoratorSpy = sinon.spy(PropertyDecorator) + this.defaultProperties = expectedResult.properties + this.originalPropertyName = this.defaultProperties[1].name() + this.defaultPropertyName = this.defaultProperties[0].name() + this.defaultPropertyOptions = { enable: false, isSortable: false } + this.customPropertyOptions = { enable: true, sortable: false } + this.options = { + properties: { + [this.defaultPropertyName]: this.defaultPropertyOptions, + newProperty: this.customPropertyOptions, + }, + } + this.decorator = new ResourceDecorator({ ...args, options: this.options }) + }) + + it('decorates all default properties - default and newProperty', function () { + expect( + Object.keys(this.decorator.properties), + ).to.have.lengthOf(this.defaultProperties.length + 1) + }) + + it('returns default property with options', function () { + expect(this.decorator.properties[this.defaultPropertyName].options).to.deep.equal( + this.defaultPropertyOptions, + ) + }) + + it('returns custom property with options', function () { + expect(this.decorator.properties.newProperty.options).to.deep.equal( + this.customPropertyOptions, + ) + }) + + it('does not pass options where there were not given', function () { + expect(this.decorator.properties[this.originalPropertyName].options).to.deep.equal({}) + }) + }) + + describe('#getProperties', function () { + context('all properties are visible', function () { + beforeEach(function () { + sinon.stub(PropertyDecorator.prototype, 'isVisible').returns(true) + }) + + it('returns first n items when limit is given', function () { + const max = 3 + const decorator = new ResourceDecorator(args) + + expect( + decorator.getProperties({ where: 'list', max }), + ).to.have.lengthOf(max) + }) + + it('returns all properties when limit is not given', function () { + const decorator = new ResourceDecorator(args) + + expect( + decorator.getProperties({ where: 'list' }), + ).to.have.lengthOf(expectedResult.properties.length) + }) + + it('returns only showProperties from options if they were given', function () { + const path = expectedResult.properties[0].path() + const decorator = new ResourceDecorator({ ...args, + options: { + showProperties: [path], + } }) + + expect( + decorator.getProperties({ where: 'show' }), + ).to.have.lengthOf(1) + }) + }) + }) + + describe('#resourceActions', function () { + context('no action were specified in custom settings', function () { + let decorator: ResourceDecorator + + beforeEach(function () { + const options = {} + decorator = new ResourceDecorator({ ...args, options }) + }) + + it('returns 2 default resource actions', function () { + const actions = decorator.resourceActions(currentAdmin) + const [action] = actions + + expect(actions).to.have.lengthOf(2) + expect(action).to.have.property('name', 'new') + }) + }) + }) + + describe('#getPropertyByKey', function () { + let decorator: ResourceDecorator + + beforeEach(function () { + decorator = new ResourceDecorator(args) + }) + + it('returns property by giving its key', function () { + const propertyPath = expectedResult.properties[0].path() + + expect( + decorator.getPropertyByKey(propertyPath), + ).to.be.an.instanceof(PropertyDecorator) + }) + + it('returns null when there is no property by given key', function () { + expect(decorator.getPropertyByKey('some-unknown-name')).to.eq(null) + }) + + it('returns mixed property', function () { + const propertyPath = expectedResult.properties.find(p => p.type() === 'mixed')?.path() + + expect( + decorator.getPropertyByKey(propertyPath as string), + ).to.be.an.instanceof(PropertyDecorator) + }) + + it('returns nested property under mixed', function () { + const property = expectedResult.properties.find(p => p.type() === 'mixed') as BaseProperty + const nested1Property = property?.subProperties().find(p => p.type() !== 'mixed') as BaseProperty + const path = [property.path(), nested1Property.path()].join('.') + + const decoratedProperty = decorator.getPropertyByKey(path) as PropertyDecorator + + expect(decoratedProperty).to.be.an.instanceof(PropertyDecorator) + expect(decoratedProperty.path).to.eq(path) + }) + + it('returns nested property under 2 level nested mixed', function () { + const property = expectedResult.properties.find(p => p.type() === 'mixed') as BaseProperty + const nested1Property = property?.subProperties().find(p => p.type() === 'mixed') as BaseProperty + const nested2Property = nested1Property?.subProperties()[0] as BaseProperty + const path = [property.path(), nested1Property.path(), nested2Property.path()].join('.') + + const decoratedProperty = decorator.getPropertyByKey(path) as PropertyDecorator + + expect(decoratedProperty).to.be.an.instanceof(PropertyDecorator) + expect(decoratedProperty.path).to.eq(path) + }) + + it('returns property when it is an array', function () { + const arrayProperty = expectedResult.properties.find(p => p.isArray()) as BaseProperty + // checking of a property of first item in an array + const path = [arrayProperty.path(), '0'].join('.') + + const decoratedProperty = decorator.getPropertyByKey(path) as PropertyDecorator + + expect(decoratedProperty).to.be.an.instanceof(PropertyDecorator) + expect(decoratedProperty.path).to.eq(arrayProperty.path()) + }) + + it('returns property when it is an nested array', function () { + const arrayProperty = expectedResult.properties + .find(p => p.isArray() && p.type() === 'mixed') as BaseProperty + const nested1Property = arrayProperty?.subProperties()[0] as BaseProperty + + // checking of a property of first item in an array + const path = [arrayProperty.path(), '0', nested1Property.path()].join('.') + + const decoratedProperty = decorator.getPropertyByKey(path) as PropertyDecorator + + expect(decoratedProperty).to.be.an.instanceof(PropertyDecorator) + expect(decoratedProperty.path).to.eq([arrayProperty.path(), nested1Property.path()].join('.')) + }) + }) + + describe('#recordAction', function () { + it('returns default actions', function () { + const actions = new ResourceDecorator({ + ...args, options: {}, + }).recordActions(stubbedRecord, currentAdmin) + + expect(actions).to.have.lengthOf(3) + }) + + it('shows custom actions specified by the user', function () { + const options = { actions: { customAction: { actionType: ['record'] } } } + const actions = new ResourceDecorator({ + ...args, options, + }).recordActions(stubbedRecord, currentAdmin) + + expect(actions).to.have.lengthOf(4) + }) + + it('hides the given action if user set isVisible to false', function () { + const options = { actions: { show: { isVisible: false } } } + const actions = new ResourceDecorator({ + ...args, options, + }).recordActions(stubbedRecord, currentAdmin) + + expect(actions).to.have.lengthOf(2) + }) + + it('passes properties to isVisible when it is a function', function () { + const someRecord = { params: { param: 'someRecord' } } as unknown as BaseRecord + const options = { actions: { show: { isVisible: (data) => { + // it passes current admin to the isVisible function + expect(data.currentAdmin).to.deep.equal(currentAdmin) + expect(data.resource.id).to.equal(stubbedResource.id) + expect(data.action.name).to.equal('show') + expect(data.record).to.equal(someRecord) + return false + } } } } + const actions = new ResourceDecorator({ + ...args, options, + }).recordActions(someRecord, currentAdmin) + + expect(actions).to.have.lengthOf(2) + }) + }) + + describe('#toJSON', function () { + it('returns JSON representation of a resource', function () { + const json = new ResourceDecorator(args).toJSON(currentAdmin) + expect(json).to.have.keys( + 'id', + 'name', + 'parent', + 'href', + 'actions', + 'titleProperty', + 'resourceActions', + 'listProperties', + 'editProperties', + 'showProperties', + 'filterProperties', + ) + }) + + it('passes current admin to the resourceActions', function () { + const resourceActionsSpy = sinon.spy(ResourceDecorator.prototype, 'resourceActions') + + new ResourceDecorator(args).toJSON(currentAdmin) + + expect(resourceActionsSpy).to.have.been.calledWith(currentAdmin) + }) + }) +}) diff --git a/src/backend/decorators/resource-decorator.ts b/src/backend/decorators/resource-decorator.ts index 2be893108..4c59e537f 100644 --- a/src/backend/decorators/resource-decorator.ts +++ b/src/backend/decorators/resource-decorator.ts @@ -22,6 +22,69 @@ import BaseRecord from '../adapters/base-record' */ export const DEFAULT_MAX_COLUMNS_IN_LIST = 8 +type PathParts = Array + +/** + * Changes path with flatten notation, with dots (.) inside, to array of all possible + * keys which can have a property. + * + * - changes: `nested.nested2.normalInner` + * - to `["nested", "nested.nested2", "nested.nested2.normalInner"]` + * + * Also it takes care of the arrays, which are separated by numbers (indexes). + * - changes: `nested.0.normalInner.1` + * - to: `nested.normalInner` + * + * Everything because when we look for a property of a given path it can be inside a + * mixed property. So first, we have to find top level mixed property, and then, + * step by step, find inside each of them. + * + * @private + * + * @param {string} propertyPath + * + * @return {PathParts} + */ +const pathToParts = (propertyPath: string): PathParts => ( + // eslint-disable-next-line no-restricted-globals + propertyPath.split('.').filter(part => isNaN(+part)).reduce((memo, part) => { + if (memo.length) { + return [ + ...memo, + [memo[memo.length - 1], part].join('.'), + ] + } + return [part] + }, [] as Array) +) + +/** + * @private + * + * @param {PathParts} pathParts parts returned by `pathToParts` method + * @param {PropertyDecorator} rootProperty where function should recursively search for + * a subProperty matching one of the pathParts + * + * @return {PropertyDecorator | null} found subProperty + */ +const findSubProperty = ( + pathParts: PathParts, + rootProperty: PropertyDecorator, +): PropertyDecorator | null => { + const subProperties = rootProperty.subProperties() + const foundPath = pathParts.find(path => ( + subProperties.find(supProperty => supProperty.path === path))) + if (foundPath) { + const subProperty = subProperties.find(supProperty => supProperty.path === foundPath) + if (subProperty && foundPath !== pathParts[pathParts.length - 1]) { + // if foundPath is not the last (full) path - checkout recursively all subProperties + return findSubProperty(pathParts, subProperty) + } + return subProperty || null + } + return null +} + /** * Base decorator class which decorates the Resource. * @@ -190,17 +253,30 @@ class ResourceDecorator { * @param {String} propertyPath property path * * @return {PropertyDecorator} - * @throws {ConfigurationError} when there is no property for given key */ - getPropertyByKey(propertyPath: string): PropertyDecorator { - const property = this.properties[propertyPath] + getPropertyByKey(propertyPath: string): PropertyDecorator | null { + const parts = pathToParts(propertyPath) + const fullPath = parts[parts.length - 1] + const property = this.properties[fullPath] + if (!property) { - throw new ConfigurationError( - `there is no property by the name of '${propertyPath}' in resource ${this.getResourceName()}`, - 'tutorial-04-customizing-resources.html', - ) + // User asks for nested property (embed inside the mixed property) + if (parts.length > 1) { + const mixedPropertyPath = parts.find(part => ( + this.properties[part] + && this.properties[part].type() === 'mixed' + )) + if (mixedPropertyPath) { + const mixedProperty = this.properties[mixedPropertyPath] + const subProperty = findSubProperty(parts, mixedProperty) + + if (subProperty) { + return subProperty + } + } + } } - return property + return property || null } /** diff --git a/src/backend/utils/filter.ts b/src/backend/utils/filter.ts index c22387dfc..fb02254c6 100644 --- a/src/backend/utils/filter.ts +++ b/src/backend/utils/filter.ts @@ -86,7 +86,7 @@ class Filter { const keys = Object.keys(this.filters) for (let index = 0; index < keys.length; index += 1) { const key = keys[index] - const referenceResource = this.resource.decorate().getPropertyByKey(key).reference() + const referenceResource = this.resource.decorate().getPropertyByKey(key)?.reference() if (referenceResource) { this.filters[key].populated = await referenceResource.findOne( this.filters[key].value as string, diff --git a/src/backend/utils/request-parser.spec.ts b/src/backend/utils/request-parser.spec.ts index 977cfee68..0a02970b0 100644 --- a/src/backend/utils/request-parser.spec.ts +++ b/src/backend/utils/request-parser.spec.ts @@ -2,7 +2,15 @@ import { expect } from 'chai' import requestParser from './request-parser' import { ActionRequest } from '../actions/action.interface' import BaseResource from '../adapters/base-resource' -import BaseProperty from '../adapters/base-property' + +const buildResourceWithProperty = (key, property) => { + const resource = { + _decorated: { getPropertyByKey: path => (key === path ? property : null) }, + } as unknown as BaseResource + return resource +} + +let resource describe('RequestParser', function () { const baseRequest: ActionRequest = { @@ -11,20 +19,13 @@ describe('RequestParser', function () { payload: {}, } - describe('array property', function () { - const resource = { - property: (name) => { - const newProperty = new BaseProperty({ path: name, type: 'string' }) - newProperty.isArray = (): boolean => true - return newProperty - }, - } as BaseResource - }) - describe('boolean values', function () { - const resource = { - property: name => new BaseProperty({ path: name, type: 'boolean' }), - } as BaseResource + beforeEach(function () { + resource = buildResourceWithProperty('isHired', { + isDisabled: () => false, + type: () => 'boolean', + }) + }) it('sets value to `false` when empty string is given', function () { const request = { ...baseRequest, payload: { isHired: '' } } @@ -44,4 +45,19 @@ describe('RequestParser', function () { expect(requestParser(request, resource).payload?.isHired).to.be.false }) }) + + describe('disabled values', function () { + beforeEach(function () { + resource = buildResourceWithProperty('anyProperty', { + isDisabled: () => true, + type: () => 'number', + }) + }) + + it('strips payload from disabled properties', function () { + const request = { ...baseRequest, payload: { anyProperty: 'yeAaa' } } + + expect(requestParser(request, resource).payload?.anyProperty).to.be.undefined + }) + }) }) diff --git a/src/backend/utils/request-parser.ts b/src/backend/utils/request-parser.ts index 226958061..1f3d1ac57 100644 --- a/src/backend/utils/request-parser.ts +++ b/src/backend/utils/request-parser.ts @@ -16,7 +16,7 @@ const RequestParser = (originalRequest: ActionRequest, resource: BaseResource): const { payload: originalPayload } = originalRequest const payload = Object.entries(originalPayload || {}).reduce((memo, [path, formValue]) => { - const property = resource.property(path) + const property = resource._decorated?.getPropertyByKey(path) let value = formValue if (formValue === FORM_VALUE_NULL) { value = null } @@ -24,6 +24,9 @@ const RequestParser = (originalRequest: ActionRequest, resource: BaseResource): if (formValue === FORM_VALUE_EMPTY_ARRAY) { value = [] } if (property) { + // strip payload from disabled properties + if (property.isDisabled()) { return { ...memo } } + if (property.type() === 'boolean') { if (value === 'true') { return { ...memo, [path]: true } } if (value === 'false') { return { ...memo, [path]: false } } From 3ef7edc5c6bc6ee16a9a241d038024a77529d04f Mon Sep 17 00:00:00 2001 From: wojtek-krysiak Date: Thu, 27 Aug 2020 10:26:24 +0200 Subject: [PATCH 07/13] style: fix linter warnings --- src/backend/decorators/resource-decorator.spec.ts | 1 - src/backend/decorators/resource-decorator.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/backend/decorators/resource-decorator.spec.ts b/src/backend/decorators/resource-decorator.spec.ts index 4ee7b0280..2424f0261 100644 --- a/src/backend/decorators/resource-decorator.spec.ts +++ b/src/backend/decorators/resource-decorator.spec.ts @@ -3,7 +3,6 @@ import { expect } from 'chai' import ResourceDecorator from './resource-decorator' import PropertyDecorator from './property-decorator' -import ConfigurationError from '../utils/configuration-error' import AdminBro, { defaultOptions } from '../../admin-bro' import resourceStub, { expectedResult } from '../../../spec/backend/helpers/resource-stub' import BaseResource from '../adapters/base-resource' diff --git a/src/backend/decorators/resource-decorator.ts b/src/backend/decorators/resource-decorator.ts index 4c59e537f..1180ab635 100644 --- a/src/backend/decorators/resource-decorator.ts +++ b/src/backend/decorators/resource-decorator.ts @@ -3,7 +3,6 @@ import BaseProperty from '../adapters/base-property' import PropertyDecorator from './property-decorator' import ActionDecorator from './action-decorator' import ViewHelpers from '../utils/view-helpers' -import ConfigurationError from '../utils/configuration-error' import BaseResource from '../adapters/base-resource' import AdminBro from '../../admin-bro' import * as ACTIONS from '../actions/index' From d21ec775afb19adf47535d85ca57cb8c8e30a7e1 Mon Sep 17 00:00:00 2001 From: wojtek-krysiak Date: Thu, 27 Aug 2020 10:35:40 +0200 Subject: [PATCH 08/13] feat: release From a26523cb642fe3f2da7db5b94b138cbd8e1e8077 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 27 Aug 2020 08:43:54 +0000 Subject: [PATCH 09/13] chore(release): 3.1.0-beta.2 [skip ci] # [3.1.0-beta.2](https://github.com/SoftwareBrothers/admin-bro/compare/v3.1.0-beta.1...v3.1.0-beta.2) (2020-08-27) ### Bug Fixes * disabled fields are stripped from the payloadrelates to [#430](https://github.com/SoftwareBrothers/admin-bro/issues/430) ([f0b4319](https://github.com/SoftwareBrothers/admin-bro/commit/f0b43192ccdca4f6438f0c7ad2ec4f01e7d823de)) ### Features * release ([d21ec77](https://github.com/SoftwareBrothers/admin-bro/commit/d21ec775afb19adf47535d85ca57cb8c8e30a7e1)) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a7a6eb0c8..022c0c847 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "admin-bro", - "version": "3.1.0-beta.1", + "version": "3.1.0-beta.2", "description": "Admin panel for apps written in node.js", "main": "index.js", "types": "index.d.ts", From cb0bd3fa272d10d4d5f8c1d67e80b659ed90cb77 Mon Sep 17 00:00:00 2001 From: wojtek-krysiak Date: Thu, 27 Aug 2020 11:03:24 +0200 Subject: [PATCH 10/13] fix: make sure old adapters also work --- src/backend/utils/request-parser.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/backend/utils/request-parser.ts b/src/backend/utils/request-parser.ts index 1f3d1ac57..c22130137 100644 --- a/src/backend/utils/request-parser.ts +++ b/src/backend/utils/request-parser.ts @@ -24,8 +24,10 @@ const RequestParser = (originalRequest: ActionRequest, resource: BaseResource): if (formValue === FORM_VALUE_EMPTY_ARRAY) { value = [] } if (property) { - // strip payload from disabled properties - if (property.isDisabled()) { return { ...memo } } + // Strip payload from disabled properties. + // isDisabled method has been added recently so this check is needed + // so adapters with older version of admin-bro will also work + if (property.isDisabled && property.isDisabled()) { return { ...memo } } if (property.type() === 'boolean') { if (value === 'true') { return { ...memo, [path]: true } } From 9083eee099101bf9806b4215bbd7d795503e3322 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 27 Aug 2020 09:13:06 +0000 Subject: [PATCH 11/13] chore(release): 3.1.0-beta.3 [skip ci] # [3.1.0-beta.3](https://github.com/SoftwareBrothers/admin-bro/compare/v3.1.0-beta.2...v3.1.0-beta.3) (2020-08-27) ### Bug Fixes * make sure old adapters also work ([cb0bd3f](https://github.com/SoftwareBrothers/admin-bro/commit/cb0bd3fa272d10d4d5f8c1d67e80b659ed90cb77)) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 022c0c847..e2f3f7c80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "admin-bro", - "version": "3.1.0-beta.2", + "version": "3.1.0-beta.3", "description": "Admin panel for apps written in node.js", "main": "index.js", "types": "index.d.ts", From 46dacb9c4485b6c5bb1f3173f33e3268e9727020 Mon Sep 17 00:00:00 2001 From: wojtek-krysiak Date: Thu, 27 Aug 2020 18:00:29 +0200 Subject: [PATCH 12/13] fix: onChange can also update other fields --- .../integration/nested/create-nested.spec.js | 19 ++++++++++++++ example-app/src/nested/nested.admin.js | 9 ++++++- .../src/nested/value-trigger.component.tsx | 25 +++++++++++++++++++ src/constants.ts | 2 +- .../property-type/default-type/edit.tsx | 7 ++---- .../property-type/password/edit.tsx | 7 ++---- .../property-type/textarea/edit.tsx | 7 ++---- src/frontend/utils/usePrevious.ts | 14 ----------- 8 files changed, 59 insertions(+), 31 deletions(-) create mode 100644 example-app/cypress/integration/nested/create-nested.spec.js create mode 100644 example-app/src/nested/value-trigger.component.tsx delete mode 100644 src/frontend/utils/usePrevious.ts diff --git a/example-app/cypress/integration/nested/create-nested.spec.js b/example-app/cypress/integration/nested/create-nested.spec.js new file mode 100644 index 000000000..6ed13251e --- /dev/null +++ b/example-app/cypress/integration/nested/create-nested.spec.js @@ -0,0 +1,19 @@ +/// +/// + +context('resources/Nested/actions/new', () => { + before(() => { + cy.login() + }) + + beforeEach(() => { + Cypress.Cookies.preserveOnce(Cypress.env('COOKIE_NAME')) + cy.visit('resources/Nested/actions/new') + }) + + it('fills name when button is clicked', () => { + cy.get('[data-testid="name-button"]').click() + cy.get('[data-testid="property-edit-name"] input') + .should('have.value', 'my new name') + }) +}) diff --git a/example-app/src/nested/nested.admin.js b/example-app/src/nested/nested.admin.js index 18bd0ad7c..d99131a4e 100644 --- a/example-app/src/nested/nested.admin.js +++ b/example-app/src/nested/nested.admin.js @@ -1,8 +1,15 @@ +const { default: AdminBro } = require('admin-bro') const { Nested } = require('./nested.entity') /** @type {import('admin-bro').ResourceOptions} */ const options = { - + properties: { + valueTrigger: { + components: { + edit: AdminBro.bundle('./value-trigger.component.tsx'), + }, + }, + }, } module.exports = { diff --git a/example-app/src/nested/value-trigger.component.tsx b/example-app/src/nested/value-trigger.component.tsx new file mode 100644 index 000000000..823bd198a --- /dev/null +++ b/example-app/src/nested/value-trigger.component.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { EditPropertyProps } from 'admin-bro' +import { Button, Box } from '@admin-bro/design-system' + +const ValueTrigger: React.FC = (props) => { + const { onChange, record } = props + + const handleClick = (): void => { + onChange({ + ...record, + params: { + ...record.params, + name: 'my new name', + }, + }) + } + + return ( + + + + ) +} + +export default ValueTrigger diff --git a/src/constants.ts b/src/constants.ts index 03b973fb0..6c03ee8c2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ -export const DOCS = 'https://softwarebrothers.github.io/admin-bro-dev' +export const DOCS = 'https://adminbro.com' export const DEFAULT_PATHS = { rootPath: '/admin', logoutPath: '/admin/logout', diff --git a/src/frontend/components/property-type/default-type/edit.tsx b/src/frontend/components/property-type/default-type/edit.tsx index 454cbc070..32667e889 100644 --- a/src/frontend/components/property-type/default-type/edit.tsx +++ b/src/frontend/components/property-type/default-type/edit.tsx @@ -6,7 +6,6 @@ import { Input, FormMessage, FormGroup, Label, selectStyles } from '@admin-bro/d import { EditPropertyProps } from '../base-property-props' import { recordPropertyIsEqual } from '../record-property-is-equal' -import usePrevious from '../../../utils/usePrevious' type CombinedProps = EditPropertyProps & {theme: DefaultTheme} @@ -52,13 +51,11 @@ const TextEdit: FC = (props) => { const propValue = record.params?.[property.name] ?? '' const [value, setValue] = useState(propValue) - const previous = usePrevious(propValue) useEffect(() => { - // this means props updated - if (propValue !== previous) { + if (value !== propValue) { setValue(propValue) } - }, []) + }, [propValue]) return ( = (props) => { const { property, record, onChange } = props @@ -13,13 +12,11 @@ const Edit: React.FC = (props) => { const error = record.errors && record.errors[property.name] const [isInput, setIsInput] = useState(false) - const previous = usePrevious(propValue) useEffect(() => { - // this means props updated - if (propValue !== previous) { + if (value !== propValue) { setValue(propValue) } - }, []) + }, [propValue]) return ( diff --git a/src/frontend/components/property-type/textarea/edit.tsx b/src/frontend/components/property-type/textarea/edit.tsx index 786078853..baf7c3c5b 100644 --- a/src/frontend/components/property-type/textarea/edit.tsx +++ b/src/frontend/components/property-type/textarea/edit.tsx @@ -4,7 +4,6 @@ import { Input, Label, FormGroup, FormMessage } from '@admin-bro/design-system' import { EditPropertyProps } from '../base-property-props' import { recordPropertyIsEqual } from '../record-property-is-equal' -import usePrevious from '../../../utils/usePrevious' const Edit: FC = (props) => { const { onChange, property, record } = props @@ -12,13 +11,11 @@ const Edit: FC = (props) => { const [value, setValue] = useState(propValue) const error = record.errors?.[property.name] - const previous = usePrevious(propValue) useEffect(() => { - // this means props updated - if (propValue !== previous) { + if (value !== propValue) { setValue(propValue) } - }, []) + }, [propValue]) return ( diff --git a/src/frontend/utils/usePrevious.ts b/src/frontend/utils/usePrevious.ts deleted file mode 100644 index 9a9d7ce04..000000000 --- a/src/frontend/utils/usePrevious.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable arrow-parens */ -import { useRef, useEffect } from 'react' - -const usePrevious = (value: T): T | null => { - const ref = useRef(value) - - useEffect(() => { - ref.current = value - }, [value]) - - return ref.current -} - -export default usePrevious From 3e80b9ef5ae3a997dc123cc892e0f4f3c9de85b4 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 27 Aug 2020 17:04:01 +0000 Subject: [PATCH 13/13] chore(release): 3.1.0-beta.4 [skip ci] # [3.1.0-beta.4](https://github.com/SoftwareBrothers/admin-bro/compare/v3.1.0-beta.3...v3.1.0-beta.4) (2020-08-27) ### Bug Fixes * onChange can also update other fields ([46dacb9](https://github.com/SoftwareBrothers/admin-bro/commit/46dacb9c4485b6c5bb1f3173f33e3268e9727020)) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e2f3f7c80..b80e0e894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "admin-bro", - "version": "3.1.0-beta.3", + "version": "3.1.0-beta.4", "description": "Admin panel for apps written in node.js", "main": "index.js", "types": "index.d.ts",