diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..793ac7589e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# Node modules (will be installed in container) +node_modules +*/node_modules +**/node_modules + +# Build outputs +dist +**/dist +build +**/build +coverage +**/coverage +*.tsbuildinfo +**/*.tsbuildinfo + +# Development files +.git +.github +.vscode +*.log +# Note: We need yarn.lock and package-lock.json for dependency resolution +# *.lock + +# Docker +*.dockerignore +Dockerfile* +docker-compose* +.docker + +# Docker volumes +docker_data/ +docker_volumes/ + +# Development +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Cypress +cypress/videos +cypress/screenshots + +# Misc +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..69d4a88f84 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Environment Variables for Docker Compose +# Copy this file to .env and modify as needed + +# Authentication Type (SNAuth or IdentityServer) +AUTH_TYPE=SNAuth + +# Node Environment +NODE_ENV=production + +# Ports +SENSENET_CLIENT_PORT=8080 +SENSENET_DEV_PORT=3000 + +# Development Settings (for hot reload) +CHOKIDAR_USEPOLLING=true +WATCHPACK_POLLING=true +DISABLE_VIEW_OPTIONS_MENU=false +WEBPACK_DEV_SERVER_CLIENT_WEB_SOCKET_URL=auto://0.0.0.0:0/ws + +# Database Settings (if using database service) +# DB_PASSWORD=YourPassword123! +# DB_NAME=SenseNet +# DB_USER=sa + +# Redis Settings (if using redis service) +# REDIS_PASSWORD= + +# Traefik Settings (if using traefik service) +# TRAEFIK_DOMAIN=sensenet.local + +# Backend API URL (if connecting to external backend) +# REACT_APP_SERVICE_URL=https://dev.demo.sensenet.com +# REACT_APP_IDENTITY_SERVER_URL=https://is.demo.sensenet.com diff --git a/.eslintrc.js b/.eslintrc.js index 623ff317fb..5c6c17b46f 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, @@ -92,6 +96,13 @@ module.exports = { ], }, overrides: [ + { + files: ['**/*.config.js'], + rules: { + // Node configuration files use CommonJS imports. + '@typescript-eslint/no-var-requires': 'off', + }, + }, { files: ['**/test/**/*.{ts,tsx}'], rules: { diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..af30865863 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.husky/* text eol=lf 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 6ef02634c1..3c3ea38285 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') }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000000..4ae75c7563 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,68 @@ +name: Docker Image CI + +on: + push: + branches: + - 'develop' + - 'main' + - 'feature/docker-containerization' + - 'feature/sn-auth-package-extraimprovements' + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches: + - 'develop' + - 'main' + - 'feature/sn-auth-package-extraimprovements' + +jobs: + build: + runs-on: ubuntu-latest + # Skip draft PRs + if: github.event.pull_request.draft == false || github.event_name == 'push' + + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Check if Dockerfile exists + id: check_dockerfile + run: | + if [ -f "Dockerfile" ]; then + echo "dockerfile_exists=true" >> $GITHUB_OUTPUT + else + echo "dockerfile_exists=false" >> $GITHUB_OUTPUT + echo "⚠️ No Dockerfile found, skipping Docker build" + fi + + - name: Set up Docker metadata + if: steps.check_dockerfile.outputs.dockerfile_exists == 'true' + id: meta + uses: docker/metadata-action@v5 + with: + images: sensenetcsp/sn-client + tags: | + # Clean branch name (e.g., feature-docker-containerization) + type=ref,event=branch + # Branch name with SHA (e.g., feature-docker-containerization-abc1234) + type=ref,event=branch,suffix=-{{sha}} + # Latest tag for main branch + type=raw,value=latest,enable={{is_default_branch}} + # PR number for pull requests + type=ref,event=pr + + - name: Login to DockerHub + if: steps.check_dockerfile.outputs.dockerfile_exists == 'true' && github.event_name == 'push' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + if: steps.check_dockerfile.outputs.dockerfile_exists == 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 6a86cc155e..288a352a61 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,8 @@ jspm_packages/ # Misc .DS_Store +pipe\[0] +# Environment variable files +.env +.env.local +.env.*.local diff --git a/.husky/pre-commit b/.husky/pre-commit index d2ae35e84b..45609d0f68 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,5 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -yarn lint-staged +PATH="$(dirname "$0")/../node_modules/.bin:$PATH" +lint-staged diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000000..0590481343 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,129 @@ +# Docker Setup for SenseNet Client + +This guide explains how to run the SenseNet client application using Docker. + +## 🚀 Quick Start + +### Development (with hot reload) +```bash +docker-compose -f docker-compose.dev.yml up -d +``` +- **URL**: http://localhost:8080 +- **Hot reload**: ✅ File changes are instantly reflected +- **Use case**: Active development + +### Production +```bash +docker-compose -f docker-compose.prod.yml up -d +``` +- **URL**: http://localhost:8080 +- **Hot reload**: ❌ Static built files +- **Use case**: Testing production builds, deployment + +## 📁 Files Overview + +| File | Purpose | +|------|---------| +| `Dockerfile` | Single Docker image for both dev and prod | +| `docker-compose.dev.yml` | Development setup with volume mounts | +| `docker-compose.prod.yml` | Production setup without volume mounts | +| `.dockerignore` | Excludes unnecessary files from build context | + +## 🔧 How It Works + +### Development Mode +- **Volume mounting**: Your local code is mounted into the container +- **File watching**: Changes trigger automatic rebuilds +- **Command**: `yarn snapp start` (webpack dev server with hot reload) + +### Production Mode +- **Built files**: Uses pre-built static files inside the container +- **No volumes**: Container is self-contained +- **Command**: `yarn snapp start` (same command, but runs webpack dev server on built files) + +## 🛠️ Common Commands + +```bash +# Start development +docker-compose -f docker-compose.dev.yml up -d + +# Stop development +docker-compose -f docker-compose.dev.yml down + +# Rebuild and start (after dependency changes) +docker-compose -f docker-compose.dev.yml up --build -d + +# View logs +docker-compose -f docker-compose.dev.yml logs -f + +# Start production +docker-compose -f docker-compose.prod.yml up -d +``` + +## 🐳 Docker Images + +Automatic builds are available on DockerHub: + +```bash +# Latest development build +docker pull sensenetcsp/sn-client:feature-docker-containerization + +# Specific commit +docker pull sensenetcsp/sn-client:feature-docker-containerization-abc1234 + +# Production (when merged to main) +docker pull sensenetcsp/sn-client:latest +``` + +## ⚙️ Configuration + +### Environment Variables +Both compose files support these environment variables: + +- `NODE_ENV`: `development` or `production` +- `AUTH_TYPE`: `SNAuth` or `IdentityServer` +- `CHOKIDAR_USEPOLLING`: `true` (dev only, for file watching) +- `WATCHPACK_POLLING`: `true` (dev only, for webpack) + +### Port Configuration +- **Default**: Port 8080 for both dev and prod +- **Customizable**: Change the host port in docker-compose files + +## 🔍 Troubleshooting + +### Container won't start +```bash +# Check logs +docker-compose -f docker-compose.dev.yml logs + +# Rebuild from scratch +docker-compose -f docker-compose.dev.yml down +docker-compose -f docker-compose.dev.yml up --build +``` + +### Hot reload not working +- Ensure you're using the dev compose file +- Restart the container if file watching stops working + +### Port already in use +```bash +# Change the port in docker-compose file +ports: + - "3000:8080" # Use port 3000 instead of 8080 +``` + +## 📦 Build Process + +The Docker build process: +1. **Copy source code** (excluding files in `.dockerignore`) +2. **Install dependencies** with `yarn install` +3. **Build packages** with `yarn build` +4. **Start application** with `yarn snapp start` + +## 🚀 CI/CD + +GitHub Actions automatically builds and pushes Docker images when: +- Code is pushed to `feature/docker-containerization` +- Pull requests target `develop`, `main`, or `feature/sn-auth-package-extraimprovements` + +Images are tagged based on branch names and commit SHAs for easy identification. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..1eeba2805c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Dockerfile for SenseNet client +FROM node:20-alpine + +# Install serve for static file serving +RUN yarn global add serve + +# Set working directory +WORKDIR /app + +# Copy everything (dockerignore excludes unwanted files) +COPY . . + +# Install dependencies +RUN HUSKY=0 yarn install --frozen-lockfile + +# 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 +EXPOSE 8080 + +# Health check (start-period is short since static server starts instantly) +HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1 + +# Serve pre-built static files (fast startup, SPA mode with -s flag) +CMD ["serve", "-s", "apps/sensenet/build", "-l", "8080"] diff --git a/apps/sensenet/README.md b/apps/sensenet/README.md index ea765a613f..6f86d064ff 100644 --- a/apps/sensenet/README.md +++ b/apps/sensenet/README.md @@ -35,6 +35,16 @@ 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. +### Explorer views + +Use the toolbar above the right panel to switch between **Details (grid)**, **List**, **Icons**, and **Thumbnails**. Icons and thumbnails support small, medium, large, and extra large sizes. Selection is kept when switching views; use Ctrl/Cmd-click to select individual items and Shift-click to select a range. + +Open **View options → Explorer** to choose the default view, icon/thumbnail size, and whether to show content types or prefer display names. These preferences are saved in this browser. The toolbar's view choice stays active while browsing folders; opening the explorer again or reloading uses the default view. Thumbnails show images and existing document previews, with a type icon when no preview is available. + +### 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. @@ -59,3 +69,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/content-types/AUIApplication.xml b/apps/sensenet/content-types/AUIApplication.xml new file mode 100644 index 0000000000..f5382c4a8a --- /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/cypress/e2e/content/explorer.cy.ts b/apps/sensenet/cypress/e2e/content/explorer.cy.ts index 90acd9747e..19c14dee03 100644 --- a/apps/sensenet/cypress/e2e/content/explorer.cy.ts +++ b/apps/sensenet/cypress/e2e/content/explorer.cy.ts @@ -1,20 +1,6 @@ import { pathWithQueryParams } from '../../../src/services/query-string-builder' -const newColumnSettings = { - columns: [ - { field: 'DisplayName', title: 'Test Display' }, - { field: 'AvailableContentTypeFields', title: 'Test' }, - ], -} - -const originalColumnSettings = { - columns: [ - { field: 'DisplayName', title: 'Display Name' }, - { field: 'AvailableContentTypeFields', title: 'Available Content Type Fields' }, - ], -} - -describe('Add new permission entry', () => { +describe('Column settings', () => { before(() => { cy.login('superAdmin') cy.visit(pathWithQueryParams({ path: '/', newParams: { repoUrl: Cypress.env('repoUrl') } })) @@ -24,34 +10,40 @@ describe('Add new permission entry', () => { it('It should open Content Explorer and change the Columns', () => { cy.get('[data-test="drawer-menu-item-content"]').click() cy.get('[data-test="column-settings"]').click() - - cy.get('.react-monaco-editor-container textarea') - .type('{ctrl}a', { force: true }) - .clear({ force: true }) - .type(JSON.stringify(newColumnSettings), { - parseSpecialCharSequences: false, - }) - - cy.get('[data-test="monaco-editor-submit"]').click() - - cy.get('[data-test="table-header-actions"]').should('be.visible').find('.MuiButtonBase-root').contains('Action') - cy.get('[data-test="table-header-availablecontenttypefields"]') - .should('be.visible') - .find('.MuiButtonBase-root') - .contains('Test') - cy.get('[data-test="table-header-displayname"]') - .should('be.visible') - .find('.MuiButtonBase-root') - .contains('Test Display') + cy.get('[data-test="column-settings-source"]').should('be.visible') + + cy.get('[data-test="column-settings-field-search"]').type('CreationDate') + cy.get('[role="option"]').should('contain', 'Creation Date').and('contain', 'CreationDate') + cy.get('[data-test="column-settings-field-search"]').type('{esc}').clear() + + const dataTransfer = new DataTransfer() + cy.get('[data-test="column-settings-drag-availablecontenttypefields"]').trigger('dragstart', { dataTransfer }) + cy.get('[data-test="column-settings-row-displayname"]') + .trigger('dragover', { dataTransfer }) + .trigger('drop', { dataTransfer }) + cy.get('[data-test^="column-settings-row-"]') + .first() + .should('have.attr', 'data-test', 'column-settings-row-availablecontenttypefields') + + cy.get('[data-test="column-settings-drag-displayname"]').trigger('dragstart', { dataTransfer }) + cy.get('[data-test="column-settings-row-availablecontenttypefields"]') + .trigger('dragover', { dataTransfer }) + .trigger('drop', { dataTransfer }) + + cy.get('[data-test="column-settings-row-displayname"] input').clear().type('Test Display') + cy.get('[data-test="column-settings-row-availablecontenttypefields"] input').clear().type('Test') + cy.get('[data-test="column-settings-save"]').click() + + cy.get('.ag-header-cell[col-id="Actions"]').should('be.visible').should('not.contain', 'Actions') + cy.get('[data-test="column-settings"]').should('be.visible') + cy.get('.ag-header-cell[col-id="AvailableContentTypeFields"]').should('be.visible').contains('Test') + cy.get('.ag-header-cell[col-id="DisplayName"]').should('be.visible').contains('Test Display') cy.get('[data-test="column-settings"]').click() - - cy.get('.react-monaco-editor-container textarea') - .type('{ctrl}a', { force: true }) - .clear({ force: true }) - .type(JSON.stringify(originalColumnSettings), { - parseSpecialCharSequences: false, - }) - cy.get('[data-test="monaco-editor-submit"]').click() + cy.get('[data-test="column-settings-row-displayname"] input').clear().type('Display Name') + cy.get('[data-test="column-settings-row-availablecontenttypefields"] input') + .clear() + .type('Available Content Type Fields') + cy.get('[data-test="column-settings-save"]').click() }) }) diff --git a/apps/sensenet/docs/auiapplications.md b/apps/sensenet/docs/auiapplications.md new file mode 100644 index 0000000000..055638a954 --- /dev/null +++ b/apps/sensenet/docs/auiapplications.md @@ -0,0 +1,404 @@ +# 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 + } + location: { + href: string + pathname: string + search: string + hash: string + params: Record + } + theme: 'light' | 'dark' + 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 + arrayBuffer(): Promise + blob(): Promise +} +``` + +Binary responses are transferred as `ArrayBuffer`, so downloads from endpoints such as +`/binaryhandler.ashx?nodeid=...&propertyname=Binary` can be read with `blob()` or +`arrayBuffer()` without converting the body through text first. + +Requests are restricted to the current repository origin. Cross-repository and arbitrary external requests are rejected by the parent Admin UI. + +## Admin UI Route Location + +The iframe is sandboxed, so custom HTML should not try to read the parent route through `window.parent.location` or infer it from `document.referrer`. The Admin UI injects the current React Router location into the bridge: + +```js +const app = window.sensenetAdminApp +const userId = app.location.params.userId +``` + +For example, if the browser address bar shows: + +```text +/content/explorer/?path=%2FContent%2FKELERData%2FKYCForm%2Fkycuserforms&userId=tarii +``` + +then inside the `AUIApplication` iframe: + +```js +window.sensenetAdminApp.location.search +// "?path=%2FContent%2FKELERData%2FKYCForm%2Fkycuserforms&userId=tarii" + +window.sensenetAdminApp.location.params.userId +// "tarii" +``` + +The `params` object is built from the parent route query string with `Object.fromEntries(new URLSearchParams(location.search))`. + +## Admin UI Theme + +The bridge also exposes the current Admin UI theme mode. Use this instead of trying to inspect parent styles from the sandboxed iframe: + +```js +const theme = window.sensenetAdminApp.theme +document.documentElement.dataset.theme = theme + +if (theme === 'dark') { + // Render dark-friendly colors. +} +``` + +The value is always either `"light"` or `"dark"`, matching the Admin UI view option. + +## 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 +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/docs/local-authentication.md b/apps/sensenet/docs/local-authentication.md new file mode 100644 index 0000000000..5a9b75c0d0 --- /dev/null +++ b/apps/sensenet/docs/local-authentication.md @@ -0,0 +1,105 @@ +# Internal repository authentication (SB-167) + +The Admin UI discovers `/authentication/capabilities` before mounting an external provider. +A 404 preserves the legacy SNAuth/IdentityServer flow. Other discovery errors do not select local +authentication. Secondary displays an explicit external/internal choice; InternalOnly opens the local +login form. Deep-link paths remain intact. A local session is restored only for the selected repository. + +Local login sends the repository username, password and optional MFA verification code to the fixed +repository login endpoint over HTTPS. Passwords and MFA codes are not stored. Tokens live in +sessionStorage, scoped by normalized repository URL and issuer; they are separate from SNAuth/OIDC storage +and are cleared by logout/session loss. Browser tabs do not share a live local session after opening +(the browser may initially copy sessionStorage when duplicating a tab). + +All local repository calls, including binary fetches through Repository.fetch, use the local bearer +transport with cookies omitted and redirects rejected. Tokens cannot be sent outside the repository +origin/path. Refresh is single-flight, uses a ten-second expiry margin, and retries a 401 only once. +Logout/refresh races cannot restore a cleared session. Logout calls only the local endpoint; a failed +server revocation is shown separately from successful local cleanup. + +The navigation repository selector supports multiple saved local sessions. Select another repository +from the login screen to establish a new one. Local and external providers use separate session lists. +The current user returned by the repository must match the JWT subject before protected content renders. + + +For an Admin UI hosted on the repository's own origin, open `/login` (or `/login/`). +Without a `repoUrl` query parameter, this selects `window.location.origin` and uses the normal +capability discovery and authentication policy. An explicit `repoUrl` still takes precedence, +including on `/login`, so shared Admin UI deployments can target another repository. Other paths +do not infer a repository from their origin. Configure the web server to serve the SPA for `/login` +and forward the repository API and authentication endpoints to the backend. + +## Server prerequisites + +Enable the optional server module in the matching sensenet SB-167 branch. Configure HTTPS, signing +keys, allowed login/token CIDRs, trusted proxies, user/group allowlists and MFA. Permit the Admin UI +origin through the repository's existing CORS settings. Disable AddJwtCookie for the local module. +Provider availability is server policy; the UI never enables internal auth during an external outage. + +## Validation + +```powershell +# Use Jest 29 with the project's jsdom 29 / ts-jest 29 dependencies. +yarn workspace @app/sensenet test --runInBand local-authentication +yarn workspace @app/sensenet build:snauth +yarn workspace @app/sensenet build:idserver +``` + +The new tests cover explicit provider choices, deep links, form credentials/MFA, current-user +verification, session isolation, refresh concurrency, bounded retries and logout/session loss. +They use controlled HTTP responses. Production SNAuth and IdentityServer bundles compile; +a live browser-to-repository deployment still needs environment-specific smoke testing. + +## Native local login and repository branding + +The local login is rendered by Admin UI, using native HTML/CSS controls. Its SNAuth-style +layout, mountain background, sensenet logo and Spartan fonts are bundled locally. +The repository remains a separate API application. + +The selected repository's capabilities return `local.appearance`: title, backgroundImageUrl, +logoUrl, backgroundColor, brandColor (left panel), buttonColor, buttonTextColor, textColor +and panelColor (form panel). Images must be public HTTPS assets; colors are hex values. +Invalid values fall back to the bundled defaults. Reconfigure the repository and reload +the UI to change its appearance, without rebuilding this app. +See the backend's `docs/local-authentication.md` for environment/configuration examples. + +When `local.mfa` is advertised, login uses a separate authenticator step. After password +verification the repository may return an enrollment QR/manual key. Passwords and authenticator codes are never saved in React state or browser storage. +The MFA challenge/setup key lives only in component memory until verification or navigation. +A session is saved only after successful verification. Existing repositories without +the progressive MFA endpoint retain the legacy combined credentials/code form. + +Forgot password appears only when the repository advertises recovery. It sends only the +email address to that repository's fixed endpoint; the trusted email return URL is a +server setting. The email link contains `repoUrl` and `#localResetToken=...`. The UI removes +the fragment immediately, prevents automatic session restoration, asks for matching new +passwords, and requires a normal sign-in after reset. MFA remains enabled. Do not add +analytics or request logging that captures this fragment or credential form data. + +## Build a new local container from source + +From the sn-client repository root: + +```powershell +docker build -t local/sn-adminui:sb167 . +if ($LASTEXITCODE -ne 0) { throw 'Admin UI build failed' } +# Choose a free port, or stop the old container using 8080 first. +docker run -d --name sb167-adminui-source -p 127.0.0.1:8080:8080 local/sn-adminui:sb167 +``` + +Open `http://localhost:8080/?repoUrl=https%3A%2F%2Finsql-daily.test.sensenet.cloud`. +This Dockerfile installs the locked dependencies and builds the source; `docker start` +alone only restarts an already created container and does not rebuild code. +After another source change, build again, remove/recreate only this local UI container. +The test backend's recovery ResetUrl should be `http://localhost:8080/`. + +Targeted tests (from the repository root with the installed workspace dependencies): + +```powershell +$env:NODE_ENV='test' +node -e "require('./packages/sn-auth-react/node_modules/jest').run(process.argv.slice(1))" -- --config apps/sensenet/jest.config.js --runInBand local-authentication local-login-page +``` + +Use Jest 29; a hoisted legacy Jest 27 binary is incompatible with this jsdom environment. +The integration tests include reset-fragment removal, native form rendering, progressive +MFA, recovery confirmation, provider selection, token isolation and legacy compatibility. diff --git a/apps/sensenet/examples/auiapplication-active-users.html b/apps/sensenet/examples/auiapplication-active-users.html new file mode 100644 index 0000000000..f2b6ff2d5a --- /dev/null +++ b/apps/sensenet/examples/auiapplication-active-users.html @@ -0,0 +1,329 @@ +
+
+

