From 81e802b131ef5c7cddece54ecee4dcbb82b0423d Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Thu, 16 Jul 2026 14:03:26 -0700 Subject: [PATCH 1/5] LF-5388 Add Loading view to Certifications container --- .../src/containers/Certifications/index.tsx | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/webapp/src/containers/Certifications/index.tsx b/packages/webapp/src/containers/Certifications/index.tsx index 51837203b5..3fb1e1202d 100644 --- a/packages/webapp/src/containers/Certifications/index.tsx +++ b/packages/webapp/src/containers/Certifications/index.tsx @@ -18,6 +18,8 @@ import { useLocation, useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import PureCertifications from '../../components/Certifications'; +import Layout from '../../components/Layout'; +import Loading from '../../components/Form/ContextForm/Loading'; import { loginSelector } from '../userFarmSlice'; import { enqueueErrorSnackbar } from '../Snackbar/snackbarSlice'; import { @@ -29,7 +31,6 @@ import { useGetSupportedCertificationSystemTypesQuery, } from '../../store/api/certifiersApi'; import { toCertificationItems } from './utils'; -import Layout from '../../components/Layout'; interface CertificationsProps { isCompactSideMenu: boolean; @@ -41,11 +42,17 @@ export default function Certifications({ isCompactSideMenu }: CertificationsProp const location = useLocation(); const history = useHistory(); const { farm_id } = useSelector(loginSelector); - const { data: rawCertifications = [] } = useGetCertificationsQuery(); - const { data: certifiers = [] } = useGetSupportedCertifiersQuery(farm_id!); - const { data: systemTypes = [] } = useGetSupportedCertificationSystemTypesQuery(farm_id!); + const { data: rawCertifications = [], isLoading: isCertificationsLoading } = + useGetCertificationsQuery(); + const { data: certifiers = [], isLoading: isCertifiersLoading } = useGetSupportedCertifiersQuery( + farm_id!, + ); + const { data: systemTypes = [], isLoading: isSystemTypesLoading } = + useGetSupportedCertificationSystemTypesQuery(farm_id!); const [deleteCertification] = useDeleteCertificationMutation(); + const isLoading = isCertificationsLoading || isCertifiersLoading || isSystemTypesLoading; + // Captured once on mount so the banner stays visible for this page visit even after // the underlying history state is cleared below. const showSavedBannerRef = useRef(Boolean((location.state as any)?.certificationSaved)); @@ -74,6 +81,15 @@ export default function Certifications({ isCompactSideMenu }: CertificationsProp } }; + if (isLoading) { + return ( + + ); + } + return ( Date: Thu, 16 Jul 2026 15:05:12 -0700 Subject: [PATCH 2/5] LF-5388 Display snackbar when adding/editing certifications --- packages/webapp/public/locales/en/message.json | 5 +++++ .../Certifications/CertificationForm.tsx | 4 +++- .../Certifications/CertificationForm/index.tsx | 15 +++++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/webapp/public/locales/en/message.json b/packages/webapp/public/locales/en/message.json index fde5930260..8987fff98c 100644 --- a/packages/webapp/public/locales/en/message.json +++ b/packages/webapp/public/locales/en/message.json @@ -37,6 +37,11 @@ "ADD": "Failed to add certification", "DELETE": "Failed to delete certification", "EDIT": "Failed to update certification" + }, + "SUCCESS": { + "ADD": "Successfully saved certification", + "DELETE": "Successfully deleted certification", + "EDIT": "Successfully updated certification" } }, "CROP_VARIETY": { diff --git a/packages/webapp/src/components/Certifications/CertificationForm.tsx b/packages/webapp/src/components/Certifications/CertificationForm.tsx index 88ab4de4ca..e2c68c9e70 100644 --- a/packages/webapp/src/components/Certifications/CertificationForm.tsx +++ b/packages/webapp/src/components/Certifications/CertificationForm.tsx @@ -73,6 +73,7 @@ type CertificationFormProps = { defaultValues?: Partial; onSubmit: (data: CertificationFormValues) => void; onBack: () => void; + isSaving: boolean; }; const DEFAULT_VALUES: CertificationFormValues = { @@ -121,6 +122,7 @@ export default function CertificationForm({ defaultValues, onSubmit, onBack, + isSaving, }: CertificationFormProps) { const { t } = useTranslation(['translation', 'common']); const { @@ -358,7 +360,7 @@ export default function CertificationForm({ (); const { farm_id } = useSelector(loginSelector); - const { data: certifications = [] } = useGetCertificationsQuery(); + const { data: certifications = [], refetch: refetchCertifications } = useGetCertificationsQuery(); const { data: certifiers = [] } = useGetSupportedCertifiersQuery(farm_id!); const { data: systemTypes = [] } = useGetSupportedCertificationSystemTypesQuery(farm_id!); - const [addCertification] = useAddCertificationMutation(); - const [editCertification] = useEditCertificationMutation(); + const [addCertification, { isLoading: isAdding }] = useAddCertificationMutation(); + const [editCertification, { isLoading: isEditing }] = useEditCertificationMutation(); const certification = certification_id ? certifications.find((c) => c.id === certification_id) @@ -71,6 +71,12 @@ export default function CertificationForm() { } else { await addCertification(body).unwrap(); } + + const message = certification_id + ? t('message:CERTIFICATION.SUCCESS.EDIT') + : t('message:CERTIFICATION.SUCCESS.ADD'); + dispatch(enqueueSuccessSnackbar(message)); + history.push('/certifications', { certificationSaved: true }); } catch { const message = certification_id @@ -88,6 +94,7 @@ export default function CertificationForm() { defaultValues={defaultValues} onSubmit={onSubmit} onBack={onBack} + isSaving={isAdding || isEditing} /> ); From 30dfdb908aac41cad8a916bebc6fbf531940bfa0 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Thu, 16 Jul 2026 15:23:39 -0700 Subject: [PATCH 3/5] LF-5388 Display snackbar when deleting certifications --- .../webapp/src/components/Certifications/index.tsx | 11 +++++++---- .../webapp/src/containers/Certifications/index.tsx | 9 ++++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/webapp/src/components/Certifications/index.tsx b/packages/webapp/src/components/Certifications/index.tsx index b256b3dc22..05d8c2322f 100644 --- a/packages/webapp/src/components/Certifications/index.tsx +++ b/packages/webapp/src/components/Certifications/index.tsx @@ -36,7 +36,8 @@ interface CertificationsProps { onAddCertification: () => void; onExport: () => void; onEditCertification: (id: string) => void; - onDeleteCertification: (id: string) => void; + onDeleteCertification: (id: string) => Promise; + isSaving: boolean; } export default function Certifications({ @@ -48,15 +49,16 @@ export default function Certifications({ onExport, onEditCertification, onDeleteCertification, + isSaving, }: CertificationsProps) { const { t } = useTranslation('translation'); const [deletingId, setDeletingId] = useState(null); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); - function handleDeleteConfirm() { - if (deletingId !== null) { - onDeleteCertification(deletingId); + async function handleDeleteConfirm() { + if (deletingId) { + await onDeleteCertification(deletingId); } setDeletingId(null); } @@ -108,6 +110,7 @@ export default function Certifications({ subject={t('CERTIFICATION.THIS_CERTIFICATION')} onClose={() => setDeletingId(null)} onConfirm={handleDeleteConfirm} + isLoading={isSaving} /> )} diff --git a/packages/webapp/src/containers/Certifications/index.tsx b/packages/webapp/src/containers/Certifications/index.tsx index 3fb1e1202d..acf8739044 100644 --- a/packages/webapp/src/containers/Certifications/index.tsx +++ b/packages/webapp/src/containers/Certifications/index.tsx @@ -21,7 +21,7 @@ import PureCertifications from '../../components/Certifications'; import Layout from '../../components/Layout'; import Loading from '../../components/Form/ContextForm/Loading'; import { loginSelector } from '../userFarmSlice'; -import { enqueueErrorSnackbar } from '../Snackbar/snackbarSlice'; +import { enqueueErrorSnackbar, enqueueSuccessSnackbar } from '../Snackbar/snackbarSlice'; import { useGetCertificationsQuery, useDeleteCertificationMutation, @@ -49,7 +49,7 @@ export default function Certifications({ isCompactSideMenu }: CertificationsProp ); const { data: systemTypes = [], isLoading: isSystemTypesLoading } = useGetSupportedCertificationSystemTypesQuery(farm_id!); - const [deleteCertification] = useDeleteCertificationMutation(); + const [deleteCertification, { isLoading: isDeleting }] = useDeleteCertificationMutation(); const isLoading = isCertificationsLoading || isCertifiersLoading || isSystemTypesLoading; @@ -76,7 +76,9 @@ export default function Certifications({ isCompactSideMenu }: CertificationsProp const onDeleteCertification = async (id: string) => { try { await deleteCertification(id).unwrap(); - } catch { + dispatch(enqueueSuccessSnackbar(t('message:CERTIFICATION.SUCCESS.DELETE'))); + } catch (e) { + console.error(e); dispatch(enqueueErrorSnackbar(t('message:CERTIFICATION.ERROR.DELETE'))); } }; @@ -101,6 +103,7 @@ export default function Certifications({ isCompactSideMenu }: CertificationsProp onAddCertification={() => history.push('/certifications/add_certification')} onEditCertification={(id) => history.push(`/certifications/${id}/edit_certification`)} onDeleteCertification={onDeleteCertification} + isSaving={isDeleting} /> ); From b61b965de7df69e60e368bf2d63a2aaa295f5ad9 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Thu, 16 Jul 2026 16:03:40 -0700 Subject: [PATCH 4/5] LF-5388 Preserve original filename in certificate document uploads Co-Authored-By: Claude Sonnet 5 --- .../src/controllers/certificationsController.ts | 4 ++-- packages/api/src/util/digitalOceanSpaces.js | 10 ++++++++++ packages/api/tests/certifications.test.ts | 3 +++ .../src/containers/Certifications/utils.ts | 17 +++++++++++++---- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/api/src/controllers/certificationsController.ts b/packages/api/src/controllers/certificationsController.ts index 9189b7dfea..ae98605e29 100644 --- a/packages/api/src/controllers/certificationsController.ts +++ b/packages/api/src/controllers/certificationsController.ts @@ -29,7 +29,7 @@ import { imaginaryPost, getPrivateS3BucketName, getPrivateS3Url, - getRandomFileName, + getFileNameWithOriginalName, } from '../util/digitalOceanSpaces.js'; import { HttpError, LiteFarmRequest } from '../types.js'; @@ -179,7 +179,7 @@ const certificationsController = { const { farm_id } = req.params; try { const s3BucketName = getPrivateS3BucketName(); - const fileName = `${farm_id}/certification/${getRandomFileName(req.file)}`; + const fileName = `${farm_id}/certification/${getFileNameWithOriginalName(req.file)}`; const uploadOriginalDocument = () => s3.send( diff --git a/packages/api/src/util/digitalOceanSpaces.js b/packages/api/src/util/digitalOceanSpaces.js index acd0e61ef8..f961022ef2 100644 --- a/packages/api/src/util/digitalOceanSpaces.js +++ b/packages/api/src/util/digitalOceanSpaces.js @@ -121,6 +121,15 @@ function getRandomFileName(file) { return `${uuidv4()}${path.extname(file.originalname)}`; } +// Like getRandomFileName, but keeps the original filename recoverable from the storage +// key itself (encodeURIComponent, not a strip-unsafe-chars sanitizer, so the name round-trips +// exactly via decodeURIComponent). The uuid prefix still guarantees no collisions. +function getFileNameWithOriginalName(file) { + const ext = path.extname(file.originalname); + const baseName = path.basename(file.originalname, ext); + return `${uuidv4()}-${encodeURIComponent(baseName)}${ext}`; +} + function getPrivateS3Url() { if (process.env.NODE_ENV === 'development') { return `${MINIO_ENDPOINT}/${getPrivateS3BucketName()}`; @@ -155,6 +164,7 @@ export { imaginaryPost, imaginaryGet, getRandomFileName, + getFileNameWithOriginalName, getPublicS3Url, getPrivateS3Url, deleteImage, diff --git a/packages/api/tests/certifications.test.ts b/packages/api/tests/certifications.test.ts index 3140ff1fd3..3f39a5dbdd 100644 --- a/packages/api/tests/certifications.test.ts +++ b/packages/api/tests/certifications.test.ts @@ -50,6 +50,9 @@ jest.mock('../src/util/digitalOceanSpaces.js', () => ({ getPrivateS3BucketName: jest.fn().mockReturnValue('test-bucket'), getPrivateS3Url: jest.fn().mockReturnValue('http://localhost:9000/test-bucket'), getRandomFileName: jest.fn().mockImplementation((file) => `random-uuid-${file.originalname}`), + getFileNameWithOriginalName: jest + .fn() + .mockImplementation((file) => `random-uuid-${file.originalname}`), imaginaryPost: jest.fn().mockResolvedValue({ data: Buffer.from('fake-image') }), })); diff --git a/packages/webapp/src/containers/Certifications/utils.ts b/packages/webapp/src/containers/Certifications/utils.ts index bdbe484fb2..bc3ac82d27 100644 --- a/packages/webapp/src/containers/Certifications/utils.ts +++ b/packages/webapp/src/containers/Certifications/utils.ts @@ -122,14 +122,23 @@ const formatCertifierLabel = ( return systemTypeName ? `${certifierName} - ${systemTypeName}` : certifierName ?? ''; }; -// The backend only ever persists a randomized `${uuid}.${ext}` storage key (see -// getRandomFileName in digitalOceanSpaces.js) — the user's original filename is never -// stored. Show a generic label instead of the UUID until a backend field exists for it. +// getFileNameWithOriginalName (digitalOceanSpaces.js) stores keys as `${uuid}-${name}.${ext}`, +// so the original filename can be recovered from the URL. Certifications uploaded before that +// change only have a bare `${uuid}.${ext}` key (see getRandomFileName) with no name to recover — +// those fall back to a generic label instead. +const UUID_NAME_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(.+)$/i; + const toDocumentFileName = (documentUrl: string | null, t: TFunction): string | null => { if (!documentUrl) { return null; } - const extension = documentUrl.split('.').pop(); + const fullFileName = documentUrl.split('/').pop() ?? ''; + const match = fullFileName.match(UUID_NAME_PATTERN); + if (match) { + return decodeURIComponent(match[1]); + } + + const extension = fullFileName.split('.').pop(); return extension ? `${t('common:DOCUMENT')}.${extension}` : t('common:DOCUMENT'); }; From 1c21fd3765af03c928d3b65cdd3a722ff4541ba8 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Thu, 16 Jul 2026 16:21:20 -0700 Subject: [PATCH 5/5] LF-5388 Fix certificate document preview for private bucket storage Co-Authored-By: Claude Sonnet 5 --- .../Certifications/CertificationForm.tsx | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/webapp/src/components/Certifications/CertificationForm.tsx b/packages/webapp/src/components/Certifications/CertificationForm.tsx index e2c68c9e70..f68d2cc88f 100644 --- a/packages/webapp/src/components/Certifications/CertificationForm.tsx +++ b/packages/webapp/src/components/Certifications/CertificationForm.tsx @@ -22,7 +22,8 @@ import RadioGroup from '../Form/RadioGroup'; import Switch from '../Form/Switch'; import ReactSelect from '../Form/ReactSelect'; import ImagePicker from '../ImagePicker'; -import useImagePickerUpload from '../ImagePicker/useImagePickerUpload'; +import useImagePickerUpload, { GetOnFileUpload } from '../ImagePicker/useImagePickerUpload'; +import useMediaWithAuthentication from '../../containers/hooks/useMediaWithAuthentication'; import FormNavigationButtons from '../Form/FormNavigationButtons'; import InputBaseLabel from '../Form/InputBase/InputBaseLabel'; import { Error } from '../Typography'; @@ -103,6 +104,38 @@ const DateError = ({ return <>{!areDatesProperlySet && {errorMessage}}; }; +const CertificateDocumentPicker = ({ + label, + value, + onChange, + onRemove, + getOnFileUpload, +}: { + label: string; + value: string | null; + onChange: (url: string | null) => void; + onRemove: () => void; + getOnFileUpload: GetOnFileUpload; +}) => { + const { mediaUrl, isLoading } = useMediaWithAuthentication({ fileUrls: value ? [value] : [] }); + + // ImagePicker only reads defaultUrl once, at mount (useState(defaultUrl), no sync effect) — + // so it must not mount until the authenticated fetch for an existing document has resolved, + // otherwise it'd permanently capture an empty preview and never pick up mediaUrl once ready. + if (value && isLoading) { + return null; + } + + return ( + + ); +}; + const certificationTypes = [ 'ORGANIC', 'BIODYNAMIC', @@ -347,11 +380,12 @@ export default function CertificationForm({ name={DOCUMENT_URL} control={control} render={({ field }) => ( - field.onChange(url))} - onRemoveImage={() => field.onChange(null)} + value={field.value} + onChange={field.onChange} + onRemove={() => field.onChange(null)} + getOnFileUpload={getOnFileUpload} /> )} />