Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/admin-server/src/components/document-uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import { Input } from './ui/input';
export const DocumentUploader: React.FC<{
form: UseFormReturn<any>;
fieldName: Path<FieldValues>;
onDocumentUploaded?: (documentObject: { url: string; name?: string }) => void;
onDocumentUploaded?: (documentObject: {
url: string;
name?: string;
size?: number;
mimeType?: string;
}) => void;
documentLabel?: string;
allowedTypes?: string[];
project?: string;
Expand Down Expand Up @@ -53,6 +58,8 @@ export const DocumentUploader: React.FC<{
onDocumentUploaded?.({
url: uploadedDocumentUrl,
name: uploadedDocument?.name,
size: uploadedDocument?.size,
mimeType: uploadedDocument?.mimeType,
});
}

Expand Down
7 changes: 6 additions & 1 deletion apps/admin-server/src/components/resource-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ const baseSchema = z.object({
document: z.string().optional(),
documents: z
.array(
z.object({ url: z.string().optional(), name: z.string().optional() })
z.object({
url: z.string().optional(),
name: z.string().optional(),
size: z.number().optional(),
mimeType: z.string().optional(),
})
)
.optional()
.default([]),
Expand Down
4 changes: 4 additions & 0 deletions apps/image-server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,8 @@ app.post('/document', documentUpload.single('document'), (req, res, next) => {
JSON.stringify({
name: sanitizeFileName(req.file.originalname),
url: protocol + url,
size: req.file.size,
mimeType: req.file.mimetype,
})
);
});
Expand All @@ -658,6 +660,8 @@ app.post(
return {
name: sanitizeFileName(file.originalname),
url: protocol + url,
size: file.size,
mimeType: file.mimetype,
};
})
)
Expand Down
59 changes: 50 additions & 9 deletions packages/resource-detail/cypress/component/utils.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,79 @@ import { formatDocumentLabel } from '../../src/utils';
describe('formatDocumentLabel', () => {
it('humanizes underscores and derives the type from the URL', () => {
expect(
formatDocumentLabel('Groenontwerp_Burggraafstraat_pdf', 'https://example.com/files/groenontwerp.pdf')
formatDocumentLabel(
'Groenontwerp_Burggraafstraat_pdf',
'https://example.com/files/groenontwerp.pdf'
)
).to.equal('Groenontwerp Burggraafstraat (PDF)');
});

it('only strips a trailing suffix when it is a known extension', () => {
expect(
formatDocumentLabel('20250630_Paneel_1_Ontwerp_pdf', 'https://example.com/p1.pdf')
formatDocumentLabel(
'20250630_Paneel_1_Ontwerp_pdf',
'https://example.com/p1.pdf'
)
).to.equal('20250630 Paneel 1 Ontwerp (PDF)');
});

it('handles hyphen-underscore combinations', () => {
expect(
formatDocumentLabel('Reactienota_Rechters-_en_Kanunnikenbuurt_pdf', 'https://example.com/r.pdf')
formatDocumentLabel(
'Reactienota_Rechters-_en_Kanunnikenbuurt_pdf',
'https://example.com/r.pdf'
)
).to.equal('Reactienota Rechters en Kanunnikenbuurt (PDF)');
});

it('supports regular dotted file names', () => {
expect(formatDocumentLabel('rapport_2026.pdf', '')).to.equal('Rapport 2026 (PDF)');
expect(formatDocumentLabel('rapport_2026.pdf', '')).to.equal(
'Rapport 2026 (PDF)'
);
});

it('ignores query strings in the URL', () => {
expect(
formatDocumentLabel('verslag_docx', 'https://example.com/verslag.docx?v=2')
formatDocumentLabel(
'verslag_docx',
'https://example.com/verslag.docx?v=2'
)
).to.equal('Verslag (DOCX)');
});

it('falls back to the URL file name when the name is empty', () => {
expect(formatDocumentLabel('', 'https://example.com/files/begroting_2026.pdf'))
.to.equal('Begroting 2026 (PDF)');
expect(
formatDocumentLabel('', 'https://example.com/files/begroting_2026.pdf')
).to.equal('Begroting 2026 (PDF)');
});

it('omits the type suffix when no extension can be derived', () => {
expect(formatDocumentLabel('Toelichting bewonersavond', '')).to.equal('Toelichting bewonersavond');
expect(formatDocumentLabel('Toelichting bewonersavond', '')).to.equal(
'Toelichting bewonersavond'
);
});

it('appends a human-readable size in MB with Dutch notation', () => {
expect(
formatDocumentLabel('rapport_pdf', 'https://example.com/r.pdf', 1300000)
).to.equal('Rapport (PDF, 1,2 MB)');
});

it('formats sizes under 1 MB in kB', () => {
expect(
formatDocumentLabel('nota_pdf', 'https://example.com/n.pdf', 5000)
).to.equal('Nota (PDF, 5 kB)');
});

it('omits the size when it is not provided', () => {
expect(
formatDocumentLabel('nota_pdf', 'https://example.com/n.pdf')
).to.equal('Nota (PDF)');
});

it('omits the size when it is zero or invalid', () => {
expect(
formatDocumentLabel('nota_pdf', 'https://example.com/n.pdf', 0)
).to.equal('Nota (PDF)');
});
});
});
8 changes: 7 additions & 1 deletion packages/resource-detail/src/resource-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export type ResourceDetailWidgetProps = {
type DocumentType = {
name?: string;
url?: string;
size?: number;
mimeType?: string;
};

function ResourceDetail({
Expand Down Expand Up @@ -748,7 +750,11 @@ function ResourceDetail({
href={document.url}
key={index}>
<Icon icon="ri-download-2-fill" iconOnly />
{formatDocumentLabel(document.name, document.url)}
{formatDocumentLabel(
document.name,
document.url,
document.size
)}
</ButtonLink>
)
)}
Expand Down
73 changes: 58 additions & 15 deletions packages/resource-detail/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,48 @@
const KNOWN_EXTENSIONS = [
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'csv', 'txt', 'zip', 'png', 'jpg', 'jpeg', 'gif', 'svg',
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'csv',
'txt',
'zip',
'png',
'jpg',
'jpeg',
'gif',
'svg',
];

// Removes the query string and hash fragment from a URL.
function stripUrlParams(url: string): string {
return url.split('?')[0].split('#')[0];
}

// Removes a trailing dotted extension, e.g. "report.pdf" -> "report".
function stripDottedExtension(value: string): string {
return value.replace(/\.[^.]+$/, '');
}

// Returns the lowercase extension of a path, or '' if there is none.
function getExtension(path: string): string {
if (!path.includes('.')) return '';
return path.split('.').pop()?.toLowerCase() ?? '';
}

// Strips query string and hash from a URL, then returns its extension.
// Returns the extension of a URL, ignoring query string and hash.
function getExtensionFromUrl(url: string): string {
const path = url.split('?')[0].split('#')[0];
return getExtension(path);
return getExtension(stripUrlParams(url));
}

// Returns the file name (without extension) from a URL path, used as a
// Returns the file name (without extension) from a URL, used as a
// fallback when the document has no stored name.
function getFileNameFromUrl(url: string): string {
const path = url.split('?')[0].split('#')[0];
const path = stripUrlParams(url);
if (!path) return '';
return path.split('/').pop()?.replace(/\.[^.]+$/, '') ?? '';
return stripDottedExtension(path.split('/').pop() ?? '');
}

// Removes a trailing "_pdf" / "-docx" style suffix, but only when it
Expand Down Expand Up @@ -54,15 +76,33 @@ function humanizeBase(base: string): string {
return words.charAt(0).toUpperCase() + words.slice(1);
}

// Formats a byte count into a human-readable size using Dutch notation
// (comma as decimal separator), e.g. 1300000 -> "1,2 MB", 5000 -> "5 kB".
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '';
const kb = bytes / 1024;
if (kb < 1024) {
return `${Math.max(1, Math.round(kb))} kB`;
}
const mb = kb / 1024;
return `${mb.toFixed(1).replace('.', ',')} MB`;
}

// Builds an accessible, human-readable label for a document link,
// e.g. "Groenontwerp Burggraafstraat (PDF)".
export function formatDocumentLabel(name?: string, url?: string): string {
// e.g. "Groenontwerp Burggraafstraat (PDF, 1,2 MB)". The size is omitted
// when it is not available (e.g. for documents uploaded before size was
// stored), falling back to "Title (PDF)".
export function formatDocumentLabel(
name?: string,
url?: string,
size?: number
): string {
const rawName = name || '';
const documentUrl = url || '';

const withoutDottedExtension = rawName.replace(/\.[^.]+$/, '');
const { base: strippedBase, extension: suffixExtension } =
stripKnownSuffix(withoutDottedExtension);
const { base: strippedBase, extension: suffixExtension } = stripKnownSuffix(
stripDottedExtension(rawName)
);

const base = strippedBase || getFileNameFromUrl(documentUrl);

Expand All @@ -73,5 +113,8 @@ export function formatDocumentLabel(name?: string, url?: string): string {
);

const title = humanizeBase(base);
return extension ? `${title} (${extension.toUpperCase()})` : title;
}
const fileSize = size === undefined ? '' : formatFileSize(size);

const meta = [extension.toUpperCase(), fileSize].filter(Boolean).join(', ');
return meta ? `${title} (${meta})` : title;
}
Loading