diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 111122ab37..e36d11df60 100644 --- a/.github/ISSUE_TEMPLATE/--bug-report.yaml +++ b/.github/ISSUE_TEMPLATE/--bug-report.yaml @@ -35,7 +35,7 @@ body: - type: textarea attributes: label: Project Details - description: Where to find it ? (See [YouTube video](https://www.youtube.com/watch?v=AUSNN-RCwhE) or [Docs](https://docs.nocodb.com/FAQs#how-to-check-my-project-info-)) + description: Click on top left icon and click `Copy Project Info`. (See [YouTube video](https://www.youtube.com/watch?v=AUSNN-RCwhE) or [Docs](https://docs.nocodb.com/FAQs#how-to-check-my-project-info-)) placeholder: | or provide the following info ``` @@ -58,4 +58,4 @@ body: placeholder: | > Drag & drop relevant image or videos validations: - required: false \ No newline at end of file + required: false diff --git a/.github/uffizzi/docker-compose.uffizzi.yml b/.github/uffizzi/docker-compose.uffizzi.yml new file mode 100644 index 0000000000..318ad64367 --- /dev/null +++ b/.github/uffizzi/docker-compose.uffizzi.yml @@ -0,0 +1,52 @@ +version: '3' + +x-uffizzi: + ingress: + service: nocodb + port: 8080 + +services: + postgres: + image: postgres + environment: + POSTGRES_PASSWORD: password + POSTGRES_USER: postgres + POSTGRES_DB: root_db + deploy: + resources: + limits: + memory: 500M + mssql: + image: "mcr.microsoft.com/mssql/server:2017-latest" + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: Password123. + deploy: + resources: + limits: + memory: 1000M + mysql: + environment: + MYSQL_DATABASE: root_db + MYSQL_PASSWORD: password + MYSQL_ROOT_PASSWORD: password + MYSQL_USER: noco + image: "mysql:8.0.32" + deploy: + resources: + limits: + memory: 500M + nocodb: + image: "${NOCODB_IMAGE}" + ports: + - "8080:8080" + entrypoint: /bin/sh + command: ["-c", "apk add wait4ports && wait4ports tcp://localhost:5432 && /usr/src/appEntry/start.sh"] + environment: + NC_DB: "pg://localhost:5432?u=postgres&p=password&d=root_db" + NC_ADMIN_EMAIL: admin@nocodb.com + NC_ADMIN_PASSWORD: password + deploy: + resources: + limits: + memory: 500M \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 8a7cb36d56..2b3c64550b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -18,6 +18,7 @@ on: - "packages/nc-gui/**" - "packages/nocodb/**" - ".github/workflows/ci-cd.yml" + - "tests/playwright/**" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -97,4 +98,4 @@ jobs: uses: ./.github/workflows/playwright-test-workflow.yml with: db: pg - shard: 2 \ No newline at end of file + shard: 2 diff --git a/.github/workflows/pr-to-master.yml b/.github/workflows/pr-to-master.yml index a4eef87b54..989f239320 100644 --- a/.github/workflows/pr-to-master.yml +++ b/.github/workflows/pr-to-master.yml @@ -55,7 +55,7 @@ jobs: echo "Pull Request URL - ${{ steps.cpr.outputs.pr_url }}" - name: automerge if: ${{ github.event.inputs.targetEnv == 'PROD' || inputs.targetEnv == 'PROD' }} - uses: "pascalgn/automerge-action@v0.14.3" + uses: "pascalgn/automerge-action@v0.15.5" env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" PULL_REQUEST: "${{ steps.cpr.outputs.pr_number }}" diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml index 2f19829129..adc17aacee 100644 --- a/.github/workflows/release-docker.yml +++ b/.github/workflows/release-docker.yml @@ -50,6 +50,10 @@ jobs: run: | DOCKER_REPOSITORY=nocodb DOCKER_BUILD_TAG=${{ github.event.inputs.tag || inputs.tag }} + DOCKER_BUILD_LATEST_TAG=latest + if [[ "$DOCKER_BUILD_TAG" =~ "-beta." ]]; then + DOCKER_BUILD_LATEST_TAG=$(echo $DOCKER_BUILD_TAG | awk -F '-beta.' '{print $1}')-beta.latest + fi if [[ ${{ github.event.inputs.targetEnv || inputs.targetEnv }} == 'DEV' ]]; then if [[ ${{ github.event.inputs.currentVersion || inputs.currentVersion || 'N/A' }} != 'N/A' ]]; then DOCKER_BUILD_TAG=${{ github.event.inputs.currentVersion || inputs.currentVersion }}-${{ github.event.inputs.tag || inputs.tag }} @@ -62,8 +66,10 @@ jobs: fi echo "DOCKER_REPOSITORY=${DOCKER_REPOSITORY}" >> $GITHUB_OUTPUT echo "DOCKER_BUILD_TAG=${DOCKER_BUILD_TAG}" >> $GITHUB_OUTPUT + echo "DOCKER_BUILD_LATEST_TAG=${DOCKER_BUILD_LATEST_TAG}" >> $GITHUB_OUTPUT echo DOCKER_REPOSITORY: ${DOCKER_REPOSITORY} echo DOCKER_BUILD_TAG: ${DOCKER_BUILD_TAG} + echo DOCKER_BUILD_LATEST_TAG: ${DOCKER_BUILD_LATEST_TAG} - name: Checkout uses: actions/checkout@v3 @@ -134,7 +140,7 @@ jobs: push: true tags: | nocodb/${{ steps.get-docker-repository.outputs.DOCKER_REPOSITORY }}:${{ steps.get-docker-repository.outputs.DOCKER_BUILD_TAG }} - nocodb/${{ steps.get-docker-repository.outputs.DOCKER_REPOSITORY }}:latest + nocodb/${{ steps.get-docker-repository.outputs.DOCKER_REPOSITORY }}:${{ steps.get-docker-repository.outputs.DOCKER_BUILD_LATEST_TAG }} # Temp fix # https://github.com/docker/build-push-action/issues/252 diff --git a/.github/workflows/release-executables.yml b/.github/workflows/release-executables.yml index fff82b1e69..c05211abe8 100644 --- a/.github/workflows/release-executables.yml +++ b/.github/workflows/release-executables.yml @@ -21,14 +21,6 @@ jobs: build-executables: runs-on: ubuntu-latest steps: - # Get the latest draft release for asset upload url - - uses: cardinalby/git-get-release-action@v1 - id: get_release - env: - GITHUB_TOKEN: ${{ secrets.NC_GITHUB_TOKEN }} - with: - latest: 1 - draft: true - uses: actions/checkout@v3 - name: Cache node modules id: cache-npm @@ -107,54 +99,25 @@ jobs: mv ./dist/Noco-macos-arm64 ./mac-dist/ mv ./dist/Noco-macos-x64 ./mac-dist/ - - name: Upload win-arm64 build to asset + - name: Upload executables to asset id: upload-release-asset - uses: actions/upload-release-asset@v1 + uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.get_release.outputs.upload_url }} - asset_path: ./scripts/pkg-executable/dist/Noco-win-arm64.exe - asset_name: Noco-win-arm64.exe - asset_content_type: application/octet-stream - - - name: Upload win-x64 build to asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.get_release.outputs.upload_url }} - asset_path: ./scripts/pkg-executable/dist/Noco-win-x64.exe - asset_name: Noco-win-x64.exe - asset_content_type: application/octet-stream - - - name: Upload linux-arm64 build to asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.get_release.outputs.upload_url }} - asset_path: ./scripts/pkg-executable/dist/Noco-linux-arm64 - asset_name: Noco-linux-arm64 - asset_content_type: application/octet-stream - - - name: Upload linux-x64 build to asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.get_release.outputs.upload_url }} - asset_path: ./scripts/pkg-executable/dist/Noco-linux-x64 - asset_name: Noco-linux-x64 - asset_content_type: application/octet-stream + draft: true + tag_name: ${{ github.event.inputs.tag || inputs.tag }} + files: | + ./scripts/pkg-executable/dist/Noco-win-arm64.exe + ./scripts/pkg-executable/dist/Noco-win-x64.exe + ./scripts/pkg-executable/dist/Noco-linux-arm64 + ./scripts/pkg-executable/dist/Noco-linux-x64 - uses: actions/upload-artifact@master with: name: ${{ github.event.inputs.tag || inputs.tag }} path: scripts/pkg-executable/mac-dist retention-days: 1 - outputs: - upload_url: ${{ steps.get_release.outputs.upload_url }} sign-mac-executables: runs-on: macos-latest needs: build-executables @@ -205,40 +168,17 @@ jobs: id: compress - - name: Upload macos-x64 build to asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.build-executables.outputs.upload_url }} - asset_path: ./scripts/pkg-executable/mac-dist/Noco-macos-x64 - asset_name: Noco-macos-x64 - asset_content_type: application/octet-stream - - - - - name: Upload macos-arm64 build to asset - uses: actions/upload-release-asset@v1 + - name: Upload macos executable to asset + uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ needs.build-executables.outputs.upload_url }} - asset_path: ./scripts/pkg-executable/mac-dist/Noco-macos-arm64 - asset_name: Noco-macos-arm64 - asset_content_type: application/octet-stream - - - - - name: Upload macos compressed build(for homebrew) to asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.build-executables.outputs.upload_url }} - asset_path: ./scripts/pkg-executable/mac-dist/nocodb.tar.gz - asset_name: nocodb.tar.gz - asset_content_type: application/octet-stream - + draft: true + tag_name: ${{ github.event.inputs.tag || inputs.tag }} + files: | + ./scripts/pkg-executable/mac-dist/Noco-macos-x64 + ./scripts/pkg-executable/mac-dist/Noco-macos-arm64 + ./scripts/pkg-executable/mac-dist/nocodb.tar.gz - name: Generate Homebrew Formula class and push run: | @@ -267,7 +207,3 @@ jobs: git commit ./Formula/nocodb.rb -m "Automatic publish" git push - - - - diff --git a/.github/workflows/release-nightly-dev.yml b/.github/workflows/release-nightly-dev.yml index 55a711c3f2..e4a66f71ce 100644 --- a/.github/workflows/release-nightly-dev.yml +++ b/.github/workflows/release-nightly-dev.yml @@ -12,6 +12,11 @@ jobs: set-tag: runs-on: 'ubuntu-latest' steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 1 + ref: ${{ github.ref }} - name: set-tag id: tag-step run: | @@ -21,7 +26,7 @@ jobs: TAG_NAME=${CURRENT_DATE}-${CURRENT_TIME} IS_DAILY='Y' # Get current version - CURRENT_VERSION=$(basename $(curl -fs -o/dev/null -w %{redirect_url} https://github.com/nocodb/nocodb/releases/latest)) + CURRENT_VERSION=$(cat ./packages/nocodb/package.json | jq -r ".version") # Set the tag if [[ ${{ github.event_name }} == 'workflow_dispatch' ]]; then IS_DAILY='N' diff --git a/.github/workflows/release-nocodb.yml b/.github/workflows/release-nocodb.yml index 3a4322d9d6..dceb2732dd 100644 --- a/.github/workflows/release-nocodb.yml +++ b/.github/workflows/release-nocodb.yml @@ -103,12 +103,12 @@ jobs: NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}" # Close all issues with target tags 'Status: Ready for Next Release' - close-issues: - needs: [release-docker, process-input] - uses: ./.github/workflows/release-close-issue.yml - with: - issue_label: '🚀 Status: Ready for Next Release' - version: ${{ needs.process-input.outputs.target_tag }} + # close-issues: + # needs: [release-docker, process-input] + # uses: ./.github/workflows/release-close-issue.yml + # with: + # issue_label: '🚀 Status: Ready for Next Release' + # version: ${{ needs.process-input.outputs.target_tag }} # Publish Docs publish-docs: diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 4da1dd71e5..55ec46d9af 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -68,7 +68,7 @@ jobs: - name: Create Pull Request if: ${{ github.event.inputs.targetEnv == 'PROD' || inputs.targetEnv == 'PROD' }} id: cpr - uses: peter-evans/create-pull-request@v3 + uses: peter-evans/create-pull-request@v4.2.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -84,7 +84,7 @@ jobs: echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" - name: automerge if: ${{ github.event.inputs.targetEnv == 'PROD' || inputs.targetEnv == 'PROD' }} - uses: "pascalgn/automerge-action@v0.14.3" + uses: "pascalgn/automerge-action@v0.15.5" env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" PULL_REQUEST: "${{ steps.cpr.outputs.pull-request-number }}" diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index d104e79089..d29e6b7a17 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -6,7 +6,8 @@ on: # reopened: closed pull request is reopened # synchronize: commit(s) pushed to the pull request # ready_for_review: non PR release - types: [opened, reopened, synchronize, ready_for_review] + # closed: pull request is closed, used to delete uffizzi previews + types: [opened, reopened, synchronize, ready_for_review, closed] paths: - "packages/nocodb-sdk/**" - "packages/nc-gui/**" @@ -20,7 +21,7 @@ concurrency: jobs: # enrich tag for pr release set-tag: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' && github.event.action != 'closed' }} runs-on: 'ubuntu-latest' steps: - name: set-tag @@ -32,7 +33,7 @@ jobs: # Get current PR number PR_NUMBER=${{github.event.number}} # Get current version - CURRENT_VERSION=$(basename $(curl -fs -o/dev/null -w %{redirect_url} https://github.com/nocodb/nocodb/releases/latest)) + CURRENT_VERSION=$(curl -fs https://docs.nocodb.com/releases | grep article | grep div | grep h2 | grep 'id\="[^"]*' -o | cut -c 5- | cut -d\: -f1) # Construct tag name TAG_NAME=pr-${PR_NUMBER}-${CURRENT_DATE}-${CURRENT_TIME} echo "TARGET_TAG=${TAG_NAME}" >> $GITHUB_OUTPUT @@ -47,7 +48,7 @@ jobs: # Build, install, publish frontend and backend to npm release-npm: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' && github.event.action != 'closed' }} needs: [set-tag] uses: ./.github/workflows/release-npm.yml with: @@ -58,7 +59,7 @@ jobs: # Build docker image and push to docker hub release-docker: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' && github.event.action != 'closed' }} needs: [release-npm, set-tag] uses: ./.github/workflows/release-docker.yml with: @@ -71,18 +72,18 @@ jobs: DOCKERHUB_TOKEN: "${{ secrets.DOCKERHUB_TOKEN }}" # Build executables and publish to GitHub - release-executables: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' }} - needs: [set-tag, release-npm] - uses: ./.github/workflows/release-timely-executables.yml - with: - tag: ${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }} - secrets: - NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}" + # release-executables: + # if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' && github.event.action != 'closed' }} + # needs: [set-tag, release-npm] + # uses: ./.github/workflows/release-timely-executables.yml + # with: + # tag: ${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }} + # secrets: + # NC_GITHUB_TOKEN: "${{ secrets.NC_GITHUB_TOKEN }}" # Add a comment for PR docker build leave-comment: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' }} + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' && github.event.action != 'closed' }} runs-on: 'ubuntu-latest' needs: [release-docker, set-tag] steps: @@ -94,38 +95,94 @@ jobs: docker run -d -p 8888:8080 nocodb/nocodb-timely:${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }} ``` - # Add a comment for PR executable build - leave-executable-comment: - if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' }} + # Create a preview for the pull request + preview-pull-request: + name: "Trigger Uffizzi Preview" + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' && github.event.action != 'closed' }} runs-on: 'ubuntu-latest' - needs: [release-executables, set-tag] + needs: [release-docker, set-tag] + outputs: + compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }} steps: - - uses: peter-evans/commit-comment@v2 + - name: Checkout git repo + uses: actions/checkout@v3 + - name: Render Compose File + run: | + NOCODB_IMAGE=nocodb/nocodb-timely:${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }} + export NOCODB_IMAGE + # Render simple template from environment variables. + envsubst < .github/uffizzi/docker-compose.uffizzi.yml > docker-compose.rendered.yml + cat docker-compose.rendered.yml + - name: Upload Rendered Compose File as Artifact + uses: actions/upload-artifact@v3 with: - body: | - ### Run Executables + name: preview-spec + path: docker-compose.rendered.yml + retention-days: 2 + - name: Serialize PR Event to File + run: | + cat << EOF > event.json + ${{ toJSON(github.event) }} + EOF + - name: Upload PR Event as Artifact + uses: actions/upload-artifact@v3 + with: + name: preview-spec + path: event.json + retention-days: 2 + + # Add a comment for PR executable build + # leave-executable-comment: + # if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' && github.event.action != 'closed' }} + # runs-on: 'ubuntu-latest' + # needs: [release-executables, set-tag] + # steps: + # - uses: peter-evans/commit-comment@v2 + # with: + # body: | + # ### Run Executables - #### MacOS + # #### MacOS - ```bash - mkdir -p ./${{ needs.set-tag.outputs.current_version }}/${{ needs.set-tag.outputs.target_tag }} && cd "$_" \ - && curl http://dl.nocodb.com/${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }}/Noco-macos-arm64 -o noco -L \ - && chmod +x noco \ - && ./noco - ``` - #### Linux + # ```bash + # mkdir -p ./${{ needs.set-tag.outputs.current_version }}/${{ needs.set-tag.outputs.target_tag }} && cd "$_" \ + # && curl http://dl.nocodb.com/${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }}/Noco-macos-arm64 -o noco -L \ + # && chmod +x noco \ + # && ./noco + # ``` + # #### Linux - ```bash - mkdir -p ./${{ needs.set-tag.outputs.current_version }}/${{ needs.set-tag.outputs.target_tag }} && cd "$_" \ - && curl http://dl.nocodb.com/${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }}/Noco-linux-x64 -o noco -L \ - && chmod +x noco \ - && ./noco - ``` - #### Windows + # ```bash + # mkdir -p ./${{ needs.set-tag.outputs.current_version }}/${{ needs.set-tag.outputs.target_tag }} && cd "$_" \ + # && curl http://dl.nocodb.com/${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }}/Noco-linux-x64 -o noco -L \ + # && chmod +x noco \ + # && ./noco + # ``` + # #### Windows - ```bash - iwr http://dl.nocodb.com/${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }}/Noco-win-arm64.exe - .\Noco-win-arm64.exe - ``` + # ```bash + # iwr http://dl.nocodb.com/${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }}/Noco-win-arm64.exe + # .\Noco-win-arm64.exe + # ``` + + # For executables visit [here](https://github.com/nocodb/nocodb-timely/releases/tag/${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }}) - For executables visit [here](https://github.com/nocodb/nocodb-timely/releases/tag/${{ needs.set-tag.outputs.current_version }}-${{ needs.set-tag.outputs.target_tag }}) + # delete the uffizzi preview created off of this PR + delete-uffizzi-preview: + name: Call for Preview Deletion + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && github.event.pull_request.draft == false && github.base_ref == 'develop' && github.event.action == 'closed' }} + steps: + # If this PR is closing, we will not render a compose file nor pass it to the next workflow. + - name: Serialize PR Event to File + run: | + cat << EOF > event.json + ${{ toJSON(github.event) }} + + EOF + - name: Upload PR Event as Artifact + uses: actions/upload-artifact@v3 + with: + name: preview-spec + path: event.json + retention-days: 2 \ No newline at end of file diff --git a/.github/workflows/uffizzi-preview.yml b/.github/workflows/uffizzi-preview.yml new file mode 100644 index 0000000000..6b4842de8c --- /dev/null +++ b/.github/workflows/uffizzi-preview.yml @@ -0,0 +1,89 @@ +name: Deploy Uffizzi Preview + +on: + workflow_run: + workflows: + - "PR Release" + types: + - completed + +jobs: + cache-compose-file: + name: Cache Compose File + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + outputs: + compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }} + pr-number: ${{ env.PR_NUMBER }} + steps: + - name: 'Download artifacts' + # Fetch output (zip archive) from the workflow run that triggered this workflow. + uses: actions/github-script@v6 + with: + script: | + let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => { + return artifact.name == "preview-spec" + })[0]; + let download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + let fs = require('fs'); + fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data)); + + - name: 'Unzip artifact' + run: unzip preview-spec.zip + + - name: Read Event into ENV + run: | + echo 'EVENT_JSON<> $GITHUB_ENV + cat event.json >> $GITHUB_ENV + echo 'EOF' >> $GITHUB_ENV + + - name: Hash Rendered Compose File + id: hash + # If the previous workflow was triggered by a PR close event, we will not have a compose file artifact. + if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }} + run: echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV + + - name: Cache Rendered Compose File + if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }} + uses: actions/cache@v3 + with: + path: docker-compose.rendered.yml + key: ${{ env.COMPOSE_FILE_HASH }} + + - name: Read PR Number From Event Object + id: pr + run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV + + - name: DEBUG - Print Job Outputs + if: ${{ runner.debug }} + run: | + echo "PR number: ${{ env.PR_NUMBER }}" + echo "Compose file hash: ${{ env.COMPOSE_FILE_HASH }}" + cat event.json + + deploy-uffizzi-preview: + name: Use Remote Workflow to Preview on Uffizzi + needs: + - cache-compose-file + uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2 + with: + # If this workflow was triggered by a PR close event, cache-key will be an empty string + # and this reusable workflow will delete the preview deployment. + compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }} + compose-file-cache-path: docker-compose.rendered.yml + server: https://app.uffizzi.com + pr-number: ${{ needs.cache-compose-file.outputs.pr-number }} + permissions: + contents: read + pull-requests: write + id-token: write diff --git a/.github/workflows/update-sdk-path.yml b/.github/workflows/update-sdk-path.yml index fed2a4e22e..b71bf2010b 100644 --- a/.github/workflows/update-sdk-path.yml +++ b/.github/workflows/update-sdk-path.yml @@ -27,7 +27,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@v3 + uses: peter-evans/create-pull-request@v4.2.3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -41,7 +41,7 @@ jobs: echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}" echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" - name: automerge - uses: "pascalgn/automerge-action@v0.14.3" + uses: "pascalgn/automerge-action@v0.15.5" env: GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" PULL_REQUEST: "${{ steps.cpr.outputs.pull-request-number }}" diff --git a/.run/test-debug.run.xml b/.run/test-debug.run.xml index 6c9c4c56c6..b6cd6887cb 100644 --- a/.run/test-debug.run.xml +++ b/.run/test-debug.run.xml @@ -1,9 +1,9 @@ - + - diff --git a/packages/nc-gui/components/account/UserList.vue b/packages/nc-gui/components/account/UserList.vue index 4ce5764c3e..4c1eaa18ae 100644 --- a/packages/nc-gui/components/account/UserList.vue +++ b/packages/nc-gui/components/account/UserList.vue @@ -71,7 +71,7 @@ const deleteUser = async (userId: string) => { Modal.confirm({ title: 'Are you sure you want to delete this user?', type: 'warn', - content: 'On deleting, user will remove from organization and any sync source(Airtable) created by user will get removed', + content: 'Upon deletion, the user will be removed from the installation.', onOk: async () => { try { await api.orgUsers.delete(userId) diff --git a/packages/nc-gui/components/cell/Checkbox.vue b/packages/nc-gui/components/cell/Checkbox.vue index 6b1bd0e50c..dfffdec862 100644 --- a/packages/nc-gui/components/cell/Checkbox.vue +++ b/packages/nc-gui/components/cell/Checkbox.vue @@ -65,7 +65,7 @@ useSelectedCellKeyupListener(active, (e) => {
+const props = defineProps<{ + value?: string | number | null + lines?: number +}>() + +const wrapper = ref() + +const key = ref(0) + +onMounted(() => { + const observer = new ResizeObserver(() => { + key.value++ + }) + + observer.observe(wrapper.value) +}) + + + diff --git a/packages/nc-gui/components/cell/Currency.vue b/packages/nc-gui/components/cell/Currency.vue index c65a699d98..9aa50944c5 100644 --- a/packages/nc-gui/components/cell/Currency.vue +++ b/packages/nc-gui/components/cell/Currency.vue @@ -10,11 +10,24 @@ const props = defineProps() const emit = defineEmits(['update:modelValue', 'save']) +const { showNull } = useGlobal() + const column = inject(ColumnInj)! const editEnabled = inject(EditModeInj)! -const vModel = useVModel(props, 'modelValue', emit) +const _vModel = useVModel(props, 'modelValue', emit) + +const vModel = computed({ + get: () => _vModel.value, + set: (value: unknown) => { + if (value === '') { + _vModel.value = null + } else { + _vModel.value = value as number + } + }, +}) const lastSaved = ref() @@ -70,6 +83,8 @@ onMounted(() => { @mousedown.stop /> + NULL + {{ currency }} diff --git a/packages/nc-gui/components/cell/DatePicker.vue b/packages/nc-gui/components/cell/DatePicker.vue index c0c662732d..1beb28fc9a 100644 --- a/packages/nc-gui/components/cell/DatePicker.vue +++ b/packages/nc-gui/components/cell/DatePicker.vue @@ -22,6 +22,8 @@ const { modelValue, isPk } = defineProps() const emit = defineEmits(['update:modelValue']) +const { showNull } = useGlobal() + const columnMeta = inject(ColumnInj, null)! const readOnly = inject(ReadonlyInj, ref(false)) @@ -71,7 +73,7 @@ watch( { flush: 'post' }, ) -const placeholder = computed(() => (isDateInvalid ? 'Invalid date' : '')) +const placeholder = computed(() => (modelValue === null && showNull.value ? 'NULL' : isDateInvalid ? 'Invalid date' : '')) useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { switch (e.key) { @@ -169,6 +171,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { v-model:value="localState" :bordered="false" class="!w-full !px-0 !border-none" + :class="{ 'nc-null': modelValue === null && showNull }" :format="dateFormat" :placeholder="placeholder" :allow-clear="!readOnly && !localState && !isPk" diff --git a/packages/nc-gui/components/cell/DateTimePicker.vue b/packages/nc-gui/components/cell/DateTimePicker.vue index 8819d23ce7..63931e018a 100644 --- a/packages/nc-gui/components/cell/DateTimePicker.vue +++ b/packages/nc-gui/components/cell/DateTimePicker.vue @@ -2,10 +2,13 @@ import dayjs from 'dayjs' import { ActiveCellInj, + ColumnInj, ReadonlyInj, + dateFormats, inject, isDrawerOrModalExist, ref, + timeFormats, useProject, useSelectedCellKeyupListener, watch, @@ -22,15 +25,23 @@ const emit = defineEmits(['update:modelValue']) const { isMysql } = useProject() +const { showNull } = useGlobal() + const readOnly = inject(ReadonlyInj, ref(false)) const active = inject(ActiveCellInj, ref(false)) const editable = inject(EditModeInj, ref(false)) +const column = inject(ColumnInj)! + let isDateInvalid = $ref(false) -const dateFormat = isMysql ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' +const dateTimeFormat = $computed(() => { + const dateFormat = column?.value?.meta?.date_format ?? dateFormats[0] + const timeFormat = column?.value?.meta?.time_format ?? timeFormats[0] + return `${dateFormat} ${timeFormat}` +}) let localState = $computed({ get() { @@ -52,7 +63,7 @@ let localState = $computed({ } if (val.isValid()) { - emit('update:modelValue', val?.format(dateFormat)) + emit('update:modelValue', val?.format(isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ')) } }, }) @@ -70,6 +81,8 @@ watch( { flush: 'post' }, ) +const placeholder = computed(() => (modelValue === null && showNull.value ? 'NULL' : isDateInvalid ? 'Invalid date' : '')) + useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { switch (e.key) { case 'Enter': @@ -163,8 +176,9 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { :show-time="true" :bordered="false" class="!w-full !px-0 !border-none" - format="YYYY-MM-DD HH:mm" - :placeholder="isDateInvalid ? 'Invalid date' : ''" + :class="{ 'nc-null': modelValue === null && showNull }" + :format="dateTimeFormat" + :placeholder="placeholder" :allow-clear="!readOnly && !localState && !isPk" :input-read-only="true" :dropdown-class-name="`${randomClass} nc-picker-datetime ${open ? 'active' : ''}`" diff --git a/packages/nc-gui/components/cell/Decimal.vue b/packages/nc-gui/components/cell/Decimal.vue index 5e7ed247e3..04b5659968 100644 --- a/packages/nc-gui/components/cell/Decimal.vue +++ b/packages/nc-gui/components/cell/Decimal.vue @@ -14,9 +14,22 @@ const props = defineProps() const emits = defineEmits() +const { showNull } = useGlobal() + const editEnabled = inject(EditModeInj) -const vModel = useVModel(props, 'modelValue', emits) +const _vModel = useVModel(props, 'modelValue', emits) + +const vModel = computed({ + get: () => _vModel.value, + set: (value: string) => { + if (value === '') { + _vModel.value = null + } else { + _vModel.value = value + } + }, +}) const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() @@ -38,6 +51,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() @selectstart.capture.stop @mousedown.stop /> + NULL {{ vModel }} diff --git a/packages/nc-gui/components/cell/Duration.vue b/packages/nc-gui/components/cell/Duration.vue index 555790359d..fe3e85fadc 100644 --- a/packages/nc-gui/components/cell/Duration.vue +++ b/packages/nc-gui/components/cell/Duration.vue @@ -20,6 +20,8 @@ const { modelValue, showValidationError = true } = defineProps() const emit = defineEmits(['update:modelValue']) +const { showNull } = useGlobal() + const column = inject(ColumnInj) const editEnabled = inject(EditModeInj) @@ -94,6 +96,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() @mousedown.stop /> + NULL + {{ localState }}
diff --git a/packages/nc-gui/components/cell/Email.vue b/packages/nc-gui/components/cell/Email.vue index 9d0de8ad18..de25a13451 100644 --- a/packages/nc-gui/components/cell/Email.vue +++ b/packages/nc-gui/components/cell/Email.vue @@ -14,6 +14,8 @@ const props = defineProps() const emits = defineEmits() +const { showNull } = useGlobal() + const editEnabled = inject(EditModeInj) const vModel = useVModel(props, 'modelValue', emits) @@ -39,6 +41,8 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() @mousedown.stop /> + NULL + {{ vModel }} diff --git a/packages/nc-gui/components/cell/Float.vue b/packages/nc-gui/components/cell/Float.vue index 86c8a47cba..fe0fcd9c85 100644 --- a/packages/nc-gui/components/cell/Float.vue +++ b/packages/nc-gui/components/cell/Float.vue @@ -14,9 +14,22 @@ const props = defineProps() const emits = defineEmits() +const { showNull } = useGlobal() + const editEnabled = inject(EditModeInj) -const vModel = useVModel(props, 'modelValue', emits) +const _vModel = useVModel(props, 'modelValue', emits) + +const vModel = computed({ + get: () => _vModel.value, + set: (value: string) => { + if (value === '') { + _vModel.value = null + } else { + _vModel.value = value + } + }, +}) const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() @@ -38,6 +51,7 @@ const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() @selectstart.capture.stop @mousedown.stop /> + NULL {{ vModel }} diff --git a/packages/nc-gui/components/cell/Integer.vue b/packages/nc-gui/components/cell/Integer.vue index e9103ea54f..d654e3d127 100644 --- a/packages/nc-gui/components/cell/Integer.vue +++ b/packages/nc-gui/components/cell/Integer.vue @@ -14,9 +14,22 @@ const props = defineProps() const emits = defineEmits() +const { showNull } = useGlobal() + const editEnabled = inject(EditModeInj) -const vModel = useVModel(props, 'modelValue', emits) +const _vModel = useVModel(props, 'modelValue', emits) + +const vModel = computed({ + get: () => _vModel.value, + set: (value: string) => { + if (value === '') { + _vModel.value = null + } else { + _vModel.value = value + } + }, +}) const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus() @@ -42,6 +55,7 @@ function onKeyDown(evt: KeyboardEvent) { @selectstart.capture.stop @mousedown.stop /> + NULL {{ vModel }} diff --git a/packages/nc-gui/components/cell/Json.vue b/packages/nc-gui/components/cell/Json.vue index f89ddf87aa..3042946ff4 100644 --- a/packages/nc-gui/components/cell/Json.vue +++ b/packages/nc-gui/components/cell/Json.vue @@ -25,6 +25,8 @@ const props = defineProps() const emits = defineEmits() +const { showNull } = useGlobal() + const editEnabled = inject(EditModeInj, ref(false)) const active = inject(ActiveCellInj, ref(false)) @@ -123,8 +125,8 @@ useSelectedCellKeyupListener(active, (e) => { diff --git a/packages/nc-gui/components/cell/MultiSelect.vue b/packages/nc-gui/components/cell/MultiSelect.vue index 758e92aba1..810e621c09 100644 --- a/packages/nc-gui/components/cell/MultiSelect.vue +++ b/packages/nc-gui/components/cell/MultiSelect.vue @@ -19,6 +19,7 @@ import { useEventListener, useMetas, useProject, + useRoles, useSelectedCellKeyupListener, watch, } from '#imports' @@ -58,6 +59,8 @@ const { $api } = useNuxtApp() const { getMeta } = useMetas() +const { hasRole } = useRoles() + const { isPg, isMysql } = useProject() // a variable to keep newly created options value @@ -81,6 +84,10 @@ const isOptionMissing = computed(() => { return (options.value ?? []).every((op) => op.title !== searchVal.value) }) +const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true)) + +const editAllowed = computed(() => hasEditRoles.value && (active.value || editable.value)) + const vModel = computed({ get: () => { const selected = selectedIds.value.reduce((acc, id) => { @@ -106,7 +113,7 @@ const vModel = computed({ const selectedTitles = computed(() => modelValue ? typeof modelValue === 'string' - ? isMysql + ? isMysql(column.value.base_id) ? modelValue.split(',').sort((a, b) => { const opa = options.value.find((el) => el.title === a) const opb = options.value.find((el) => el.title === b) @@ -155,10 +162,12 @@ watch( ) watch(isOpen, (n, _o) => { - if (!n) { - aselect.value?.$el?.querySelector('input')?.blur() - } else { - aselect.value?.$el?.querySelector('input')?.focus() + if (editAllowed.value) { + if (!n) { + aselect.value?.$el?.querySelector('input')?.blur() + } else { + aselect.value?.$el?.querySelector('input')?.focus() + } } }) @@ -168,7 +177,7 @@ useSelectedCellKeyupListener(active, (e) => { isOpen.value = false break case 'Enter': - if (active.value && !isOpen.value) { + if (editAllowed.value && active.value && !isOpen.value) { isOpen.value = true } break @@ -180,6 +189,10 @@ useSelectedCellKeyupListener(active, (e) => { // skip break default: + if (!editAllowed.value) { + e.preventDefault() + break + } // toggle only if char key pressed if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) { e.stopPropagation() @@ -212,7 +225,7 @@ async function addIfMissingAndSave() { // todo: refactor and avoid repetition if (updatedColMeta.cdf) { // Postgres returns default value wrapped with single quotes & casted with type so we have to get value between single quotes to keep it unified for all databases - if (isPg.value) { + if (isPg(column.value.base_id)) { updatedColMeta.cdf = updatedColMeta.cdf.substring( updatedColMeta.cdf.indexOf(`'`) + 1, updatedColMeta.cdf.lastIndexOf(`'`), @@ -220,7 +233,7 @@ async function addIfMissingAndSave() { } // Mysql escapes single quotes with backslash so we keep quotes but others have to unescaped - if (!isMysql.value) { + if (!isMysql(column.value.base_id)) { updatedColMeta.cdf = updatedColMeta.cdf.replace(/''/g, "'") } } @@ -269,18 +282,18 @@ const onTagClick = (e: Event, onClose: Function) => { v-model:value="vModel" v-model:open="isOpen" mode="multiple" - class="w-full" + class="w-full overflow-hidden" :bordered="false" clear-icon show-search - :show-arrow="!readOnly" + :show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel.length === 0))" :open="isOpen && (active || editable)" :disabled="readOnly" - :class="{ '!ml-[-8px]': readOnly }" + :class="{ '!ml-[-8px]': readOnly, 'caret-transparent': !hasEditRoles }" :dropdown-class-name="`nc-dropdown-multi-select-cell ${isOpen ? 'active' : ''}`" @search="search" @keydown.stop - @click="isOpen = (active || editable) && !isOpen" + @click="isOpen = editAllowed && !isOpen" > { @@ -323,7 +336,7 @@ const onTagClick = (e: Event, onClose: Function) => { class="rounded-tag nc-selected-option" :style="{ display: 'flex', alignItems: 'center' }" :color="options.find((el) => el.title === val)?.color" - :closable="(active || editable) && (vModel.length > 1 || !column?.rqd)" + :closable="editAllowed && (active || editable) && (vModel.length > 1 || !column?.rqd)" :close-icon="h(MdiCloseCircle, { class: ['ms-close-icon'] })" @click="onTagClick($event, onClose)" @close="onClose" @@ -390,4 +403,8 @@ const onTagClick = (e: Event, onClose: Function) => { :deep(.ant-select-selection-overflow-item) { @apply "flex overflow-hidden"; } + +:deep(.ant-select-selection-overflow) { + @apply flex-nowrap; +} diff --git a/packages/nc-gui/components/cell/Percent.vue b/packages/nc-gui/components/cell/Percent.vue index c8907656d2..37ac682b56 100644 --- a/packages/nc-gui/components/cell/Percent.vue +++ b/packages/nc-gui/components/cell/Percent.vue @@ -10,9 +10,22 @@ const props = defineProps() const emits = defineEmits(['update:modelValue']) +const { showNull } = useGlobal() + const editEnabled = inject(EditModeInj) -const vModel = useVModel(props, 'modelValue', emits) +const _vModel = useVModel(props, 'modelValue', emits) + +const vModel = computed({ + get: () => _vModel.value, + set: (value) => { + if (value === '') { + _vModel.value = null + } else { + _vModel.value = value + } + }, +}) const focus: VNodeRef = (el) => { ;(el as HTMLInputElement)?.focus() @@ -36,5 +49,6 @@ const focus: VNodeRef = (el) => { @selectstart.capture.stop @mousedown.stop /> + NULL {{ vModel }} diff --git a/packages/nc-gui/components/cell/SingleSelect.vue b/packages/nc-gui/components/cell/SingleSelect.vue index 733abe34c0..f652f9d7d3 100644 --- a/packages/nc-gui/components/cell/SingleSelect.vue +++ b/packages/nc-gui/components/cell/SingleSelect.vue @@ -15,6 +15,7 @@ import { inject, ref, useEventListener, + useRoles, useSelectedCellKeyupListener, watch, } from '#imports' @@ -51,6 +52,8 @@ const searchVal = ref() const { getMeta } = useMetas() +const { hasRole } = useRoles() + const { isPg, isMysql } = useProject() // a variable to keep newly created option value @@ -75,6 +78,10 @@ const isOptionMissing = computed(() => { return (options.value ?? []).every((op) => op.title !== searchVal.value) }) +const hasEditRoles = computed(() => hasRole('owner', true) || hasRole('creator', true) || hasRole('editor', true)) + +const editAllowed = computed(() => hasEditRoles.value && (active.value || editable.value)) + const vModel = computed({ get: () => tempSelectedOptState.value ?? modelValue, set: (val) => { @@ -89,10 +96,12 @@ const vModel = computed({ }) watch(isOpen, (n, _o) => { - if (!n) { - aselect.value?.$el?.querySelector('input')?.blur() - } else { - aselect.value?.$el?.querySelector('input')?.focus() + if (editAllowed.value) { + if (!n) { + aselect.value?.$el?.querySelector('input')?.blur() + } else { + aselect.value?.$el?.querySelector('input')?.focus() + } } }) @@ -102,11 +111,15 @@ useSelectedCellKeyupListener(active, (e) => { isOpen.value = false break case 'Enter': - if (active.value && !isOpen.value) { + if (editAllowed.value && active.value && !isOpen.value) { isOpen.value = true } break default: + if (!editAllowed.value) { + e.preventDefault() + break + } // toggle only if char key pressed if (!(e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) && e.key?.length === 1) { e.stopPropagation() @@ -136,7 +149,7 @@ async function addIfMissingAndSave() { // todo: refactor and avoid repetition if (updatedColMeta.cdf) { // Postgres returns default value wrapped with single quotes & casted with type so we have to get value between single quotes to keep it unified for all databases - if (isPg.value) { + if (isPg(column.value.base_id)) { updatedColMeta.cdf = updatedColMeta.cdf.substring( updatedColMeta.cdf.indexOf(`'`) + 1, updatedColMeta.cdf.lastIndexOf(`'`), @@ -144,7 +157,7 @@ async function addIfMissingAndSave() { } // Mysql escapes single quotes with backslash so we keep quotes but others have to unescaped - if (!isMysql.value) { + if (!isMysql(column.value.base_id)) { updatedColMeta.cdf = updatedColMeta.cdf.replace(/''/g, "'") } } @@ -176,7 +189,7 @@ const toggleMenu = (e: Event) => { vModel.value = '' return } - isOpen.value = (active.value || editable.value) && !isOpen.value + isOpen.value = editAllowed.value && !isOpen.value } const handleClose = (e: MouseEvent) => { @@ -193,11 +206,12 @@ useEventListener(document, 'click', handleClose) ref="aselect" v-model:value="vModel" class="w-full" - :allow-clear="!column.rqd && active" + :class="{ 'caret-transparent': !hasEditRoles }" + :allow-clear="!column.rqd && editAllowed" :bordered="false" - :open="isOpen && (active || editable)" + :open="isOpen" :disabled="readOnly" - :show-arrow="!readOnly && (active || editable || vModel === null)" + :show-arrow="hasEditRoles && !readOnly && (editable || (active && vModel === null))" :dropdown-class-name="`nc-dropdown-single-select-cell ${isOpen ? 'active' : ''}`" show-search @select="isOpen = false" @@ -226,9 +240,8 @@ useEventListener(document, 'click', handleClose) - diff --git a/packages/nc-gui/components/cell/Text.vue b/packages/nc-gui/components/cell/Text.vue index ad85967e5e..d185167b56 100644 --- a/packages/nc-gui/components/cell/Text.vue +++ b/packages/nc-gui/components/cell/Text.vue @@ -10,6 +10,8 @@ const props = defineProps() const emits = defineEmits(['update:modelValue']) +const { showNull } = useGlobal() + const editEnabled = inject(EditModeInj) const readonly = inject(ReadonlyInj, ref(false)) @@ -38,5 +40,7 @@ const focus: VNodeRef = (el) => { @mousedown.stop /> - {{ vModel }} + NULL + + diff --git a/packages/nc-gui/components/cell/TextArea.vue b/packages/nc-gui/components/cell/TextArea.vue index f0280bf12e..96dc64ebdf 100644 --- a/packages/nc-gui/components/cell/TextArea.vue +++ b/packages/nc-gui/components/cell/TextArea.vue @@ -1,18 +1,40 @@ diff --git a/packages/nc-gui/components/cell/TimePicker.vue b/packages/nc-gui/components/cell/TimePicker.vue index 6402de2bed..862d7a3036 100644 --- a/packages/nc-gui/components/cell/TimePicker.vue +++ b/packages/nc-gui/components/cell/TimePicker.vue @@ -13,15 +13,19 @@ const emit = defineEmits(['update:modelValue']) const { isMysql } = useProject() +const { showNull } = useGlobal() + const readOnly = inject(ReadonlyInj, ref(false)) const active = inject(ActiveCellInj, ref(false)) const editable = inject(EditModeInj, ref(false)) +const column = inject(ColumnInj)! + let isTimeInvalid = $ref(false) -const dateFormat = isMysql.value ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' +const dateFormat = isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' const localState = $computed({ get() { @@ -70,6 +74,8 @@ watch( { flush: 'post' }, ) +const placeholder = computed(() => (modelValue === null && showNull.value ? 'NULL' : isTimeInvalid ? 'Invalid time' : '')) + useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { switch (e.key) { case 'Enter': @@ -95,7 +101,8 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { use12-hours format="HH:mm" class="!w-full !px-0 !border-none" - :placeholder="isTimeInvalid ? 'Invalid time' : ''" + :class="{ 'nc-null': modelValue === null && showNull }" + :placeholder="placeholder" :allow-clear="!readOnly && !localState && !isPk" :input-read-only="true" :open="(readOnly || (localState && isPk)) && !active && !editable ? false : open" diff --git a/packages/nc-gui/components/cell/Url.vue b/packages/nc-gui/components/cell/Url.vue index 742ee0e661..11a2ba5562 100644 --- a/packages/nc-gui/components/cell/Url.vue +++ b/packages/nc-gui/components/cell/Url.vue @@ -24,6 +24,8 @@ const emit = defineEmits(['update:modelValue']) const { t } = useI18n() +const { showNull } = useGlobal() + const column = inject(ColumnInj)! const editEnabled = inject(EditModeInj)! @@ -88,6 +90,8 @@ watch( @mousedown.stop /> + NULL + () const emit = defineEmits(['update:modelValue']) +const { showNull } = useGlobal() + const readOnly = inject(ReadonlyInj, ref(false)) const active = inject(ActiveCellInj, ref(false)) @@ -58,7 +60,7 @@ watch( { flush: 'post' }, ) -const placeholder = computed(() => (isYearInvalid ? 'Invalid year' : '')) +const placeholder = computed(() => (modelValue === null && showNull.value ? 'NULL' : isYearInvalid ? 'Invalid year' : '')) useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { switch (e.key) { @@ -82,6 +84,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => { picker="year" :bordered="false" class="!w-full !px-0 !border-none" + :class="{ 'nc-null': modelValue === null && showNull }" :placeholder="placeholder" :allow-clear="!readOnly && !localState && !isPk" :input-read-only="true" diff --git a/packages/nc-gui/components/cell/attachment/Carousel.vue b/packages/nc-gui/components/cell/attachment/Carousel.vue index 4933e7d396..bf0bf3983b 100644 --- a/packages/nc-gui/components/cell/attachment/Carousel.vue +++ b/packages/nc-gui/components/cell/attachment/Carousel.vue @@ -47,7 +47,7 @@ onClickOutside(carouselRef, () => { diff --git a/packages/nc-gui/components/cell/attachment/index.vue b/packages/nc-gui/components/cell/attachment/index.vue index 7a4e99a315..7cf0364f86 100644 --- a/packages/nc-gui/components/cell/attachment/index.vue +++ b/packages/nc-gui/components/cell/attachment/index.vue @@ -60,6 +60,7 @@ const { selectedImage, isReadonly, storedFiles, + getAttachmentUrl, } = useProvideAttachmentCell(updateModelValue) watch( @@ -97,10 +98,19 @@ const { isOverDropZone } = useDropZone(currentCellRef as any, onDrop) /** on new value, reparse our stored attachments */ watch( () => modelValue, - (nextModel) => { + async (nextModel) => { if (nextModel) { try { - const nextAttachments = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean) + let nextAttachments = ((typeof nextModel === 'string' ? JSON.parse(nextModel) : nextModel) || []).filter(Boolean) + + // reconstruct the url + // See /packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts for the details + nextAttachments = await Promise.all( + nextAttachments.map(async (attachment: any) => ({ + ...attachment, + url: await getAttachmentUrl(attachment), + })), + ) if (isPublic.value && isForm.value) { storedFiles.value = nextAttachments diff --git a/packages/nc-gui/components/cell/attachment/utils.ts b/packages/nc-gui/components/cell/attachment/utils.ts index 637d967476..6b202c519c 100644 --- a/packages/nc-gui/components/cell/attachment/utils.ts +++ b/packages/nc-gui/components/cell/attachment/utils.ts @@ -1,3 +1,5 @@ +import type { AttachmentType } from 'nocodb-sdk' +import RenameFile from './RenameFile.vue' import { ColumnInj, EditModeInj, @@ -24,13 +26,6 @@ import MdiFilePowerpointBox from '~icons/mdi/file-powerpoint-box' import MdiFileExcelOutline from '~icons/mdi/file-excel-outline' import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file' -interface AttachmentProps extends File { - data?: any - file: File - title: string - mimetype: string -} - export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( (updateModelValue: (data: string | Record[]) => void) => { const isReadonly = inject(ReadonlyInj, ref(false)) @@ -46,12 +41,13 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( const editEnabled = inject(EditModeInj, ref(false)) /** keep user selected File object */ - const storedFiles = ref([]) + const storedFiles = ref([]) - const attachments = ref([]) + const attachments = ref([]) const modalVisible = ref(false) + /** for image carousel */ const selectedImage = ref() const { project } = useProject() @@ -60,17 +56,37 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( const { files, open } = useFileDialog() + const { appInfo } = useGlobal() + const { t } = useI18n() + const defaultAttachmentMeta = { + ...(appInfo.value.ee && { + // Maximum Number of Attachments per cell + maxNumberOfAttachments: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 50) || 50, + // Maximum File Size per file + maxAttachmentSize: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 20) || 20, + supportedAttachmentMimeTypes: ['*'], + }), + } + + /** our currently visible items, either the locally stored or the ones from db, depending on isPublic & isForm status */ + const visibleItems = computed(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value)) + + /** for bulk download */ + const selectedVisibleItems = ref(Array.from({ length: visibleItems.value.length }, () => false)) + /** remove a file from our stored attachments (either locally stored or saved ones) */ function removeFile(i: number) { if (isPublic.value) { storedFiles.value.splice(i, 1) attachments.value.splice(i, 1) + selectedVisibleItems.value.splice(i, 1) updateModelValue(storedFiles.value) } else { attachments.value.splice(i, 1) + selectedVisibleItems.value.splice(i, 1) updateModelValue(JSON.stringify(attachments.value)) } @@ -80,12 +96,58 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( async function onFileSelect(selectedFiles: FileList | File[]) { if (!selectedFiles.length) return + const attachmentMeta = { + ...defaultAttachmentMeta, + ...(typeof column.value?.meta === 'string' ? JSON.parse(column.value.meta) : column.value?.meta), + } + + const newAttachments = [] + + const files: File[] = [] + + for (const file of selectedFiles) { + if (appInfo.value.ee) { + // verify number of files + if (visibleItems.value.length + selectedFiles.length > attachmentMeta.maxNumberOfAttachments) { + message.error( + `You can only upload at most ${attachmentMeta.maxNumberOfAttachments} file${ + attachmentMeta.maxNumberOfAttachments > 1 ? 's' : '' + } to this cell.`, + ) + return + } + + // verify file size + if (file.size > attachmentMeta.maxAttachmentSize * 1024 * 1024) { + message.error(`The size of ${file.name} exceeds the maximum file size ${attachmentMeta.maxAttachmentSize} MB.`) + continue + } + + // verify mime type + if ( + !attachmentMeta.supportedAttachmentMimeTypes.includes('*') && + !attachmentMeta.supportedAttachmentMimeTypes.includes(file.type) && + !attachmentMeta.supportedAttachmentMimeTypes.includes(file.type.split('/')[0]) + ) { + message.error(`${file.name} has the mime type ${file.type} which is not allowed in this column.`) + continue + } + } + + files.push(file) + } + if (isPublic.value && isForm.value) { - const newFiles = await Promise.all( - Array.from(selectedFiles).map( + const newFiles = await Promise.all( + Array.from(files).map( (file) => - new Promise((resolve) => { - const res: AttachmentProps = { ...file, file, title: file.name, mimetype: file.type } + new Promise((resolve) => { + const res: { file: File; title: string; mimetype: string; data?: any } = { + ...file, + file, + title: file.name, + mimetype: file.type, + } if (isImage(file.name, (file).mimetype ?? file.type)) { const reader = new FileReader() @@ -107,35 +169,47 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( }), ), ) - attachments.value = [...attachments.value, ...newFiles] return updateModelValue(attachments.value) } - const newAttachments = [] - - for (const file of selectedFiles) { - try { - const data = await api.storage.upload( - { - path: [NOCO, project.value.title, meta.value?.title, column.value?.title].join('/'), - }, - { - files: file, - json: '{}', - }, - ) - - newAttachments.push(...data) - } catch (e: any) { - message.error(e.message || t('msg.error.internalError')) - } + try { + const data = await api.storage.upload( + { + path: [NOCO, project.value.title, meta.value?.title, column.value?.title].join('/'), + }, + { + files, + json: '{}', + }, + ) + newAttachments.push(...data) + } catch (e: any) { + message.error(e.message || t('msg.error.internalError')) } updateModelValue(JSON.stringify([...attachments.value, ...newAttachments])) } + async function renameFile(attachment: AttachmentType, idx: number) { + return new Promise((resolve) => { + const { close } = useDialog(RenameFile, { + title: attachment.title, + onRename: (newTitle: string) => { + attachments.value[idx].title = newTitle + updateModelValue(JSON.stringify(attachments.value)) + close() + resolve(true) + }, + onCancel: () => { + close() + resolve(true) + }, + }) + }) + } + /** save files on drop */ async function onDrop(droppedFiles: File[] | null) { if (droppedFiles) { @@ -144,11 +218,41 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( } } + /** bulk download selected files */ + async function bulkDownloadFiles() { + await Promise.all(selectedVisibleItems.value.map(async (v, i) => v && (await downloadFile(visibleItems.value[i])))) + selectedVisibleItems.value = Array.from({ length: visibleItems.value.length }, () => false) + } + /** download a file */ - async function downloadFile(item: Record) { + async function downloadFile(item: AttachmentType) { ;(await import('file-saver')).saveAs(item.url || item.data, item.title) } + /** construct the attachment url + * See /packages/nocodb/src/lib/version-upgrader/ncAttachmentUpgrader.ts for the details + * */ + async function getAttachmentUrl(item: AttachmentType) { + const path = item?.path + // if path doesn't exist, use `item.url` + if (path) { + // try ${appInfo.value.ncSiteUrl}/${item.path} first + const url = `${appInfo.value.ncSiteUrl}/${item.path}` + try { + const res = await fetch(url) + if (res.ok) { + // use `url` if it is accessible + return Promise.resolve(url) + } + } catch { + // for some cases, `url` is not accessible as expected + // do nothing here + } + } + // if it fails, use the original url + return Promise.resolve(item.url) + } + const FileIcon = (icon: string) => { switch (icon) { case 'mdi-pdf-box': @@ -164,9 +268,6 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( } } - /** our currently visible items, either the locally stored or the ones from db, depending on isPublic & isForm status */ - const visibleItems = computed(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value)) - watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles)) return { @@ -185,10 +286,15 @@ export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( modalVisible, FileIcon, removeFile, + renameFile, downloadFile, updateModelValue, selectedImage, + selectedVisibleItems, storedFiles, + bulkDownloadFiles, + defaultAttachmentMeta, + getAttachmentUrl, } }, 'useAttachmentCell', diff --git a/packages/nc-gui/components/dashboard/TreeView.vue b/packages/nc-gui/components/dashboard/TreeView.vue index bf05399189..1fdb747cfc 100644 --- a/packages/nc-gui/components/dashboard/TreeView.vue +++ b/packages/nc-gui/components/dashboard/TreeView.vue @@ -1,19 +1,24 @@