Skip to content

Commit

Permalink
Merge pull request #2860 from LiteFarmOrg/LF-3592/Create_custom_reven…
Browse files Browse the repository at this point in the history
…ue_type_form_container_and_frontend_route

LF-3592: Create custom revenue type form container and frontend route
  • Loading branch information
kathyavini authored Sep 15, 2023
2 parents ab26cdf + 7d4391e commit 38f169d
Show file tree
Hide file tree
Showing 18 changed files with 381 additions and 62 deletions.
36 changes: 36 additions & 0 deletions packages/api/db/migration/20230913181516_add_columns_to_sale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2023 LiteFarm.org
* This file is part of LiteFarm.
*
* LiteFarm is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LiteFarm is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
*/

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const up = async function (knex) {
await knex.schema.alterTable('sale', (table) => {
table.float('value');
table.string('note');
});
};

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const down = async function (knex) {
await knex.schema.alterTable('sale', (table) => {
table.dropColumn('value');
table.dropColumn('note');
});
};
27 changes: 16 additions & 11 deletions packages/api/src/middleware/validation/sale.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,23 @@

async function validateSale(req, res, next) {
// TODO replace upsertGraph
const { crop_variety_sale } = req.body;
if (!(crop_variety_sale && crop_variety_sale[0])) {
return res.status(400).send('Crop is required');
const { crop_variety_sale, revenue_type_id } = req.body;
// TODO: implement properly once LF-3595 is complete
const isCropRevenue = revenue_type_id === 1;
if (isCropRevenue && !(crop_variety_sale && crop_variety_sale[0])) {
return res.status(400).send('crop_variety_sale is required');
}
for (const singleCropVarietySale of crop_variety_sale) {
if (
!singleCropVarietySale.crop_variety_id ||
singleCropVarietySale.managementPlan ||
singleCropVarietySale.farm ||
singleCropVarietySale.crop_variety
) {
return res.status(400).send('Crop is required');

if (isCropRevenue && crop_variety_sale) {
for (const singleCropVarietySale of crop_variety_sale) {
if (
!singleCropVarietySale.crop_variety_id ||
singleCropVarietySale.managementPlan ||
singleCropVarietySale.farm ||
singleCropVarietySale.crop_variety
) {
return res.status(400).send('Crop is required');
}
}
}
return next();
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/models/saleModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ class Sale extends baseModel {
sale_date: { type: 'string', minLength: 1, maxLength: 255 },
farm_id: { type: 'string' },
revenue_type_id: { type: 'integer' },
value: { type: ['number', 'null'], format: 'float' },
note: { type: ['string', 'null'], maxLength: 10000 },
...this.baseProperties,
},
additionalProperties: false,
Expand Down
38 changes: 38 additions & 0 deletions packages/api/tests/mock.factories.js
Original file line number Diff line number Diff line change
Expand Up @@ -2112,6 +2112,42 @@ function fakeNotification(defaultData = {}) {
};
}

async function populateDefaultRevenueTypes() {
const types = [{ revenue_name: 'Crop Sale', revenue_translation_key: 'CROP_SALE' }];
for (const { revenue_name, revenue_translation_key } of types) {
const [revenueTypeInDb] = await knex('revenue_type').where({
revenue_name: 'Crop Sale',
});
if (!revenueTypeInDb) {
const base = baseProperties(1);
return knex('revenue_type')
.insert({ revenue_name, revenue_translation_key, ...base })
.returning('*');
}
}
}

function fakeRevenueType(defaultData = {}) {
return {
revenue_name: faker.lorem.word(),
revenue_translation_key: faker.lorem.word(),
...defaultData,
};
}

async function revenue_typeFactory(
{ promisedFarm = farmFactory() } = {},
revenueType = fakeRevenueType(),
) {
const [farm, user] = await Promise.all([promisedFarm, usersFactory()]);
const [{ farm_id }] = farm;
const [{ user_id }] = user;
const base = baseProperties(user_id);
return knex('revenue_type')
.insert({ farm_id, ...revenueType, ...base })
.returning('*');
}

export default {
weather_stationFactory,
fakeStation,
Expand Down Expand Up @@ -2237,5 +2273,7 @@ export default {
fakeOrganicHistory,
organic_historyFactory,
notification_userFactory,
populateDefaultRevenueTypes,
revenue_typeFactory,
baseProperties,
};
37 changes: 37 additions & 0 deletions packages/api/tests/sale.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ describe('Sale Tests', () => {
token = global.token;
});

beforeAll(async () => {
await mocks.populateDefaultRevenueTypes();
});

function postSaleRequest(data, { user_id = owner.user_id, farm_id = farm.farm_id }, callback) {
chai
.request(server)
Expand Down Expand Up @@ -532,6 +536,39 @@ describe('Sale Tests', () => {
});
});

const testGeneralSale = async (done, userId) => {
// TODO: update once LF-3595 is complete
const [{ revenue_type_id }] = await mocks.revenue_typeFactory({
promisedFarm: [{ farm_id: farm.farm_id }],
});
delete sampleReqBody.crop_variety_sale;
sampleReqBody.value = 50.5;
sampleReqBody.note = 'notes';
sampleReqBody.revenue_type_id = revenue_type_id;

postSaleRequest(sampleReqBody, { user_id: userId }, async (err, res) => {
expect(res.status).toBe(201);
const sales = await saleModel.query().where('farm_id', farm.farm_id);
expect(sales.length).toBe(1);
expect(sales[0].customer_name).toBe(sampleReqBody.customer_name);
expect(sales[0].value).toBe(sampleReqBody.value);
expect(sales[0].note).toBe(sampleReqBody.note);
done();
});
};

test(`Owner should post and get a general sale`, async (done) => {
testGeneralSale(done, owner.userId);
});

test(`Manager should post and get a general sale`, async (done) => {
testGeneralSale(done, manager.userId);
});

test(`Worker should post and get a general sale`, async (done) => {
testGeneralSale(done, worker.userId);
});

test('should return 403 status if sale is posted by unauthorized user', async (done) => {
postSaleRequest(sampleReqBody, { user_id: unAuthorizedUser.user_id }, async (err, res) => {
expect(res.status).toBe(403);
Expand Down
1 change: 1 addition & 0 deletions packages/api/tests/testEnvironment.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ async function tableCleanup(knex) {
DELETE FROM "price";
DELETE FROM "crop_variety_sale";
DELETE FROM "sale";
DELETE FROM "revenue_type";
DELETE FROM "broadcast_method";
DELETE FROM "container_method";
DELETE FROM "row_method";
Expand Down
1 change: 1 addition & 0 deletions packages/webapp/public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"ACTIVE": "Active",
"ADD": "Add",
"ADD_ANOTHER_ITEM": "Add another item",
"ADD_ITEM": "Add {{itemName}}",
"ALL": "All",
"APPLY": "Apply",
"BACK": "Go Back",
Expand Down
1 change: 1 addition & 0 deletions packages/webapp/public/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"ACTIVE": "Activo",
"ADD": "Agregar",
"ADD_ANOTHER_ITEM": "MISSING",
"ADD_ITEM": "MISSING",
"ALL": "Todo",
"APPLY": "Aplicar",
"BACK": "Atrás",
Expand Down
1 change: 1 addition & 0 deletions packages/webapp/public/locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"ACTIVE": "Actif",
"ADD": "Ajouter",
"ADD_ANOTHER_ITEM": "MISSING",
"ADD_ITEM": "MISSING",
"ALL": "Tout",
"APPLY": "Appliquer",
"BACK": "Retour",
Expand Down
1 change: 1 addition & 0 deletions packages/webapp/public/locales/pt/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"ACTIVE": "Ativo",
"ADD": "Adicionar",
"ADD_ANOTHER_ITEM": "MISSING",
"ADD_ITEM": "MISSING",
"ALL": "Tudo",
"APPLY": "Aplicar",
"BACK": "Voltar",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { useForm } from 'react-hook-form';
import MultiStepPageTitle from '../../PageTitle/MultiStepPageTitle';
import { IconLink, Main } from '../../Typography';
import Form from '../../Form';
Expand All @@ -36,13 +37,16 @@ export default function PureFinanceTypeSelection({
onGoToManageCustomType,
isTypeSelected,
formatTileData,
getFormatTileDataFunc,
progressValue,
useHookFormPersist,
persistedFormData = {},
iconLinkId,
Wrapper = FallbackWrapper,
}) {
const { t } = useTranslation();
const { historyCancel } = useHookFormPersist();
const { getValues, setValue } = useForm({ defaultValues: persistedFormData });
const { historyCancel } = useHookFormPersist(getValues);

return (
<Wrapper>
Expand All @@ -64,7 +68,11 @@ export default function PureFinanceTypeSelection({
style={{ marginBottom: '24px' }}
/>
<Main className={styles.leadText}>{leadText}</Main>
<Tiles tileType={tileTypes.ICON_LABEL} tileData={types} formatTileData={formatTileData} />
<Tiles
tileType={tileTypes.ICON_LABEL}
tileData={types}
formatTileData={getFormatTileDataFunc ? getFormatTileDataFunc(setValue) : formatTileData}
/>
<div className={styles.manageCustomTypeLinkContainer}>
<IconLink
id={iconLinkId}
Expand All @@ -91,8 +99,11 @@ PureFinanceTypeSelection.prototype = {
onGoToManageCustomType: PropTypes.func,
isTypeSelected: PropTypes.bool,
formatTileData: PropTypes.func,
/** takes setValue returned from useForm */
getFormatTileDataFunc: PropTypes.func,
progressValue: PropTypes.number,
useHookFormPersist: PropTypes.func,
persistedFormData: PropTypes.object,
iconLinkId: PropTypes.string,
/** used for spotlight */
Wrapper: PropTypes.node,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Error } from '../../Typography';
import { harvestAmounts } from '../../../util/convert-units/unit';
import { getDateInputFormat } from '../../../util/moment';

const SaleForm = ({
const CropSaleForm = ({
cropVarietyOptions,
onSubmit,
onClickDelete,
Expand Down Expand Up @@ -253,4 +253,4 @@ const SaleForm = ({
);
};

export default SaleForm;
export default CropSaleForm;
107 changes: 107 additions & 0 deletions packages/webapp/src/components/Forms/GeneralRevenue/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright 2023 LiteFarm.org
* This file is part of LiteFarm.
*
* LiteFarm is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LiteFarm is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
*/
import React from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Form from '../../Form';
import Button from '../../Form/Button';
import Input, { getInputErrors } from '../../Form/Input';
import InputAutoSize from '../../Form/InputAutoSize';
import PageTitle from '../../PageTitle/v2';
import { getLocalDateInYYYYDDMM } from '../../../util/date';
import { hookFormMaxCharsValidation } from '../../Form/hookformValidationUtils';

const SALE_DATE = 'sale_date';
const SALE_CUSTOMER = 'customer_name';
const VALUE = `value`;
const NOTE = `note`;

const GeneralRevenue = ({
onSubmit,
title,
dateLabel,
customerLabel,
currency,
sale,
useHookFormPersist,
persistedFormData,
}) => {
const { t } = useTranslation();

const {
register,
handleSubmit,
formState: { errors, isValid },
getValues,
} = useForm({
mode: 'onChange',
defaultValues: {
[SALE_DATE]:
(sale?.[SALE_DATE] && getLocalDateInYYYYDDMM(sale.SALE_DATE)) ||
persistedFormData?.[SALE_DATE] ||
getLocalDateInYYYYDDMM(),
[SALE_CUSTOMER]: sale?.[SALE_CUSTOMER] || persistedFormData?.[SALE_CUSTOMER] || '',
[VALUE]: sale?.[VALUE] || null,
[NOTE]: sale?.[NOTE] || '',
},
});

useHookFormPersist(getValues);

return (
<Form
onSubmit={handleSubmit(onSubmit)}
buttonGroup={
<Button disabled={!isValid} fullLength type={'submit'}>
{t('common:SAVE')}
</Button>
}
>
<PageTitle title={title} onGoBack={() => history.back()} style={{ marginBottom: '24px' }} />
<Input
label={customerLabel}
hookFormRegister={register(SALE_CUSTOMER, { required: true })}
style={{ marginBottom: '40px' }}
errors={getInputErrors(errors, SALE_CUSTOMER)}
type={'text'}
/>
<Input
label={dateLabel}
hookFormRegister={register(SALE_DATE, { required: true })}
style={{ marginBottom: '40px' }}
type={'date'}
errors={getInputErrors(errors, SALE_DATE)}
/>
<Input
label={t('SALE.DETAIL.VALUE')}
type="number"
hookFormRegister={register(VALUE, { required: true, valueAsNumber: true })}
currency={currency}
style={{ marginBottom: '40px' }}
errors={getInputErrors(errors, VALUE)}
/>
<InputAutoSize
style={{ marginBottom: '40px' }}
label={t('LOG_COMMON.NOTES')}
optional={true}
hookFormRegister={register(NOTE, { maxLength: hookFormMaxCharsValidation(10000) })}
name={NOTE}
errors={getInputErrors(errors, NOTE)}
/>
</Form>
);
};

export default GeneralRevenue;
Loading

0 comments on commit 38f169d

Please sign in to comment.