From a982c646e88594da51cfbb30f745c9651203054c Mon Sep 17 00:00:00 2001 From: maros Date: Fri, 15 May 2026 12:00:35 +0200 Subject: [PATCH 01/10] refactor: streamline Dockerfile and docker-compose for improved build process --- Dockerfile | 5 +---- apps/sensenet/webpack.common.js | 26 ++++++++++++++++++++++++++ docker-compose.dev.yml | 2 +- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index d2c04299d..b52196de9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,10 +13,7 @@ COPY . . # Install dependencies RUN yarn install -# Build packages (required for the app to work) -RUN yarn build - -# Build the app bundle in production mode (avoids runtime webpack rebuild) +# Build the app bundle in production mode (the snapp webpack config resolves workspace packages from src) RUN NODE_ENV=production yarn snapp build # Expose port diff --git a/apps/sensenet/webpack.common.js b/apps/sensenet/webpack.common.js index ea654e0f1..5fbcf7c31 100644 --- a/apps/sensenet/webpack.common.js +++ b/apps/sensenet/webpack.common.js @@ -2,12 +2,38 @@ const path = require('path') const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin') const webpack = require('webpack') +const packageSourceAliases = { + '@sensenet/authentication-oidc-react$': path.resolve( + __dirname, + '../../packages/sn-authentication-oidc-react/src/index.ts', + ), + '@sensenet/client-core$': path.resolve(__dirname, '../../packages/sn-client-core/src/index.ts'), + '@sensenet/client-utils$': path.resolve(__dirname, '../../packages/sn-client-utils/src/index.ts'), + '@sensenet/control-mapper$': path.resolve(__dirname, '../../packages/sn-control-mapper/src/index.ts'), + '@sensenet/controls-react$': path.resolve(__dirname, '../../packages/sn-controls-react/src/index.ts'), + '@sensenet/default-content-types$': path.resolve(__dirname, '../../packages/sn-default-content-types/src/index.ts'), + '@sensenet/document-viewer-react$': path.resolve(__dirname, '../../packages/sn-document-viewer-react/src/index.ts'), + '@sensenet/editor-react$': path.resolve(__dirname, '../../packages/sn-editor-react/src/index.ts'), + '@sensenet/hooks-react$': path.resolve(__dirname, '../../packages/sn-hooks-react/src/index.ts'), + '@sensenet/icons-react$': path.resolve(__dirname, '../../packages/sn-icons-react/src/index.ts'), + '@sensenet/list-controls-react$': path.resolve( + __dirname, + '../../packages/sn-list-controls-react/src/ContentList/index.ts', + ), + '@sensenet/pickers-react$': path.resolve(__dirname, '../../packages/sn-pickers-react/src/index.ts'), + '@sensenet/query$': path.resolve(__dirname, '../../packages/sn-query/src/index.ts'), + '@sensenet/repository-events$': path.resolve(__dirname, '../../packages/sn-repository-events/src/index.ts'), + '@sensenet/search-react$': path.resolve(__dirname, '../../packages/sn-search-react/src/index.ts'), + '@sensenet/sn-auth-react$': path.resolve(__dirname, '../../packages/sn-auth-react/src/index.ts'), +} + module.exports = { output: { path: path.resolve(__dirname, 'build'), publicPath: '/', }, resolve: { + alias: packageSourceAliases, extensions: ['.ts', '.tsx', '.js', '.json'], }, module: { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a133c9fef..e12154e57 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -16,7 +16,7 @@ services: - .:/app - sensenet-root-node-modules:/app/node_modules - sensenet-app-node-modules:/app/apps/sensenet/node_modules - command: sh -c "yarn build && yarn snapp start" + command: sh -c "yarn snapp start" healthcheck: test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/" ] interval: 30s From 1c698e2a38c645088efcbd8ae5be7349bb3018d7 Mon Sep 17 00:00:00 2001 From: maros Date: Fri, 15 May 2026 12:54:49 +0200 Subject: [PATCH 02/10] feat: add CSV export functionality with dialog and localization support --- apps/sensenet/src/components/BatchActions.tsx | 21 + .../src/components/CsvExportDialog.tsx | 434 ++++++++++++++++++ apps/sensenet/src/localization/default.ts | 16 + apps/sensenet/src/localization/hungarian.ts | 16 + apps/sensenet/src/services/csv-export.ts | 99 ++++ 5 files changed, 586 insertions(+) create mode 100644 apps/sensenet/src/components/CsvExportDialog.tsx create mode 100644 apps/sensenet/src/services/csv-export.ts diff --git a/apps/sensenet/src/components/BatchActions.tsx b/apps/sensenet/src/components/BatchActions.tsx index 6e8ab84ff..c611c1f15 100644 --- a/apps/sensenet/src/components/BatchActions.tsx +++ b/apps/sensenet/src/components/BatchActions.tsx @@ -2,10 +2,12 @@ import { createStyles, IconButton, makeStyles, Theme, Tooltip } from '@material- import DeleteIcon from '@material-ui/icons/Delete' import FileCopyIcon from '@material-ui/icons/FileCopy' import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined' +import GetAppIcon from '@material-ui/icons/GetApp' import { CurrentContentContext } from '@sensenet/hooks-react' import React, { useContext, useEffect, useState } from 'react' import { useGlobalStyles } from '../globalStyles' import { useLocalization, useSelectionService } from '../hooks' +import { CsvExportDialog } from './CsvExportDialog' import { useDialog } from './dialogs' const useStyles = makeStyles((theme: Theme) => @@ -37,6 +39,7 @@ export const BatchActions = () => { const classes = useStyles() const { openDialog } = useDialog() const [selected, setSelected] = useState(selectionService.selection.getValue()) + const [isExportDialogOpen, setIsExportDialogOpen] = useState(false) const parent = useContext(CurrentContentContext) useEffect(() => { @@ -51,6 +54,24 @@ export const BatchActions = () => { return (
+ + + setIsExportDialogOpen(true)}> + + + + + setIsExportDialogOpen(false)} + /> void +} + +const systemFieldOptions = preferredCsvColumns.map((fieldName) => ({ + name: fieldName, + displayName: fieldName, + type: 'System', + visibleBrowse: FieldVisibility.Show, +})) + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + content: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + minHeight: '420px', + }, + layout: { + display: 'grid', + gridTemplateColumns: 'minmax(260px, 360px) 1fr', + gap: theme.spacing(3), + [theme.breakpoints.down('xs')]: { + gridTemplateColumns: '1fr', + }, + }, + fieldToolbar: { + display: 'flex', + gap: theme.spacing(1), + margin: `${theme.spacing(1)}px 0`, + }, + fieldList: { + border: `1px solid ${theme.palette.divider}`, + maxHeight: '300px', + overflowY: 'auto', + padding: theme.spacing(1), + }, + fieldLabel: { + alignItems: 'flex-start', + display: 'flex', + marginRight: 0, + width: '100%', + }, + fieldLabelText: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + fieldName: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + fieldMeta: { + color: theme.palette.text.secondary, + fontSize: '0.75rem', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + selectedFields: { + alignContent: 'flex-start', + border: `1px solid ${theme.palette.divider}`, + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + marginTop: theme.spacing(1), + maxHeight: '300px', + minHeight: '128px', + overflowY: 'auto', + padding: theme.spacing(1), + }, + selectedFieldsHeader: { + alignItems: 'center', + display: 'flex', + gap: theme.spacing(1), + justifyContent: 'space-between', + }, + }), +) + +const getContentTypeNames = (contents: GenericContent[]) => + Array.from(new Set(contents.map((content) => content.Type).filter(Boolean))).sort((left, right) => + left.localeCompare(right), + ) + +const getSortedFieldOptions = (fieldOptions: CsvFieldOption[]) => + [...fieldOptions].sort((left, right) => { + const leftPreferredIndex = preferredCsvColumns.indexOf(left.name) + const rightPreferredIndex = preferredCsvColumns.indexOf(right.name) + + if (leftPreferredIndex >= 0 || rightPreferredIndex >= 0) { + if (leftPreferredIndex === -1) { + return 1 + } + if (rightPreferredIndex === -1) { + return -1 + } + return leftPreferredIndex - rightPreferredIndex + } + + return left.displayName.localeCompare(right.displayName) + }) + +const getFieldOptionsForContentType = (fieldSettings: FieldSetting[]) => { + const fieldOptionsByName = new Map(systemFieldOptions.map((fieldOption) => [fieldOption.name, fieldOption])) + + fieldSettings.forEach((fieldSetting) => { + fieldOptionsByName.set(fieldSetting.Name, { + name: fieldSetting.Name, + displayName: fieldSetting.DisplayName || fieldSetting.Name, + type: fieldSetting.Type, + visibleBrowse: fieldSetting.VisibleBrowse, + }) + }) + + return getSortedFieldOptions(Array.from(fieldOptionsByName.values())) +} + +const getDefaultSelectedFields = (contentTypeFieldOptions: CsvFieldOption[][]) => { + const fieldNames = new Set(preferredCsvColumns) + + contentTypeFieldOptions.forEach((fieldOptions) => { + fieldOptions.forEach((fieldOption) => { + if (fieldOption.visibleBrowse === FieldVisibility.Show) { + fieldNames.add(fieldOption.name) + } + }) + }) + + return Array.from(fieldNames) +} + +export const CsvExportDialog: React.FC = ({ open, selected, parent, onClose }) => { + const classes = useStyles() + const localization = useLocalization() + const logger = useLogger('CsvExportDialog') + const repository = useRepository() + const contentTypeNames = useMemo(() => getContentTypeNames(selected), [selected]) + const [activeContentType, setActiveContentType] = useState('') + const [selectedFields, setSelectedFields] = useState([]) + const [searchTerm, setSearchTerm] = useState('') + const [isExporting, setIsExporting] = useState(false) + + const fieldOptionsByContentType = useMemo(() => { + return contentTypeNames.reduce((optionsByType, contentTypeName) => { + const schema = repository.schemas.getSchemaByName(contentTypeName) + optionsByType[contentTypeName] = getFieldOptionsForContentType(schema.FieldSettings) + + return optionsByType + }, {} as Record) + }, [contentTypeNames, repository.schemas]) + + useEffect(() => { + if (!open) { + return + } + + setActiveContentType(contentTypeNames[0] || '') + setSelectedFields(contentTypeNames.length ? getDefaultSelectedFields(Object.values(fieldOptionsByContentType)) : []) + setSearchTerm('') + }, [contentTypeNames, fieldOptionsByContentType, open]) + + const activeFieldOptions = activeContentType ? fieldOptionsByContentType[activeContentType] || [] : [] + const filteredFieldOptions = activeFieldOptions.filter((fieldOption) => { + const normalizedSearchTerm = searchTerm.toLocaleLowerCase() + + return ( + fieldOption.name.toLocaleLowerCase().includes(normalizedSearchTerm) || + fieldOption.displayName.toLocaleLowerCase().includes(normalizedSearchTerm) || + fieldOption.type.toLocaleLowerCase().includes(normalizedSearchTerm) + ) + }) + + const fieldLabelsByName = useMemo(() => { + const labelsByName = new Map() + + Object.values(fieldOptionsByContentType).forEach((fieldOptions) => { + fieldOptions.forEach((fieldOption) => { + if (!labelsByName.has(fieldOption.name)) { + labelsByName.set(fieldOption.name, fieldOption.displayName) + } + }) + }) + + return labelsByName + }, [fieldOptionsByContentType]) + + const getFieldLabel = (fieldName: string) => { + const displayName = fieldLabelsByName.get(fieldName) + + return displayName && displayName !== fieldName ? `${displayName} (${fieldName})` : fieldName + } + + const toggleField = (fieldName: string) => { + setSelectedFields((currentFields) => + currentFields.includes(fieldName) + ? currentFields.filter((currentField) => currentField !== fieldName) + : [...currentFields, fieldName], + ) + } + + const selectActiveContentTypeFields = () => { + setSelectedFields((currentFields) => + Array.from(new Set([...currentFields, ...activeFieldOptions.map((fieldOption) => fieldOption.name)])), + ) + } + + const clearActiveContentTypeFields = () => { + const activeFieldNames = new Set(activeFieldOptions.map((fieldOption) => fieldOption.name)) + setSelectedFields((currentFields) => currentFields.filter((fieldName) => !activeFieldNames.has(fieldName))) + } + + const exportSelectedContent = async () => { + if (!selected.length || !selectedFields.length) { + return + } + + setIsExporting(true) + + try { + const contents = await Promise.all( + selected.map(async (content) => { + const response = await repository.load({ + idOrPath: content.Id, + oDataOptions: { select: 'all' }, + }) + + return response.d + }), + ) + const csvContent = createCsvFromContents(contents, selectedFields) + + downloadCsv(csvContent, getCsvExportFileName(contents, parent)) + logger.information({ + message: localization.batchActions.exportCsvSuccess.replace('{0}', String(contents.length)), + data: { + relatedRepository: repository.configuration.repositoryUrl, + details: { + exportedContent: contents, + selectedFields, + }, + }, + }) + onClose() + } catch (error) { + logger.error({ + message: localization.batchActions.exportCsvError, + data: { + error, + relatedRepository: repository.configuration.repositoryUrl, + details: { + selectedContent: selected, + selectedFields, + }, + }, + }) + } finally { + setIsExporting(false) + } + } + + const selectionSummary = localization.batchActions.exportCsvSelectionSummary + .replace('{0}', String(selected.length)) + .replace('{1}', String(contentTypeNames.length)) + const selectedFieldSummary = localization.batchActions.exportCsvSelectedFieldCount.replace( + '{0}', + String(selectedFields.length), + ) + + return ( + + {localization.batchActions.exportCsvDialogTitle} + + + {selectionSummary} + +
+
+ + + {localization.batchActions.exportCsvContentType} + + + + setSearchTerm(event.target.value)} + label={localization.batchActions.exportCsvSearchFields} + variant="outlined" + margin="normal" + fullWidth + /> +
+ + +
+
+ {filteredFieldOptions.length ? ( + filteredFieldOptions.map((fieldOption) => ( + toggleField(fieldOption.name)} + /> + } + label={ + + {fieldOption.displayName} + + {fieldOption.name} - {fieldOption.type} + + + } + /> + )) + ) : ( + + {localization.batchActions.exportCsvNoFields} + + )} +
+
+
+
+
+ {localization.batchActions.exportCsvSelectedFields} + + {selectedFieldSummary} + +
+ +
+
+ {selectedFields.length ? ( + selectedFields.map((fieldName) => ( + + setSelectedFields((currentFields) => + currentFields.filter((currentField) => currentField !== fieldName), + ) + } + /> + )) + ) : ( + + {localization.batchActions.exportCsvNoSelectedFields} + + )} +
+
+
+
+ + + + +
+ ) +} diff --git a/apps/sensenet/src/localization/default.ts b/apps/sensenet/src/localization/default.ts index 7d5b665f4..89ece3430 100644 --- a/apps/sensenet/src/localization/default.ts +++ b/apps/sensenet/src/localization/default.ts @@ -558,6 +558,22 @@ const values = { move: 'Move selected items', copy: 'Copy selected items', copyPath: 'Copy path', + exportCsv: 'Export selected items to CSV', + exportCsvSuccess: '{0} items exported to CSV', + exportCsvError: 'There was an error during CSV export', + exportCsvDialogTitle: 'Export CSV', + exportCsvSelectionSummary: '{0} selected items from {1} content types', + exportCsvContentType: 'Content type', + exportCsvSearchFields: 'Search fields', + exportCsvSelectAllTypeFields: 'Select all', + exportCsvClearTypeFields: 'Clear', + exportCsvNoFields: 'No fields available', + exportCsvSelectedFields: 'Selected fields', + exportCsvSelectedFieldCount: '{0} fields selected', + exportCsvClearSelectedFields: 'Clear all', + exportCsvNoSelectedFields: 'Select at least one field to export', + exportCsvExportButton: 'Download', + exportCsvExporting: 'Preparing...', }, referenceContentListDialog: { errorAlreadyInList: 'The selected item is already in the list', diff --git a/apps/sensenet/src/localization/hungarian.ts b/apps/sensenet/src/localization/hungarian.ts index 1d839aab4..ba17b5aed 100644 --- a/apps/sensenet/src/localization/hungarian.ts +++ b/apps/sensenet/src/localization/hungarian.ts @@ -226,6 +226,22 @@ const values: Localization = { delete: 'Kijelölt elemek törlése', move: 'Kijelölt elemek áthelyezése', copy: 'Kijelölt elemek másolása', + exportCsv: 'Kijelölt elemek exportálása CSV-be', + exportCsvSuccess: '{0} elem exportálva CSV-be', + exportCsvError: 'Hiba történt a CSV exportálás során', + exportCsvDialogTitle: 'CSV export', + exportCsvSelectionSummary: '{0} kijelölt elem, {1} tartalomtípus alapján', + exportCsvContentType: 'Tartalomtípus', + exportCsvSearchFields: 'Mezők keresése', + exportCsvSelectAllTypeFields: 'Összes kijelölése', + exportCsvClearTypeFields: 'Törlés', + exportCsvNoFields: 'Nincs elérhető mező', + exportCsvSelectedFields: 'Kijelölt mezők', + exportCsvSelectedFieldCount: '{0} mező kijelölve', + exportCsvClearSelectedFields: 'Összes törlése', + exportCsvNoSelectedFields: 'Válassz legalább egy mezőt az exporthoz', + exportCsvExportButton: 'Letöltés', + exportCsvExporting: 'Előkészítés...', }, permissionEditor: { assign: 'Új jogosultság hozzáadása', diff --git a/apps/sensenet/src/services/csv-export.ts b/apps/sensenet/src/services/csv-export.ts new file mode 100644 index 000000000..76ffffffb --- /dev/null +++ b/apps/sensenet/src/services/csv-export.ts @@ -0,0 +1,99 @@ +import { GenericContent } from '@sensenet/default-content-types' + +export const preferredCsvColumns = [ + 'Id', + 'Path', + 'Name', + 'DisplayName', + 'Type', + 'CreatedBy', + 'CreationDate', + 'ModifiedBy', + 'ModificationDate', + 'Version', + 'Index', +] + +const excludedColumns = new Set(['Actions', 'Children', '__metadata']) + +const getColumnNames = (contents: GenericContent[]) => { + const columnNames = new Set() + + contents.forEach((content) => { + Object.keys(content).forEach((key) => { + if (!excludedColumns.has(key)) { + columnNames.add(key) + } + }) + }) + + return [ + ...preferredCsvColumns.filter((columnName) => columnNames.has(columnName)), + ...Array.from(columnNames) + .filter((columnName) => !preferredCsvColumns.includes(columnName)) + .sort((left, right) => left.localeCompare(right)), + ] +} + +const serializeValue = (value: unknown): string => { + if (value === undefined || value === null) { + return '' + } + + if (value instanceof Date) { + return value.toISOString() + } + + if (['string', 'number', 'boolean'].includes(typeof value)) { + return String(value) + } + + if (Array.isArray(value)) { + return value.map((item) => serializeValue(item)).join('; ') + } + + if (typeof value === 'object') { + const contentLikeValue = value as Partial + return contentLikeValue.DisplayName || contentLikeValue.Name || contentLikeValue.Path || JSON.stringify(value) + } + + return String(value) +} + +const escapeCsvValue = (value: unknown) => { + const serializedValue = serializeValue(value) + + return /[",\r\n;]/.test(serializedValue) ? `"${serializedValue.replace(/"/g, '""')}"` : serializedValue +} + +export const createCsvFromContents = (contents: GenericContent[], selectedColumns?: string[]) => { + const columnNames = selectedColumns?.length ? selectedColumns : getColumnNames(contents) + const header = columnNames.map(escapeCsvValue).join(',') + const rows = contents.map((content) => + columnNames.map((columnName) => escapeCsvValue((content as any)[columnName])).join(','), + ) + + return [header, ...rows].join('\r\n') +} + +export const downloadCsv = (csvContent: string, fileName: string) => { + const blob = new Blob(['\uFEFF', csvContent], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + + link.href = url + link.download = fileName + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +export const getCsvExportFileName = (contents: GenericContent[], parent?: GenericContent) => { + const parentName = parent?.Name || 'sensenet-content' + const timeStamp = new Date().toISOString().replace(/[:.]/g, '-') + const suffix = contents.length === 1 ? contents[0].Name || contents[0].Id : `${contents.length}-items` + + return `${parentName}-${suffix}-${timeStamp}.csv`.replace(/[\\/:*?"<>|]/g, '_') +} From 3f91095ed3ecf78abe1767bb52e4ded5737cab33 Mon Sep 17 00:00:00 2001 From: maros Date: Fri, 15 May 2026 14:46:55 +0200 Subject: [PATCH 03/10] feat: implement loading state management for grid and children components, enhance icon loading logic, and optimize CSV export handling --- .../src/components/CsvExportDialog.tsx | 40 +++++--- apps/sensenet/src/components/Icon.tsx | 11 ++- apps/sensenet/src/components/IconFromPath.tsx | 99 +++++++++++-------- .../src/components/content/Explore.tsx | 46 ++++++++- apps/sensenet/src/components/grid/Grid.tsx | 64 +++++++++--- .../grid/Providers/GridLoadingProvider.tsx | 2 +- .../tree/Contexts/ExpandedItemsProvider.tsx | 1 + apps/sensenet/src/services/EventService.ts | 52 +++++++++- .../src/context/current-children.tsx | 52 +++++++--- 9 files changed, 278 insertions(+), 89 deletions(-) diff --git a/apps/sensenet/src/components/CsvExportDialog.tsx b/apps/sensenet/src/components/CsvExportDialog.tsx index 65f23c0ea..a9616e95a 100644 --- a/apps/sensenet/src/components/CsvExportDialog.tsx +++ b/apps/sensenet/src/components/CsvExportDialog.tsx @@ -43,6 +43,7 @@ const systemFieldOptions = preferredCsvColumns.map((fieldName) = type: 'System', visibleBrowse: FieldVisibility.Show, })) +const exportRequestBatchSize = 8 const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -167,6 +168,21 @@ const getDefaultSelectedFields = (contentTypeFieldOptions: CsvFieldOption[][]) = return Array.from(fieldNames) } +const loadContentsForExport = async ( + contents: GenericContent[], + loadContent: (content: GenericContent) => Promise, +) => { + const loadedContents: GenericContent[] = [] + + for (let startIndex = 0; startIndex < contents.length; startIndex += exportRequestBatchSize) { + const contentBatch = contents.slice(startIndex, startIndex + exportRequestBatchSize) + const loadedBatch = await Promise.all(contentBatch.map(loadContent)) + loadedContents.push(...loadedBatch) + } + + return loadedContents +} + export const CsvExportDialog: React.FC = ({ open, selected, parent, onClose }) => { const classes = useStyles() const localization = useLocalization() @@ -255,16 +271,14 @@ export const CsvExportDialog: React.FC = ({ open, selected setIsExporting(true) try { - const contents = await Promise.all( - selected.map(async (content) => { - const response = await repository.load({ - idOrPath: content.Id, - oDataOptions: { select: 'all' }, - }) - - return response.d - }), - ) + const contents = await loadContentsForExport(selected, async (content) => { + const response = await repository.load({ + idOrPath: content.Id, + oDataOptions: { select: 'all' }, + }) + + return response.d + }) const csvContent = createCsvFromContents(contents, selectedFields) downloadCsv(csvContent, getCsvExportFileName(contents, parent)) @@ -273,8 +287,8 @@ export const CsvExportDialog: React.FC = ({ open, selected data: { relatedRepository: repository.configuration.repositoryUrl, details: { - exportedContent: contents, - selectedFields, + exportedContentCount: contents.length, + selectedFieldCount: selectedFields.length, }, }, }) @@ -286,7 +300,7 @@ export const CsvExportDialog: React.FC = ({ open, selected error, relatedRepository: repository.configuration.repositoryUrl, details: { - selectedContent: selected, + selectedContentCount: selected.length, selectedFields, }, }, diff --git a/apps/sensenet/src/components/Icon.tsx b/apps/sensenet/src/components/Icon.tsx index 4bfc42af6..89be9d744 100644 --- a/apps/sensenet/src/components/Icon.tsx +++ b/apps/sensenet/src/components/Icon.tsx @@ -330,10 +330,15 @@ export const IconComponent: FunctionComponent<{ ...defaultNotificationResolvers, ] const defaultIcon = props.defaultIcon || || null - const assignedResolver = resolvers.find((r) => (r.get(props.item, options) ? true : false)) - if (assignedResolver) { - return assignedResolver.get(props.item, options)! + + for (const resolver of resolvers) { + const icon = resolver.get(props.item, options) + + if (icon) { + return icon + } } + return defaultIcon } diff --git a/apps/sensenet/src/components/IconFromPath.tsx b/apps/sensenet/src/components/IconFromPath.tsx index 2528cd91c..66d13bbe6 100644 --- a/apps/sensenet/src/components/IconFromPath.tsx +++ b/apps/sensenet/src/components/IconFromPath.tsx @@ -3,57 +3,74 @@ import React, { memo, useEffect, useMemo, useState } from 'react' import { IconOptions } from './Icon' // Global cache for icons -const iconCache = new Map() +const iconCache = new Map() +const iconRequestCache = new Map>() -const IconFromPath = ({ path, options }: { path: string; options: IconOptions }) => { - const [icon, setIcon] = useState(iconCache.get(path) || null) +const loadIcon = (path: string, options: IconOptions) => { + if (iconCache.has(path)) { + return Promise.resolve(iconCache.get(path)!) + } - useEffect(() => { - const controller = new AbortController() - const { signal } = controller - - const fetchIcon = async () => { - // Check cache first - if (iconCache.has(path)) { - setIcon(iconCache.get(path)!) - return - } + const pendingRequest = iconRequestCache.get(path) - const imageUrl = PathHelper.joinPaths(options.repo.configuration.repositoryUrl, path) - - if (path.endsWith('.svg')) { - try { - const response = await options.repo.fetch(imageUrl, { cache: 'force-cache', signal }) - if (!response.ok) return - const svg = await response.text() - const resizedSvg = svg - .replace('width=', 'width="24px" oldwidth=') - .replace('height=', 'height="24px" oldheight=') - if (!signal.aborted) { - iconCache.set(path, resizedSvg) // Store in cache - setIcon(resizedSvg) - } - } catch (err) { - if ((err as any).name !== 'AbortError') { - console.error('Failed to load SVG:', err) - } - } - } else { - if (!signal.aborted) { - iconCache.set(path, imageUrl) // Store in cache - setIcon(imageUrl) - } + if (pendingRequest) { + return pendingRequest + } + + const imageUrl = PathHelper.joinPaths(options.repo.configuration.repositoryUrl, path) + const request = (async () => { + if (!path.endsWith('.svg')) { + iconCache.set(path, imageUrl) + return imageUrl + } + + try { + const response = await options.repo.fetch(imageUrl, { cache: 'force-cache' }) + + if (!response.ok) { + iconCache.set(path, null) + return null } + + const svg = await response.text() + const resizedSvg = svg.replace('width=', 'width="24px" oldwidth=').replace('height=', 'height="24px" oldheight=') + + iconCache.set(path, resizedSvg) + return resizedSvg + } catch { + iconCache.set(path, null) + return null } + })() - if (!icon) { - fetchIcon() + iconRequestCache.set(path, request) + request.finally(() => iconRequestCache.delete(path)) + + return request +} + +const IconFromPath = ({ path, options }: { path: string; options: IconOptions }) => { + const [icon, setIcon] = useState(() => iconCache.get(path) || null) + + useEffect(() => { + let isMounted = true + + if (iconCache.has(path)) { + setIcon(iconCache.get(path) || null) + return } + setIcon(null) + loadIcon(path, options).then((loadedIcon) => { + if (isMounted) { + setIcon(loadedIcon) + } + }) + return () => { - controller.abort() + isMounted = false } - }, [path, options.repo, icon]) + }, [path, options.repo]) // Memoize the rendered output to prevent unnecessary DOM updates const renderedIcon = useMemo(() => { diff --git a/apps/sensenet/src/components/content/Explore.tsx b/apps/sensenet/src/components/content/Explore.tsx index ffbb9fa23..69d1b6126 100644 --- a/apps/sensenet/src/components/content/Explore.tsx +++ b/apps/sensenet/src/components/content/Explore.tsx @@ -12,7 +12,7 @@ import { import { ColumnSetting } from '@sensenet/list-controls-react/src/ContentList/content-list-base-props' import { ColDef } from 'ag-grid-community' import { clsx } from 'clsx' -import React, { useContext, useRef, useState } from 'react' +import React, { useContext, useMemo, useRef, useState } from 'react' import { useHistory } from 'react-router' import { GridKeyEnum } from '../../../src/components/grid/enums/GridKey.enum' import { ResponsivePersonalSettings } from '../../context' @@ -28,6 +28,42 @@ import { BrowseView, EditView, ImageView, NewView, PermissionView, VersionView } import WopiPage from '../wopi-page' import { ContentInfo } from './ContentInfo' +const requiredGridLoadFields: ODataFieldParameter = [ + 'Id', + 'ParentId', + 'Path', + 'Name', + 'DisplayName', + 'Type', + 'Icon', + 'IsFolder', + 'IsFile', + 'Actions', + 'CreatedBy', + 'CreationDate', + 'ModifiedBy', + 'ModificationDate', + 'Index', + 'Locked', +] + +const getGridLoadChildrenSettings = (colDef: ColDef[]): ODataParams => { + const selectFields = new Set(requiredGridLoadFields) + + colDef.forEach((columnDefinition) => { + if (columnDefinition.field && columnDefinition.field !== '0') { + selectFields.add(columnDefinition.field as keyof GenericContent) + } + }) + + return { + orderby: [['DisplayName', 'asc']], + select: Array.from(selectFields), + expand: ['CreatedBy', 'ModifiedBy'], + onlyselectList: true, + } +} + const useStyles = makeStyles((theme) => createStyles({ breadcrumbsWrapper: { @@ -186,6 +222,10 @@ export function Explore({ const pathFromUrl = useQuery().get('path') const snRoute = useSnRoute() const activeAction = snRoute.match!.params.action + const currentChildrenLoadSettings = useMemo( + () => loadChildrenSettings || getGridLoadChildrenSettings(colDef), + [colDef, loadChildrenSettings], + ) const onActivateItemOverride = async (activeItem: GenericContent) => { const expandedItem = await repository.load({ idOrPath: activeItem.Id, @@ -273,7 +313,9 @@ export function Explore({ } return ( - + diff --git a/apps/sensenet/src/components/grid/Grid.tsx b/apps/sensenet/src/components/grid/Grid.tsx index 59afe822a..7f6d35f86 100644 --- a/apps/sensenet/src/components/grid/Grid.tsx +++ b/apps/sensenet/src/components/grid/Grid.tsx @@ -1,6 +1,6 @@ -import { debounce, LinearProgress, useTheme } from '@material-ui/core' +import { CircularProgress, debounce, LinearProgress, Typography, useTheme } from '@material-ui/core' import { GenericContent } from '@sensenet/default-content-types' -import { CurrentChildrenContext, CurrentContentContext } from '@sensenet/hooks-react' +import { CurrentChildrenContext, CurrentChildrenIsLoadingContext, CurrentContentContext } from '@sensenet/hooks-react' import { CellContextMenuEvent, ColDef, @@ -11,8 +11,8 @@ import { SelectionChangedEvent, } from 'ag-grid-community' import { AgGridReact } from 'ag-grid-react' -import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' -import { useSelectionService } from '../../hooks' +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useLocalization, useSelectionService } from '../../hooks' import { ContentContextMenu } from '../context-menu/content-context-menu' import { DropFileArea } from '../DropFileArea' import { GridProps } from './Props/GridProps' @@ -23,16 +23,21 @@ const SMALL_SCREEN_COL_FILTER = ['Id', 'Actions'] export function Grid(props: GridProps) { const { isGridLoading, setIsGridLoading } = useGridLoading() const selectionService = useSelectionService() + const localization = useLocalization().common const parentContent = useContext(CurrentContentContext) - const children = (useContext(CurrentChildrenContext) as GenericContent[]).sort((a, b) => { - const aIsFolder = a.Type?.toLowerCase().includes('folder') ?? false - const bIsFolder = b.Type?.toLowerCase().includes('folder') ?? false + const currentChildren = useContext(CurrentChildrenContext) as GenericContent[] + const isCurrentChildrenLoading = useContext(CurrentChildrenIsLoadingContext) + const children = useMemo(() => { + return [...currentChildren].sort((a, b) => { + const aIsFolder = a.Type?.toLowerCase().includes('folder') ?? false + const bIsFolder = b.Type?.toLowerCase().includes('folder') ?? false - if (aIsFolder && !bIsFolder) return -1 - if (!aIsFolder && bIsFolder) return 1 + if (aIsFolder && !bIsFolder) return -1 + if (!aIsFolder && bIsFolder) return 1 - return (a.DisplayName ?? '').localeCompare(b.DisplayName ?? '') - }) + return (a.DisplayName ?? '').localeCompare(b.DisplayName ?? '') + }) + }, [currentChildren]) const theme = useTheme() const [contextMenuItem, setContextMenuItem] = useState(null) @@ -203,9 +208,11 @@ export function Grid(props: GridProps } }, [props.gridKey]) + const showGridLoading = isGridLoading || isCurrentChildrenLoading + return ( - - {isGridLoading && ( + + {showGridLoading && ( (props: GridProps }} /> )} -
+
(props: GridProps suppressNoRowsOverlay={true} />
+ {showGridLoading && ( +
+
+ + {localization.loadingContent} +
+
+ )} {contextMenuItem && ( = ({ children }) => { - const [isGridLoading, setIsGridLoading] = useState(true) + const [isGridLoading, setIsGridLoading] = useState(false) return ( {children} diff --git a/apps/sensenet/src/components/tree/Contexts/ExpandedItemsProvider.tsx b/apps/sensenet/src/components/tree/Contexts/ExpandedItemsProvider.tsx index 42c61d38d..c93653ce1 100644 --- a/apps/sensenet/src/components/tree/Contexts/ExpandedItemsProvider.tsx +++ b/apps/sensenet/src/components/tree/Contexts/ExpandedItemsProvider.tsx @@ -54,6 +54,7 @@ const ExpandedItemsProvider = ({ children }: { children: ReactNode }) => { path, oDataOptions: { select: ['Id', 'Path', 'Name', 'DisplayName', 'Type', 'Actions', 'Icon', 'ParentId'], + filter: 'IsFolder eq true', onlyselectList: true, }, }) diff --git a/apps/sensenet/src/services/EventService.ts b/apps/sensenet/src/services/EventService.ts index 4417229ba..2d71de72a 100644 --- a/apps/sensenet/src/services/EventService.ts +++ b/apps/sensenet/src/services/EventService.ts @@ -10,6 +10,7 @@ export class EventService { public static storageDebounceInterval = 1000 public static storageKey = `sn-app-eventservice-events` + private static maxStoredEventLogLength = 500000 public dismiss(entry: EventLogEntry) { this.values.setValue( @@ -34,9 +35,56 @@ export class EventService { private storeChanges = debounce(() => { const values = [...this.values.getValue()] const entries = values.slice(values.length - this.personalSettings.effectiveValue.getValue().eventLogSize) - localStorage.setItem(EventService.storageKey, JSON.stringify(entries)) + EventService.storeEntries(entries) }, EventService.storageDebounceInterval) + private static storeEntries(entries: Array>) { + try { + localStorage.setItem(EventService.storageKey, JSON.stringify(entries)) + } catch { + try { + localStorage.setItem(EventService.storageKey, JSON.stringify(EventService.getCompactEntries(entries))) + } catch { + localStorage.removeItem(EventService.storageKey) + } + } + } + + private static getStoredEntries(): Array> { + const storedEntries = localStorage.getItem(EventService.storageKey) + + if (!storedEntries) { + return [] + } + + if (storedEntries.length > EventService.maxStoredEventLogLength) { + localStorage.removeItem(EventService.storageKey) + return [] + } + + try { + const parsedEntries = JSON.parse(storedEntries) + + return Array.isArray(parsedEntries) ? EventService.getCompactEntries(parsedEntries) : [] + } catch { + localStorage.removeItem(EventService.storageKey) + return [] + } + } + + private static getCompactEntries(entries: Array>) { + return entries.map((entry) => ({ + ...entry, + data: { + added: entry.data?.added, + digestMessage: entry.data?.digestMessage, + guid: entry.data?.guid, + isDismissed: entry.data?.isDismissed, + multiple: entry.data?.multiple, + }, + })) + } + public add(...notifications: Array>) { // const newValues = this.values.getValue().push()) this.values.setValue([ @@ -54,7 +102,7 @@ export class EventService { } public values: ObservableValue>> = new ObservableValue( - JSON.parse(localStorage.getItem(EventService.storageKey) || '[]') || [], + EventService.getStoredEntries(), ) public notificationValues: ObservableValue<{ [key: string]: Array> }> = new ObservableValue( diff --git a/packages/sn-hooks-react/src/context/current-children.tsx b/packages/sn-hooks-react/src/context/current-children.tsx index 19a629f8f..1ecfcdcc2 100644 --- a/packages/sn-hooks-react/src/context/current-children.tsx +++ b/packages/sn-hooks-react/src/context/current-children.tsx @@ -12,6 +12,9 @@ import { LoadSettingsContext } from './load-settings' export const CurrentChildrenContext = createContext([]) CurrentChildrenContext.displayName = 'CurrentChildrenContext' +export const CurrentChildrenIsLoadingContext = createContext(false) +CurrentChildrenIsLoadingContext.displayName = 'CurrentChildrenIsLoadingContext' + export interface CurrentChildrenProviderProps { loadSettings?: ODataParams alwaysRefresh?: boolean @@ -25,6 +28,7 @@ export interface CurrentChildrenProviderProps { export const CurrentChildrenProvider: FunctionComponent = (props) => { const currentContent = useContext(CurrentContentContext) const [children, setChildren] = useState([]) + const [isLoading, setIsLoading] = useState(false) const alwaysRefresh = props.alwaysRefresh || currentContent.Type === 'SmartFolder' @@ -38,23 +42,41 @@ export const CurrentChildrenProvider: FunctionComponent { const ac = new AbortController() + let isCurrentRequest = true ;(async () => { - if (currentContent.Path) { - try { - const childrenResult = await repo.loadCollection({ - path: currentContent.Path, - requestInit: { signal: ac.signal }, - oDataOptions: deepMerge(loadSettings.loadChildrenSettings, props.loadSettings), - }) + if (!currentContent.Path) { + setChildren([]) + setIsLoading(false) + return + } + + setError(undefined) + setIsLoading(true) + + try { + const childrenResult = await repo.loadCollection({ + path: currentContent.Path, + requestInit: { signal: ac.signal }, + oDataOptions: deepMerge(loadSettings.loadChildrenSettings, props.loadSettings), + }) + + if (isCurrentRequest) { setChildren(childrenResult.d.results) - } catch (err) { - if (!ac.signal.aborted) { - setError(err) - } + } + } catch (err) { + if (isCurrentRequest && !ac.signal.aborted) { + setError(err) + } + } finally { + if (isCurrentRequest) { + setIsLoading(false) } } })() - return () => ac.abort() + return () => { + isCurrentRequest = false + ac.abort() + } }, [currentContent.Path, loadSettings.loadChildrenSettings, props.loadSettings, repo, reloadToken]) useEffect(() => { @@ -152,5 +174,9 @@ export const CurrentChildrenProvider: FunctionComponent{props.children} + return ( + + {props.children} + + ) } From 41894bf0bdf74590f0c6ea5a7d967023cc29e792 Mon Sep 17 00:00:00 2001 From: maros Date: Fri, 15 May 2026 15:18:21 +0200 Subject: [PATCH 04/10] feat: add ZIP download functionality for selected items with success and error handling --- apps/sensenet/src/components/BatchActions.tsx | 65 ++- apps/sensenet/src/localization/default.ts | 3 + apps/sensenet/src/localization/hungarian.ts | 3 + apps/sensenet/src/services/zip-download.ts | 398 ++++++++++++++++++ 4 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 apps/sensenet/src/services/zip-download.ts diff --git a/apps/sensenet/src/components/BatchActions.tsx b/apps/sensenet/src/components/BatchActions.tsx index c611c1f15..be59a491e 100644 --- a/apps/sensenet/src/components/BatchActions.tsx +++ b/apps/sensenet/src/components/BatchActions.tsx @@ -1,12 +1,14 @@ -import { createStyles, IconButton, makeStyles, Theme, Tooltip } from '@material-ui/core' +import { CircularProgress, createStyles, IconButton, makeStyles, Theme, Tooltip } from '@material-ui/core' +import ArchiveIcon from '@material-ui/icons/Archive' import DeleteIcon from '@material-ui/icons/Delete' import FileCopyIcon from '@material-ui/icons/FileCopy' import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined' -import GetAppIcon from '@material-ui/icons/GetApp' -import { CurrentContentContext } from '@sensenet/hooks-react' +import TableChartIcon from '@material-ui/icons/TableChart' +import { CurrentContentContext, useLogger, useRepository } from '@sensenet/hooks-react' import React, { useContext, useEffect, useState } from 'react' import { useGlobalStyles } from '../globalStyles' import { useLocalization, useSelectionService } from '../hooks' +import { downloadContentsAsZip } from '../services/zip-download' import { CsvExportDialog } from './CsvExportDialog' import { useDialog } from './dialogs' @@ -38,8 +40,11 @@ export const BatchActions = () => { const globalClasses = useGlobalStyles() const classes = useStyles() const { openDialog } = useDialog() + const repository = useRepository() + const logger = useLogger('BatchActions') const [selected, setSelected] = useState(selectionService.selection.getValue()) const [isExportDialogOpen, setIsExportDialogOpen] = useState(false) + const [isZipDownloading, setIsZipDownloading] = useState(false) const parent = useContext(CurrentContentContext) useEffect(() => { @@ -52,6 +57,46 @@ export const BatchActions = () => { } }, [selectionService.selection]) + const downloadSelectedContentAsZip = async () => { + if (!selected.length || isZipDownloading) { + return + } + + setIsZipDownloading(true) + + try { + const result = await downloadContentsAsZip({ repository, contents: selected, parent }) + + logger.information({ + message: localization.batchActions.downloadZipSuccess + .replace('{0}', String(result.fileCount)) + .replace('{1}', String(result.folderCount)), + data: { + relatedRepository: repository.configuration.repositoryUrl, + details: { + fileCount: result.fileCount, + folderCount: result.folderCount, + skippedContentCount: result.skippedContentCount, + fileName: result.fileName, + }, + }, + }) + } catch (error) { + logger.error({ + message: localization.batchActions.downloadZipError, + data: { + error, + relatedRepository: repository.configuration.repositoryUrl, + details: { + selectedContentCount: selected.length, + }, + }, + }) + } finally { + setIsZipDownloading(false) + } + } + return (
@@ -62,7 +107,7 @@ export const BatchActions = () => { aria-label="export-csv" disabled={selected.length === 0} onClick={() => setIsExportDialogOpen(true)}> - + @@ -72,6 +117,18 @@ export const BatchActions = () => { parent={parent} onClose={() => setIsExportDialogOpen(false)} /> + + + + {isZipDownloading ? : } + + + { + const table = new Uint32Array(256) + + for (let index = 0; index < table.length; index++) { + let value = index + + for (let bit = 0; bit < 8; bit++) { + value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 + } + + table[index] = value >>> 0 + } + + return table +})() + +const getCrc32 = (data: Uint8Array) => { + let crc = 0xffffffff + + for (const byte of data) { + crc = (crc >>> 8) ^ crc32Table[(crc ^ byte) & 0xff] + } + + return (crc ^ 0xffffffff) >>> 0 +} + +const getDosTime = (date: Date) => { + return (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2) +} + +const getDosDate = (date: Date) => { + return ((date.getFullYear() - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate() +} + +const getContentDate = (content: GenericContent) => { + const dateValue = content.ModificationDate || content.CreationDate + const date = dateValue ? new Date(dateValue) : new Date() + + return Number.isNaN(date.getTime()) ? new Date() : date +} + +const sanitizeZipPathSegment = (value: string | number | undefined) => { + const segment = String(value || 'content') + .replace(/[\\/:*?"<>|\x00-\x1f]/g, '_') + .trim() + + return segment || 'content' +} + +const getContentZipPathSegment = (content: GenericContent) => { + return sanitizeZipPathSegment(content.Name || content.DisplayName || content.Id) +} + +const addDuplicateSuffix = (zipPath: string, index: number, isDirectory: boolean) => { + const pathWithoutTrailingSlash = isDirectory ? zipPath.replace(/\/$/, '') : zipPath + const slashIndex = pathWithoutTrailingSlash.lastIndexOf('/') + const parentPath = slashIndex >= 0 ? pathWithoutTrailingSlash.slice(0, slashIndex + 1) : '' + const fileName = slashIndex >= 0 ? pathWithoutTrailingSlash.slice(slashIndex + 1) : pathWithoutTrailingSlash + const dotIndex = !isDirectory ? fileName.lastIndexOf('.') : -1 + + if (dotIndex > 0) { + return `${parentPath}${fileName.slice(0, dotIndex)}(${index})${fileName.slice(dotIndex)}` + } + + return `${parentPath}${fileName}(${index})${isDirectory ? '/' : ''}` +} + +const reserveUniqueZipPath = (usedZipPaths: Set, zipPath: string, isDirectory: boolean) => { + const normalizedZipPath = isDirectory ? `${zipPath.replace(/\/$/, '')}/` : zipPath + let nextZipPath = normalizedZipPath + let duplicateIndex = 1 + + while (usedZipPaths.has(nextZipPath)) { + nextZipPath = addDuplicateSuffix(normalizedZipPath, duplicateIndex, isDirectory) + duplicateIndex += 1 + } + + usedZipPaths.add(nextZipPath) + + return nextZipPath +} + +const createZipEntry = (path: string, data: Uint8Array, date: Date, isDirectory = false): ZipEntry => ({ + path, + data, + date, + isDirectory, +}) + +const loadAllChildren = async (repository: Repository, path: string) => { + const children: GenericContent[] = [] + let skip = 0 + + while (true) { + const result = await repository.loadCollection({ + path, + oDataOptions: { + select: ['Id', 'Path', 'Name', 'DisplayName', 'Type', 'IsFile', 'IsFolder', 'CreationDate', 'ModificationDate'], + top: contentLoadBatchSize, + skip, + orderby: 'Name', + }, + }) + const loadedChildren = result.d.results + + children.push(...loadedChildren) + + if (loadedChildren.length < contentLoadBatchSize) { + break + } + + skip += contentLoadBatchSize + } + + return children +} + +const loadContentKindFields = async (repository: Repository, content: GenericContent) => { + if (content.IsFile !== undefined || content.IsFolder !== undefined) { + return content + } + + const result = await repository.load({ + idOrPath: content.Id, + oDataOptions: { + select: ['Id', 'Path', 'Name', 'DisplayName', 'Type', 'IsFile', 'IsFolder', 'CreationDate', 'ModificationDate'], + }, + }) + + return { ...content, ...result.d } +} + +const fetchFileBytes = async (repository: Repository, content: GenericContent) => { + const headers = new Headers() + + if (repository.configuration.token) { + headers.set('Authorization', `Bearer ${repository.configuration.token}`) + } + + const response = await fetch( + `${repository.configuration.repositoryUrl}${content.Path}?download&t=${Date.now()}`, + headers.has('Authorization') ? { headers } : undefined, + ) + + if (!response.ok) { + throw new Error(`Failed to download ${content.Path}: ${response.status} ${response.statusText}`) + } + + return new Uint8Array(await response.arrayBuffer()) +} + +const createLocalFileHeader = (entry: ZipEntry, fileNameBytes: Uint8Array, crc32: number) => { + const header = new Uint8Array(30 + fileNameBytes.length) + const view = new DataView(header.buffer) + + view.setUint32(0, 0x04034b50, true) + view.setUint16(4, zipVersionNeeded, true) + view.setUint16(6, zipUtf8Flag, true) + view.setUint16(8, 0, true) + view.setUint16(10, getDosTime(entry.date), true) + view.setUint16(12, getDosDate(entry.date), true) + view.setUint32(14, crc32, true) + view.setUint32(18, entry.data.length, true) + view.setUint32(22, entry.data.length, true) + view.setUint16(26, fileNameBytes.length, true) + view.setUint16(28, 0, true) + header.set(fileNameBytes, 30) + + return header +} + +const createCentralDirectoryHeader = ( + entry: ZipEntry, + fileNameBytes: Uint8Array, + crc32: number, + localHeaderOffset: number, +) => { + const header = new Uint8Array(46 + fileNameBytes.length) + const view = new DataView(header.buffer) + + view.setUint32(0, 0x02014b50, true) + view.setUint16(4, zipVersionNeeded, true) + view.setUint16(6, zipVersionNeeded, true) + view.setUint16(8, zipUtf8Flag, true) + view.setUint16(10, 0, true) + view.setUint16(12, getDosTime(entry.date), true) + view.setUint16(14, getDosDate(entry.date), true) + view.setUint32(16, crc32, true) + view.setUint32(20, entry.data.length, true) + view.setUint32(24, entry.data.length, true) + view.setUint16(28, fileNameBytes.length, true) + view.setUint16(30, 0, true) + view.setUint16(32, 0, true) + view.setUint16(34, 0, true) + view.setUint16(36, 0, true) + view.setUint32(38, entry.isDirectory ? 0x00100000 : 0, true) + view.setUint32(42, localHeaderOffset, true) + header.set(fileNameBytes, 46) + + return header +} + +const createEndOfCentralDirectory = ( + entryCount: number, + centralDirectorySize: number, + centralDirectoryOffset: number, +) => { + const header = new Uint8Array(22) + const view = new DataView(header.buffer) + + view.setUint32(0, 0x06054b50, true) + view.setUint16(4, 0, true) + view.setUint16(6, 0, true) + view.setUint16(8, entryCount, true) + view.setUint16(10, entryCount, true) + view.setUint32(12, centralDirectorySize, true) + view.setUint32(16, centralDirectoryOffset, true) + view.setUint16(20, 0, true) + + return header +} + +const createZipBlob = (entries: ZipEntry[]) => { + if (entries.length > 0xffff) { + throw new Error('Too many ZIP entries. ZIP64 is not supported by this exporter.') + } + + const localFileParts: Uint8Array[] = [] + const centralDirectoryParts: Uint8Array[] = [] + let localFileOffset = 0 + + entries.forEach((entry) => { + const fileNameBytes = textEncoder.encode(entry.path) + const crc32 = getCrc32(entry.data) + const localFileHeader = createLocalFileHeader(entry, fileNameBytes, crc32) + const centralDirectoryHeader = createCentralDirectoryHeader(entry, fileNameBytes, crc32, localFileOffset) + + localFileParts.push(localFileHeader, entry.data) + centralDirectoryParts.push(centralDirectoryHeader) + localFileOffset += localFileHeader.length + entry.data.length + }) + + const centralDirectoryOffset = localFileOffset + const centralDirectorySize = centralDirectoryParts.reduce((totalSize, part) => totalSize + part.length, 0) + const endOfCentralDirectory = createEndOfCentralDirectory( + entries.length, + centralDirectorySize, + centralDirectoryOffset, + ) + + return new Blob([...localFileParts, ...centralDirectoryParts, endOfCentralDirectory], { type: 'application/zip' }) +} + +const downloadBlob = (blob: Blob, fileName: string) => { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + + link.href = url + link.download = fileName + link.style.display = 'none' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +export const getZipDownloadFileName = (contents: GenericContent[], parent?: GenericContent) => { + const parentName = sanitizeZipPathSegment(parent?.Name || 'sensenet-content') + const timeStamp = new Date().toISOString().replace(/[:.]/g, '-') + const suffix = + contents.length === 1 ? sanitizeZipPathSegment(contents[0].Name || contents[0].Id) : `${contents.length}-items` + + return `${parentName}-${suffix}-${timeStamp}.zip` +} + +export const downloadContentsAsZip = async ({ + repository, + contents, + parent, +}: ZipDownloadOptions): Promise => { + const zipEntries: ZipEntry[] = [] + const filesToDownload: DownloadableContent[] = [] + const visitedContentIds = new Set() + const usedZipPaths = new Set() + let folderCount = 0 + let skippedContentCount = 0 + + const collectContent = async (content: GenericContent, parentZipPath = ''): Promise => { + if (visitedContentIds.has(content.Id)) { + return + } + + const contentWithKind = await loadContentKindFields(repository, content) + + if (visitedContentIds.has(contentWithKind.Id)) { + return + } + + visitedContentIds.add(contentWithKind.Id) + + const zipPath = parentZipPath + ? `${parentZipPath}/${getContentZipPathSegment(contentWithKind)}` + : getContentZipPathSegment(contentWithKind) + + if (contentWithKind.IsFolder) { + const uniqueDirectoryPath = reserveUniqueZipPath(usedZipPaths, zipPath, true) + zipEntries.push(createZipEntry(uniqueDirectoryPath, new Uint8Array(), getContentDate(contentWithKind), true)) + folderCount += 1 + + const children = await loadAllChildren(repository, contentWithKind.Path) + + for (const child of children) { + await collectContent(child, uniqueDirectoryPath.replace(/\/$/, '')) + } + + return + } + + if (contentWithKind.IsFile) { + filesToDownload.push({ + content: contentWithKind, + zipPath: reserveUniqueZipPath(usedZipPaths, zipPath, false), + }) + + return + } + + skippedContentCount += 1 + } + + for (const content of contents) { + await collectContent(content) + } + + for (let startIndex = 0; startIndex < filesToDownload.length; startIndex += fileDownloadBatchSize) { + const batch = filesToDownload.slice(startIndex, startIndex + fileDownloadBatchSize) + const downloadedFiles = await Promise.all( + batch.map(async (file) => ({ + file, + bytes: await fetchFileBytes(repository, file.content), + })), + ) + + downloadedFiles.forEach(({ file, bytes }) => { + zipEntries.push(createZipEntry(file.zipPath, bytes, getContentDate(file.content))) + }) + } + + if (!zipEntries.length) { + throw new Error('There is no downloadable content in the current selection.') + } + + const fileName = getZipDownloadFileName(contents, parent) + const zipBlob = createZipBlob(zipEntries) + + downloadBlob(zipBlob, fileName) + + return { + fileCount: filesToDownload.length, + folderCount, + skippedContentCount, + fileName, + } +} From ee07cb7e0836f74e0295cb7479cb9ae40ea083d5 Mon Sep 17 00:00:00 2001 From: maros Date: Fri, 15 May 2026 20:50:46 +0200 Subject: [PATCH 05/10] feat: add FormPropertiesEditor component with localization support and JSON handling --- .../field-controls/form-properties-editor.tsx | 613 ++++++++++++++++++ .../src/components/field-controls/index.ts | 1 + .../src/components/react-control-mapper.ts | 6 + apps/sensenet/src/localization/default.ts | 23 + apps/sensenet/src/localization/hungarian.ts | 23 + 5 files changed, 666 insertions(+) create mode 100644 apps/sensenet/src/components/field-controls/form-properties-editor.tsx diff --git a/apps/sensenet/src/components/field-controls/form-properties-editor.tsx b/apps/sensenet/src/components/field-controls/form-properties-editor.tsx new file mode 100644 index 000000000..397df8921 --- /dev/null +++ b/apps/sensenet/src/components/field-controls/form-properties-editor.tsx @@ -0,0 +1,613 @@ +import { + Button, + Checkbox, + createStyles, + Divider, + FormControlLabel, + Grid, + IconButton, + makeStyles, + MenuItem, + Paper, + Tab, + Tabs, + TextField, + Theme, + Typography, +} from '@material-ui/core' +import AddIcon from '@material-ui/icons/Add' +import DeleteIcon from '@material-ui/icons/Delete' +import { ReactClientFieldSetting } from '@sensenet/controls-react' +import { LongTextFieldSetting } from '@sensenet/default-content-types' +import React, { useEffect, useMemo, useState } from 'react' +import { useLocalization } from '../../hooks' + +type FormFieldConfig = { + fieldName?: string + displayName?: string + placeholder?: string + required?: boolean + formatErrorMessage?: string + type?: string + minRows?: number + [key: string]: unknown +} + +type PrivacyPolicyConfig = { + label?: string + tooltip?: string + modalCheckboxLabel?: string + acceptLabel?: string + declineLabel?: string + [key: string]: unknown +} + +type FormPropertiesConfig = { + formTitle?: string + recaptcha?: boolean + formDesc?: string + submitBtn?: string + responseTitle?: string + responseMessage?: string + fieldErrorMessage?: string + requiredMessage?: string + requiredErrorMessage?: string + captchaErrorMessage?: string + errorMessage?: string + rows?: FormFieldConfig[][] + privacyPolicy?: PrivacyPolicyConfig + [key: string]: unknown +} + +type FormPropertiesTextFieldKey = + | 'formTitle' + | 'formDesc' + | 'submitBtn' + | 'responseTitle' + | 'responseMessage' + | 'fieldErrorMessage' + | 'requiredMessage' + | 'requiredErrorMessage' + | 'captchaErrorMessage' + | 'errorMessage' + +type PrivacyPolicyFieldKey = 'label' | 'tooltip' | 'modalCheckboxLabel' | 'acceptLabel' | 'declineLabel' + +const defaultFormProperties: FormPropertiesConfig = { + recaptcha: false, + rows: [], + privacyPolicy: {}, +} + +const textFieldKeys: FormPropertiesTextFieldKey[] = [ + 'formTitle', + 'formDesc', + 'submitBtn', + 'responseTitle', + 'responseMessage', + 'fieldErrorMessage', + 'requiredMessage', + 'requiredErrorMessage', + 'captchaErrorMessage', + 'errorMessage', +] + +const privacyPolicyKeys: PrivacyPolicyFieldKey[] = [ + 'label', + 'tooltip', + 'modalCheckboxLabel', + 'acceptLabel', + 'declineLabel', +] + +const formFieldTypeOptions = ['text', 'email', 'tel', 'textarea', 'number', 'checkbox', 'date', 'select'] + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + marginTop: theme.spacing(1), + }, + section: { + borderRadius: 4, + marginTop: theme.spacing(2), + padding: theme.spacing(2), + }, + sectionHeader: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacing(1), + }, + fieldCard: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: 4, + marginTop: theme.spacing(1), + padding: theme.spacing(2), + }, + fieldHeader: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacing(1), + }, + jsonEditor: { + fontFamily: 'monospace', + }, + errorText: { + color: theme.palette.error.main, + marginTop: theme.spacing(1), + }, + actions: { + display: 'flex', + gap: theme.spacing(1), + justifyContent: 'flex-end', + marginTop: theme.spacing(2), + }, + }), +) + +const formatLabel = (value: string) => { + return value + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (firstLetter) => firstLetter.toUpperCase()) + .trim() +} + +const parseFormProperties = (rawValue: string): { value?: FormPropertiesConfig; error?: string } => { + if (!rawValue.trim()) { + return { value: { ...defaultFormProperties } } + } + + try { + const parsedValue = JSON.parse(rawValue) as FormPropertiesConfig + + return { + value: { + ...defaultFormProperties, + ...parsedValue, + rows: Array.isArray(parsedValue.rows) ? parsedValue.rows : [], + privacyPolicy: parsedValue.privacyPolicy || {}, + }, + } + } catch (error) { + return { error: error instanceof Error ? error.message : 'Invalid JSON' } + } +} + +const stringifyFormProperties = (value: FormPropertiesConfig) => JSON.stringify(value, null, 2) + +export const FormPropertiesEditor: React.FC> = (props) => { + const classes = useStyles() + const localization = useLocalization().formPropertiesEditor + const initialRawValue = useMemo( + () => props.fieldValue || (props.actionName === 'new' && props.settings.DefaultValue) || '{}', + [props.actionName, props.fieldValue, props.settings.DefaultValue], + ) + const initialParsedValue = useMemo(() => parseFormProperties(initialRawValue), [initialRawValue]) + const [activeTab, setActiveTab] = useState<'visual' | 'json'>(initialParsedValue.error ? 'json' : 'visual') + const [rawValue, setRawValue] = useState(initialRawValue) + const [formProperties, setFormProperties] = useState( + initialParsedValue.value || { ...defaultFormProperties }, + ) + const [parseError, setParseError] = useState(initialParsedValue.error) + const isReadonly = Boolean(props.settings.ReadOnly) + + useEffect(() => { + setRawValue(initialRawValue) + setParseError(initialParsedValue.error) + + if (initialParsedValue.value) { + setFormProperties(initialParsedValue.value) + } + + setActiveTab(initialParsedValue.error ? 'json' : 'visual') + }, [initialParsedValue.error, initialParsedValue.value, initialRawValue]) + + const emitFormProperties = (nextValue: FormPropertiesConfig) => { + const nextRawValue = stringifyFormProperties(nextValue) + + setFormProperties(nextValue) + setRawValue(nextRawValue) + setParseError(undefined) + props.fieldOnChange?.(props.settings.Name, nextRawValue) + } + + const updateRootField = (fieldName: FormPropertiesTextFieldKey | 'recaptcha', value: string | boolean) => { + emitFormProperties({ + ...formProperties, + [fieldName]: value, + }) + } + + const updatePrivacyPolicyField = (fieldName: PrivacyPolicyFieldKey, value: string) => { + emitFormProperties({ + ...formProperties, + privacyPolicy: { + ...(formProperties.privacyPolicy || {}), + [fieldName]: value, + }, + }) + } + + const updateFormField = (rowIndex: number, fieldIndex: number, fieldPatch: Partial) => { + const rows = (formProperties.rows || []).map((row, currentRowIndex) => + currentRowIndex === rowIndex + ? row.map((field, currentFieldIndex) => + currentFieldIndex === fieldIndex ? { ...field, ...fieldPatch } : field, + ) + : row, + ) + + emitFormProperties({ + ...formProperties, + rows, + }) + } + + const updateFormFieldMinRows = (rowIndex: number, fieldIndex: number, value: string) => { + const nextMinRows = Number(value) + const rows = (formProperties.rows || []).map((row, currentRowIndex) => + currentRowIndex === rowIndex + ? row.map((field, currentFieldIndex) => { + if (currentFieldIndex !== fieldIndex) { + return field + } + + const nextField = { ...field } + + if (value && Number.isFinite(nextMinRows)) { + nextField.minRows = nextMinRows + } else { + delete nextField.minRows + } + + return nextField + }) + : row, + ) + + emitFormProperties({ + ...formProperties, + rows, + }) + } + + const addRow = () => { + emitFormProperties({ + ...formProperties, + rows: [...(formProperties.rows || []), []], + }) + } + + const removeRow = (rowIndex: number) => { + emitFormProperties({ + ...formProperties, + rows: (formProperties.rows || []).filter((_, currentRowIndex) => currentRowIndex !== rowIndex), + }) + } + + const addField = (rowIndex: number) => { + const rows = (formProperties.rows || []).map((row, currentRowIndex) => + currentRowIndex === rowIndex + ? [ + ...row, + { + fieldName: '', + displayName: '', + placeholder: '', + required: false, + type: 'text', + }, + ] + : row, + ) + + emitFormProperties({ + ...formProperties, + rows, + }) + } + + const removeField = (rowIndex: number, fieldIndex: number) => { + const rows = (formProperties.rows || []).map((row, currentRowIndex) => + currentRowIndex === rowIndex ? row.filter((_, currentFieldIndex) => currentFieldIndex !== fieldIndex) : row, + ) + + emitFormProperties({ + ...formProperties, + rows, + }) + } + + const handleRawValueChange = (event: React.ChangeEvent) => { + const nextRawValue = event.target.value + const parsedValue = parseFormProperties(nextRawValue) + + setRawValue(nextRawValue) + setParseError(parsedValue.error) + + if (parsedValue.value) { + setFormProperties(parsedValue.value) + } + + props.fieldOnChange?.(props.settings.Name, nextRawValue) + } + + const formatRawValue = () => { + const parsedValue = parseFormProperties(rawValue) + + if (parsedValue.value) { + emitFormProperties(parsedValue.value) + } else { + setParseError(parsedValue.error) + } + } + + const renderJsonEditor = () => ( + <> + + {parseError && ( + + {localization.invalidJson}: {parseError} + + )} +
+ +
+ + ) + + const renderVisualEditor = () => { + const rows = formProperties.rows || [] + + if (parseError) { + return ( + + {localization.fixJsonFirst} + + ) + } + + return ( + <> + + {localization.generalSettings} + + {textFieldKeys.map((fieldName) => ( + + updateRootField(fieldName, event.target.value)} + value={(formProperties[fieldName] as string) || ''} + variant="outlined" + /> + + ))} + + updateRootField('recaptcha', event.target.checked)} + /> + } + label={localization.recaptcha} + /> + + + + + +
+ {localization.rows} + +
+ {rows.map((row, rowIndex) => ( + +
+ + {localization.row} {rowIndex + 1} + +
+ + removeRow(rowIndex)} size="small"> + + +
+
+ {!row.length && ( + + {localization.emptyRow} + + )} + {row.map((field, fieldIndex) => { + const typeOptions = formFieldTypeOptions.includes(field.type || '') + ? formFieldTypeOptions + : [field.type || 'text', ...formFieldTypeOptions] + + return ( +
+
+ + {field.fieldName || `${localization.field} ${fieldIndex + 1}`} + + removeField(rowIndex, fieldIndex)} size="small"> + + +
+ + + updateFormField(rowIndex, fieldIndex, { fieldName: event.target.value })} + value={field.fieldName || ''} + variant="outlined" + /> + + + + updateFormField(rowIndex, fieldIndex, { displayName: event.target.value }) + } + value={field.displayName || ''} + variant="outlined" + /> + + + updateFormField(rowIndex, fieldIndex, { type: event.target.value })} + select + value={field.type || 'text'} + variant="outlined"> + {typeOptions.map((typeOption) => ( + + {typeOption} + + ))} + + + + + updateFormField(rowIndex, fieldIndex, { placeholder: event.target.value }) + } + value={field.placeholder || ''} + variant="outlined" + /> + + + + updateFormField(rowIndex, fieldIndex, { formatErrorMessage: event.target.value }) + } + value={field.formatErrorMessage || ''} + variant="outlined" + /> + + + + updateFormField(rowIndex, fieldIndex, { required: event.target.checked }) + } + /> + } + label={localization.required} + /> + + {field.type === 'textarea' && ( + + updateFormFieldMinRows(rowIndex, fieldIndex, event.target.value)} + type="number" + value={field.minRows ?? ''} + variant="outlined" + /> + + )} + +
+ ) + })} +
+ ))} +
+ + + {localization.privacyPolicy} + + {privacyPolicyKeys.map((fieldName) => ( + + updatePrivacyPolicyField(fieldName, event.target.value)} + value={String((formProperties.privacyPolicy || {})[fieldName] || '')} + variant="outlined" + /> + + ))} + + + + ) + } + + if (props.actionName === 'browse') { + return ( +
+ + {`${props.settings.DisplayName} (${props.settings.Name})`} + +
{rawValue}
+
+ ) + } + + return ( +
+ + {`${props.settings.DisplayName} (${props.settings.Name})`} + + {props.settings.Description && !props.hideDescription && ( + + {props.settings.Description} + + )} + setActiveTab(value)} textColor="primary" value={activeTab}> + + + + + {activeTab === 'visual' ? renderVisualEditor() : renderJsonEditor()} +
+ ) +} diff --git a/apps/sensenet/src/components/field-controls/index.ts b/apps/sensenet/src/components/field-controls/index.ts index 3c7dbf03c..2f53f74c5 100644 --- a/apps/sensenet/src/components/field-controls/index.ts +++ b/apps/sensenet/src/components/field-controls/index.ts @@ -8,3 +8,4 @@ export * from './webhook-headers' export * from './webhook-payload' export * from './html-editor' export * from './tinymce-editor' +export * from './form-properties-editor' diff --git a/apps/sensenet/src/components/react-control-mapper.ts b/apps/sensenet/src/components/react-control-mapper.ts index f835ba408..abfb02d89 100644 --- a/apps/sensenet/src/components/react-control-mapper.ts +++ b/apps/sensenet/src/components/react-control-mapper.ts @@ -30,7 +30,13 @@ export const reactControlMapper = (repository: Repository) => { } }) .setupFieldSettingDefault('LongTextFieldSetting', (setting) => { + if (setting.Name === 'FormPropertiesJSON') { + return FieldControls.FormPropertiesEditor + } + switch (setting.ControlHint) { + case 'sn:FormPropertiesEditor': + return FieldControls.FormPropertiesEditor case 'sn:WebhookFilter': return FieldControls.WebhookTrigger case 'sn:WebhookHeaders': diff --git a/apps/sensenet/src/localization/default.ts b/apps/sensenet/src/localization/default.ts index edb78029a..8fdaf2fde 100644 --- a/apps/sensenet/src/localization/default.ts +++ b/apps/sensenet/src/localization/default.ts @@ -553,6 +553,29 @@ const values = { common: { loadingContent: 'Loading content...', }, + formPropertiesEditor: { + visualEditor: 'Visual editor', + jsonEditor: 'JSON', + generalSettings: 'General settings', + rows: 'Rows', + row: 'Row', + field: 'Field', + addRow: 'Add row', + addField: 'Add field', + emptyRow: 'This row has no fields yet.', + fieldName: 'Field name', + displayName: 'Display name', + placeholder: 'Placeholder', + type: 'Type', + required: 'Required', + formatErrorMessage: 'Format error message', + minRows: 'Minimum rows', + recaptcha: 'Use recaptcha', + privacyPolicy: 'Privacy policy', + invalidJson: 'Invalid JSON', + fixJsonFirst: 'The visual editor is available after the JSON is valid.', + formatJson: 'Format JSON', + }, batchActions: { delete: 'Delete selected items', move: 'Move selected items', diff --git a/apps/sensenet/src/localization/hungarian.ts b/apps/sensenet/src/localization/hungarian.ts index 8768053d2..5b9747fb8 100644 --- a/apps/sensenet/src/localization/hungarian.ts +++ b/apps/sensenet/src/localization/hungarian.ts @@ -222,6 +222,29 @@ const values: Localization = { common: { loadingContent: 'Tartalom betöltése...', }, + formPropertiesEditor: { + visualEditor: 'Grafikus szerkesztő', + jsonEditor: 'JSON', + generalSettings: 'Általános beállítások', + rows: 'Sorok', + row: 'Sor', + field: 'Mező', + addRow: 'Sor hozzáadása', + addField: 'Mező hozzáadása', + emptyRow: 'Ebben a sorban még nincs mező.', + fieldName: 'Mező neve', + displayName: 'Megjelenített név', + placeholder: 'Placeholder', + type: 'Típus', + required: 'Kötelező', + formatErrorMessage: 'Formátumhiba üzenet', + minRows: 'Minimum sorok', + recaptcha: 'Recaptcha használata', + privacyPolicy: 'Adatvédelmi nyilatkozat', + invalidJson: 'Érvénytelen JSON', + fixJsonFirst: 'A grafikus szerkesztő akkor használható, ha a JSON érvényes.', + formatJson: 'JSON formázása', + }, batchActions: { delete: 'Kijelölt elemek törlése', move: 'Kijelölt elemek áthelyezése', From c86e8e08872a21e66aa389700b8a721deef65446 Mon Sep 17 00:00:00 2001 From: maros Date: Sat, 16 May 2026 14:13:20 +0200 Subject: [PATCH 06/10] feat: add AUIApplication content type and related documentation - Introduced AUIApplication content type with custom HTML rendering capabilities. - Added Admin UI Applications documentation for usage and API details. - Implemented AUIApplicationView component to handle rendering of AUIApplication content. - Updated Explore component to conditionally render AUIApplicationView. - Enhanced IconFromPath component to accept repository as a parameter. - Created example application for AUIApplication demonstrating content listing and editing. - Added localization for content type templates. - Improved zip download service with path sanitization. --- apps/sensenet/README.md | 4 + .../sensenet/content-types/AUIApplication.xml | 19 + apps/sensenet/docs/auiapplications.md | 348 ++++++++++++++++ .../auiapplication-banner-images.html | 278 +++++++++++++ apps/sensenet/src/components/IconFromPath.tsx | 17 +- .../components/content/AUIApplicationView.tsx | 376 ++++++++++++++++++ .../src/components/content/Explore.tsx | 57 ++- .../components/edit/default-content-type.ts | 26 ++ .../components/editor/content-type-preset.tsx | 21 +- apps/sensenet/src/localization/default.ts | 1 + apps/sensenet/src/services/zip-download.ts | 13 +- 11 files changed, 1138 insertions(+), 22 deletions(-) create mode 100644 apps/sensenet/content-types/AUIApplication.xml create mode 100644 apps/sensenet/docs/auiapplications.md create mode 100644 apps/sensenet/examples/auiapplication-banner-images.html create mode 100644 apps/sensenet/src/components/content/AUIApplicationView.tsx diff --git a/apps/sensenet/README.md b/apps/sensenet/README.md index 34ec0099e..24f73527c 100644 --- a/apps/sensenet/README.md +++ b/apps/sensenet/README.md @@ -35,6 +35,10 @@ The repositories you've visited will be also saved in your Personal Settings - y You can browse the whole repository with the **Content** menu. You can adjust the Content view in the personal setting's _"content"_ section. +### Admin UI Applications + +The Admin UI can render repository-defined `AUIApplication` contents as small custom HTML applications inside the content explorer. See the [Admin UI Applications documentation](./docs/auiapplications.md) for the content type, bridge API, and repository read/update examples. + ## 🌈 Command palette The command palette is useful if you want to search in the repository, navigate to a specific page or execute a specific command on the current content. diff --git a/apps/sensenet/content-types/AUIApplication.xml b/apps/sensenet/content-types/AUIApplication.xml new file mode 100644 index 000000000..f5382c4a8 --- /dev/null +++ b/apps/sensenet/content-types/AUIApplication.xml @@ -0,0 +1,19 @@ + + Admin UI Application + Custom Admin UI application that renders its HTML field instead of the folder grid. + Content + true + + + HTML + HTML rendered by Admin UI when this content is opened. Referenced CSS and JavaScript files can be placed under this application folder and loaded with relative URLs. + + LongText + sn:HtmlEditor + Show + Show + Show + + + + diff --git a/apps/sensenet/docs/auiapplications.md b/apps/sensenet/docs/auiapplications.md new file mode 100644 index 000000000..e5afe50aa --- /dev/null +++ b/apps/sensenet/docs/auiapplications.md @@ -0,0 +1,348 @@ +# Admin UI Applications + +`AUIApplication` is a lightweight extension point for the sensenet Admin UI. It lets repository editors create a folder-like content that contains custom HTML. When the Admin UI opens this content, it renders the HTML in place of the regular child grid. + +This is useful for small internal admin tools, dashboards, data fix-up screens, reports, and workflow helpers that should live in the repository instead of being compiled into the Admin UI bundle. + +## Content Type + +The content type definition is available here: + +```text +apps/sensenet/content-types/AUIApplication.xml +``` + +It derives from `Folder` and adds one editable field: + +```xml + + HTML + + LongText + sn:HtmlEditor + Show + Show + Show + + +``` + +After the CTD exists in the repository, create a new `AUIApplication` content anywhere under `/Root/Content`, then put the application markup into its `Html` field. + +## Rendering Model + +When the current content has type `AUIApplication`, `Explore` renders `AUIApplicationView` instead of the grid. + +The custom HTML is loaded from the `Html` field and rendered in an iframe. The iframe receives: + +```js +window.sensenetAdminApp +``` + +This object is injected by the Admin UI before your HTML runs. + +## Bridge Concept + +The HTML app runs inside an iframe. Calling the repository directly from that iframe can hit CORS or authentication problems, and exposing the bearer token directly to arbitrary HTML would be a bad extension pattern. + +Instead, the Admin UI provides a small bridge: + +1. Your HTML calls `window.sensenetAdminApp.fetch(...)`. +2. The iframe sends a `postMessage` request to the parent Admin UI. +3. The parent Admin UI validates that the request targets the current repository. +4. The parent calls `repository.fetch(...)`. +5. The normal Admin UI auth header/token is attached by the repository client. +6. The response body is sent back to the iframe. + +So custom apps can use authenticated repository APIs without reading or storing the token themselves. + +## Available API + +```ts +window.sensenetAdminApp = { + repositoryUrl: string + adminUiUrl: string + content: { + Id?: number + Path?: string + Name?: string + DisplayName?: string + Type?: string + } + fetch(input: string, init?: { + method?: string + headers?: Record + body?: string + }): Promise +} +``` + +The `fetch` function intentionally supports a small subset of the browser `fetch` API: + +```ts +type BridgeResponse = { + ok: boolean + status: number + statusText: string + url: string + headers: { + get(name: string): string | null + entries(): Array<[string, string]> + } + text(): Promise + json(): Promise +} +``` + +Requests are restricted to the current repository origin. Cross-repository and arbitrary external requests are rejected by the parent Admin UI. + +## URL Rules + +Use repository-relative URLs when possible: + +```js +await window.sensenetAdminApp.fetch('/odata.svc/Root/Content') +``` + +Absolute URLs are also accepted if they point to the same repository origin: + +```js +await window.sensenetAdminApp.fetch('https://example.test.sensenet.com/odata.svc/Root/Content') +``` + +Use `adminUiUrl` when you need to navigate back to Admin UI routes. Do not use root-relative links for Admin UI navigation inside an `AUIApplication`, because the injected `` tag points relative asset URLs to the repository content path. + +```js +const adminPath = (path) => path.replace(/^\/Root(?=\/|$)/, '') || '/' +const adminUiUrl = window.sensenetAdminApp.adminUiUrl || new URL(document.referrer).origin +const query = new URLSearchParams({ + path: adminPath('/Root/Content/test/BannerImages'), + content: adminPath('/Root/Content/test/BannerImages/example.png'), +}) + +const editUrl = `${adminUiUrl}/content/explorer/edit?${query.toString()}` +``` + +For assets such as CSS or JavaScript, the Admin UI injects a `` tag that points to the current `AUIApplication` content path. This means relative references can point to files stored under the application folder: + +```html + + +``` + +## Read Children + +```js +const app = window.sensenetAdminApp + +async function loadChildren(path) { + const url = + `/odata.svc${path}` + + '?$select=Id,Path,Name,DisplayName,Type,IsFolder,IsFile,CreationDate,ModificationDate' + + '&$orderby=Name' + + const response = await app.fetch(url, { + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`) + } + + const result = await response.json() + + return result.d.results +} + +const items = await loadChildren('/Root/Content/test/BannerImages') +console.log(items) +``` + +## Read One Content + +```js +async function loadContent(path) { + const response = await window.sensenetAdminApp.fetch( + `/odata.svc${path}?$select=Id,Path,Name,DisplayName,Type,Description`, + { + headers: { + Accept: 'application/json', + }, + }, + ) + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`) + } + + const result = await response.json() + + return result.d +} +``` + +## Update Content + +Use `PATCH` for partial updates. Always send a JSON string body and set `Content-Type`. + +```js +async function updateDisplayName(path, displayName) { + const response = await window.sensenetAdminApp.fetch(`/odata.svc${path}`, { + method: 'PATCH', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + DisplayName: displayName, + }), + }) + + if (!response.ok) { + const details = await response.text() + throw new Error(`Update failed: ${response.status} ${response.statusText} ${details}`) + } + + return response.json() +} + +await updateDisplayName('/Root/Content/test/BannerImages/example.png', 'New display name') +``` + +## Create Content + +Use `POST` on the parent path and include `__ContentType`. + +```js +async function createFolder(parentPath, name, displayName) { + const response = await window.sensenetAdminApp.fetch(`/odata.svc${parentPath}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + __ContentType: 'Folder', + Name: name, + DisplayName: displayName, + }), + }) + + if (!response.ok) { + const details = await response.text() + throw new Error(`Create failed: ${response.status} ${response.statusText} ${details}`) + } + + return response.json() +} + +await createFolder('/Root/Content/test', 'NewBannerFolder', 'New banner folder') +``` + +## Small Helper Wrapper + +For real applications, define a tiny repository helper in your HTML or external JavaScript file: + +```js +const sn = { + request: async (url, init = {}) => { + const response = await window.sensenetAdminApp.fetch(url, { + ...init, + headers: { + Accept: 'application/json', + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + ...(init.headers || {}), + }, + }) + + if (!response.ok) { + const details = await response.text() + throw new Error(`${response.status} ${response.statusText}: ${details}`) + } + + return response.json() + }, + + loadChildren: async (path) => { + const result = await sn.request(`/odata.svc${path}?$select=Id,Path,Name,DisplayName,Type&$orderby=Name`) + return result.d.results + }, + + patch: async (path, content) => { + const result = await sn.request(`/odata.svc${path}`, { + method: 'PATCH', + body: JSON.stringify(content), + }) + return result.d + }, +} +``` + +## Example Application + +The first example application is here: + +```text +apps/sensenet/examples/auiapplication-banner-images.html +``` + +It lists the children of: + +```text +/Root/Content/test/BannerImages +``` + +The table displays `Name` and `Type`, and each row has an Edit button that navigates back to the normal Admin UI edit view. + +## Recommended Structure + +For small tools, putting everything into the `Html` field is fine: + +```html +
...
+ + +``` + +For larger tools, store assets under the `AUIApplication` folder: + +```text +MyAdminTool + index HTML in the Html field + app.js + styles.css +``` + +Then reference them with relative URLs: + +```html + + +``` + +## Security Notes + +`AUIApplication` is a powerful extension point. Treat it as trusted admin-defined code. + +Important guardrails: + +- The bridge does not expose the bearer token to the iframe. +- Bridge requests are restricted to the current repository origin. +- The iframe is sandboxed and does not get direct parent DOM access. +- User-clicked links may navigate the top-level Admin UI window, which is needed for Edit links and similar Admin UI routes. +- Users who can edit an `AUIApplication` can run JavaScript inside the Admin UI page, so editing rights should be limited to trusted administrators. +- Do not paste third-party scripts into an `AUIApplication` unless they are reviewed and trusted. + +## Limitations + +- `sensenetAdminApp.fetch` is not a full browser `fetch` replacement. +- Request bodies must currently be strings. Use `JSON.stringify(...)` for JSON payloads. +- The response object supports `ok`, `status`, `statusText`, `url`, `headers.get`, `headers.entries`, `text()`, and `json()`. +- File upload and streaming APIs are not exposed through this bridge yet. +- High-level repository methods such as `load`, `patch`, or `post` are not exposed directly. Build tiny wrappers around `fetch` in your app. diff --git a/apps/sensenet/examples/auiapplication-banner-images.html b/apps/sensenet/examples/auiapplication-banner-images.html new file mode 100644 index 000000000..d8a1eb055 --- /dev/null +++ b/apps/sensenet/examples/auiapplication-banner-images.html @@ -0,0 +1,278 @@ +
+
+

Admin UI Application példa

+

Banner képek

+

+ Ez a kis alkalmazás a /Root/Content/test/BannerImages alatti contenteket listázza, és minden sorhoz + ad egy gyors szerkesztés gombot. +

+
+ +
+
+

Contentek

+ +
+ +
Betöltés...
+ + + + + + + + + + + +
+
+ + + + diff --git a/apps/sensenet/src/components/IconFromPath.tsx b/apps/sensenet/src/components/IconFromPath.tsx index 66d13bbe6..27c280d6c 100644 --- a/apps/sensenet/src/components/IconFromPath.tsx +++ b/apps/sensenet/src/components/IconFromPath.tsx @@ -6,7 +6,7 @@ import { IconOptions } from './Icon' const iconCache = new Map() const iconRequestCache = new Map>() -const loadIcon = (path: string, options: IconOptions) => { +const loadIcon = (path: string, repo: IconOptions['repo']) => { if (iconCache.has(path)) { return Promise.resolve(iconCache.get(path)!) } @@ -17,7 +17,7 @@ const loadIcon = (path: string, options: IconOptions) => { return pendingRequest } - const imageUrl = PathHelper.joinPaths(options.repo.configuration.repositoryUrl, path) + const imageUrl = PathHelper.joinPaths(repo.configuration.repositoryUrl, path) const request = (async () => { if (!path.endsWith('.svg')) { iconCache.set(path, imageUrl) @@ -25,7 +25,7 @@ const loadIcon = (path: string, options: IconOptions) => { } try { - const response = await options.repo.fetch(imageUrl, { cache: 'force-cache' }) + const response = await repo.fetch(imageUrl, { cache: 'force-cache' }) if (!response.ok) { iconCache.set(path, null) @@ -50,6 +50,7 @@ const loadIcon = (path: string, options: IconOptions) => { } const IconFromPath = ({ path, options }: { path: string; options: IconOptions }) => { + const { repo, style } = options const [icon, setIcon] = useState(() => iconCache.get(path) || null) useEffect(() => { @@ -61,7 +62,7 @@ const IconFromPath = ({ path, options }: { path: string; options: IconOptions }) } setIcon(null) - loadIcon(path, options).then((loadedIcon) => { + loadIcon(path, repo).then((loadedIcon) => { if (isMounted) { setIcon(loadedIcon) } @@ -70,18 +71,18 @@ const IconFromPath = ({ path, options }: { path: string; options: IconOptions }) return () => { isMounted = false } - }, [path, options.repo]) + }, [path, repo]) // Memoize the rendered output to prevent unnecessary DOM updates const renderedIcon = useMemo(() => { if (!icon) return null return path.endsWith('.svg') ? ( -