diff --git a/.github/workflows/ci_web.yml b/.github/workflows/ci_web.yml index b97bd93e24..d0d9ace00e 100644 --- a/.github/workflows/ci_web.yml +++ b/.github/workflows/ci_web.yml @@ -53,9 +53,13 @@ jobs: run: yarn i18n --fail-on-update - name: Build run: yarn build + + # TODO: Remove after dockerizing the web. - name: Pack if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release' run: mv dist reearth-cms-web && tar -zcvf reearth-cms-web.tar.gz reearth-cms-web + + # TODO: Remove after dockerizing the web. - uses: actions/upload-artifact@v4 if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release' with: diff --git a/.github/workflows/deploy_test.yml b/.github/workflows/deploy_test.yml index 2b70d2a23a..cf3c1126f5 100644 --- a/.github/workflows/deploy_test.yml +++ b/.github/workflows/deploy_test.yml @@ -1,26 +1,36 @@ name: deploy-test on: workflow_run: - workflows: [server-build, ci-web, worker-build] + workflows: [server-build, ci-web, web-build, worker-build] types: [completed] branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}-${{github.event.workflow_run.name}} cancel-in-progress: true env: + GCP_REGION: us-central1 + + # TODO: Remove after dockerizing the web. + GCS_DEST: gs://cms.test.reearth.dev + # server - IMAGE: reearth/reearth-cms:nightly - IMAGE_NAME: us-central1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/reearth/reearth-cms:nightly - IMAGE_GCP: us-central1-docker.pkg.dev/reearth-oss/reearth/reearth-cms:nightly + SERVER_IMAGE: reearth/reearth-cms:nightly + SERVER_IMAGE_NAME: us-central1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/reearth/reearth-cms:nightly + SERVER_IMAGE_GCP: us-central1-docker.pkg.dev/reearth-oss/reearth/reearth-cms:nightly + + # web + WEB_IMAGE: reearth/reearth-cms-web:nightly + WEB_IMAGE_NAME: us-central1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/reearth/reearth-cms-web:nightly + WEB_IMAGE_GCP: us-central1-docker.pkg.dev/reearth-oss/reearth/reearth-cms-web:nightly + # worker WORKER_IMAGE: reearth/reearth-cms-worker:nightly WORKER_IMAGE_NAME: us-central1-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/reearth/reearth-cms-worker:nightly WORKER_IMAGE_GCP: us-central1-docker.pkg.dev/reearth-oss/reearth/reearth-cms-worker:nightly - GCP_REGION: us-central1 - GCS_DEST: gs://cms.test.reearth.dev jobs: - deploy_web: + # TODO: Remove after dockerizing the web. + deploy_web_gcs: name: Deploy web to test env if: github.event.repository.full_name == 'reearth/reearth-cms' && github.event.workflow_run.name == 'ci-web' && github.event.workflow_run.conclusion != 'failure' && github.event.workflow_run.head_branch == 'main' runs-on: ubuntu-latest @@ -67,13 +77,40 @@ jobs: run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet - name: docker push run: | - docker pull $IMAGE - docker tag $IMAGE $IMAGE_GCP - docker push $IMAGE_GCP + docker pull $SERVER_IMAGE + docker tag $SERVER_IMAGE $SERVER_IMAGE_GCP + docker push $SERVER_IMAGE_GCP - name: Deploy to Cloud Run run: | gcloud run deploy reearth-cms-backend \ - --image $IMAGE_GCP \ + --image $SERVER_IMAGE_GCP \ + --region $GCP_REGION \ + --platform managed \ + --quiet + + deploy_web: + name: Deploy web to test env + runs-on: ubuntu-latest + if: github.event.repository.full_name == 'reearth/reearth-cms' && github.event.workflow_run.name == 'web-build' && github.event.workflow_run.conclusion != 'failure' && github.event.workflow_run.head_branch == 'main' + steps: + - uses: actions/checkout@v4 + + - uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + - name: Configure docker + run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet + - name: docker push + run: | + docker pull $WEB_IMAGE + docker tag $WEB_IMAGE $WEB_IMAGE_GCP + docker push $WEB_IMAGE_GCP + - name: Deploy to Cloud Run + run: | + gcloud run deploy reearth-cms-web \ + --image $WEB_IMAGE_GCP \ --region $GCP_REGION \ --platform managed \ --quiet diff --git a/.github/workflows/web_build.yml b/.github/workflows/web_build.yml new file mode 100644 index 0000000000..b35646a6f2 --- /dev/null +++ b/.github/workflows/web_build.yml @@ -0,0 +1,107 @@ +name: web-build +on: + workflow_run: + workflows: [ci-web] + types: [completed] + branches: [main, release] +concurrency: + group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + info: + name: Collect information + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion != 'failure' && github.event.repository.full_name == 'reearth/reearth-cms' && (github.event.workflow_run.head_branch == 'release' || !startsWith(github.event.head_commit.message, 'v')) + outputs: + sha_short: ${{ steps.info.outputs.sha_short }} + new_tag: ${{ steps.info.outputs.new_tag }} + new_tag_short: ${{ steps.info.outputs.new_tag_short }} + name: ${{ steps.info.outputs.name }} + steps: + - name: checkout + uses: actions/checkout@v4 + - name: Fetch tags + run: git fetch --prune --unshallow --tags + - name: Get info + id: info + # The tag name should be retrieved lazily, as tagging may be delayed. + env: + BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" + if [[ "$BRANCH" = "release" ]]; then + TAG=$(git tag --points-at HEAD) + if [[ ! -z "$TAG" ]]; then + echo "::set-output name=new_tag::$TAG" + echo "::set-output name=new_tag_short::${TAG#v}" + else + echo "::set-output name=name::rc" + fi + else + echo "::set-output name=name::nightly" + fi + - name: Show info + env: + SHA_SHORT: ${{ steps.info.outputs.sha_short }} + NEW_TAG: ${{ steps.info.outputs.new_tag }} + NEW_TAG_SHORT: ${{ steps.info.outputs.new_tag_short }} + NAME: ${{ steps.info.outputs.name }} + run: echo "sha_short=$SHA_SHORT, new_tag=$NEW_TAG, new_tag_short=$NEW_TAG_SHORT, name=$NAME" + + docker: + name: Build and push Docker image + runs-on: ubuntu-latest + needs: + - info + if: needs.info.outputs.name || needs.info.outputs.new_tag + env: + IMAGE_NAME: reearth/reearth-cms-web + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Get options + id: options + env: + TAG: ${{ needs.info.outputs.tag_short }} + NAME: ${{ needs.info.outputs.name }} + SHA: ${{ needs.info.outputs.sha_short }} + run: | + if [[ -n $TAG ]]; then + PLATFORMS=linux/amd64,linux/arm64 + VERSION=$TAG + TAGS=$IMAGE_NAME:$TAG + if [[ ! $TAG =~ '-' ]]; then + TAGS+=,${IMAGE_NAME}:${TAG%.*} + TAGS+=,${IMAGE_NAME}:${TAG%%.*} + TAGS+=,${IMAGE_NAME}:latest + fi + else + PLATFORMS=linux/amd64 + VERSION=$SHA + TAGS=$IMAGE_NAME:$NAME + fi + echo "::set-output name=platforms::$PLATFORMS" + echo "::set-output name=version::$VERSION" + echo "::set-output name=tags::$TAGS" + - name: Build and push docker image + uses: docker/build-push-action@v6 + with: + context: ./web + platforms: ${{ steps.options.outputs.platforms }} + push: true + build-args: | + GITHUB_SHA=${{ needs.info.outputs.sha_short }} + VERSION=${{ steps.options.outputs.version }} + tags: ${{ steps.options.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/server/e2e/integration_item_test.go b/server/e2e/integration_item_test.go index 48eabba9b3..f3edcd4ac6 100644 --- a/server/e2e/integration_item_test.go +++ b/server/e2e/integration_item_test.go @@ -39,6 +39,7 @@ var ( wId0 = accountdomain.NewWorkspaceID() uId = accountdomain.NewUserID() iId = id.NewIntegrationID() + mId0 = id.NewModelID() mId1 = id.NewModelID() mId2 = id.NewModelID() mId3 = id.NewModelID() @@ -72,7 +73,9 @@ var ( ikey2 = key.Random() ikey3 = key.Random() ikey4 = key.Random() + ikey0 = id.RandomKey() pid = id.NewProjectID() + sid0 = id.NewSchemaID() sid1 = id.NewSchemaID() sid2 = id.NewSchemaID() sid3 = id.NewSchemaID() @@ -153,6 +156,11 @@ func baseSeeder(ctx context.Context, r *repo.Container) error { sf3 := schema.NewField(schema.NewReference(mId1, sid1, nil, nil).TypeProperty()).ID(fId3).Key(sfKey3).MustBuild() sf4 := schema.NewField(schema.NewBool().TypeProperty()).ID(fId4).Key(sfKey4).MustBuild() + s0 := schema.New().ID(sid0).Workspace(w.ID()).Project(p.ID()).Fields([]*schema.Field{}).MustBuild() + if err := r.Schema.Save(ctx, s0); err != nil { + return err + } + s1 := schema.New().ID(sid1).Workspace(w.ID()).Project(p.ID()).Fields([]*schema.Field{sf1, sf2}).TitleField(sf1.ID().Ref()).MustBuild() if err := r.Schema.Save(ctx, s1); err != nil { return err @@ -168,6 +176,19 @@ func baseSeeder(ctx context.Context, r *repo.Container) error { return err } + m0 := model.New(). + ID(mId0). + Name("m0"). + Description("m0 desc"). + Public(true). + Key(ikey0). + Project(p.ID()). + Schema(s0.ID()). + MustBuild() + if err := r.Model.Save(ctx, m0); err != nil { + return err + } + m1 := model.New(). ID(mId1). Name("m1"). diff --git a/server/e2e/integration_model_test.go b/server/e2e/integration_model_test.go index e00974020f..818e03ae6b 100644 --- a/server/e2e/integration_model_test.go +++ b/server/e2e/integration_model_test.go @@ -76,7 +76,27 @@ func TestIntegrationModelUpdateAPI(t *testing.T) { Expect(). Status(http.StatusUnauthorized) - obj := e.PATCH(endpoint, mId1). + // update empty model + obj := e.PATCH(endpoint, mId0). + WithHeader("authorization", "Bearer "+secret). + WithJSON(map[string]interface{}{ + "name": "M0 updated", + "description": "M0 desc updated", + "key": "M0KeyUpdated", + }). + Expect(). + Status(http.StatusOK). + JSON(). + Object() + obj. + ContainsKey("id"). + ContainsKey("schemaId"). + HasValue("projectId", pid). + HasValue("name", "M0 updated"). + HasValue("description", "M0 desc updated"). + HasValue("key", "M0KeyUpdated") + + obj = e.PATCH(endpoint, mId1). WithHeader("authorization", "Bearer "+secret). WithJSON(map[string]interface{}{ "name": "newM1 updated", @@ -187,12 +207,22 @@ func TestIntegrationModelFilterAPI(t *testing.T) { Object(). HasValue("page", 1). HasValue("perPage", 10). - HasValue("totalCount", 5). + HasValue("totalCount", 6). Value("models"). Array() - models.Length().IsEqual(5) + models.Length().IsEqual(6) + + obj0 := models.Value(0).Object() + obj0. + HasValue("id", mId0.String()). + HasValue("name", "m0"). + HasValue("description", "m0 desc"). + HasValue("public", true). + HasValue("key", ikey0.String()). + HasValue("projectId", pid). + HasValue("schemaId", sid0) - obj1 := models.Value(0).Object() + obj1 := models.Value(1).Object() obj1. HasValue("id", mId1.String()). HasValue("name", "m1"). @@ -206,7 +236,7 @@ func TestIntegrationModelFilterAPI(t *testing.T) { obj1.Value("updatedAt").NotNull() obj1.Value("lastModified").NotNull() - obj2 := models.Value(1).Object() + obj2 := models.Value(2).Object() obj2. HasValue("id", mId2.String()). HasValue("name", "m2"). diff --git a/server/e2e/integration_schema_test.go b/server/e2e/integration_schema_test.go index 8a37a2c553..6ecd143832 100644 --- a/server/e2e/integration_schema_test.go +++ b/server/e2e/integration_schema_test.go @@ -43,12 +43,22 @@ func TestIntegrationScemaFilterAPI(t *testing.T) { Object(). HasValue("page", 1). HasValue("perPage", 10). - HasValue("totalCount", 5). + HasValue("totalCount", 6). Value("models"). Array() - models.Length().IsEqual(5) + models.Length().IsEqual(6) - obj1 := models.Value(0).Object() + obj0 := models.Value(0).Object() + obj0. + HasValue("id", mId0.String()). + HasValue("name", "m0"). + HasValue("description", "m0 desc"). + HasValue("public", true). + HasValue("key", ikey0.String()). + HasValue("projectId", pid). + HasValue("schemaId", sid0) + + obj1 := models.Value(1).Object() obj1. HasValue("id", mId1.String()). HasValue("name", "m1"). @@ -62,7 +72,7 @@ func TestIntegrationScemaFilterAPI(t *testing.T) { obj1.Value("updatedAt").NotNull() obj1.Value("lastModified").NotNull() - obj2 := models.Value(1).Object() + obj2 := models.Value(2).Object() obj2. HasValue("id", mId2.String()). HasValue("name", "m2"). diff --git a/server/internal/adapter/integration/model.go b/server/internal/adapter/integration/model.go index d40581102b..02b74afca7 100644 --- a/server/internal/adapter/integration/model.go +++ b/server/internal/adapter/integration/model.go @@ -173,7 +173,7 @@ func (s *Server) ModelUpdate(ctx context.Context, request ModelUpdateRequestObje } lastModified, err := uc.Item.LastModifiedByModel(ctx, request.ModelId, op) - if err != nil { + if err != nil && !errors.Is(err, rerror.ErrNotFound) { return nil, err } diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000000..c497bd9266 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,11 @@ +* + +!docker/ +!src/ + +!index.html +!i18next-parser.config.js +!tsconfig.json +!package.json +!vite.config.ts +!yarn.lock diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000000..7dd957c3f5 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,54 @@ +FROM node:20.18.0-slim AS builder +WORKDIR /app + +ARG NODE_OPTIONS="--max-old-space-size=4096" +ARG GITHUB_SHA +ENV NODE_OPTIONS=$NODE_OPTIONS +ENV GITHUB_SHA=$GITHUB_SHA + +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=yarn.lock,target=yarn.lock \ + --mount=type=cache,target=/root/.yarn,sharing=locked \ + yarn install --frozen-lockfile --production=false + +RUN --mount=type=bind,source=package.json,target=package.json \ + --mount=type=bind,source=yarn.lock,target=yarn.lock \ + --mount=type=bind,source=index.html,target=index.html \ + --mount=type=bind,source=tsconfig.json,target=tsconfig.json \ + --mount=type=bind,source=vite.config.ts,target=vite.config.ts \ + --mount=type=bind,source=src,target=src \ + --mount=type=cache,target=/root/.yarn,sharing=locked \ + yarn build + +FROM nginx:1.27-alpine +WORKDIR /app + +# Quite the Nginx startup logs. +ENV NGINX_ENTRYPOINT_QUIET_LOGS=true + +# Default to Cloud Run port. +# Ref: https://cloud.google.com/run/docs/reference/container-contract#port +ENV PORT=8080 + +# Defaults Google Cloud Load Balancer header. +# Ref: https://cloud.google.com/load-balancing/docs/https#target-proxies +ENV REAL_IP_HEADER=X-Forwarded-For + +# Default values. +# Cesium Ion access token is not a secret. +ENV REEARTH_CMS_CESIUM_ION_ACCESS_TOKEN=null + +# All values in reearth_config.json must have a default value. +ENV REEARTH_CMS_API=null +ENV REEARTH_CMS_COVER_IMAGE_URL=null +ENV REEARTH_CMS_EDITOR_URL=null +ENV REEARTH_CMS_LOGO_URL=null +ENV REEARTH_CMS_MULTI_TENANT=null + +COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html +COPY --chown=nginx:nginx docker/nginx.conf.template /etc/nginx/templates/nginx.conf.template +COPY --chown=nginx:nginx docker/40-envsubst-on-reearth-config.sh /docker-entrypoint.d +COPY --chown=nginx:nginx docker/reearth_config.json.template /opt/reearth-cms/reearth_config.json.template + +ENTRYPOINT ["/docker-entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/web/docker/40-envsubst-on-reearth-config.sh b/web/docker/40-envsubst-on-reearth-config.sh new file mode 100755 index 0000000000..1b116b4164 --- /dev/null +++ b/web/docker/40-envsubst-on-reearth-config.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +set -e + +_REEARTH_CONFIG_TEMPLATE_FILE="/opt/reearth-cms/reearth_config.json.template" +_REEARTH_CONFIG_OUTPUT_FILE="/usr/share/nginx/html/reearth_config.json" + +# Wrap with "" if the value doesn't start with '{' and end with '}' (JSON) or "null". +wrap_reearth_cms_variables() { + for var in $(env | grep '^REEARTH_CMS_' | cut -d= -f1); do + value=$(printenv "$var") + [ "$value" != "null" ] && ! echo "$value" | grep -qE '^\{.*\}$' && export "$var=\"${value}\"" + done +} + +wrap_reearth_cms_variables $@ +envsubst < "$_REEARTH_CONFIG_TEMPLATE_FILE" > "$_REEARTH_CONFIG_OUTPUT_FILE" diff --git a/web/docker/nginx.conf.template b/web/docker/nginx.conf.template new file mode 100644 index 0000000000..b2c19d2567 --- /dev/null +++ b/web/docker/nginx.conf.template @@ -0,0 +1,35 @@ +log_format json escape=json '{' + '"body_bytes_sent": "$body_bytes_sent",' + '"http_referer": "$http_referer",' + '"http_user_agent": "$http_user_agent",' + '"remote_ip": "$remote_addr",' + '"remote_user": "$remote_user",' + '"request": "$request",' + '"request_id": "$request_id",' + '"request_method": "$request_method",' + '"request_time": "$request_time",' + '"request_uri": "$request_uri",' + '"server_name": "$server_name",' + '"status": "$status",' + '"time": "$time_iso8601"' +'}'; + +real_ip_header ${REAL_IP_HEADER}; + +server { + listen ${PORT}; + root /usr/share/nginx/html; + server_name _; + + access_log /dev/stdout json; + error_log /dev/stderr warn; + + location / { + try_files $uri /index.html =404; + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/web/docker/reearth_config.json.template b/web/docker/reearth_config.json.template new file mode 100644 index 0000000000..75bc0a5fcb --- /dev/null +++ b/web/docker/reearth_config.json.template @@ -0,0 +1,11 @@ +{ + "api": $REEARTH_CMS_API, + "auth0Audience": $REEARTH_CMS_AUTH0_AUDIENCE, + "auth0ClientId": $REEARTH_CMS_AUTH0_CLIENT_ID, + "auth0Domain": $REEARTH_CMS_AUTH0_DOMAIN, + "cesiumIonAccessToken": $REEARTH_CMS_CESIUM_ION_ACCESS_TOKEN, + "coverImageUrl": $REEARTH_CMS_COVER_IMAGE_URL, + "editorUrl": $REEARTH_CMS_EDITOR_URL, + "logoUrl": $REEARTH_CMS_LOGO_URL, + "multiTenant": $REEARTH_CMS_MULTI_TENANT +} diff --git a/web/e2e/project/item/fields/group.spec.ts b/web/e2e/project/item/fields/group.spec.ts index a0358c8f66..db71283ff0 100644 --- a/web/e2e/project/item/fields/group.spec.ts +++ b/web/e2e/project/item/fields/group.spec.ts @@ -94,8 +94,7 @@ test("Group field creating and updating has succeeded", async ({ page }) => { await page.getByRole("button", { name: "plus New" }).click(); await page.getByLabel("Set default value").click(); await page.getByLabel("Set default value").fill("text12"); - await page.getByRole("button", { name: "OK" }).click(); - await closeNotification(page, false); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); await page.getByLabel("Set default value").click(); await page.getByLabel("Set default value").fill("text1"); await page.getByRole("button", { name: "OK" }).click(); @@ -220,12 +219,12 @@ test("Group field editing has succeeded", async ({ page }) => { await expect(page.getByRole("main")).toContainText("new group1 (2)"); await page .locator("div") - .filter({ hasText: /^0 \/ 500text1 description$/ }) + .filter({ hasText: /^0text1 description$/ }) .getByLabel("text1") .click(); await page .locator("div") - .filter({ hasText: /^0 \/ 500text1 description$/ }) + .filter({ hasText: /^0text1 description$/ }) .getByLabel("text1") .fill("text1-2"); await page.getByRole("button", { name: "Save" }).click(); @@ -235,13 +234,13 @@ test("Group field editing has succeeded", async ({ page }) => { await expect( page .locator("div") - .filter({ hasText: /^5 \/ 500text1 description$/ }) + .filter({ hasText: /^5text1 description$/ }) .getByLabel("text1"), ).toHaveValue("text1"); await expect( page .locator("div") - .filter({ hasText: /^7 \/ 500text1 description$/ }) + .filter({ hasText: /^7text1 description$/ }) .getByLabel("text1"), ).toHaveValue("text1-2"); await page.getByLabel("Back").click(); diff --git a/web/e2e/project/item/fields/int.spec.ts b/web/e2e/project/item/fields/int.spec.ts index 42246a5a75..1b46ac512a 100644 --- a/web/e2e/project/item/fields/int.spec.ts +++ b/web/e2e/project/item/fields/int.spec.ts @@ -85,8 +85,7 @@ test("Int field editing has succeeded", async ({ page }) => { await page.getByLabel("Set minimum value").fill("10"); await page.getByLabel("Set maximum value").click(); await page.getByLabel("Set maximum value").fill("2"); - await page.getByRole("button", { name: "OK" }).click(); - await closeNotification(page, false); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); await page.getByLabel("Set minimum value").click(); await page.getByLabel("Set minimum value").fill("2"); await page.getByLabel("Set maximum value").click(); @@ -96,12 +95,10 @@ test("Int field editing has succeeded", async ({ page }) => { await page.getByRole("tab", { name: "Default value" }).click(); await expect(page.getByLabel("Set default value")).toBeVisible(); await expect(page.getByLabel("Set default value")).toHaveValue("1"); - await page.getByRole("button", { name: "OK" }).click(); - await closeNotification(page, false); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); await page.getByLabel("Set default value").click(); await page.getByLabel("Set default value").fill("11"); - await page.getByRole("button", { name: "OK" }).click(); - await closeNotification(page, false); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); await page.getByLabel("Set default value").click(); await page.getByLabel("Set default value").fill("2"); await page.getByRole("button", { name: "plus New" }).click(); diff --git a/web/e2e/project/item/fields/markdown.spec.ts b/web/e2e/project/item/fields/markdown.spec.ts index 9434804394..2e14fe77ae 100644 --- a/web/e2e/project/item/fields/markdown.spec.ts +++ b/web/e2e/project/item/fields/markdown.spec.ts @@ -73,14 +73,12 @@ test("Markdown field editing has succeeded", async ({ page }) => { await page.getByRole("tab", { name: "Default value" }).click(); await expect(page.getByLabel("Set default value")).toHaveValue("text1 default value"); await page.getByRole("button", { name: "plus New" }).click(); - await page.locator("div:nth-child(2) > .css-1ago99h > .css-7olgor").click(); + await page.locator("div:nth-child(2) > .css-1ago99h").click(); await page.getByRole("textbox").fill("text2"); - await page.locator("div:nth-child(1) > .css-1ago99h > .css-7olgor").click(); + await page.locator("div:nth-child(1) > .css-1ago99h").click(); await page.getByRole("textbox").fill("text1"); await page.getByRole("button", { name: "arrow-down" }).first().click(); - await expect(page.locator("div:nth-child(2) > .css-1ago99h > .css-7olgor")).toContainText( - "text1", - ); + await expect(page.locator("div:nth-child(2) > .css-1ago99h")).toContainText("text1"); await page.getByRole("button", { name: "OK" }).click(); await closeNotification(page); await expect(page.getByText("new text1 *#new-text1(unique)")).toBeVisible(); @@ -92,12 +90,8 @@ test("Markdown field editing has succeeded", async ({ page }) => { await expect(page.getByText("new text1(unique)")).toBeVisible(); await page.getByText("new text1 description").click(); await expect(page.getByText("new text1 description")).toBeVisible(); - await expect(page.locator("div:nth-child(1) > .css-1ago99h > .css-7olgor")).toContainText( - "text2", - ); - await expect(page.locator("div:nth-child(2) > .css-1ago99h > .css-7olgor")).toContainText( - "text1", - ); + await expect(page.locator("div:nth-child(1) > .css-1ago99h")).toContainText("text2"); + await expect(page.locator("div:nth-child(2) > .css-1ago99h")).toContainText("text1"); await page.getByRole("button", { name: "Save" }).click(); await closeNotification(page); await page.getByLabel("Back").click(); @@ -110,17 +104,15 @@ test("Markdown field editing has succeeded", async ({ page }) => { await page.getByRole("button", { name: "plus New" }).click(); await page.getByRole("button", { name: "Save" }).click(); await closeNotification(page, false); - await page.locator("div:nth-child(1) > .css-1ago99h > .css-7olgor").click(); + await page.locator("div:nth-child(1) > .css-1ago99h").click(); await page.getByRole("textbox").fill("text"); await page.getByRole("button", { name: "plus New" }).click(); await page.getByRole("button", { name: "plus New" }).click(); - await page.locator("div:nth-child(2) > .css-1ago99h > .css-7olgor").click(); + await page.locator("div:nth-child(2) > .css-1ago99h").click(); await page.getByRole("textbox").fill("text2"); await page.getByRole("button", { name: "arrow-up" }).nth(1).click(); - await expect(page.locator("div:nth-child(1) > .css-1ago99h > .css-7olgor")).toContainText( - "text2", - ); - await expect(page.locator("div:nth-child(2) > .css-1ago99h > .css-7olgor")).toContainText("text"); + await expect(page.locator("div:nth-child(1) > .css-1ago99h")).toContainText("text2"); + await expect(page.locator("div:nth-child(2) > .css-1ago99h")).toContainText("text"); await page.getByRole("button", { name: "Save" }).click(); await closeNotification(page); await page.getByLabel("Back").click(); diff --git a/web/e2e/project/item/metadata/text.spec.ts b/web/e2e/project/item/metadata/text.spec.ts index d3c95051d8..cbd721832c 100644 --- a/web/e2e/project/item/metadata/text.spec.ts +++ b/web/e2e/project/item/metadata/text.spec.ts @@ -114,8 +114,7 @@ test("Text metadata editing has succeeded", async ({ page }) => { await page.getByRole("button", { name: "plus New" }).click(); await page.locator("#defaultValue").nth(1).click(); await page.locator("#defaultValue").nth(1).fill("text2"); - await page.getByRole("button", { name: "OK" }).click(); - await closeNotification(page, false); + await expect(page.getByRole("button", { name: "OK" })).toBeDisabled(); await page.locator("#defaultValue").nth(0).click(); await page.locator("#defaultValue").nth(0).fill("text1"); await page.getByRole("button", { name: "arrow-down" }).first().click(); diff --git a/web/e2e/project/request.spec.ts b/web/e2e/project/request.spec.ts index b4ec4c6a84..d19c84bd69 100644 --- a/web/e2e/project/request.spec.ts +++ b/web/e2e/project/request.spec.ts @@ -133,8 +133,8 @@ test("Creating a new request and adding to request has succeeded", async ({ page await closeNotification(page); await page.getByText("Request", { exact: true }).click(); await page.getByLabel("edit").locator("svg").click(); - await expect(page.getByRole("button", { name: "right e2e model name" }).first()).toBeVisible(); - await expect(page.getByRole("button", { name: "right e2e model name" }).nth(1)).toBeVisible(); + await expect(page.getByRole("button", { name: "collapsed e2e model name" }).nth(0)).toBeVisible(); + await expect(page.getByRole("button", { name: "collapsed e2e model name" }).nth(1)).toBeVisible(); }); test("Navigating from request to item has succeeded", async ({ page }) => { diff --git a/web/package.json b/web/package.json index 2cc00f98a2..67a6728dfb 100644 --- a/web/package.json +++ b/web/package.json @@ -136,6 +136,7 @@ "react-svg": "16.1.34", "remark-gfm": "4.0.0", "resium": "1.18.2", + "runes2": "1.1.4", "ulid": "2.3.0" } } diff --git a/web/src/components/atoms/Input/index.tsx b/web/src/components/atoms/Input/index.tsx index 91c7aae7bc..e86df62641 100644 --- a/web/src/components/atoms/Input/index.tsx +++ b/web/src/components/atoms/Input/index.tsx @@ -1,6 +1,33 @@ -import { Input, InputProps } from "antd"; +import { Input as AntDInput, InputProps, InputRef } from "antd"; +import { forwardRef, useMemo } from "react"; +import { runes } from "runes2"; export type { SearchProps } from "antd/lib/input"; +type Props = { + value?: string; +} & InputProps; + +const Input = forwardRef(({ value, maxLength, ...props }, ref) => { + const status = useMemo(() => { + if (maxLength && value && runes(value).length > maxLength) { + return "error"; + } + }, [maxLength, value]); + + return ( + runes(txt).length, + }} + value={value} + ref={ref} + status={status} + {...props} + /> + ); +}); + export default Input; export type { InputProps }; diff --git a/web/src/components/atoms/InputNumber/index.tsx b/web/src/components/atoms/InputNumber/index.tsx index 2adb2e1fe3..f276249a29 100644 --- a/web/src/components/atoms/InputNumber/index.tsx +++ b/web/src/components/atoms/InputNumber/index.tsx @@ -1,3 +1,20 @@ -import { InputNumber } from "antd"; +import { InputNumber as AntDInputNumber, InputNumberProps } from "antd"; +import { useMemo } from "react"; + +const InputNumber: ( + props: React.PropsWithChildren> & React.RefAttributes, +) => React.ReactElement = ({ value, ...props }) => { + const status = useMemo(() => { + if (value) { + if (props.max && Number(value) > Number(props.max)) { + return "error"; + } else if (props.min && Number(value) < Number(props.min)) { + return "error"; + } + } + }, [props.max, props.min, value]); + + return ; +}; export default InputNumber; diff --git a/web/src/components/atoms/Markdown/index.tsx b/web/src/components/atoms/Markdown/index.tsx index cd48c45057..f2ba814861 100644 --- a/web/src/components/atoms/Markdown/index.tsx +++ b/web/src/components/atoms/Markdown/index.tsx @@ -1,6 +1,7 @@ import styled from "@emotion/styled"; -import { useRef, useState, FocusEvent, useCallback } from "react"; +import { useRef, useState, FocusEvent, useCallback, useMemo } from "react"; import ReactMarkdown from "react-markdown"; +import { runes } from "runes2"; import TextArea, { TextAreaProps } from "@reearth-cms/components/atoms/TextArea"; @@ -9,9 +10,13 @@ type Props = { onChange?: (value: string) => void; } & TextAreaProps; -const MarkdownInput: React.FC = ({ value = "", onChange, ...props }) => { +const MarkdownInput: React.FC = ({ value, onChange, ...props }) => { const [showMD, setShowMD] = useState(true); const textareaRef = useRef(null); + const isError = useMemo( + () => (props.maxLength && value ? runes(value).length > props.maxLength : false), + [props.maxLength, value], + ); const handleBlur = useCallback((event: FocusEvent) => { event.stopPropagation(); @@ -40,7 +45,7 @@ const MarkdownInput: React.FC = ({ value = "", onChange, ...props }) => { ref={textareaRef} showCount /> -