diff --git a/.eslintrc.js b/.eslintrc.js index 623ff317fb..c55783cead 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,7 +12,7 @@ module.exports = { ], parser: '@typescript-eslint/parser', plugins: ['@typescript-eslint', 'react', 'cypress', 'jsdoc', 'import', 'react-hooks'], - env: { browser: true, node: true, es6: true, jest: true, 'cypress/globals': true }, + env: { browser: true, node: true, es6: true, 'cypress/globals': true }, parserOptions: { ecmaVersion: 6, sourceType: 'module', @@ -36,6 +36,9 @@ module.exports = { }, rules: { 'react/prop-types': 0, + 'no-unused-vars': 'off', + 'import/export': 0, + '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-types': 'off', '@typescript-eslint/no-empty-function': 'off', @@ -45,6 +48,7 @@ module.exports = { '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/array-type': ['error', { default: 'array-simple', readonly: 'array-simple' }], 'require-jsdoc': 1, + 'cypress/unsafe-to-chain-command': 'off', 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies 'import/default': 0, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b642aede3a..e33ce6f9df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions/cache + - uses: actions/cache@v4 with: path: ~/.cache/yarn key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} @@ -46,15 +46,9 @@ jobs: - name: build run: yarn build - - name: build examples - run: yarn build:examples - - name: build snapp run: yarn snapp build env: RELATIVE_CI_KEY: ${{ secrets.RELATIVE_CI_KEY }} - - name: test - run: NODE_OPTIONS='--max-old-space-size=4096' yarn test --coverage --logHeapUsage - - uses: codecov/codecov-action@v1 diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index c8a43d2218..a55f4f6be7 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -8,7 +8,7 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions/cache@v4 # Updated from v1 to v4 + - uses: actions/cache@v4 with: path: ~/.cache/yarn key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} @@ -36,35 +36,3 @@ jobs: env: NETLIFY_SITE_ID: 94fb346c-b540-40f7-aaaf-21eee2a9c891 NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - - deploy-storybook: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - uses: actions/cache@v4 # Updated from v1 to v4 - with: - path: ~/.cache/yarn - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- - - - name: install - run: yarn install - env: - CYPRESS_INSTALL_BINARY: 0 - - - name: build - run: yarn build - - - name: build storybook - run: yarn storybook build-storybook - - - name: wake up deploy notifier - run: yarn wait-on https://sensenet-sn-deploy-notifier.glitch.me/ -l -t 300000 -i 10000 - - - name: Publish - run: npx netlify-cli@v2.41.0 deploy --dir=./examples/sn-react-component-docs/storybook-static --message ${{ github.event.pull_request.number }} - env: - NETLIFY_SITE_ID: 1747b330-27d8-4ddd-bf74-39469c257010 - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} diff --git a/.gitignore b/.gitignore index 6a86cc155e..7e678127c9 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ jspm_packages/ # Misc .DS_Store +pipe\[0] diff --git a/apps/sensenet/README.md b/apps/sensenet/README.md index ea765a613f..34ec0099ee 100644 --- a/apps/sensenet/README.md +++ b/apps/sensenet/README.md @@ -59,3 +59,67 @@ If you start typing a Content query term (that starts with a '+' sign), the term ## ℹ Version info (Coming soon...) + +# sensenet Admin UI + +React-based UI for sensenet. This application provides a rich UI for managing your sensenet content repository. It was designed to take advantage of the modern web technologies - which means we built it for evergreen browsers (Edge, Chrome, Firefox). If you need legacy browser support (e.g. IE11) please use the [old admin UI](https://github.com/SenseNet/sensenet/tree/master/src/nuget/snadmin/install-webpages) instead. + +## Authentication Configuration + +The application supports two authentication methods: + +- **SNAuth**: sensenet's JWT-based authentication +- **IdentityServer**: OIDC-based authentication with Identity Server + +You can specify which authentication method to use during the build process. This is a build-time configuration, meaning the application will be built to use only one authentication method. + +### Building with specific authentication method + +To build the application with SNAuth (default): + +```bash +yarn build:snauth +# or npm run build:snauth +``` + +To build the application with Identity Server authentication: + +```bash +yarn build:idserver +# or npm run build:idserver +``` + +### Development with specific authentication method + +To run the development server with SNAuth: + +```bash +yarn start:snauth +# or npm run start:snauth +``` + +To run the development server with Identity Server authentication: + +```bash +yarn start:idserver +# or npm run start:idserver +``` + +If you don't specify an authentication method, the application will default to using SNAuth. + +## Development + +To run the application locally: + +```bash +yarn install +yarn start +``` + +Navigate to http://localhost:8080 in your browser. + +To build the application: + +```bash +yarn build +``` diff --git a/apps/sensenet/index.html b/apps/sensenet/index.html index 4f32ba2e91..bc7f6ac4c8 100644 --- a/apps/sensenet/index.html +++ b/apps/sensenet/index.html @@ -1,42 +1,45 @@ - - - - - sensenet - - - - -
- - + /* Track */ + ::-webkit-scrollbar-track { + background: rgba(128, 128, 128, 0.3); + } + + /* Handle */ + ::-webkit-scrollbar-thumb { + background: #888; + } + + /* Handle on hover */ + ::-webkit-scrollbar-thumb:hover { + background: #555; + } + + + + + +
+ + + \ No newline at end of file diff --git a/apps/sensenet/package.json b/apps/sensenet/package.json index 1edf524ec2..0d80852cfc 100644 --- a/apps/sensenet/package.json +++ b/apps/sensenet/package.json @@ -17,7 +17,12 @@ "fix:prettier": "prettier \"{,!(dist|temp|bundle)/**/}*.{ts,tsx}\" --write", "build": "cross-env NODE_OPTIONS=--openssl-legacy-provider webpack --config webpack.prod.js", "build:stats": "webpack --config webpack.prod.js --profile --json > stats.json", + "build:snauth": "cross-env NODE_OPTIONS=--openssl-legacy-provider AUTH_TYPE=SNAuth webpack --config webpack.prod.js", + "build:idserver": "cross-env NODE_OPTIONS=--openssl-legacy-provider AUTH_TYPE=IdentityServer webpack --config webpack.prod.js", "start": "cross-env NODE_OPTIONS=--openssl-legacy-provider webpack serve --progress --config webpack.dev.js", + "start:snauth": "cross-env NODE_OPTIONS=--openssl-legacy-provider AUTH_TYPE=SNAuth webpack serve --progress --config webpack.dev.js", + "start:idserver": "cross-env NODE_OPTIONS=--openssl-legacy-provider AUTH_TYPE=IdentityServer webpack serve --progress --config webpack.dev.js", + "buildstart": "cd ../../ && yarn build && cd apps/sensenet && yarn start", "cypress": "cypress open --env coverage=false", "cypress:local": "cypress open --env coverage=false --config baseUrl=http://localhost:8080", "cypress:all": "cypress run --env coverage=false", @@ -60,6 +65,7 @@ "cypress-file-upload": "^5.0.8", "cypress-xpath": "^1.6.2", "eslint-config-prettier": "8.6.0", + "eslint-config-react-app": "^7.0.1", "file-loader": "^6.1.1", "fork-ts-checker-webpack-plugin": "^6.3.1", "html-webpack-plugin": "^5.5.0", @@ -82,6 +88,7 @@ "webpack-merge": "^5.8.0" }, "dependencies": { + "@ag-grid-community/styles": "30.0.5", "@iconify-icons/logos": "1.2.23", "@iconify/react": "4.1.0", "@material-ui/core": "4.11.4", @@ -100,12 +107,18 @@ "@sensenet/pickers-react": "^2.1.4", "@sensenet/query": "^2.1.3", "@sensenet/repository-events": "^2.1.3", + "@sensenet/sn-auth-react": "^1.0.3", + "@tiptap/pm": "^2.6.6", + "ag-grid-community": "27.3.0", + "ag-grid-enterprise": "27.3.0", + "ag-grid-react": "27.3.0", "autosuggest-highlight": "^3.3.4", "clsx": "1.2.1", "date-fns": "2.29.3", "filesize": "10.0.6", "react": "^16.13.0", "react-autosuggest": "^10.1.0", + "react-data-grid": "6.1.0", "react-day-picker": "^8.6.0", "react-dom": "^16.13.0", "react-markdown": "6.0.3", diff --git a/apps/sensenet/src/application-paths.ts b/apps/sensenet/src/application-paths.ts index 0d640e2ba5..e9a2c961f5 100644 --- a/apps/sensenet/src/application-paths.ts +++ b/apps/sensenet/src/application-paths.ts @@ -9,18 +9,21 @@ export const PATHS = { usersAndGroups: { appPath: '/users-and-groups/:browseType/:action?', snPath: '/Root/IMS' }, dashboard: { appPath: '/dashboard' }, contentTypes: { appPath: '/content-types/:browseType/:action?', snPath: '/Root/System/Schema/ContentTypes' }, - search: { appPath: '/search' }, - content: { appPath: '/content/:browseType/:action?', snPath: '/Root/Content' }, + search: { appPath: '/search', snPath: '/Root' }, + content: { appPath: '/content/:browseType/:action?', snPath: '/Root' }, contentTemplates: { appPath: '/content-templates/:browseType/:action?', snPath: '/Root/ContentTemplates' }, - custom: { appPath: '/custom/:browseType/:path/:action?' }, + custom: { appPath: '/custom/:browseType/:path/:action?', snPath: '/Root' }, configuration: { appPath: '/system/settings/:action?', snPath: '/Root/System/Settings' }, localization: { appPath: '/system/localization/:action?', snPath: '/Root/Localization' }, webhooks: { appPath: '/system/webhooks/:action?', snPath: '/Root/System/WebHooks' }, - settings: { appPath: '/system/:submenu?' }, + settings: { appPath: '/system/:submenu?', snPath: '/Root/System/Settings' }, apiKeys: { appPath: '/system/apikeys' }, + landingPath: { appPath: '/content/explorer/' }, + root: { appPath: '/Root', snPath: '/Root' }, + home: { appPath: '/', snPath: '/' }, } as const -type SettingsItemType = 'stats' | 'apikeys' | 'webhooks' | 'adminui' +type SettingsItemType = 'stats' | 'settings' | 'apikeys' | 'webhooks' | 'adminui' type RoutesWithContentBrowser = keyof Pick< typeof PATHS, @@ -30,28 +33,28 @@ type RoutesWithContentBrowser = keyof Pick< type RoutesWithActionParam = keyof Pick type Options = - | { path: (typeof PATHS)['events']['appPath']; params?: { eventGuid: string;[index: string]: string } } + | { path: (typeof PATHS)['events']['appPath']; params?: { eventGuid: string; [index: string]: string } } | { - path: (typeof PATHS)[RoutesWithContentBrowser]['appPath'] - params: { browseType: (typeof BrowseType)[number]; action?: string;[index: string]: string | undefined } - } + path: (typeof PATHS)[RoutesWithContentBrowser]['appPath'] + params: { browseType: (typeof BrowseType)[number]; action?: string; [index: string]: string | undefined } + } | { - path: (typeof PATHS)['custom']['appPath'] - params: { - browseType: (typeof BrowseType)[number] - path: string - action?: string - [index: string]: string | undefined + path: (typeof PATHS)['custom']['appPath'] + params: { + browseType: (typeof BrowseType)[number] + path: string + action?: string + [index: string]: string | undefined + } } - } | { - path: (typeof PATHS)[RoutesWithActionParam]['appPath'] - params?: { action: string;[index: string]: string } - } + path: (typeof PATHS)[RoutesWithActionParam]['appPath'] + params?: { action: string; [index: string]: string } + } | { - path: (typeof PATHS)['settings']['appPath'] - params?: { submenu: SettingsItemType;[index: string]: string | SettingsItemType } - } + path: (typeof PATHS)['settings']['appPath'] + params?: { submenu: SettingsItemType; [index: string]: string | SettingsItemType } + } export const resolvePathParams = ({ path, params }: Options) => { let currentPath: string = path diff --git a/apps/sensenet/src/assets/sensenet-logo.svg b/apps/sensenet/src/assets/sensenet-logo.svg new file mode 100644 index 0000000000..04e78ea5d4 --- /dev/null +++ b/apps/sensenet/src/assets/sensenet-logo.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/apps/sensenet/src/auth-config.ts b/apps/sensenet/src/auth-config.ts new file mode 100644 index 0000000000..2b3ceed021 --- /dev/null +++ b/apps/sensenet/src/auth-config.ts @@ -0,0 +1,10 @@ +export type AuthServerType = 'SNAuth' | 'IdentityServer' + +export interface AuthenticationConfig { + authType: AuthServerType +} + +// Use process.env.AUTH_TYPE if available (from build), otherwise default to 'SNAuth' +export const defaultAuthConfig: AuthenticationConfig = { + authType: (process.env.AUTH_TYPE || 'SNAuth') as AuthServerType, +} diff --git a/apps/sensenet/src/components/AddButton.tsx b/apps/sensenet/src/components/AddButton.tsx index eed47f8a0a..fe05b4d01d 100644 --- a/apps/sensenet/src/components/AddButton.tsx +++ b/apps/sensenet/src/components/AddButton.tsx @@ -28,6 +28,7 @@ const useStyles = makeStyles((theme: Theme) => { return createStyles({ addWrapper: { position: 'relative', + margin: 0, }, addListLoader: { color: theme.palette.secondary.main, @@ -43,12 +44,8 @@ const useStyles = makeStyles((theme: Theme) => { }, }, listItem: { - width: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'space-evenly', height: globals.common.addButtonHeight, - paddingLeft: '2px', + paddingLeft: '4px', }, listDropdown: { padding: '10px 0 10px 10px', @@ -64,10 +61,14 @@ const useStyles = makeStyles((theme: Theme) => { disabled: { cursor: 'not-allowed', }, + drawerIconButtonWrapper: { + height: '40px', + }, }) }) export interface AddButtonProps { isOpened?: boolean + isDisabled?: boolean } export const AddButton: FunctionComponent = (props) => { @@ -96,7 +97,7 @@ export const AddButton: FunctionComponent = (props) => { try { const actions = await repo.getActions({ idOrPath: currentPath }) const isActionFound = actions.d.results.some((action) => action.Name === 'Add' || action.Name === 'Upload') - setAvailable(isActionFound && !activeAction) + setAvailable(isActionFound && !activeAction && !props.isDisabled) } catch (error) { logger.error({ message: localization.errorGettingActions, @@ -107,12 +108,12 @@ export const AddButton: FunctionComponent = (props) => { } } - if (currentPath) { + if (currentPath && currentPath !== '/') { getActions() } else { setAvailable(false) } - }, [localization.errorGettingActions, logger, repo, currentPath, activeAction]) + }, [localization.errorGettingActions, logger, repo, currentPath, activeAction, props.isDisabled]) useEffect(() => { const getAllowedChildTypes = async () => { @@ -157,14 +158,15 @@ export const AddButton: FunctionComponent = (props) => { ]) return ( -
+
{!props.isOpened ? ( -
+
{isAvailable ? (
) => { if (isLoading) return setAnchorEl(event.currentTarget) @@ -181,6 +183,7 @@ export const AddButton: FunctionComponent = (props) => { className={clsx(globalClasses.drawerButton, { [classes.addButtonDisabled]: !isAvailable, })} + style={{ margin: 4 }} data-test="add-button" disabled={true}> @@ -196,7 +199,7 @@ export const AddButton: FunctionComponent = (props) => { setShowSelectType(true) }} disabled={!isAvailable}> - + = (props) => { - + )} {!isLoading && ( diff --git a/apps/sensenet/src/components/BatchActions.tsx b/apps/sensenet/src/components/BatchActions.tsx new file mode 100644 index 0000000000..6e8ab84ffc --- /dev/null +++ b/apps/sensenet/src/components/BatchActions.tsx @@ -0,0 +1,126 @@ +import { createStyles, IconButton, makeStyles, Theme, Tooltip } from '@material-ui/core' +import DeleteIcon from '@material-ui/icons/Delete' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined' +import { CurrentContentContext } from '@sensenet/hooks-react' +import React, { useContext, useEffect, useState } from 'react' +import { useGlobalStyles } from '../globalStyles' +import { useLocalization, useSelectionService } from '../hooks' +import { useDialog } from './dialogs' + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + batchActionWrapper: { + '& .MuiIconButton-root': { + color: theme.palette.type === 'light' ? theme.palette.common.black : theme.palette.common.white, + }, + marginLeft: '12px', + display: 'flex', + alignItems: 'center', + marginRight: '8px', + height: '36px', + }, + actionButton: { + width: '40px', + marginRight: '2px', + '&:disabled': { + opacity: 0.2, + }, + }, + }), +) + +export const BatchActions = () => { + const selectionService = useSelectionService() + const localization = useLocalization() + const globalClasses = useGlobalStyles() + const classes = useStyles() + const { openDialog } = useDialog() + const [selected, setSelected] = useState(selectionService.selection.getValue()) + const parent = useContext(CurrentContentContext) + + useEffect(() => { + const selectedComponentsObserve = selectionService.selection.subscribe((newSelectedComponents) => { + setSelected(newSelectedComponents) + }) + + return function cleanup() { + selectedComponentsObserve.dispose() + } + }, [selectionService.selection]) + + return ( +
+ + + + openDialog({ + name: 'delete', + props: { content: selected }, + dialogProps: { disableBackdropClick: true, disableEscapeKeyDown: true }, + }) + }> + + + + + + + + openDialog({ + name: 'copy-move', + props: { + content: selected, + currentParent: parent, + operation: 'move', + }, + dialogProps: { + disableBackdropClick: true, + disableEscapeKeyDown: true, + classes: { paper: globalClasses.pickerDialog }, + }, + }) + }> + + + + + + + + openDialog({ + name: 'copy-move', + props: { + content: selected, + currentParent: parent, + operation: 'copy', + }, + dialogProps: { + disableBackdropClick: true, + disableEscapeKeyDown: true, + classes: { paper: globalClasses.pickerDialog }, + }, + }) + }> + + + + +
+ ) +} diff --git a/apps/sensenet/src/components/Breadcrumbs.tsx b/apps/sensenet/src/components/Breadcrumbs.tsx index f6ce22575c..16bf610d37 100644 --- a/apps/sensenet/src/components/Breadcrumbs.tsx +++ b/apps/sensenet/src/components/Breadcrumbs.tsx @@ -1,8 +1,10 @@ +import { Menu, MenuItem } from '@material-ui/core' import MUIBreadcrumbs from '@material-ui/core/Breadcrumbs' import Button from '@material-ui/core/Button' import Tooltip from '@material-ui/core/Tooltip' import { GenericContent } from '@sensenet/default-content-types' -import React, { MouseEvent, useState } from 'react' +import { useRepository } from '@sensenet/hooks-react' +import React, { MouseEvent, useEffect, useState } from 'react' import { ContentContextMenu } from './context-menu/content-context-menu' import CopyPath from './CopyPath' import { DropFileArea } from './DropFileArea' @@ -16,7 +18,77 @@ export interface BreadcrumbItem { export interface BreadcrumbProps { items: Array> - onItemClick: (event: MouseEvent, item: BreadcrumbItem) => void + onItemClick: (event: MouseEvent, item: any) => void +} + +export interface BreadcrumbSeparatorProps { + itemPath: string + onItemClick: (event: MouseEvent, item: any) => void +} + +export function BreadcrumbSeparator(props: BreadcrumbSeparatorProps) { + const { itemPath, onItemClick } = props + const [anchorEl, setAnchorEl] = useState(null) + const [siblings, setSiblings] = useState([]) + const repo = useRepository() + + useEffect(() => { + let isMounted = true + const fetchSiblings = async () => { + if (!itemPath) return + try { + const siblingsResult = await repo.loadCollection({ + path: itemPath, + oDataOptions: { + select: ['Id', 'Path', 'Name'], + orderby: 'Name', + metadata: 'no', + }, + }) + if (isMounted) { + setSiblings( + siblingsResult.d.results.map((s) => { + return { content: s, DisplayName: s.DisplayName, Id: s.Id } + }), + ) + } + } catch (error) { + console.error(error) + } + } + fetchSiblings() + return () => { + isMounted = false + } + }, [itemPath, repo]) + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + return ( + <> + + + {siblings.map((sibling) => ( + { + onItemClick(ev, sibling) + handleClose() + }}> + {sibling.DisplayName} + + ))} + + + ) } export function Breadcrumbs(props: BreadcrumbProps) { @@ -26,12 +98,8 @@ export function Breadcrumbs(props: BreadcrumbProps) return ( <> - - {props.items.map((item) => ( + + {props.items.map((item, index) => ( + {index < props.items.length - 1 && ( + + )} ))} diff --git a/apps/sensenet/src/components/ContentBreadcrumbs.tsx b/apps/sensenet/src/components/ContentBreadcrumbs.tsx index 42b7b74689..e94150c7d5 100644 --- a/apps/sensenet/src/components/ContentBreadcrumbs.tsx +++ b/apps/sensenet/src/components/ContentBreadcrumbs.tsx @@ -1,36 +1,20 @@ -import { createStyles, IconButton, makeStyles, Theme, Tooltip } from '@material-ui/core' -import DeleteIcon from '@material-ui/icons/Delete' -import FileCopyIcon from '@material-ui/icons/FileCopy' -import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined' +import { createStyles, makeStyles } from '@material-ui/core' import { GenericContent } from '@sensenet/default-content-types' import { CurrentAncestorsContext, CurrentContentContext, useRepository } from '@sensenet/hooks-react' -import React, { useContext, useEffect, useState } from 'react' +import React, { useContext } from 'react' import { useHistory } from 'react-router-dom' import { ResponsivePersonalSettings } from '../context' -import { useGlobalStyles } from '../globalStyles' -import { useLocalization, useSelectionService } from '../hooks' +import { useSelectionService } from '../hooks' import { getPrimaryActionUrl } from '../services' +import { BatchActions } from './BatchActions' import { BreadcrumbItem, Breadcrumbs } from './Breadcrumbs' -import { useDialog } from './dialogs' -const useStyles = makeStyles((theme: Theme) => { +const useStyles = makeStyles(() => { return createStyles({ - batchActionWrapper: { - ' & .MuiIconButton-root': { - color: theme.palette.type === 'light' ? theme.palette.common.black : theme.palette.common.white, - }, - marginLeft: 'auto', - display: 'flex', - marginRight: '8px', - height: '40px', - }, buttonsWrapper: { display: 'flex', alignItems: 'center', - }, - actionButton: { - width: '40px', - marginRight: '2px', + marginLeft: '10px', }, }) }) @@ -47,22 +31,8 @@ export const ContentBreadcrumbs = (pr const repository = useRepository() const history = useHistory() const { location } = history - const localization = useLocalization() - const globalClasses = useGlobalStyles() const classes = useStyles() - const { openDialog } = useDialog() const selectionService = useSelectionService() - const [selected, setSelected] = useState(selectionService.selection.getValue()) - - useEffect(() => { - const selectedComponentsObserve = selectionService.selection.subscribe((newSelectedComponents) => - setSelected(newSelectedComponents), - ) - - return function cleanup() { - selectedComponentsObserve.dispose() - } - }, [selectionService.selection]) return (
@@ -88,71 +58,7 @@ export const ContentBreadcrumbs = (pr : history.push(getPrimaryActionUrl({ content: item.content, repository, uiSettings, location })) }} /> - {props.batchActions && selected.length > 0 ? ( -
- - { - openDialog({ - name: 'delete', - props: { content: selected }, - dialogProps: { disableBackdropClick: true, disableEscapeKeyDown: true }, - }) - }}> - - - - - { - openDialog({ - name: 'copy-move', - props: { - content: selected, - currentParent: parent, - operation: 'move', - }, - dialogProps: { - disableBackdropClick: true, - disableEscapeKeyDown: true, - classes: { paper: globalClasses.pickerDialog }, - }, - }) - }}> - - - - - { - openDialog({ - name: 'copy-move', - props: { - content: selected, - currentParent: parent, - operation: 'copy', - }, - dialogProps: { - disableBackdropClick: true, - disableEscapeKeyDown: true, - classes: { paper: globalClasses.pickerDialog }, - }, - }) - }}> - - - -
- ) : null} + {props.batchActions && }
) } diff --git a/apps/sensenet/src/components/Home.tsx b/apps/sensenet/src/components/Home.tsx new file mode 100644 index 0000000000..5580766740 --- /dev/null +++ b/apps/sensenet/src/components/Home.tsx @@ -0,0 +1,417 @@ +import { + createStyles, + makeStyles, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Theme, + Tooltip, +} from '@material-ui/core' +import Accordion from '@material-ui/core/Accordion' +import AccordionDetails from '@material-ui/core/AccordionDetails' +import AccordionSummary from '@material-ui/core/AccordionSummary' +import Typography from '@material-ui/core/Typography' +import ExpandMoreIcon from '@material-ui/icons/ExpandMore' +import { useRepository } from '@sensenet/hooks-react' +import React, { lazy, useEffect, useState } from 'react' +// import { useAuth } from '../context/auth-provider' +import { useLocalization } from '../hooks' +import { DateTimeFormatter } from './grid/Formatters/DateTimeFormatter' + +const DashboardComponent = lazy(() => import(/* webpackChunkName: "dashboard" */ './dashboard')) + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + homeCont: { + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + backgroundColor: theme.palette.type === 'light' ? '#f5f7fa' : '#1e1e1e', + padding: '0 20px', + }, + title: { + display: 'flex', + gap: '24px', + padding: '16px 0', + alignItems: 'center', + borderBottom: `1px solid ${theme.palette.type === 'light' ? '#e0e0e0' : '#333'}`, + '& img': { + width: 70, + height: 70, + backgroundColor: 'transparent', + filter: theme.palette.type === 'light' ? 'invert(0)' : 'invert(1)', + }, + '& h1': { + fontSize: '36px', + fontWeight: 600, + color: theme.palette.type === 'light' ? '#333' : '#fff', + }, + }, + sensenetLogo: { + width: 70, + height: 70, + }, + gridsCont: { + gridTemplateColumns: '1fr 1fr', + gap: '24px', + marginTop: '20px', + height: 'calc(100% - 80px)', + overflowY: 'auto', + }, + gridCont: { + backgroundColor: theme.palette.type === 'light' ? '#fff' : '#2c2c2c', + borderRadius: '8px', + boxShadow: '0 2px 10px rgba(0, 0, 0, 0.1)', + padding: '0 20px', + overflowY: 'auto', + '& h2': { + fontSize: '24px', + fontWeight: 500, + color: theme.palette.type === 'light' ? '#333' : '#fff', + marginBottom: '16px', + textAlign: 'left', + }, + }, + accordion: { + backgroundColor: theme.palette.type === 'light' ? '#fafafa' : '#3c3c3c', + borderRadius: '4px', + marginBottom: '12px', + '&.Mui-expanded': { + margin: '0 0 12px 0', + }, + '&:before': { + display: 'none', + }, + '&:hover': { + cursor: 'default', + }, + }, + accordionSummary: { + padding: '12px 16px', + '& .MuiAccordionSummary-content': { + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr 3.5fr', + gap: '12px', + alignItems: 'center', + [theme.breakpoints.down(900)]: { + gridTemplateColumns: '1fr 1fr', + }, + [theme.breakpoints.down(600)]: { + gridTemplateColumns: '1fr', + }, + }, + }, + accordionDetails: { + padding: '16px', + backgroundColor: theme.palette.type === 'light' ? '#f9f9f9' : '#444', + borderTop: `1px solid ${theme.palette.type === 'light' ? '#e0e0e0' : '#555'}`, + }, + truncatedText: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + fixedItem: { + maxWidth: '150px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + color: theme.palette.type === 'light' ? '#555' : '#ccc', + }, + contentPathItem: { + flexGrow: 1, + minWidth: 0, + color: theme.palette.type === 'light' ? '#333' : '#ddd', + }, + table: { + width: '100%', + borderCollapse: 'separate', + borderSpacing: '0 8px', + + '& th': { + backgroundColor: theme.palette.type === 'light' ? '#f5f5f5' : '#3c3c3c', + color: theme.palette.type === 'light' ? '#333' : '#fff', + fontWeight: 500, + }, + '& td': { + color: theme.palette.type === 'light' ? '#666' : '#bbb', + }, + '& thead th:first-child': { + backgroundColor: 'unset', + }, + '& thead th:nth-child(2)': { + borderTopLeftRadius: 8, + borderBottomLeftRadius: 8, + }, + '& thead th:last-child': { + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + }, + '& tbody tr td:first-child': { + borderTopLeftRadius: 8, + borderBottomLeftRadius: 8, + }, + '& tbody tr td:last-child': { + borderTopRightRadius: 8, + borderBottomRightRadius: 8, + }, + '& tbody td:nth-child(2), & tbody td:last-child': { + wordBreak: 'break-all', + }, + }, + tableRow: { + borderRadius: '4px', + '&:hover': { + backgroundColor: theme.palette.type === 'light' ? '#f0f0f0' : '#3a3a3a', + }, + }, + '& :hover': { + cursor: 'default', + }, + }), +) + +export const Home = () => { + const classes = useStyles() + const repo = useRepository() + const localization = useLocalization().home + // const { user } = useAuth() + + const [lastMinuteLogs, setLastMinuteLogs] = useState([]) + // const [myLogs, setMyLogs] = useState([]) + const [hasGetLogs, setHasGetLogs] = useState(false) + // const [hasGetTopLogsByUser, setHasGetTopLogsByUser] = useState(false) + const [canContentHistory, setCanContentHistory] = useState(false) + const [contentHistories, setContentHistories] = useState>({}) + + useEffect(() => { + async function getActionsAndLogs() { + try { + const { d } = await repo.getActions({ idOrPath: '/Root' }) + const actionNames = d.results.map((a: any) => a.Name) + + const canGetLogs = actionNames.includes('GetLogsForLastMinutes') + setCanContentHistory(actionNames.includes('GetTopLogsByContentId')) + // const canGetUserLogs = actionNames.includes('GetTopLogsByUser') + + setHasGetLogs(canGetLogs) + // setHasGetTopLogsByUser(canGetUserLogs) + + if (canGetLogs) await getLatestChanges() + // if (canGetUserLogs) await getMyChanges() + } catch (error: any) { + console.error('Fetching actions failed:', error.message) + } + } + + async function getLatestChanges() { + try { + const response = await repo.executeAction({ + idOrPath: '/Root', + name: 'LogEntries/GetLogsForLastMinutes', + method: 'GET', + oDataOptions: { + top: 100, + minutes: 600, + } as any, + }) + + if (response) { + setLastMinuteLogs(response.filter((res: any) => res.ContentPath !== '/Root/System/Cache/DatabaseUsage.cache')) + } else { + setHasGetLogs(false) + } + } catch (error: any) { + console.error('GetLogsForLastMinutes error:', error.message) + setHasGetLogs(false) + } + } + + // async function getMyChanges() { + // try { + // const response = await repo.executeAction({ + // idOrPath: '/Root', + // name: 'LogEntries/GetTopLogsByUser', + // method: 'GET', + // oDataOptions: { + // userName: user?.LoginName, + // limit: 100, + // minutes: 2400, + // } as any, + // }) + // // if (response) { + // // setMyLogs(response) + // // } else { + // // setHasGetTopLogsByUser(false) + // // } + // } catch (error: any) { + // console.error('GetTopLogsByUser error:', error.message) + // setHasGetTopLogsByUser(false) + // } + // } + + getActionsAndLogs() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const getTable = (log: any) => { + const changes = log.ExtendedProperties?.ChangedData + if (!changes) return null + const rows = changes + .map((change: any[]) => { + const nameObj = change.find((obj) => obj.name) + const oldValueObj = change.find((obj) => obj.oldValue) + const newValueObj = change.find((obj) => obj.newValue) + + if (!nameObj || nameObj.name === 'raw') return null + + return { + key: nameObj.name, + oldValue: oldValueObj?.oldValue ?? '', + newValue: newValueObj?.newValue ?? '', + } + }) + .filter(Boolean) + return ( + + + + + + {localization.oldValue} + + + {localization.newValue} + + + + + {rows.map((row: any, idx: any) => ( + + + {row.key.replace(/([a-z])([A-Z])/g, '$1 $2')} + + {row.oldValue} + {row.newValue} + + ))} + +
+ ) + } + + async function fetchContentHistory(contentId: string) { + try { + const response = await repo.executeAction({ + idOrPath: '/Root', + name: 'LogEntries/GetTopLogsByContentId', + method: 'GET', + oDataOptions: { contentId, limit: 10 } as any, + }) + setContentHistories((prev) => ({ + ...prev, + [contentId]: response || [], + })) + } catch (error: any) { + console.error('GetTopLogsByContentId error:', error.message) + } + } + + return !hasGetLogs ? ( + + ) : ( +
+
+ {hasGetLogs && ( +
+

{localization.latestChanges}

+ {lastMinuteLogs.map((log, index) => { + const hasChanges = log.ExtendedProperties?.ChangedData?.length > 0 + return ( + + } + aria-controls={`panel${index}-content`} + id={`panel${index}-header`} + className={classes.accordionSummary}> + + {log.LogDate ? DateTimeFormatter({ value: log.LogDate }) : ''} + + + {log.Message.replace(/([a-z])([A-Z])/g, '$1 $2') || ''} + + {log.UserName || ''} + + + {log.ContentPath || ''} + + + + {hasChanges && ( + {getTable(log)} + )} + {canContentHistory && ( + <> + fetchContentHistory(log.ContentId)} + style={{ margin: '8px' }}> + } + aria-controls="content-history-content" + id="content-history-header" + className={''}> + {localization.contentHistory} + + + {(contentHistories[log.ContentId] || []).map((historyLog, idx) => ( +
{getTable(historyLog)}
+ ))} +
+
+ + )} +
+ ) + })} +
+ )} + + {/* {hasGetTopLogsByUser && ( +
+

My Changes

+ {myLogs.map((log, index) => { + const hasChanges = log.ExtendedProperties?.ChangedData?.length > 0 + return ( + + } + aria-controls={`panel${index}-content`} + id={`panel${index}-header`} + className={classes.accordionSummary}> + + {log.LogDate ? DateTimeFormatter({ value: log.LogDate }) : ''} + + {log.Message || ''} + {log.UserName || ''} + + + {log.ContentPath || ''} + + + + {hasChanges && ( + {getTable(log)} + )} + + ) + })} +
+ )} */} +
+
+ ) +} diff --git a/apps/sensenet/src/components/Icon.tsx b/apps/sensenet/src/components/Icon.tsx index 237efa12a6..4bfc42af62 100644 --- a/apps/sensenet/src/components/Icon.tsx +++ b/apps/sensenet/src/components/Icon.tsx @@ -184,18 +184,35 @@ const getIconByName = (name: string | undefined, options: IconOptions) => { } } -const getIconByPath = (icon: string | undefined, options: IconOptions) => { - if (!icon || !icon.startsWith('/')) { - return null - } - - return -} - /* eslint-disable react/display-name */ export const defaultContentResolvers: Array> = [ { - get: (item, options) => getIconByPath(item.Icon, options), + get: (item, options) => { + let icon = item.Icon + const name = item.Name?.toLowerCase() + const type = item.Type?.toLowerCase() + const path = item.Path?.toLowerCase() + if (!icon || !type || !path) { + return null + } else if (icon.toLowerCase() === 'excel') { + icon = '/Root/System/Images/Icons/colors/csv.svg' + } else if (icon.toLowerCase() === 'word') { + icon = '/Root/System/Images/Icons/colors/word.svg' + } else if (icon.toLowerCase() === 'acrobat' || icon.toLowerCase() === 'adobe') { + icon = '/Root/System/Images/Icons/colors/pdf.svg' + } else if (name.endsWith('.xls') || name.endsWith('.xlsx') || name.endsWith('.xlsm')) { + icon = '/Root/System/Images/Icons/colors/xls.svg' + } else if (type.endsWith('file')) { + if (path.endsWith('.csv')) { + icon = '/Root/System/Images/Icons/colors/csv.svg' + } else if (path.endsWith('.svg')) { + icon = '/Root/System/Images/Icons/colors/file_img.svg' + } + } else if (!icon.startsWith('/')) { + return null + } + return + }, }, { get: (item, options) => diff --git a/apps/sensenet/src/components/IconFromPath.tsx b/apps/sensenet/src/components/IconFromPath.tsx index 641e1a4d87..2528cd91ce 100644 --- a/apps/sensenet/src/components/IconFromPath.tsx +++ b/apps/sensenet/src/components/IconFromPath.tsx @@ -1,48 +1,81 @@ import { PathHelper } from '@sensenet/client-utils' -import React, { memo, useEffect, useState } from 'react' +import React, { memo, useEffect, useMemo, useState } from 'react' import { IconOptions } from './Icon' +// Global cache for icons +const iconCache = new Map() + const IconFromPath = ({ path, options }: { path: string; options: IconOptions }) => { - const [icon, setIcon] = useState(null) + const [icon, setIcon] = useState(iconCache.get(path) || null) useEffect(() => { - async function fetchData() { - if (options.repo.iconCache.has(path)) { - const cachedData = options.repo.iconCache.get(path) ?? '' - setIcon(cachedData) + const controller = new AbortController() + const { signal } = controller + + const fetchIcon = async () => { + // Check cache first + if (iconCache.has(path)) { + setIcon(iconCache.get(path)!) return } const imageUrl = PathHelper.joinPaths(options.repo.configuration.repositoryUrl, path) if (path.endsWith('.svg')) { - const fetchedSvg = await options.repo.fetch(imageUrl, { cache: 'force-cache' }) - if (!fetchedSvg.ok) { - return + 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) } - const svg = await fetchedSvg.text().catch(() => '') - options.repo.iconCache.set(path, svg) - setIcon(svg) - return } + } + + if (!icon) { + fetchIcon() + } - options.repo.iconCache.set(path, imageUrl) - setIcon(imageUrl) + return () => { + controller.abort() } - fetchData() - }, [options.repo, path]) + }, [path, options.repo, icon]) - if (!icon) { - return null - } + // Memoize the rendered output to prevent unnecessary DOM updates + const renderedIcon = useMemo(() => { + if (!icon) return null - if (path.endsWith('.svg')) { - return - } + return path.endsWith('.svg') ? ( +