Admin UI Application példa

+

Aktív userek

+

+ Ó, admin UI istene, ki a káoszból táblázatot, a bizonytalanságból jogosultságot, a kattintásból pedig + működő workflow-t teremtesz: legyen ma kegyes hozzánk a grid, és mutassa meg a + /Root/IMS alatt élő aktív felhasználókat. +

+
+ +
+
+
+

Felhasználók

+

Betöltés...

+
+
+ + +
+
+ +
Betöltés...
+ + + + + + + + + + +
+
+ + + + diff --git a/apps/sensenet/examples/auiapplication-banner-images.html b/apps/sensenet/examples/auiapplication-banner-images.html new file mode 100644 index 0000000000..869e73b3c4 --- /dev/null +++ b/apps/sensenet/examples/auiapplication-banner-images.html @@ -0,0 +1,274 @@ +
+
+

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/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/jest.config.js b/apps/sensenet/jest.config.js new file mode 100644 index 0000000000..3f58fe3ff3 --- /dev/null +++ b/apps/sensenet/jest.config.js @@ -0,0 +1,46 @@ +const path = require('path') + +module.exports = { + rootDir: '../..', + setupFiles: ['/apps/sensenet/test/setup.js'], + testEnvironment: require.resolve('jest-environment-jsdom', { + paths: [path.resolve(__dirname, '../../packages/sn-auth-react')], + }), + modulePathIgnorePatterns: [ + '/packages/sn-controls-react/test/__mocks__', + '/packages/sn-control-mapper/test/__mocks__', + ], + moduleNameMapper: { + '\\.(css|png|svg|ttf)$': '/apps/sensenet/test/asset-mock.js', + '^@sensenet/list-controls-react$': '/packages/sn-list-controls-react/src/ContentList', + '^@sensenet/sn-auth-react$': '/packages/sn-auth-react/src', + '^@sensenet/(.*)$': '/packages/sn-$1/src', + '^react$': path.dirname(require.resolve('react/package.json')), + '^react-dom(.*)$': `${path.dirname(require.resolve('react-dom/package.json'))}$1`, + '^@material-ui/core(.*)$': `${path.dirname(require.resolve('@material-ui/core/package.json'))}$1`, + '^uuid$': '/node_modules/uuid/dist/index.js', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + diagnostics: false, + isolatedModules: true, + tsconfig: { + target: 'ES2019', + module: 'CommonJS', + jsx: 'react', + esModuleInterop: true, + experimentalDecorators: true, + emitDecoratorMetadata: true, + }, + }, + ], + 'ag-grid-react/lib/reactUi/header/headerCellComp\\.js$': '/apps/sensenet/loaders/ag-grid-react-aria.js', + }, + transformIgnorePatterns: ['/node_modules/(?!ag-grid-react/lib/reactUi/header/headerCellComp\\.js$)'], + testMatch: [ + '/apps/sensenet/test/**/*.test.ts?(x)', + '/packages/sn-auth-react/test/**/*.test.ts?(x)', + ], +} diff --git a/apps/sensenet/loaders/ag-grid-react-aria.js b/apps/sensenet/loaders/ag-grid-react-aria.js new file mode 100644 index 0000000000..0c20137621 --- /dev/null +++ b/apps/sensenet/loaders/ag-grid-react-aria.js @@ -0,0 +1,24 @@ +// AG Grid 27 renders aria-description as a React prop, but React 16/17 do not +// recognize it. Set the same accessible description on the DOM node instead. +// Keep this workaround scoped to the pinned header component; remove it when +// upgrading to a React version that supports aria-description. +module.exports = function agGridReactAria(source) { + const setter = 'setAriaDescription: function (description) { return setAriaDescription(description); }' + const prop = ', "aria-description": ariaDescription' + if (!source.includes(setter) || !source.includes(prop)) { + throw new Error('AG Grid header changed: review the React 16 aria-description compatibility loader') + } + return source + .replace( + setter, + `setAriaDescription: function (description) { + if (!eGui.current) return; + if (description) eGui.current.setAttribute('aria-description', description); + else eGui.current.removeAttribute('aria-description'); + }`, + ) + .replace(prop, '') +} + +// Use the same compatibility code when exercising the real grid in Jest. +module.exports.process = (source) => ({ code: module.exports(source) }) diff --git a/apps/sensenet/package.json b/apps/sensenet/package.json index 1edf524ec2..52d1e5c69c 100644 --- a/apps/sensenet/package.json +++ b/apps/sensenet/package.json @@ -14,10 +14,16 @@ "content management" ], "scripts": { + "test": "jest --config jest.config.js", "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 +66,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,11 +89,12 @@ "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", + "@material-ui/core": "4.12.4", "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "4.0.0-alpha.58", + "@material-ui/lab": "4.0.0-alpha.61", "@material-ui/pickers": "^3.3.10", "@sensenet/authentication-oidc-react": "^2.3.1", "@sensenet/client-core": "^4.1.0", @@ -100,12 +108,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 079593a5f6..56dc0965f1 100644 --- a/apps/sensenet/src/application-paths.ts +++ b/apps/sensenet/src/application-paths.ts @@ -1,4 +1,5 @@ import { BrowseType } from './components/content' +import { FAVORITES_ROOT_PATH } from './services/favorites-constants' export const PATHS = { loginCallback: { appPath: '/authentication/login-callback' }, @@ -9,22 +10,26 @@ 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' }, + favorites: { appPath: '/favorites/:browseType/:action?', snPath: FAVORITES_ROOT_PATH }, + 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, - 'content' | 'usersAndGroups' | 'contentTypes' | 'trash' | 'contentTemplates' + 'content' | 'favorites' | 'usersAndGroups' | 'contentTypes' | 'trash' | 'contentTemplates' > type RoutesWithActionParam = keyof Pick 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..e82cd3575b --- /dev/null +++ b/apps/sensenet/src/auth-config.ts @@ -0,0 +1,10 @@ +export type AuthServerType = 'SNAuth' | 'IdentityServer' | 'Local' + +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..5ef92fdb43 --- /dev/null +++ b/apps/sensenet/src/components/BatchActions.tsx @@ -0,0 +1,256 @@ +import { useTheme } from '@material-ui/core/styles' +import AppsIcon from '@material-ui/icons/Apps' +import ArchiveIcon from '@material-ui/icons/Archive' +import CheckBoxOutlined from '@material-ui/icons/CheckBoxOutlined' +import Close from '@material-ui/icons/Close' +import DeleteIcon from '@material-ui/icons/Delete' +import EditOutlined from '@material-ui/icons/EditOutlined' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import FileCopyOutlinedIcon from '@material-ui/icons/FileCopyOutlined' +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 { supportsFullscreenEdit } from '../services' +import { downloadContentsAsZip } from '../services/zip-download' +import { useContextMenuActions } from './context-menu/use-context-menu-actions' +import { CsvExportDialog } from './CsvExportDialog' +import { useDialog } from './dialogs' +import { ExplorerActionMenu } from './ExplorerActionMenu' +import './batch-actions.css' + +export const BatchActions = () => { + const selectionService = useSelectionService() + const localization = useLocalization() + const globalClasses = useGlobalStyles() + const theme = useTheme() + 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) + const { runAction } = useContextMenuActions(selected[0] || parent, () => undefined) + const selectedCountLabel = localization.contentViews.selectedCount.replace('{0}', String(selected.length)) + + useEffect(() => { + const selectedComponentsObserve = selectionService.selection.subscribe((newSelectedComponents) => { + setSelected(newSelectedComponents) + }) + + return function cleanup() { + selectedComponentsObserve.dispose() + } + }, [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) + } + } + + const secondaryActions = [ + ...(selected.length === 1 && supportsFullscreenEdit(selected[0]) + ? [ + { + id: 'content-fullscreen-edit-action', + label: localization.settings.fullscreenEdit, + icon: , + onClick: () => runAction('EditBinary'), + }, + ] + : []), + { + id: 'batch-odata-actions', + label: localization.customActions.oDataActionsDialog.menuTitle, + icon: , + disabled: selected.length !== 1, + onClick: () => + openDialog({ + name: 'odata-actions', + props: { content: selected[0] }, + dialogProps: { classes: { paper: globalClasses.pickerDialog } }, + }), + }, + { + id: 'batch-export-csv', + label: localization.batchActions.exportCsv, + icon: , + disabled: selected.length === 0, + onClick: () => setIsExportDialogOpen(true), + }, + { + id: 'batch-download-zip', + label: localization.batchActions.downloadZip, + icon: isZipDownloading ? : , + disabled: selected.length === 0 || isZipDownloading, + onClick: downloadSelectedContentAsZip, + }, + ] + + if (!selected.length) return null + + return ( +
+
+ + + +
+
+
+ + + +
+
+ setIsExportDialogOpen(false)} + /> +
+ ) +} diff --git a/apps/sensenet/src/components/Breadcrumbs.tsx b/apps/sensenet/src/components/Breadcrumbs.tsx index f6ce22575c..04fd07b974 100644 --- a/apps/sensenet/src/components/Breadcrumbs.tsx +++ b/apps/sensenet/src/components/Breadcrumbs.tsx @@ -1,11 +1,16 @@ +import { Menu, useTheme } 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 ChevronRightOutlined from '@material-ui/icons/ChevronRightOutlined' import { GenericContent } from '@sensenet/default-content-types' -import React, { MouseEvent, useState } from 'react' +import { useRepository } from '@sensenet/hooks-react' +import React, { CSSProperties, MouseEvent, useEffect, useState } from 'react' +import { useLocalization } from '../hooks' +import { contentDragAttributes } from './content/content-drag-drop' import { ContentContextMenu } from './context-menu/content-context-menu' -import CopyPath from './CopyPath' import { DropFileArea } from './DropFileArea' +import { Icon } from './Icon' export interface BreadcrumbItem { url: string @@ -16,7 +21,115 @@ 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() + const theme = useTheme() + const localization = useLocalization() + const workspace = anchorEl?.closest('.sn-explorer') + const colors = workspace ? getComputedStyle(workspace) : undefined + const menuColors = { + '--sn-menu-surface': colors?.getPropertyValue('--sn-explorer-surface') || theme.palette.background.paper, + '--sn-menu-border': colors?.getPropertyValue('--sn-explorer-border') || theme.palette.divider, + '--sn-menu-text': colors?.getPropertyValue('--sn-explorer-text') || theme.palette.text.primary, + '--sn-menu-muted': colors?.getPropertyValue('--sn-explorer-muted') || theme.palette.text.secondary, + '--sn-menu-hover': colors?.getPropertyValue('--sn-explorer-hover') || theme.palette.action.hover, + colorScheme: theme.palette.type, + } as CSSProperties + + useEffect(() => { + let isMounted = true + const fetchSiblings = async () => { + if (!itemPath) return + try { + const siblingsResult = await repo.loadCollection({ + path: itemPath, + oDataOptions: { + select: ['Id', 'Path', 'Name', 'DisplayName', 'Type', 'Icon'], + orderby: 'Name', + metadata: 'no', + }, + }) + if (isMounted) { + setSiblings( + siblingsResult.d.results.map((s) => { + return { content: s, DisplayName: s.DisplayName || s.Name, 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) => ( + + ))} + {!siblings.length && ( +
+ {localization.contentViews.empty} +
+ )} +
+ + ) } export function Breadcrumbs(props: BreadcrumbProps) { @@ -26,15 +139,12 @@ export function Breadcrumbs(props: BreadcrumbProps) return ( <> - - {props.items.map((item) => ( + + {props.items.map((item, index) => ( + {index < props.items.length - 1 && ( + + )} ))} - {contextMenuItem ? ( { - 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', - }, - }) -}) +import CopyPath from './CopyPath' +import { EditableBreadcrumbPath } from './EditableBreadcrumbPath' +import { getTreeModeAction, getTreeModeTargetPath, isTreeEditAction } from './tree/tree-mode-navigation' +import './explorer-header.css' type ContentBreadcrumbsProps = { onItemClick?: (item: BreadcrumbItem) => void - batchActions?: boolean + explorerNavigation?: boolean + onRefresh?: () => void + rootPath?: string } export const ContentBreadcrumbs = (props: ContentBreadcrumbsProps) => { @@ -48,111 +28,134 @@ export const ContentBreadcrumbs = (pr const history = useHistory() const { location } = history const localization = useLocalization() - const globalClasses = useGlobalStyles() - const classes = useStyles() - const { openDialog } = useDialog() + const theme = useTheme() + const pathSegments = useRef(null) const selectionService = useSelectionService() - const [selected, setSelected] = useState(selectionService.selection.getValue()) + const device = useContext(ResponsiveContext) + const snRoute = useSnRoute() + const rootPath = props.rootPath || snRoute.path || '/Root' + const action = snRoute.match?.params.action + const locationPath = getTreeModeTargetPath({ rootPath, currentPath: parent.Path, action, search: location.search }) useEffect(() => { - const selectedComponentsObserve = selectionService.selection.subscribe((newSelectedComponents) => - setSelected(newSelectedComponents), - ) + if (pathSegments.current) pathSegments.current.scrollLeft = pathSegments.current.scrollWidth + }, [ancestors, device, parent.Path]) - return function cleanup() { - selectedComponentsObserve.dispose() - } - }, [selectionService.selection]) + const items = [ + ...ancestors.map((content) => ({ + displayName: content.DisplayName || content.Name, + title: content.Path, + url: getPrimaryActionUrl({ content, repository, uiSettings, location }), + content, + })), + { + displayName: parent.DisplayName || parent.Name, + title: parent.Path, + url: getPrimaryActionUrl({ content: parent, repository, uiSettings, location }), + content: parent, + }, + ] + + const handleItemClick = (item: BreadcrumbItem) => { + selectionService.activeContent.setValue(item.content) + props.onItemClick + ? props.onItemClick(item) + : history.push(getPrimaryActionUrl({ content: item.content, repository, uiSettings, location })) + } + + const ancestorItem = items[items.length - 2] + const pathContents = ( + <> +
+ items={items} onItemClick={(_ev, item) => handleItemClick(item)} /> +
+
+ +
+ + ) return ( -
- - items={[ - ...ancestors.map((content) => ({ - displayName: content.DisplayName || content.Name, - title: content.Path, - url: getPrimaryActionUrl({ content, repository, uiSettings, location }), - content, - })), - { - displayName: parent.DisplayName || parent.Name, - title: parent.Path, - url: getPrimaryActionUrl({ content: parent, repository, uiSettings, location }), - content: parent, - }, - ]} - onItemClick={(_ev, item) => { - selectionService.activeContent.setValue(item.content) - props.onItemClick - ? props.onItemClick(item) - : 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 }, - }, - }) - }}> - - - +
+
+
+ {props.explorerNavigation && ( + + )} + {props.explorerNavigation && device !== 'mobile' && ( + + )} + + {props.explorerNavigation && props.onRefresh && device !== 'mobile' && ( + + )}
- ) : null} + {props.explorerNavigation ? ( + { + history.push( + getUrlForContent({ + content, + uiSettings, + location, + snRoute: { ...snRoute, path: rootPath }, + action: getTreeModeAction(content, isTreeEditAction(action)), + }), + ) + }}> + {pathContents} + + ) : ( +
{pathContents}
+ )} +
) } diff --git a/apps/sensenet/src/components/CsvExportDialog.tsx b/apps/sensenet/src/components/CsvExportDialog.tsx new file mode 100644 index 0000000000..a9616e95a1 --- /dev/null +++ b/apps/sensenet/src/components/CsvExportDialog.tsx @@ -0,0 +1,448 @@ +import { + Button, + Checkbox, + Chip, + createStyles, + Dialog, + DialogActions, + DialogContent, + FormControl, + FormControlLabel, + InputLabel, + makeStyles, + MenuItem, + Select, + TextField, + Theme, + Typography, +} from '@material-ui/core' +import { FieldSetting, FieldVisibility, GenericContent } from '@sensenet/default-content-types' +import { useLogger, useRepository } from '@sensenet/hooks-react' +import React, { useEffect, useMemo, useState } from 'react' +import { useLocalization } from '../hooks' +import { createCsvFromContents, downloadCsv, getCsvExportFileName, preferredCsvColumns } from '../services/csv-export' +import { DialogTitle } from './dialogs' + +type CsvFieldOption = { + name: string + displayName: string + type: string + visibleBrowse?: FieldVisibility +} + +type CsvExportDialogProps = { + open: boolean + selected: GenericContent[] + parent?: GenericContent + onClose: () => void +} + +const systemFieldOptions = preferredCsvColumns.map((fieldName) => ({ + name: fieldName, + displayName: fieldName, + type: 'System', + visibleBrowse: FieldVisibility.Show, +})) +const exportRequestBatchSize = 8 + +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) +} + +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() + 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 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)) + logger.information({ + message: localization.batchActions.exportCsvSuccess.replace('{0}', String(contents.length)), + data: { + relatedRepository: repository.configuration.repositoryUrl, + details: { + exportedContentCount: contents.length, + selectedFieldCount: selectedFields.length, + }, + }, + }) + onClose() + } catch (error) { + logger.error({ + message: localization.batchActions.exportCsvError, + data: { + error, + relatedRepository: repository.configuration.repositoryUrl, + details: { + selectedContentCount: selected.length, + 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/components/DropFileArea.tsx b/apps/sensenet/src/components/DropFileArea.tsx index 7eeb31448f..f8208fc8d7 100644 --- a/apps/sensenet/src/components/DropFileArea.tsx +++ b/apps/sensenet/src/components/DropFileArea.tsx @@ -4,6 +4,7 @@ import { GenericContent } from '@sensenet/default-content-types' import { clsx } from 'clsx' import React, { CSSProperties, DragEvent, FunctionComponent, useState } from 'react' import { useGlobalStyles } from '../globalStyles' +import { contentDragAttributes } from './content/content-drag-drop' import { useDialog } from './dialogs' import { getFilesFromDragEvent } from './dialogs/upload/helper' @@ -47,7 +48,10 @@ export const DropFileArea: FunctionComponent = (props) => { const classes = useStyles() const globalClasses = useGlobalStyles() + const isFileDrag = (event: DragEvent) => Array.from(event.dataTransfer.types).includes('Files') + const onDrop = async (event: DragEvent) => { + if (!isFileDrag(event)) return event.stopPropagation() event.preventDefault() setDragOver(false) @@ -67,21 +71,25 @@ export const DropFileArea: FunctionComponent = (props) => { return ( <>
{ + if (!isFileDrag(ev)) return ev.stopPropagation() ev.preventDefault() setDragOver(true) }} onDragLeave={(ev) => { + if (!isFileDrag(ev)) return ev.stopPropagation() ev.preventDefault() setDragOver(false) }} onDragOver={(ev) => { + if (!isFileDrag(ev)) return ev.stopPropagation() ev.preventDefault() setDragOver(true) diff --git a/apps/sensenet/src/components/EditableBreadcrumbPath.tsx b/apps/sensenet/src/components/EditableBreadcrumbPath.tsx new file mode 100644 index 0000000000..6059392418 --- /dev/null +++ b/apps/sensenet/src/components/EditableBreadcrumbPath.tsx @@ -0,0 +1,326 @@ +import { ArrowForward, EditOutlined, FolderOutlined, InsertDriveFileOutlined } from '@material-ui/icons' +import { GenericContent } from '@sensenet/default-content-types' +import { useRepository } from '@sensenet/hooks-react' +import React, { CSSProperties, ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useLocalization, usePersonalSettings } from '../hooks' +import { findExplorerPath, loadExplorerPathSuggestions, normalizeExplorerPath } from '../services/explorer-path-service' + +type Props = { + path: string + currentFolder: string + onNavigate: (content: GenericContent) => void + children: ReactNode +} +let nextAddressId = 0 + +export const EditableBreadcrumbPath = ({ path, currentFolder, onNavigate, children }: Props) => { + const repository = useRepository() + const localization = useLocalization().contentViews + const settings = usePersonalSettings() + const [id] = useState(() => `explorer-path-${++nextAddressId}`) + const host = useRef(null) + const input = useRef(null) + const popup = useRef(null) + const openRequest = useRef() + const restoreFocus = useRef(false) + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(path) + const [suggestions, setSuggestions] = useState<{ value: string; items: GenericContent[] }>() + const [active, setActive] = useState(-1) + const [loading, setLoading] = useState(false) + const [opening, setOpening] = useState(false) + const [error, setError] = useState('') + const [suggestionError, setSuggestionError] = useState(false) + const [popupStyle, setPopupStyle] = useState() + const items = suggestions && suggestions.value === draft ? suggestions.items : [] + + const close = () => { + openRequest.current?.abort() + setEditing(false) + setOpening(false) + } + const edit = () => { + setDraft(path || currentFolder || '/Root') + setError('') + setActive(-1) + setEditing(true) + } + + useEffect(() => { + close() + return () => openRequest.current?.abort() + // Only a change of location/repository ends an editing session. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path, repository]) + + useEffect(() => { + if (editing) { + input.current?.focus() + input.current?.select() + } else if (restoreFocus.current) { + restoreFocus.current = false + host.current?.querySelector('[data-test="explorer-path-edit"]')?.focus() + } + }, [editing]) + + useEffect(() => { + if (!editing) return + const controller = new AbortController() + setSuggestions(undefined) + setActive(-1) + setLoading(true) + setSuggestionError(false) + const timer = window.setTimeout(() => { + loadExplorerPathSuggestions(repository, draft, currentFolder, controller.signal, settings.showHiddenItems) + .then((contents) => { + if (!controller.signal.aborted) setSuggestions({ value: draft, items: contents }) + }) + .catch(() => { + if (!controller.signal.aborted) setSuggestionError(true) + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false) + }) + }, 180) + return () => { + clearTimeout(timer) + controller.abort() + } + }, [currentFolder, draft, editing, repository, settings.showHiddenItems]) + + useLayoutEffect(() => { + if (!editing) return + const reposition = () => { + const element = host.current + if (!element) return + const rect = element.getBoundingClientRect() + const colors = getComputedStyle(element) + const visibleBottom = window.visualViewport + ? window.visualViewport.offsetTop + window.visualViewport.height + : window.innerHeight + const width = Math.min(Math.max(rect.width, 280), window.innerWidth - 16) + setPopupStyle({ + position: 'fixed', + left: Math.min(Math.max(8, rect.left), window.innerWidth - width - 8), + top: rect.bottom + 5, + width, + maxHeight: Math.max(80, visibleBottom - rect.bottom - 13), + '--sn-address-surface': colors.getPropertyValue('--sn-explorer-surface') || '#fff', + '--sn-address-text': colors.getPropertyValue('--sn-explorer-text') || '#202733', + '--sn-address-muted': colors.getPropertyValue('--sn-explorer-muted') || '#707a88', + '--sn-address-border': colors.getPropertyValue('--sn-explorer-border') || '#dce2e8', + '--sn-address-hover': colors.getPropertyValue('--sn-explorer-hover') || '#eef1f5', + '--sn-address-accent': colors.getPropertyValue('--sn-explorer-accent') || '#087cdd', + } as CSSProperties) + } + const dismiss = (event: PointerEvent) => { + if (!host.current?.contains(event.target as Node) && !popup.current?.contains(event.target as Node)) close() + } + reposition() + window.addEventListener('resize', reposition) + window.addEventListener('scroll', reposition, true) + document.addEventListener('pointerdown', dismiss) + window.visualViewport?.addEventListener('resize', reposition) + window.visualViewport?.addEventListener('scroll', reposition) + return () => { + window.removeEventListener('resize', reposition) + window.removeEventListener('scroll', reposition, true) + document.removeEventListener('pointerdown', dismiss) + window.visualViewport?.removeEventListener('resize', reposition) + window.visualViewport?.removeEventListener('scroll', reposition) + } + }, [editing]) + + useEffect(() => { + popup.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) + }, [active]) + + const complete = (item: GenericContent) => { + openRequest.current?.abort() + setOpening(false) + setError('') + setDraft(`${item.Path}${item.IsFolder ? '/' : ''}`) + setActive(-1) + input.current?.focus() + } + + const open = async () => { + if (opening) return + const selected = items[active] + const target = normalizeExplorerPath(selected?.Path || draft, currentFolder) + if (!target) { + setError(localization.pathNotFound) + return + } + const controller = new AbortController() + openRequest.current?.abort() + openRequest.current = controller + setOpening(true) + setError('') + try { + const content = await findExplorerPath(repository, target, controller.signal) + if (controller.signal.aborted) return + if (!content) { + setError(localization.pathNotFound) + return + } + onNavigate(content) + close() + } catch (loadError) { + if (!controller.signal.aborted) { + const status = (loadError as { statusCode?: number }).statusCode + setError(status === 404 || status === 403 ? localization.pathNotFound : localization.pathLoadFailed) + } + } finally { + if (!controller.signal.aborted) setOpening(false) + } + } + + return ( +
{ + if (!editing && !(event.target as Element).closest('button, a, input')) edit() + }}> + {editing ? ( +
{ + event.preventDefault() + void open() + }}> + { + openRequest.current?.abort() + setOpening(false) + setError('') + setDraft(event.target.value) + }} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) { + if (event.key === 'Enter') event.preventDefault() + return + } + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + restoreFocus.current = true + close() + } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + const direction = event.key === 'ArrowDown' ? 1 : -1 + setActive((current) => + items.length + ? current < 0 + ? direction > 0 + ? 0 + : items.length - 1 + : (current + direction + items.length) % items.length + : -1, + ) + } else if (event.key === 'Tab' && !event.shiftKey && items.length) { + event.preventDefault() + complete(items[active >= 0 ? active : 0]) + } + }} + /> + +
+ ) : ( + <> + {children} + + + )} + {editing && + popupStyle && + createPortal( +
+ {error && ( + + )} +
+ {items.map((item, index) => ( + + ))} +
+ {!items.length && ( +
+ {loading + ? localization.loading + : suggestionError + ? localization.pathSuggestionsFailed + : localization.noPathSuggestions} +
+ )} +
+ {opening ? localization.loading : localization.pathHelp} +
+
, + document.body, + )} +
+ ) +} diff --git a/apps/sensenet/src/components/ExplorerActionMenu.tsx b/apps/sensenet/src/components/ExplorerActionMenu.tsx new file mode 100644 index 0000000000..c91226a8b1 --- /dev/null +++ b/apps/sensenet/src/components/ExplorerActionMenu.tsx @@ -0,0 +1,244 @@ +import { makeStyles, useTheme } from '@material-ui/core' +import { Check, ExpandMore, MoreHoriz } from '@material-ui/icons' +import React, { CSSProperties, ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +export type ExplorerMenuAction = { + id: string + label: string + icon?: ReactNode + disabled?: boolean + onClick: () => void + danger?: boolean + checked?: boolean + groupLabel?: string +} + +const useStyles = makeStyles((theme) => ({ + trigger: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + minHeight: 32, + padding: '6px 9px', + border: '1px solid transparent', + borderRadius: 7, + background: 'transparent', + color: 'var(--sn-explorer-muted, currentColor)', + font: 'inherit', + fontSize: 12, + fontWeight: 500, + whiteSpace: 'nowrap', + cursor: 'pointer', + '& svg': { width: 17, height: 17 }, + '&:hover, &[aria-expanded="true"]': { background: 'var(--sn-explorer-hover, rgba(128,128,128,.12))' }, + '&:focus-visible': { outline: '2px solid var(--sn-explorer-accent, #0078d4)', outlineOffset: 1 }, + '&:disabled': { opacity: 0.4, cursor: 'default' }, + }, + menu: { + position: 'fixed', + zIndex: theme.zIndex.modal + 10, + padding: 5, + boxSizing: 'border-box', + overflowY: 'auto', + overscrollBehavior: 'contain', + border: '1px solid var(--sn-menu-border)', + borderRadius: 10, + background: 'var(--sn-menu-surface)', + color: 'var(--sn-menu-text)', + boxShadow: '0 8px 30px rgba(0, 0, 0, 0.18)', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + }, + item: { + display: 'flex', + alignItems: 'center', + gap: 10, + width: '100%', + minHeight: 36, + padding: '8px 10px', + border: 0, + borderRadius: 5, + background: 'transparent', + color: 'inherit', + font: 'inherit', + fontSize: 13, + lineHeight: 1.4, + textAlign: 'left', + cursor: 'pointer', + '&:hover:not(:disabled), &:focus-visible': { background: 'var(--sn-menu-hover)', outline: 'none' }, + '&:disabled': { opacity: 0.4, cursor: 'default' }, + '& svg': { width: 17, height: 17, flexShrink: 0 }, + }, + icon: { display: 'inline-flex', width: 17, flexShrink: 0 }, + danger: { color: 'var(--sn-menu-danger)' }, +})) + +export const ExplorerActionMenu = ({ + label, + icon =