diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 44bae0efa..0d8d2af09 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -2,6 +2,27 @@ name: Branch Build on: workflow_dispatch: + inputs: + build-web: + required: false + description: "Build Web" + type: boolean + default: false + build-space: + required: false + description: "Build Space" + type: boolean + default: false + build-api: + required: false + description: "Build API" + type: boolean + default: false + build-proxy: + required: false + description: "Build Proxy" + type: boolean + default: false push: branches: - master @@ -18,7 +39,7 @@ jobs: name: Build-Push Web/Space/API/Proxy Docker Image runs-on: ubuntu-latest outputs: - gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} @@ -74,7 +95,7 @@ jobs: - nginx/** branch_build_push_frontend: - if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -126,7 +147,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_space: - if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event.inputs.build-space=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -178,7 +199,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_backend: - if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event.inputs.build-api=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -230,7 +251,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_proxy: - if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -280,4 +301,3 @@ jobs: DOCKER_BUILDKIT: 1 DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml index 7b9f5ffcc..c5eec3cd3 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -4,70 +4,196 @@ on: workflow_dispatch: inputs: web-build: - required: true + required: false + description: 'Build Web' type: boolean default: true space-build: - required: true + required: false + description: 'Build Space' type: boolean default: false +env: + BUILD_WEB: ${{ github.event.inputs.web-build }} + BUILD_SPACE: ${{ github.event.inputs.space-build }} + jobs: + setup-feature-build: + name: Feature Build Setup + runs-on: ubuntu-latest + steps: + - name: Checkout + run: | + echo "BUILD_WEB=$BUILD_WEB" + echo "BUILD_SPACE=$BUILD_SPACE" + outputs: + web-build: ${{ env.BUILD_WEB}} + space-build: ${{env.BUILD_SPACE}} + + feature-build-web: + if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }} + needs: setup-feature-build + name: Feature Build Web + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Checkout + uses: actions/checkout@v4 + with: + path: plane + - name: Install Dependencies + run: | + cd $GITHUB_WORKSPACE/plane + yarn install + - name: Build Web + id: build-web + run: | + cd $GITHUB_WORKSPACE/plane + yarn build --filter=web + cd $GITHUB_WORKSPACE + + TAR_NAME="web.tar.gz" + tar -czf $TAR_NAME ./plane + + FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") + aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + + feature-build-space: + if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }} + needs: setup-feature-build + name: Feature Build Space + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1 + NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + outputs: + do-build: ${{ needs.setup-feature-build.outputs.space-build }} + s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }} + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Checkout + uses: actions/checkout@v4 + with: + path: plane + - name: Install Dependencies + run: | + cd $GITHUB_WORKSPACE/plane + yarn install + - name: Build Space + id: build-space + run: | + cd $GITHUB_WORKSPACE/plane + yarn build --filter=space + cd $GITHUB_WORKSPACE + + TAR_NAME="space.tar.gz" + tar -czf $TAR_NAME ./plane + + FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") + aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + feature-deploy: + if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }} + needs: [feature-build-web, feature-build-space] name: Feature Deploy runs-on: ubuntu-latest env: - KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG }} - BUILD_WEB: ${{ (github.event.inputs.web-build == '' && true) || github.event.inputs.web-build }} - BUILD_SPACE: ${{ (github.event.inputs.space-build == '' && false) || github.event.inputs.space-build }} - + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }} steps: + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli - name: Tailscale uses: tailscale/github-action@v2 with: oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} tags: tag:ci - - name: Kubectl Setup run: | - curl -LO "https://dl.k8s.io/release/${{secrets.KUBE_VERSION}}/bin/linux/amd64/kubectl" + curl -LO "https://dl.k8s.io/release/${{ vars.FEATURE_PREVIEW_KUBE_VERSION }}/bin/linux/amd64/kubectl" chmod +x kubectl mkdir -p ~/.kube echo "$KUBE_CONFIG_FILE" > ~/.kube/config chmod 600 ~/.kube/config - - name: HELM Setup run: | curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 chmod 700 get_helm.sh ./get_helm.sh - - name: App Deploy run: | - helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ secrets.FEATURE_PREVIEW_HELM_CHART_URL }} - GIT_BRANCH=${{ github.ref_name }} - APP_NAMESPACE=${{ secrets.FEATURE_PREVIEW_NAMESPACE }} + WEB_S3_URL="" + if [ ${{ env.BUILD_WEB }} == true ]; then + WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600) + fi - METADATA=$(helm install feature-preview/${{ secrets.FEATURE_PREVIEW_HELM_CHART_NAME }} \ - --kube-insecure-skip-tls-verify \ - --generate-name \ - --namespace $APP_NAMESPACE \ - --set shared_config.git_repo=${{github.server_url}}/${{ github.repository }}.git \ - --set shared_config.git_branch="$GIT_BRANCH" \ - --set web.enabled=${{ env.BUILD_WEB }} \ - --set space.enabled=${{ env.BUILD_SPACE }} \ - --output json \ - --timeout 1000s) + SPACE_S3_URL="" + if [ ${{ env.BUILD_SPACE }} == true ]; then + SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600) + fi - APP_NAME=$(echo $METADATA | jq -r '.name') + if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then - INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \ - -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \ - jq -r '.spec.rules[0].host') + helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }} - echo "****************************************" - echo "APP NAME ::: $APP_NAME" - echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" - echo "****************************************" + APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}" + DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}" + + METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \ + --generate-name \ + --namespace $APP_NAMESPACE \ + --set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \ + --set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ + --set web.enabled=${{ env.BUILD_WEB || false }} \ + --set web.artifact_url=$WEB_S3_URL \ + --set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ + --set space.enabled=${{ env.BUILD_SPACE || false }} \ + --set space.artifact_url=$SPACE_S3_URL \ + --set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \ + --set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \ + --output json \ + --timeout 1000s) + + APP_NAME=$(echo $METADATA | jq -r '.name') + + INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \ + -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \ + jq -r '.spec.rules[0].host') + + echo "****************************************" + echo "APP NAME ::: $APP_NAME" + echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" + echo "****************************************" + fi diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 42904a8fc..e777a93a6 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -192,7 +192,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def list(self, request, slug, project_id): - queryset = self.get_queryset() + queryset = self.get_queryset().annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) cycle_view = request.GET.get("cycle_view", "all") # Update the order by @@ -223,6 +231,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", + "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -345,6 +354,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "external_id", "progress_snapshot", # meta fields + "total_issues", "is_favorite", "cancelled_issues", "completed_issues", diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index ee9718b59..881730d65 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -79,6 +79,15 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ), ) ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) .annotate( completed_issues=Count( "issue_module__issue__state__group", @@ -214,6 +223,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "external_source", "external_id", # computed fields + "total_issues", "is_favorite", "cancelled_issues", "completed_issues", diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py new file mode 100644 index 000000000..d36a784d0 --- /dev/null +++ b/apiserver/plane/db/management/commands/test_email.py @@ -0,0 +1,61 @@ +from django.core.mail import EmailMultiAlternatives, get_connection +from django.core.management import BaseCommand, CommandError + +from plane.license.utils.instance_value import get_email_configuration + + +class Command(BaseCommand): + """Django command to pause execution until db is available""" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("to_email", type=str, help="receiver's email") + + def handle(self, *args, **options): + receiver_email = options.get("to_email") + + if not receiver_email: + raise CommandError("Reciever email is required") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + timeout=30, + ) + # Prepare email details + subject = "Email Notification from Plane" + message = ( + "This is a sample email notification sent from Plane application." + ) + + self.stdout.write(self.style.SUCCESS("Trying to send test email...")) + + # Send the email + try: + msg = EmailMultiAlternatives( + subject=subject, + body=message, + from_email=EMAIL_FROM, + to=[receiver_email], + connection=connection, + ) + msg.send() + self.stdout.write(self.style.SUCCESS("Email succesfully sent")) + except Exception as e: + self.stdout.write( + self.style.ERROR( + f"Error: Email could not be delivered due to {e}" + ) + ) diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app index e6bd24b9e..ace0a0b79 100644 --- a/deploy/1-click/plane-app +++ b/deploy/1-click/plane-app @@ -494,13 +494,6 @@ function install() { update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP" update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')" - - if command -v crontab &> /dev/null; then - sudo touch /etc/cron.daily/makeplane - sudo chmod +x /etc/cron.daily/makeplane - sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane - sudo crontab /etc/cron.daily/makeplane - fi show_message "Plane Installed Successfully ✅" show_message "" @@ -606,11 +599,6 @@ function uninstall() { sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null - - if command -v crontab &> /dev/null; then - sudo crontab -r &> /dev/null - sudo rm /etc/cron.daily/makeplane &> /dev/null - fi # rm -rf $PLANE_INSTALL_DIR &> /dev/null show_message "- Configuration Cleaned ✅" diff --git a/packages/types/src/importer/index.d.ts b/packages/types/src/importer/index.d.ts index 877c07196..271d685b6 100644 --- a/packages/types/src/importer/index.d.ts +++ b/packages/types/src/importer/index.d.ts @@ -1,7 +1,7 @@ export * from "./github-importer"; export * from "./jira-importer"; -import { IProjectLite } from "../projects"; +import { IProjectLite } from "../project"; // types import { IUserLite } from "../users"; diff --git a/packages/types/src/inbox/inbox-types.d.ts b/packages/types/src/inbox/inbox-types.d.ts index 9db71c3ee..c3ec8461e 100644 --- a/packages/types/src/inbox/inbox-types.d.ts +++ b/packages/types/src/inbox/inbox-types.d.ts @@ -1,5 +1,5 @@ import { TIssue } from "../issues/base"; -import type { IProjectLite } from "../projects"; +import type { IProjectLite } from "../project"; export type TInboxIssueExtended = { completed_at: string | null; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index eeec266b5..48d0c1448 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -2,10 +2,10 @@ export * from "./users"; export * from "./workspace"; export * from "./cycle"; export * from "./dashboard"; -export * from "./projects"; +export * from "./project"; export * from "./state"; export * from "./issues"; -export * from "./modules"; +export * from "./module"; export * from "./views"; export * from "./integration"; export * from "./pages"; diff --git a/packages/types/src/module/index.ts b/packages/types/src/module/index.ts new file mode 100644 index 000000000..783634662 --- /dev/null +++ b/packages/types/src/module/index.ts @@ -0,0 +1,2 @@ +export * from "./module_filters"; +export * from "./modules"; diff --git a/packages/types/src/module/module_filters.d.ts b/packages/types/src/module/module_filters.d.ts new file mode 100644 index 000000000..10d56c328 --- /dev/null +++ b/packages/types/src/module/module_filters.d.ts @@ -0,0 +1,32 @@ +export type TModuleOrderByOptions = + | "name" + | "-name" + | "progress" + | "-progress" + | "issues_length" + | "-issues_length" + | "target_date" + | "-target_date" + | "created_at" + | "-created_at"; + +export type TModuleLayoutOptions = "list" | "board" | "gantt"; + +export type TModuleDisplayFilters = { + favorites?: boolean; + layout?: TModuleLayoutOptions; + order_by?: TModuleOrderByOptions; +}; + +export type TModuleFilters = { + lead?: string[] | null; + members?: string[] | null; + start_date?: string[] | null; + status?: string[] | null; + target_date?: string[] | null; +}; + +export type TModuleStoredFilters = { + display_filters?: TModuleDisplayFilters; + filters?: TModuleFilters; +}; diff --git a/packages/types/src/modules.d.ts b/packages/types/src/module/modules.d.ts similarity index 100% rename from packages/types/src/modules.d.ts rename to packages/types/src/module/modules.d.ts diff --git a/packages/types/src/project/index.ts b/packages/types/src/project/index.ts new file mode 100644 index 000000000..ef7308bf7 --- /dev/null +++ b/packages/types/src/project/index.ts @@ -0,0 +1,2 @@ +export * from "./project_filters"; +export * from "./projects"; diff --git a/packages/types/src/project/project_filters.d.ts b/packages/types/src/project/project_filters.d.ts new file mode 100644 index 000000000..02ad09ee1 --- /dev/null +++ b/packages/types/src/project/project_filters.d.ts @@ -0,0 +1,25 @@ +export type TProjectOrderByOptions = + | "sort_order" + | "name" + | "-name" + | "created_at" + | "-created_at" + | "members_length" + | "-members_length"; + +export type TProjectDisplayFilters = { + my_projects?: boolean; + order_by?: TProjectOrderByOptions; +}; + +export type TProjectFilters = { + access?: string[] | null; + lead?: string[] | null; + members?: string[] | null; + created_at?: string[] | null; +}; + +export type TProjectStoredFilters = { + display_filters?: TProjectDisplayFilters; + filters?: TProjectFilters; +}; diff --git a/packages/types/src/projects.d.ts b/packages/types/src/project/projects.d.ts similarity index 99% rename from packages/types/src/projects.d.ts rename to packages/types/src/project/projects.d.ts index afae5199f..f310d9c66 100644 --- a/packages/types/src/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -7,7 +7,7 @@ import type { IWorkspace, IWorkspaceLite, TStateGroups, -} from "."; +} from ".."; export type TProjectLogoProps = { in_use: "emoji" | "icon"; diff --git a/packages/ui/src/emoji/emoji-icon-picker.tsx b/packages/ui/src/emoji/emoji-icon-picker.tsx index 42c367938..5bfcdbe17 100644 --- a/packages/ui/src/emoji/emoji-icon-picker.tsx +++ b/packages/ui/src/emoji/emoji-icon-picker.tsx @@ -103,7 +103,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => { style={styles.popper} {...attributes.popper} className={cn( - "h-80 w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden", + "w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden", dropdownClassName )} > @@ -146,7 +146,7 @@ export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => { }} /> </Tab.Panel> - <Tab.Panel> + <Tab.Panel className="h-80 w-full"> <IconsList defaultColor={defaultIconColor} onChange={(val) => { diff --git a/packages/ui/src/tooltip/tooltip.tsx b/packages/ui/src/tooltip/tooltip.tsx index 65d014efe..92bab8d04 100644 --- a/packages/ui/src/tooltip/tooltip.tsx +++ b/packages/ui/src/tooltip/tooltip.tsx @@ -29,6 +29,7 @@ interface ITooltipProps { className?: string; openDelay?: number; closeDelay?: number; + isMobile?: boolean; } export const Tooltip: React.FC<ITooltipProps> = ({ @@ -40,6 +41,7 @@ export const Tooltip: React.FC<ITooltipProps> = ({ className = "", openDelay = 200, closeDelay, + isMobile = false, }) => ( <Tooltip2 disabled={disabled} @@ -47,7 +49,7 @@ export const Tooltip: React.FC<ITooltipProps> = ({ hoverCloseDelay={closeDelay} content={ <div - className={`relative z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md ${className}`} + className={`relative ${isMobile ? "hidden" : "block"} z-50 max-w-xs gap-1 overflow-hidden break-words rounded-md bg-custom-background-100 p-2 text-xs text-custom-text-200 shadow-md ${className}`} > {tooltipHeading && <h5 className="font-medium text-custom-text-100">{tooltipHeading}</h5>} {tooltipContent} diff --git a/web/components/api-token/modal/generated-token-details.tsx b/web/components/api-token/modal/generated-token-details.tsx index fcae6b249..d21caf36c 100644 --- a/web/components/api-token/modal/generated-token-details.tsx +++ b/web/components/api-token/modal/generated-token-details.tsx @@ -6,6 +6,8 @@ import { renderFormattedDate } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; // types import { IApiToken } from "@plane/types"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { handleClose: () => void; @@ -14,7 +16,7 @@ type Props = { export const GeneratedTokenDetails: React.FC<Props> = (props) => { const { handleClose, tokenDetails } = props; - + const { isMobile } = usePlatformOS(); const copyApiToken = (token: string) => { copyTextToClipboard(token).then(() => setToast({ @@ -40,7 +42,7 @@ export const GeneratedTokenDetails: React.FC<Props> = (props) => { className="mt-4 flex w-full items-center justify-between rounded-md border-[0.5px] border-custom-border-200 px-3 py-2 text-sm font-medium outline-none" > {tokenDetails.token} - <Tooltip tooltipContent="Copy secret key"> + <Tooltip tooltipContent="Copy secret key" isMobile={isMobile}> <Copy className="h-4 w-4 text-custom-text-400" /> </Tooltip> </button> diff --git a/web/components/api-token/token-list-item.tsx b/web/components/api-token/token-list-item.tsx index 88af9a0a2..3dd381d53 100644 --- a/web/components/api-token/token-list-item.tsx +++ b/web/components/api-token/token-list-item.tsx @@ -3,6 +3,7 @@ import { XCircle } from "lucide-react"; // components import { Tooltip } from "@plane/ui"; import { DeleteApiTokenModal } from "components/api-token"; +import { usePlatformOS } from "hooks/use-platform-os"; // ui // helpers import { renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper"; @@ -17,12 +18,14 @@ export const ApiTokenListItem: React.FC<Props> = (props) => { const { token } = props; // states const [deleteModalOpen, setDeleteModalOpen] = useState(false); + // hooks + const { isMobile } = usePlatformOS(); return ( <> <DeleteApiTokenModal isOpen={deleteModalOpen} onClose={() => setDeleteModalOpen(false)} tokenId={token.id} /> <div className="group relative flex flex-col justify-center border-b border-custom-border-200 px-4 py-3"> - <Tooltip tooltipContent="Delete token"> + <Tooltip tooltipContent="Delete token" isMobile={isMobile}> <button onClick={() => setDeleteModalOpen(true)} className="absolute right-4 hidden place-items-center group-hover:grid" @@ -33,9 +36,8 @@ export const ApiTokenListItem: React.FC<Props> = (props) => { <div className="flex w-4/5 items-center"> <h5 className="truncate text-sm font-medium">{token.label}</h5> <span - className={`${ - token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400" - } ml-2 flex h-4 max-h-fit items-center rounded-sm px-2 text-xs font-medium`} + className={`${token.is_active ? "bg-green-500/10 text-green-500" : "bg-custom-background-80 text-custom-text-400" + } ml-2 flex h-4 max-h-fit items-center rounded-sm px-2 text-xs font-medium`} > {token.is_active ? "Active" : "Expired"} </span> diff --git a/web/components/command-palette/command-modal.tsx b/web/components/command-palette/command-modal.tsx index 747075181..cffd3ff11 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -20,12 +20,11 @@ import { } from "components/command-palette"; import { ISSUE_DETAILS } from "constants/fetch-keys"; import { useApplication, useEventTracker, useProject } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // services import useDebounce from "hooks/use-debounce"; import { IssueService } from "services/issue"; import { WorkspaceService } from "services/workspace.service"; -// hooks -// components // types import { IWorkspaceSearchResults } from "@plane/types"; // fetch-keys @@ -37,6 +36,7 @@ const issueService = new IssueService(); export const CommandModal: React.FC = observer(() => { // hooks const { getProjectById } = useProject(); + const { isMobile } = usePlatformOS(); // states const [placeholder, setPlaceholder] = useState("Type a command or search..."); const [resultsCount, setResultsCount] = useState(0); @@ -197,7 +197,7 @@ export const CommandModal: React.FC = observer(() => { </div> )} {projectId && ( - <Tooltip tooltipContent="Toggle workspace level search"> + <Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}> <div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center"> <button type="button" diff --git a/web/components/common/breadcrumb-link.tsx b/web/components/common/breadcrumb-link.tsx index dfa437231..6fdffb790 100644 --- a/web/components/common/breadcrumb-link.tsx +++ b/web/components/common/breadcrumb-link.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { Tooltip } from "@plane/ui"; +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { label?: string; @@ -9,8 +10,9 @@ type Props = { export const BreadcrumbLink: React.FC<Props> = (props) => { const { href, label, icon } = props; + const { isMobile } = usePlatformOS(); return ( - <Tooltip tooltipContent={label} position="bottom"> + <Tooltip tooltipContent={label} position="bottom" isMobile={isMobile}> <li className="flex items-center space-x-2" tabIndex={-1}> <div className="flex flex-wrap items-center gap-2.5"> {href ? ( diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 020e88ccc..7ec59a073 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; +import { usePlatformOS } from "hooks/use-platform-os"; // store hooks // icons import { @@ -29,9 +30,13 @@ import { IIssueActivity } from "@plane/types"; export const IssueLink = ({ activity }: { activity: IIssueActivity }) => { const router = useRouter(); const { workspaceSlug } = router.query; + const { isMobile } = usePlatformOS(); return ( - <Tooltip tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}> + <Tooltip + tooltipContent={activity?.issue_detail ? activity.issue_detail.name : "This issue has been deleted"} + isMobile={isMobile} + > {activity?.issue_detail ? ( <a aria-disabled={activity.issue === null} diff --git a/web/components/core/modals/existing-issues-list-modal.tsx b/web/components/core/modals/existing-issues-list-modal.tsx index 3e3c2871c..79f134b31 100644 --- a/web/components/core/modals/existing-issues-list-modal.tsx +++ b/web/components/core/modals/existing-issues-list-modal.tsx @@ -5,7 +5,7 @@ import { Rocket, Search, X } from "lucide-react"; import { Button, LayersIcon, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import useDebounce from "hooks/use-debounce"; - +import { usePlatformOS } from "hooks/use-platform-os"; import { ProjectService } from "services/project"; // ui // types @@ -40,7 +40,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => { const [isSearching, setIsSearching] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); - + const { isMobile } = usePlatformOS(); const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const handleClose = () => { @@ -154,7 +154,7 @@ export const ExistingIssuesListModal: React.FC<Props> = (props) => { </div> )} {workspaceLevelToggle && ( - <Tooltip tooltipContent="Toggle workspace level search"> + <Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}> <div className={`flex flex-shrink-0 cursor-pointer items-center gap-1 text-xs ${ isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200" diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index 3e068e4f0..7b33f6f12 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -7,6 +7,7 @@ import { ExternalLinkIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import { calculateTimeAgo } from "helpers/date-time.helper"; // hooks import { useMember } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // types import { ILinkDetails, UserAuth } from "@plane/types"; @@ -19,7 +20,7 @@ type Props = { export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => { const { getUserDetails } = useMember(); - + const { isMobile } = usePlatformOS(); const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const copyToClipboard = (text: string) => { @@ -42,7 +43,7 @@ export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, h <span className="py-1"> <LinkIcon className="h-3 w-3 flex-shrink-0" /> </span> - <Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url}> + <Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}> <span className="cursor-pointer truncate text-xs" onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} diff --git a/web/components/cycles/active-cycle/root.tsx b/web/components/cycles/active-cycle/root.tsx index 1f33ef15c..dd5d65f7e 100644 --- a/web/components/cycles/active-cycle/root.tsx +++ b/web/components/cycles/active-cycle/root.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import useSWR from "swr"; // hooks import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { SingleProgressStats } from "components/core"; import { @@ -46,6 +47,8 @@ interface IActiveCycleDetails { export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => { // props const { workspaceSlug, projectId } = props; + // hooks + const { isMobile } = usePlatformOS(); // store hooks const { issues: { fetchActiveCycleIssues }, @@ -197,7 +200,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) = <span className="h-5 w-5"> <CycleGroupIcon cycleGroup={cycleStatus} className="h-4 w-4" /> </span> - <Tooltip tooltipContent={activeCycle.name} position="top-left"> + <Tooltip tooltipContent={activeCycle.name} position="top-left" isMobile={isMobile}> <h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3> </Tooltip> </span> @@ -325,6 +328,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) = <PriorityIcon priority={issue.priority} withContainer size={12} /> <Tooltip + isMobile={isMobile} tooltipHeading="Issue ID" tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`} > @@ -332,7 +336,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) = {currentProjectDetails?.identifier}-{issue.sequence_id} </span> </Tooltip> - <Tooltip position="top-left" tooltipContent={issue.name}> + <Tooltip position="top-left" tooltipContent={issue.name} isMobile={isMobile}> <span className="text-[0.825rem] text-custom-text-100">{truncateText(issue.name, 30)}</span> </Tooltip> </div> @@ -345,7 +349,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) = buttonVariant="background-with-text" /> {issue.target_date && ( - <Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}> + <Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)} isMobile={isMobile}> <div className="flex h-full cursor-not-allowed items-center gap-1.5 rounded bg-custom-background-80 px-2 py-0.5 text-xs"> <CalendarCheck className="h-3 w-3 flex-shrink-0" /> <span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span> diff --git a/web/components/cycles/applied-filters/date.tsx b/web/components/cycles/applied-filters/date.tsx index 0298f12d2..84ca45692 100644 --- a/web/components/cycles/applied-filters/date.tsx +++ b/web/components/cycles/applied-filters/date.tsx @@ -4,7 +4,7 @@ import { X } from "lucide-react"; import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { editable: boolean | undefined; @@ -18,7 +18,7 @@ export const AppliedDateFilters: React.FC<Props> = observer((props) => { const getDateLabel = (value: string): string => { let dateLabel = ""; - const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value); + const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value); if (dateDetails) dateLabel = dateDetails.name; else { diff --git a/web/components/cycles/board/cycles-board-card.tsx b/web/components/cycles/board/cycles-board-card.tsx index ac95f790d..c8ac630e7 100644 --- a/web/components/cycles/board/cycles-board-card.tsx +++ b/web/components/cycles/board/cycles-board-card.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // components import { Info, Star } from "lucide-react"; import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui"; @@ -38,6 +39,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => { const { getUserDetails } = useMember(); // computed const cycleDetails = getCycleById(cycleId); + // hooks + const { isMobile } = usePlatformOS(); if (!cycleDetails) return null; @@ -145,7 +148,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => { <span className="flex-shrink-0"> <CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" /> </span> - <Tooltip tooltipContent={cycleDetails.name} position="top"> + <Tooltip tooltipContent={cycleDetails.name} position="top" isMobile={isMobile}> <span className="truncate text-base font-medium">{cycleDetails.name}</span> </Tooltip> </div> @@ -176,7 +179,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => { <span className="text-xs text-custom-text-300">{issueCount}</span> </div> {cycleDetails.assignee_ids.length > 0 && ( - <Tooltip tooltipContent={`${cycleDetails.assignee_ids.length} Members`}> + <Tooltip tooltipContent={`${cycleDetails.assignee_ids.length} Members`} isMobile={isMobile}> <div className="flex cursor-default items-center gap-1"> <AvatarGroup showTooltip={false}> {cycleDetails.assignee_ids.map((assigne_id) => { @@ -190,6 +193,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => { </div> <Tooltip + isMobile={isMobile} tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`} position="top-left" > diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index b0feede0e..f7ff3567c 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -5,6 +5,7 @@ import { ListFilter, Search, X } from "lucide-react"; // hooks import { useCycleFilter } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // components import { CycleFiltersSelection } from "components/cycles"; import { FiltersDropdown } from "components/issues"; @@ -36,6 +37,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => { updateFilters, updateSearchQuery, } = useCycleFilter(); + const { isMobile } = usePlatformOS(); // outside click detector hook useOutsideClickDetector(inputRef, () => { if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); @@ -62,7 +64,10 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => { const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Escape") { if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); - else setIsSearchOpen(false); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } } }; @@ -107,7 +112,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => { <Search className="h-3.5 w-3.5" /> <input ref={inputRef} - className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 focus:outline-none" + className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none" placeholder="Search" value={searchQuery} onChange={(e) => updateSearchQuery(e.target.value)} @@ -131,7 +136,7 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => { </FiltersDropdown> <div className="flex items-center gap-1 rounded bg-custom-background-80 p-1"> {CYCLE_VIEW_LAYOUTS.map((layout) => ( - <Tooltip key={layout.key} tooltipContent={layout.title}> + <Tooltip key={layout.key} tooltipContent={layout.title} isMobile={isMobile}> <button type="button" className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${ diff --git a/web/components/cycles/dropdowns/filters/end-date.tsx b/web/components/cycles/dropdowns/filters/end-date.tsx index 10a401500..0af92da41 100644 --- a/web/components/cycles/dropdowns/filters/end-date.tsx +++ b/web/components/cycles/dropdowns/filters/end-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterEndDate: React.FC<Props> = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/cycles/dropdowns/filters/start-date.tsx b/web/components/cycles/dropdowns/filters/start-date.tsx index 87def7e29..3c47eb286 100644 --- a/web/components/cycles/dropdowns/filters/start-date.tsx +++ b/web/components/cycles/dropdowns/filters/start-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterStartDate: React.FC<Props> = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/cycles/gantt-chart/blocks.tsx b/web/components/cycles/gantt-chart/blocks.tsx index e9fdd50de..e8b09d2f5 100644 --- a/web/components/cycles/gantt-chart/blocks.tsx +++ b/web/components/cycles/gantt-chart/blocks.tsx @@ -1,6 +1,8 @@ +import Link from "next/link"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Tooltip, ContrastIcon } from "@plane/ui"; // helpers @@ -22,7 +24,7 @@ export const CycleGanttBlock: React.FC<Props> = observer((props) => { const { getCycleById } = useCycle(); // derived values const cycleDetails = getCycleById(cycleId); - + const { isMobile } = usePlatformOS(); const cycleStatus = cycleDetails?.status.toLocaleLowerCase(); return ( @@ -33,17 +35,18 @@ export const CycleGanttBlock: React.FC<Props> = observer((props) => { cycleStatus === "current" ? "#09a953" : cycleStatus === "upcoming" - ? "#f7ae59" - : cycleStatus === "completed" - ? "#3f76ff" - : cycleStatus === "draft" - ? "rgb(var(--color-text-200))" - : "", + ? "#f7ae59" + : cycleStatus === "completed" + ? "#3f76ff" + : cycleStatus === "draft" + ? "rgb(var(--color-text-200))" + : "", }} onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} > <div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" /> <Tooltip + isMobile={isMobile} tooltipContent={ <div className="space-y-1"> <h5>{cycleDetails?.name}</h5> @@ -63,8 +66,6 @@ export const CycleGanttBlock: React.FC<Props> = observer((props) => { export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => { const { cycleId } = props; - // router - const router = useRouter(); // store hooks const { router: { workspaceSlug }, @@ -76,9 +77,9 @@ export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => { const cycleStatus = cycleDetails?.status.toLocaleLowerCase(); return ( - <div + <Link className="relative flex h-full w-full items-center gap-2" - onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} + href={`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`} > <ContrastIcon className="h-5 w-5 flex-shrink-0" @@ -86,15 +87,15 @@ export const CycleGanttSidebarBlock: React.FC<Props> = observer((props) => { cycleStatus === "current" ? "#09a953" : cycleStatus === "upcoming" - ? "#f7ae59" - : cycleStatus === "completed" - ? "#3f76ff" - : cycleStatus === "draft" - ? "rgb(var(--color-text-200))" - : "" + ? "#f7ae59" + : cycleStatus === "completed" + ? "#3f76ff" + : cycleStatus === "draft" + ? "rgb(var(--color-text-200))" + : "" }`} /> <h6 className="flex-grow truncate text-sm font-medium">{cycleDetails?.name}</h6> - </div> + </Link> ); }); diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index 90c6d5d02..52eb3f732 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; import { Check, Info, Star, User2 } from "lucide-react"; import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; import { CycleQuickActions } from "components/cycles"; @@ -33,6 +34,8 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; // router const router = useRouter(); + // hooks + const { isMobile } = usePlatformOS(); // store hooks const { captureEvent } = useEventTracker(); const { @@ -164,7 +167,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { <div className="relative flex items-center gap-2.5 overflow-hidden"> <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5 flex-shrink-0" /> - <Tooltip tooltipContent={cycleDetails.name} position="top"> + <Tooltip tooltipContent={cycleDetails.name} position="top" isMobile={isMobile}> <span className="line-clamp-1 inline-block overflow-hidden truncate text-base font-medium"> {cycleDetails.name} </span> @@ -196,7 +199,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => { </div> <div className="relative flex flex-shrink-0 items-center gap-3"> - <Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`}> + <Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}> <div className="flex w-10 cursor-default items-center justify-center"> {cycleDetails.assignee_ids?.length > 0 ? ( <AvatarGroup showTooltip={false}> diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx index 1e031cacd..cd4115ac7 100644 --- a/web/components/dashboard/widgets/assigned-issues.tsx +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -7,6 +7,7 @@ import { useDashboard } from "hooks/store"; // components import { DurationFilterDropdown, + IssuesErrorState, TabsList, WidgetIssuesList, WidgetLoader, @@ -26,10 +27,12 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => { // states const [fetching, setFetching] = useState(false); // store hooks - const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } = + useDashboard(); // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY); + const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY); const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE; const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab); const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? []; @@ -73,74 +76,91 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => { const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); - if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />; + if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />; return ( <div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96"> - <div className="flex items-center justify-between gap-2 p-6 pl-7"> - <Link - href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`} - className="text-lg font-semibold text-custom-text-300 hover:underline" - > - Assigned to you - </Link> - <DurationFilterDropdown - customDates={selectedCustomDates} - value={selectedDurationFilter} - onChange={(val, customDates) => { - if (val === "custom" && customDates) { - handleUpdateFilters({ - duration: val, - custom_dates: customDates, - }); - return; - } - - if (val === selectedDurationFilter) return; - - let newTab = selectedTab; - // switch to pending tab if target date is changed to none - if (val === "none" && selectedTab !== "completed") newTab = "pending"; - // switch to upcoming tab if target date is changed to other than none - if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming"; - + {widgetStatsError ? ( + <IssuesErrorState + isRefreshing={fetching} + onClick={() => handleUpdateFilters({ - duration: val, - tab: newTab, - }); - }} + duration: EDurationFilters.NONE, + tab: "pending", + }) + } /> - </div> - <Tab.Group - as="div" - selectedIndex={selectedTabIndex} - onChange={(i) => { - const newSelectedTab = tabsList[i]; - handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" }); - }} - className="h-full flex flex-col" - > - <div className="px-6"> - <TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} /> - </div> - <Tab.Panels as="div" className="h-full"> - {tabsList.map((tab) => { - if (tab.key !== selectedTab) return null; + ) : ( + widgetStats && ( + <> + <div className="flex items-center justify-between gap-2 p-6 pl-7"> + <Link + href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`} + className="text-lg font-semibold text-custom-text-300 hover:underline" + > + Assigned to you + </Link> + <DurationFilterDropdown + customDates={selectedCustomDates} + value={selectedDurationFilter} + onChange={(val, customDates) => { + if (val === "custom" && customDates) { + handleUpdateFilters({ + duration: val, + custom_dates: customDates, + }); + return; + } - return ( - <Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static> - <WidgetIssuesList - tab={tab.key} - type="assigned" - workspaceSlug={workspaceSlug} - widgetStats={widgetStats} - isLoading={fetching} - /> - </Tab.Panel> - ); - })} - </Tab.Panels> - </Tab.Group> + if (val === selectedDurationFilter) return; + + let newTab = selectedTab; + // switch to pending tab if target date is changed to none + if (val === "none" && selectedTab !== "completed") newTab = "pending"; + // switch to upcoming tab if target date is changed to other than none + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") + newTab = "upcoming"; + + handleUpdateFilters({ + duration: val, + tab: newTab, + }); + }} + /> + </div> + <Tab.Group + as="div" + selectedIndex={selectedTabIndex} + onChange={(i) => { + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" }); + }} + className="h-full flex flex-col" + > + <div className="px-6"> + <TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} /> + </div> + <Tab.Panels as="div" className="h-full"> + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + <Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static> + <WidgetIssuesList + tab={tab.key} + type="assigned" + workspaceSlug={workspaceSlug} + widgetStats={widgetStats} + isLoading={fetching} + /> + </Tab.Panel> + ); + })} + </Tab.Panels> + </Tab.Group> + </> + ) + )} </div> ); }); diff --git a/web/components/dashboard/widgets/created-issues.tsx b/web/components/dashboard/widgets/created-issues.tsx index d36260f21..2b2954018 100644 --- a/web/components/dashboard/widgets/created-issues.tsx +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -7,6 +7,7 @@ import { useDashboard } from "hooks/store"; // components import { DurationFilterDropdown, + IssuesErrorState, TabsList, WidgetIssuesList, WidgetLoader, @@ -26,10 +27,12 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => { // states const [fetching, setFetching] = useState(false); // store hooks - const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } = + useDashboard(); // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY); + const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY); const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE; const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab); const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? []; @@ -70,74 +73,91 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => { const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); - if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />; + if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />; return ( <div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96"> - <div className="flex items-center justify-between gap-2 p-6 pl-7"> - <Link - href={`/${workspaceSlug}/workspace-views/created/${filterParams}`} - className="text-lg font-semibold text-custom-text-300 hover:underline" - > - Created by you - </Link> - <DurationFilterDropdown - customDates={selectedCustomDates} - value={selectedDurationFilter} - onChange={(val, customDates) => { - if (val === "custom" && customDates) { - handleUpdateFilters({ - duration: val, - custom_dates: customDates, - }); - return; - } - - if (val === selectedDurationFilter) return; - - let newTab = selectedTab; - // switch to pending tab if target date is changed to none - if (val === "none" && selectedTab !== "completed") newTab = "pending"; - // switch to upcoming tab if target date is changed to other than none - if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming"; - + {widgetStatsError ? ( + <IssuesErrorState + isRefreshing={fetching} + onClick={() => handleUpdateFilters({ - duration: val, - tab: newTab, - }); - }} + duration: EDurationFilters.NONE, + tab: "pending", + }) + } /> - </div> - <Tab.Group - as="div" - selectedIndex={selectedTabIndex} - onChange={(i) => { - const newSelectedTab = tabsList[i]; - handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" }); - }} - className="h-full flex flex-col" - > - <div className="px-6"> - <TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} /> - </div> - <Tab.Panels as="div" className="h-full"> - {tabsList.map((tab) => { - if (tab.key !== selectedTab) return null; + ) : ( + widgetStats && ( + <> + <div className="flex items-center justify-between gap-2 p-6 pl-7"> + <Link + href={`/${workspaceSlug}/workspace-views/created/${filterParams}`} + className="text-lg font-semibold text-custom-text-300 hover:underline" + > + Created by you + </Link> + <DurationFilterDropdown + customDates={selectedCustomDates} + value={selectedDurationFilter} + onChange={(val, customDates) => { + if (val === "custom" && customDates) { + handleUpdateFilters({ + duration: val, + custom_dates: customDates, + }); + return; + } - return ( - <Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static> - <WidgetIssuesList - tab={tab.key} - type="created" - workspaceSlug={workspaceSlug} - widgetStats={widgetStats} - isLoading={fetching} - /> - </Tab.Panel> - ); - })} - </Tab.Panels> - </Tab.Group> + if (val === selectedDurationFilter) return; + + let newTab = selectedTab; + // switch to pending tab if target date is changed to none + if (val === "none" && selectedTab !== "completed") newTab = "pending"; + // switch to upcoming tab if target date is changed to other than none + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") + newTab = "upcoming"; + + handleUpdateFilters({ + duration: val, + tab: newTab, + }); + }} + /> + </div> + <Tab.Group + as="div" + selectedIndex={selectedTabIndex} + onChange={(i) => { + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" }); + }} + className="h-full flex flex-col" + > + <div className="px-6"> + <TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} /> + </div> + <Tab.Panels as="div" className="h-full"> + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + <Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static> + <WidgetIssuesList + tab={tab.key} + type="created" + workspaceSlug={workspaceSlug} + widgetStats={widgetStats} + isLoading={fetching} + /> + </Tab.Panel> + ); + })} + </Tab.Panels> + </Tab.Group> + </> + ) + )} </div> ); }); diff --git a/web/components/dashboard/widgets/error-states/index.ts b/web/components/dashboard/widgets/error-states/index.ts new file mode 100644 index 000000000..bd8854f37 --- /dev/null +++ b/web/components/dashboard/widgets/error-states/index.ts @@ -0,0 +1 @@ +export * from "./issues"; diff --git a/web/components/dashboard/widgets/error-states/issues.tsx b/web/components/dashboard/widgets/error-states/issues.tsx new file mode 100644 index 000000000..6cfce13b4 --- /dev/null +++ b/web/components/dashboard/widgets/error-states/issues.tsx @@ -0,0 +1,32 @@ +import { AlertTriangle, RefreshCcw } from "lucide-react"; +// ui +import { Button } from "@plane/ui"; + +type Props = { + isRefreshing: boolean; + onClick: () => void; +}; + +export const IssuesErrorState: React.FC<Props> = (props) => { + const { isRefreshing, onClick } = props; + + return ( + <div className="h-full w-full grid place-items-center"> + <div className="text-center"> + <div className="h-24 w-24 bg-red-500/20 rounded-full grid place-items-center mx-auto"> + <AlertTriangle className="h-12 w-12 text-red-500" /> + </div> + <p className="mt-7 text-custom-text-300 text-sm font-medium">There was an error in fetching widget details</p> + <Button + variant="neutral-primary" + prependIcon={<RefreshCcw className="h-3 w-3" />} + className="mt-2 mx-auto" + onClick={onClick} + loading={isRefreshing} + > + {isRefreshing ? "Retrying" : "Retry"} + </Button> + </div> + </div> + ); +}; diff --git a/web/components/dashboard/widgets/index.ts b/web/components/dashboard/widgets/index.ts index a481a8881..31fc645d4 100644 --- a/web/components/dashboard/widgets/index.ts +++ b/web/components/dashboard/widgets/index.ts @@ -1,5 +1,6 @@ export * from "./dropdowns"; export * from "./empty-states"; +export * from "./error-states"; export * from "./issue-panels"; export * from "./loaders"; export * from "./assigned-issues"; diff --git a/web/components/dashboard/widgets/recent-collaborators/default-list.tsx b/web/components/dashboard/widgets/recent-collaborators/default-list.tsx index a27534bbf..8fb884c9b 100644 --- a/web/components/dashboard/widgets/recent-collaborators/default-list.tsx +++ b/web/components/dashboard/widgets/recent-collaborators/default-list.tsx @@ -37,21 +37,36 @@ export const DefaultCollaboratorsList: React.FC<Props> = (props) => { /> ); + const showViewMoreButton = pageCount < totalPages && resultsCount !== 0; + const showViewLessButton = pageCount > 1; + return ( <> <div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8"> {collaboratorsPages} </div> - {pageCount < totalPages && resultsCount !== 0 && ( + {(showViewLessButton || showViewMoreButton) && ( <div className="flex items-center justify-center text-xs w-full"> - <Button - variant="link-primary" - size="sm" - className="my-3 hover:bg-custom-primary-100/20" - onClick={handleLoadMore} - > - Load more - </Button> + {showViewLessButton && ( + <Button + variant="link-primary" + size="sm" + className="my-3 hover:bg-custom-primary-100/20" + onClick={() => setPageCount(1)} + > + View less + </Button> + )} + {showViewMoreButton && ( + <Button + variant="link-primary" + size="sm" + className="my-3 hover:bg-custom-primary-100/20" + onClick={handleLoadMore} + > + View more + </Button> + )} </div> )} </> diff --git a/web/components/dashboard/widgets/recent-collaborators/root.tsx b/web/components/dashboard/widgets/recent-collaborators/root.tsx index d65b15db7..f544e0da6 100644 --- a/web/components/dashboard/widgets/recent-collaborators/root.tsx +++ b/web/components/dashboard/widgets/recent-collaborators/root.tsx @@ -17,9 +17,9 @@ export const RecentCollaboratorsWidget: React.FC<WidgetProps> = (props) => { <div className="w-full rounded-xl border-[0.5px] border-custom-border-200 bg-custom-background-100 duration-300 hover:shadow-custom-shadow-4xl"> <div className="flex items-start justify-between px-7 pt-6"> <div> - <h4 className="text-lg font-semibold text-custom-text-300">Most active members</h4> + <h4 className="text-lg font-semibold text-custom-text-300">Collaborators</h4> <p className="mt-2 text-xs font-medium text-custom-text-300"> - Top eight active members in your project by last activity + View and find all members you collaborate with across projects </p> </div> <div className="flex min-w-72 items-center justify-start gap-2 rounded-md border border-custom-border-200 px-2.5 py-1.5 placeholder:text-custom-text-400"> diff --git a/web/components/dashboard/widgets/recent-collaborators/search-list.tsx b/web/components/dashboard/widgets/recent-collaborators/search-list.tsx index 32baa72ad..7323ad944 100644 --- a/web/components/dashboard/widgets/recent-collaborators/search-list.tsx +++ b/web/components/dashboard/widgets/recent-collaborators/search-list.tsx @@ -48,6 +48,9 @@ export const SearchedCollaboratorsList: React.FC<Props> = (props) => { /> ); + const showViewMoreButton = pageCount < totalPages && resultsCount !== 0; + const showViewLessButton = pageCount > 1; + const emptyStateImage = resolvedTheme === "dark" ? DarkImage : LightImage; return ( @@ -63,16 +66,28 @@ export const SearchedCollaboratorsList: React.FC<Props> = (props) => { <p className="font-medium text-sm">No matching member</p> </div> )} - {pageCount < totalPages && resultsCount !== 0 && ( + {(showViewLessButton || showViewMoreButton) && ( <div className="flex items-center justify-center text-xs w-full"> - <Button - variant="link-primary" - size="sm" - className="my-3 hover:bg-custom-primary-100/20" - onClick={handleLoadMore} - > - Load more - </Button> + {showViewLessButton && ( + <Button + variant="link-primary" + size="sm" + className="my-3 hover:bg-custom-primary-100/20" + onClick={() => setPageCount(1)} + > + View less + </Button> + )} + {showViewMoreButton && ( + <Button + variant="link-primary" + size="sm" + className="my-3 hover:bg-custom-primary-100/20" + onClick={handleLoadMore} + > + View more + </Button> + )} </div> )} </> diff --git a/web/components/dropdowns/buttons.tsx b/web/components/dropdowns/buttons.tsx index d5d08a115..27be7dd9a 100644 --- a/web/components/dropdowns/buttons.tsx +++ b/web/components/dropdowns/buttons.tsx @@ -4,7 +4,7 @@ import { cn } from "helpers/common.helper"; // types import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS } from "./constants"; import { TButtonVariants } from "./types"; -// constants +import { usePlatformOS } from "hooks/use-platform-os"; export type DropdownButtonProps = { children: React.ReactNode; @@ -27,7 +27,6 @@ type ButtonProps = { export const DropdownButton: React.FC<DropdownButtonProps> = (props) => { const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip, variant } = props; - const ButtonToRender: React.FC<ButtonProps> = BORDER_BUTTON_VARIANTS.includes(variant) ? BorderButton : BACKGROUND_BUTTON_VARIANTS.includes(variant) @@ -49,9 +48,10 @@ export const DropdownButton: React.FC<DropdownButtonProps> = (props) => { const BorderButton: React.FC<ButtonProps> = (props) => { const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip } = props; + const { isMobile } = usePlatformOS(); return ( - <Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip}> + <Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip} isMobile={isMobile}> <div className={cn( "h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5", @@ -67,9 +67,9 @@ const BorderButton: React.FC<ButtonProps> = (props) => { const BackgroundButton: React.FC<ButtonProps> = (props) => { const { children, className, tooltipContent, tooltipHeading, showTooltip } = props; - + const { isMobile } = usePlatformOS(); return ( - <Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip}> + <Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip} isMobile={isMobile}> <div className={cn( "h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80", @@ -84,9 +84,9 @@ const BackgroundButton: React.FC<ButtonProps> = (props) => { const TransparentButton: React.FC<ButtonProps> = (props) => { const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip } = props; - + const { isMobile } = usePlatformOS(); return ( - <Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip}> + <Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip} isMobile={isMobile}> <div className={cn( "h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80", diff --git a/web/components/dropdowns/module/index.tsx b/web/components/dropdowns/module/index.tsx index 882604712..5827f5aa8 100644 --- a/web/components/dropdowns/module/index.tsx +++ b/web/components/dropdowns/module/index.tsx @@ -8,6 +8,7 @@ import { cn } from "helpers/common.helper"; import { useModule } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // components import { DropdownButton } from "../buttons"; // icons @@ -47,6 +48,7 @@ type ButtonContentProps = { onChange: (moduleIds: string[]) => void; placeholder: string; showCount: boolean; + showTooltip?: boolean; value: string | string[] | null; }; @@ -60,10 +62,12 @@ const ButtonContent: React.FC<ButtonContentProps> = (props) => { onChange, placeholder, showCount, + showTooltip = false, value, } = props; // store hooks const { getModuleById } = useModule(); + const { isMobile } = usePlatformOS(); if (Array.isArray(value)) return ( @@ -90,12 +94,12 @@ const ButtonContent: React.FC<ButtonContentProps> = (props) => { > {!hideIcon && <DiceIcon className="h-2.5 w-2.5 flex-shrink-0" />} {!hideText && ( - <Tooltip tooltipHeading="Title" tooltipContent={moduleDetails?.name}> + <Tooltip tooltipHeading="Title" tooltipContent={moduleDetails?.name} disabled={!showTooltip} isMobile={isMobile}> <span className="max-w-40 flex-grow truncate text-xs font-medium">{moduleDetails?.name}</span> </Tooltip> )} {!disabled && ( - <Tooltip tooltipContent="Remove"> + <Tooltip tooltipContent="Remove" disabled={!showTooltip} isMobile={isMobile}> <button type="button" className="flex-shrink-0" @@ -265,6 +269,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => { hideText={BUTTON_VARIANTS_WITHOUT_TEXT.includes(buttonVariant)} placeholder={placeholder} showCount={showCount} + showTooltip={showTooltip} value={value} onChange={onChange as any} /> diff --git a/web/components/dropdowns/priority.tsx b/web/components/dropdowns/priority.tsx index 2409971f3..b753c2ae1 100644 --- a/web/components/dropdowns/priority.tsx +++ b/web/components/dropdowns/priority.tsx @@ -9,6 +9,7 @@ import { ISSUE_PRIORITIES } from "constants/issue"; import { cn } from "helpers/common.helper"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // icons // helpers // types @@ -61,8 +62,10 @@ const BorderButton = (props: ButtonProps) => { none: "hover:bg-custom-background-80 border-custom-border-300", }; + const { isMobile } = usePlatformOS(); + return ( - <Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip}> + <Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip} isMobile={isMobile}> <div className={cn( "h-full flex items-center gap-1.5 border-[0.5px] rounded text-xs px-2 py-0.5", @@ -130,8 +133,10 @@ const BackgroundButton = (props: ButtonProps) => { none: "bg-custom-background-80", }; + const { isMobile } = usePlatformOS(); + return ( - <Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip}> + <Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip} isMobile={isMobile}> <div className={cn( "h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5", @@ -200,8 +205,10 @@ const TransparentButton = (props: ButtonProps) => { none: "hover:text-custom-text-300", }; + const { isMobile } = usePlatformOS(); + return ( - <Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip}> + <Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip} isMobile={isMobile}> <div className={cn( "h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80", diff --git a/web/components/empty-state/empty-state.tsx b/web/components/empty-state/empty-state.tsx index a9ff4decd..e718c065a 100644 --- a/web/components/empty-state/empty-state.tsx +++ b/web/components/empty-state/empty-state.tsx @@ -9,12 +9,12 @@ import { useUser } from "hooks/store"; import { Button, TButtonVariant } from "@plane/ui"; import { ComicBoxButton } from "./comic-box-button"; // constant -import { EMPTY_STATE_DETAILS, EmptyStateKeys } from "constants/empty-state"; +import { EMPTY_STATE_DETAILS, EmptyStateType } from "constants/empty-state"; // helpers import { cn } from "helpers/common.helper"; export type EmptyStateProps = { - type: EmptyStateKeys; + type: EmptyStateType; size?: "sm" | "md" | "lg"; layout?: "widget-simple" | "screen-detailed" | "screen-simple"; additionalPath?: string; diff --git a/web/components/gantt-chart/chart/views/month.tsx b/web/components/gantt-chart/chart/views/month.tsx index b09bcc671..b5787eb61 100644 --- a/web/components/gantt-chart/chart/views/month.tsx +++ b/web/components/gantt-chart/chart/views/month.tsx @@ -19,7 +19,7 @@ export const MonthChartView: FC<any> = observer(() => { {monthBlocks?.map((block, rootIndex) => ( <div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col"> <div - className="w-full sticky top-0 z-[5] bg-custom-background-100" + className="w-full sticky top-0 z-[5] bg-custom-background-100 flex-shrink-0" style={{ height: `${HEADER_HEIGHT}px`, }} @@ -55,7 +55,7 @@ export const MonthChartView: FC<any> = observer(() => { ))} </div> </div> - <div className="h-full w-full flex divide-x divide-custom-border-100/50"> + <div className="h-full w-full flex-grow flex divide-x divide-custom-border-100/50"> {block?.children?.map((monthDay, index) => ( <div key={`column-${rootIndex}-${index}`} diff --git a/web/components/gantt-chart/helpers/add-block.tsx b/web/components/gantt-chart/helpers/add-block.tsx index d12c9f20e..334ab7fcf 100644 --- a/web/components/gantt-chart/helpers/add-block.tsx +++ b/web/components/gantt-chart/helpers/add-block.tsx @@ -9,6 +9,8 @@ import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-ti // types import { useGanttChart } from "../hooks/use-gantt-chart"; import { IBlockUpdateData, IGanttBlock } from "../types"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { block: IGanttBlock; @@ -23,6 +25,8 @@ export const ChartAddBlock: React.FC<Props> = observer((props) => { const [buttonStartDate, setButtonStartDate] = useState<Date | null>(null); // refs const containerRef = useRef<HTMLDivElement>(null); + // hooks + const { isMobile } = usePlatformOS(); // chart hook const { currentViewData } = useGanttChart(); @@ -73,7 +77,7 @@ export const ChartAddBlock: React.FC<Props> = observer((props) => { > <div ref={containerRef} className="h-full w-full" /> {isButtonVisible && ( - <Tooltip tooltipContent={buttonStartDate && renderFormattedDate(buttonStartDate)}> + <Tooltip tooltipContent={buttonStartDate && renderFormattedDate(buttonStartDate)} isMobile={isMobile}> <button type="button" className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-8 w-8 bg-custom-background-80 p-1.5 rounded border border-custom-border-300 grid place-items-center text-custom-text-200 hover:text-custom-text-100" diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 6f9c61545..8c5018ffb 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -3,6 +3,8 @@ import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; +// components import { ArrowRight, Plus, PanelRight } from "lucide-react"; import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui"; import { ProjectAnalyticsModal } from "components/analytics"; @@ -26,7 +28,6 @@ import { useIssues, } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; -// components // ui // icons // helpers @@ -43,6 +44,7 @@ const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { const { getCycleById } = useCycle(); // derived values const cycle = getCycleById(cycleId); + // if (!cycle) return null; @@ -84,6 +86,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { const { project: { projectMemberIds }, } = useMember(); + const { isMobile } = usePlatformOS() const activeLayout = issueFilters?.displayFilters?.layout; @@ -207,6 +210,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { <p className="truncate">{cycleDetails?.name && cycleDetails.name}</p> {issueCount && issueCount > 0 ? ( <Tooltip + isMobile={isMobile} tooltipContent={`There are ${issueCount} ${ issueCount > 1 ? "issues" : "issue" } in this cycle`} diff --git a/web/components/headers/global-issues.tsx b/web/components/headers/global-issues.tsx index effe60fe4..13dccba41 100644 --- a/web/components/headers/global-issues.tsx +++ b/web/components/headers/global-issues.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; import { List, PlusIcon, Sheet } from "lucide-react"; import { Breadcrumbs, Button, LayersIcon, PhotoFilterIcon, Tooltip } from "@plane/ui"; import { BreadcrumbLink } from "components/common"; @@ -46,6 +47,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => { const { workspace: { workspaceMemberIds }, } = useMember(); + const { isMobile } = usePlatformOS(); const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined; @@ -133,7 +135,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => { {GLOBAL_VIEW_LAYOUTS.map((layout) => ( <Link key={layout.key} href={`/${workspaceSlug}/${layout.link}`}> <span> - <Tooltip tooltipContent={layout.title}> + <Tooltip tooltipContent={layout.title} isMobile={isMobile}> <div className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${ activeLayout === layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : "" diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 9505d7145..b904f5565 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; import { ArrowRight, PanelRight, Plus } from "lucide-react"; import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui"; import { ProjectAnalyticsModal } from "components/analytics"; @@ -66,6 +67,8 @@ export const ModuleIssuesHeader: React.FC = observer(() => { // router const router = useRouter(); const { workspaceSlug, projectId, moduleId } = router.query; + // hooks + const { isMobile } = usePlatformOS(); // store hooks const { issuesFilter: { issueFilters }, @@ -208,6 +211,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { <p className="truncate">{moduleDetails?.name && moduleDetails.name}</p> {issueCount && issueCount > 0 ? ( <Tooltip + isMobile={isMobile} tooltipContent={`There are ${issueCount} ${ issueCount > 1 ? "issues" : "issue" } in this module`} diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index a1233ae52..fcd26dd59 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,24 +1,36 @@ +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// icons -import { GanttChartSquare, LayoutGrid, List, Plus } from "lucide-react"; -// ui -import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui"; +import { GanttChartSquare, LayoutGrid, List, ListFilter, Plus, Search, X } from "lucide-react"; +// hooks +import { useApplication, useEventTracker, useMember, useModuleFilter, useProject, useUser } from "hooks/store"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { ProjectLogo } from "components/project"; // constants import { MODULE_VIEW_LAYOUTS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; // hooks -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; -import { ProjectLogo } from "components/project"; +import { usePlatformOS } from "hooks/use-platform-os"; +import { ModuleFiltersSelection, ModuleOrderByDropdown } from "components/modules"; +import { FiltersDropdown } from "components/issues"; +// ui +import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TModuleFilters } from "@plane/types"; export const ModulesListHeader: React.FC = observer(() => { + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef<HTMLInputElement>(null); // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); @@ -26,11 +38,56 @@ export const ModulesListHeader: React.FC = observer(() => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); + const { isMobile } = usePlatformOS(); + const { + workspace: { workspaceMemberIds }, + } = useMember(); + const { + currentProjectDisplayFilters: displayFilters, + currentProjectFilters: filters, + searchQuery, + updateDisplayFilters, + updateFilters, + updateSearchQuery, + } = useModuleFilter(); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); - const { storedValue: modulesView, setValue: setModulesView } = useLocalStorage("modules_view", "grid"); + const handleFilters = useCallback( + (key: keyof TModuleFilters, value: string | string[]) => { + if (!projectId) return; + const newValues = filters?.[key] ?? []; + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + else { + if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [filters, projectId, updateFilters] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + } + }; + + // auth const canUserCreateModule = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + return ( <div> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> @@ -62,26 +119,97 @@ export const ModulesListHeader: React.FC = observer(() => { </div> </div> <div className="flex items-center gap-2"> - <div className="items-center gap-1 rounded bg-custom-background-80 p-1 hidden md:flex"> - {MODULE_VIEW_LAYOUTS.map((layout) => ( - <Tooltip key={layout.key} tooltipContent={layout.title}> + <div className="flex items-center"> + {!isSearchOpen && ( + <button + type="button" + className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center" + onClick={() => { + setIsSearchOpen(true); + inputRef.current?.focus(); + }} + > + <Search className="h-3.5 w-3.5" /> + </button> + )} + <div + className={cn( + "ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0", + { + "w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen, + } + )} + > + <Search className="h-3.5 w-3.5" /> + <input + ref={inputRef} + className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none" + placeholder="Search" + value={searchQuery} + onChange={(e) => updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( <button type="button" - className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${ - modulesView == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : "" - }`} - onClick={() => setModulesView(layout.key)} + className="grid place-items-center" + onClick={() => { + // updateSearchQuery(""); + setIsSearchOpen(false); + }} + > + <X className="h-3 w-3" /> + </button> + )} + </div> + </div> + <div className="hidden md:flex items-center gap-1 rounded bg-custom-background-80 p-1"> + {MODULE_VIEW_LAYOUTS.map((layout) => ( + <Tooltip key={layout.key} tooltipContent={layout.title} isMobile={isMobile}> + <button + type="button" + className={cn( + "group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100", + { + "bg-custom-background-100 shadow-custom-shadow-2xs": displayFilters?.layout === layout.key, + } + )} + onClick={() => { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), { layout: layout.key }); + }} > <layout.icon strokeWidth={2} - className={`h-3.5 w-3.5 ${ - modulesView == layout.key ? "text-custom-text-100" : "text-custom-text-200" - }`} + className={cn("h-3.5 w-3.5 text-custom-text-200", { + "text-custom-text-100": displayFilters?.layout === layout.key, + })} /> </button> </Tooltip> ))} </div> + <ModuleOrderByDropdown + value={displayFilters?.order_by} + onChange={(val) => { + if (!projectId || val === displayFilters?.order_by) return; + updateDisplayFilters(projectId.toString(), { + order_by: val, + }); + }} + /> + <FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end"> + <ModuleFiltersSelection + displayFilters={displayFilters ?? {}} + filters={filters ?? {}} + handleDisplayFiltersUpdate={(val) => { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} + handleFiltersUpdate={handleFilters} + memberIds={workspaceMemberIds ?? undefined} + /> + </FiltersDropdown> {canUserCreateModule && ( <Button variant="primary" @@ -104,9 +232,9 @@ export const ModulesListHeader: React.FC = observer(() => { // placement="bottom-start" customButton={ <span className="flex items-center gap-2"> - {modulesView === "gantt_chart" ? ( + {displayFilters?.layout === "gantt" ? ( <GanttChartSquare className="w-3 h-3" /> - ) : modulesView === "grid" ? ( + ) : displayFilters?.layout === "board" ? ( <LayoutGrid className="w-3 h-3" /> ) : ( <List className="w-3 h-3" /> @@ -120,7 +248,10 @@ export const ModulesListHeader: React.FC = observer(() => { {MODULE_VIEW_LAYOUTS.map((layout) => ( <CustomMenu.MenuItem key={layout.key} - onClick={() => setModulesView(layout.key)} + onClick={() => { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), { layout: layout.key }); + }} className="flex items-center gap-2" > <layout.icon className="w-3 h-3" /> diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index ce226b58e..ca21c8a80 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { ArrowLeft } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // constants // ui import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; @@ -31,10 +32,10 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { const { project: { projectMemberIds }, } = useMember(); - // for archived issues list layout is the only option const activeLayout = "list"; - + // hooks + const { isMobile } = usePlatformOS(); const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; @@ -119,6 +120,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { </Breadcrumbs> {issueCount && issueCount > 0 ? ( <Tooltip + isMobile={isMobile} tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's archived`} position="bottom" > diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index 789c3f60f..c3604f3dc 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/components/headers/project-draft-issues.tsx @@ -2,6 +2,7 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // components import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; import { BreadcrumbLink } from "components/common"; @@ -28,7 +29,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => { const { project: { projectMemberIds }, } = useMember(); - + const { isMobile } = usePlatformOS(); const activeLayout = issueFilters?.displayFilters?.layout; const handleFiltersUpdate = useCallback( @@ -112,6 +113,7 @@ export const ProjectDraftIssueHeader: FC = observer(() => { </Breadcrumbs> {issueCount && issueCount > 0 ? ( <Tooltip + isMobile={isMobile} tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's draft`} position="bottom" > diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 9739e7832..645d832a2 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui"; import { ProjectAnalyticsModal } from "components/analytics"; import { BreadcrumbLink } from "components/common"; @@ -52,7 +53,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const { currentProjectDetails } = useProject(); const { projectStates } = useProjectState(); const { projectLabels } = useLabel(); - + const { isMobile } = usePlatformOS(); const activeLayout = issueFilters?.displayFilters?.layout; const handleFiltersUpdate = useCallback( @@ -153,6 +154,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { </Breadcrumbs> {issueCount && issueCount > 0 ? ( <Tooltip + isMobile={isMobile} tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in this project`} position="bottom" > diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 3810860aa..81f77cdbb 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -1,26 +1,81 @@ +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Search, Plus, Briefcase } from "lucide-react"; +import { Search, Plus, Briefcase, X, ListFilter } from "lucide-react"; // hooks -// ui -import { Breadcrumbs, Button } from "@plane/ui"; -// constants +import { useApplication, useEventTracker, useMember, useProject, useProjectFilter, useUser } from "hooks/store"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +// ui +import { Breadcrumbs, Button } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// constants import { EUserWorkspaceRoles } from "constants/workspace"; -// components -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; +import { FiltersDropdown } from "components/issues"; +import { ProjectFiltersSelection, ProjectOrderByDropdown } from "components/project"; +import { TProjectFilters } from "@plane/types"; export const ProjectsHeader = observer(() => { + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef<HTMLInputElement>(null); // store hooks - const { commandPalette: commandPaletteStore } = useApplication(); + const { + commandPalette: commandPaletteStore, + router: { workspaceSlug }, + } = useApplication(); const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); - const { workspaceProjectIds, searchQuery, setSearchQuery } = useProject(); - + const { workspaceProjectIds } = useProject(); + const { + currentWorkspaceDisplayFilters: displayFilters, + currentWorkspaceFilters: filters, + updateFilters, + updateDisplayFilters, + searchQuery, + updateSearchQuery, + } = useProjectFilter(); + const { + workspace: { workspaceMemberIds }, + } = useMember(); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); + // auth const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + const handleFilters = useCallback( + (key: keyof TProjectFilters, value: string | string[]) => { + if (!workspaceSlug) return; + const newValues = filters?.[key] ?? []; + + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + else { + if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, { [key]: newValues }); + }, + [filters, updateFilters, workspaceSlug] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + return ( <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4"> <div className="flex flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> @@ -34,18 +89,74 @@ export const ProjectsHeader = observer(() => { </Breadcrumbs> </div> </div> - <div className="flex w-full justify-end items-center gap-3"> + <div className="w-full flex items-center justify-end gap-3"> {workspaceProjectIds && workspaceProjectIds?.length > 0 && ( - <div className=" flex items-center justify-start gap-1 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5 text-custom-text-400"> - <Search className="h-3.5" /> - <input - className="border-none w-full bg-transparent text-sm focus:outline-none" - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - placeholder="Search" - /> + <div className="flex items-center"> + {!isSearchOpen && ( + <button + type="button" + className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center" + onClick={() => { + setIsSearchOpen(true); + inputRef.current?.focus(); + }} + > + <Search className="h-3.5 w-3.5" /> + </button> + )} + <div + className={cn( + "ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0", + { + "w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen, + } + )} + > + <Search className="h-3.5 w-3.5" /> + <input + ref={inputRef} + className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 focus:outline-none" + placeholder="Search" + value={searchQuery} + onChange={(e) => updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + <button + type="button" + className="grid place-items-center" + onClick={() => { + updateSearchQuery(""); + setIsSearchOpen(false); + }} + > + <X className="h-3 w-3" /> + </button> + )} + </div> </div> )} + <ProjectOrderByDropdown + value={displayFilters?.order_by} + onChange={(val) => { + if (!workspaceSlug || val === displayFilters?.order_by) return; + updateDisplayFilters(workspaceSlug, { + order_by: val, + }); + }} + /> + <FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end"> + <ProjectFiltersSelection + displayFilters={displayFilters ?? {}} + filters={filters ?? {}} + handleFiltersUpdate={handleFilters} + handleDisplayFiltersUpdate={(val) => { + if (!workspaceSlug) return; + updateDisplayFilters(workspaceSlug, val); + }} + memberIds={workspaceMemberIds ?? undefined} + /> + </FiltersDropdown> {isAuthorizedUser && ( <Button prependIcon={<Plus />} @@ -54,9 +165,9 @@ export const ProjectsHeader = observer(() => { setTrackElement("Projects page"); commandPaletteStore.toggleCreateProjectModal(true); }} - className="items-center" + className="items-center gap-1" > - <div className="hidden sm:block">Add</div> Project + <span className="hidden sm:inline-block">Add</span> Project </Button> )} </div> diff --git a/web/components/inbox/sidebar/inbox-list-item.tsx b/web/components/inbox/sidebar/inbox-list-item.tsx index 2ba28404d..faaaa662a 100644 --- a/web/components/inbox/sidebar/inbox-list-item.tsx +++ b/web/components/inbox/sidebar/inbox-list-item.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/router"; // icons import { CalendarDays } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Tooltip, PriorityIcon } from "@plane/ui"; // helpers @@ -33,7 +34,7 @@ export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => { const { issue: { getIssueById }, } = useIssueDetail(); - + const { isMobile } = usePlatformOS(); const inboxIssueDetail = getInboxIssueByIssueId(inboxId, issueId); const issue = getIssueById(issueId); @@ -83,10 +84,10 @@ export const InboxIssueListItem: FC<TInboxIssueListItem> = observer((props) => { </div> <div className="flex flex-wrap items-center gap-2"> - <Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}> + <Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`} isMobile={isMobile}> <PriorityIcon priority={issue.priority ?? null} className="h-3.5 w-3.5" /> </Tooltip> - <Tooltip tooltipHeading="Created on" tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`}> + <Tooltip tooltipHeading="Created on" tooltipContent={`${renderFormattedDate(issue.created_at ?? "")}`} isMobile={isMobile}> <div className="flex items-center gap-1 rounded border border-custom-border-200 px-2 py-[0.19rem] text-xs text-custom-text-200 shadow-sm"> <CalendarDays size={12} strokeWidth={1.5} /> <span>{renderFormattedDate(issue.created_at ?? "")}</span> diff --git a/web/components/instance/sidebar-dropdown.tsx b/web/components/instance/sidebar-dropdown.tsx index 63ee1f2d1..a5a268af4 100644 --- a/web/components/instance/sidebar-dropdown.tsx +++ b/web/components/instance/sidebar-dropdown.tsx @@ -9,6 +9,7 @@ import { Menu, Transition } from "@headlessui/react"; // icons import { LogIn, LogOut, Settings, UserCog2 } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; import { Avatar, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import { useApplication, useUser } from "hooks/store"; // ui @@ -34,7 +35,7 @@ export const InstanceSidebarDropdown = observer(() => { const { signOut, currentUser, currentUserSettings } = useUser(); // hooks const { setTheme } = useTheme(); - + const { isMobile } = usePlatformOS(); // redirect url for normal mode const redirectWorkspaceSlug = workspaceSlug || @@ -73,7 +74,7 @@ export const InstanceSidebarDropdown = observer(() => { {!sidebarCollapsed && ( <div className="flex w-full gap-2"> <h4 className="grow truncate text-base font-medium text-custom-text-200">Instance admin</h4> - <Tooltip position="bottom-left" tooltipContent="Exit God Mode"> + <Tooltip position="bottom-left" tooltipContent="Exit God Mode" isMobile={isMobile}> <div className="flex-shrink-0"> <Link href={`/${redirectWorkspaceSlug}`}> <span> diff --git a/web/components/instance/sidebar-menu.tsx b/web/components/instance/sidebar-menu.tsx index 782cc90d9..e6719895f 100644 --- a/web/components/instance/sidebar-menu.tsx +++ b/web/components/instance/sidebar-menu.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; import { Tooltip } from "@plane/ui"; import { useApplication } from "hooks/store"; // ui @@ -46,6 +47,7 @@ export const InstanceAdminSidebarMenu = () => { } = useApplication(); // router const router = useRouter(); + const { isMobile } = usePlatformOS(); return ( <div className="flex h-full w-full flex-col gap-2.5 overflow-y-auto px-4 py-6"> @@ -55,7 +57,7 @@ export const InstanceAdminSidebarMenu = () => { return ( <Link key={index} href={item.href}> <div> - <Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!sidebarCollapsed}> + <Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!sidebarCollapsed} isMobile={isMobile}> <div className={`group flex w-full items-center gap-3 rounded-md px-3 py-2 outline-none ${ isActive diff --git a/web/components/integration/single-integration-card.tsx b/web/components/integration/single-integration-card.tsx index dac1720bc..027c274ec 100644 --- a/web/components/integration/single-integration-card.tsx +++ b/web/components/integration/single-integration-card.tsx @@ -11,6 +11,7 @@ import { WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; // hooks import { useApplication, useUser } from "hooks/store"; import useIntegrationPopup from "hooks/use-integration-popup"; +import { usePlatformOS } from "hooks/use-platform-os"; // services import { IntegrationService } from "services/integrations"; // icons @@ -55,7 +56,7 @@ export const SingleIntegrationCard: React.FC<Props> = observer(({ integration }) } = useUser(); const isUserAdmin = currentWorkspaceRole === 20; - + const { isMobile } = usePlatformOS(); const { startAuth, isConnecting: isInstalling } = useIntegrationPopup({ provider: integration.provider, github_app_name: envConfig?.github_app_name || "", @@ -129,6 +130,7 @@ export const SingleIntegrationCard: React.FC<Props> = observer(({ integration }) {workspaceIntegrations ? ( isInstalled ? ( <Tooltip + isMobile={isMobile} disabled={isUserAdmin} tooltipContent={!isUserAdmin ? "You don't have permission to perform this" : null} > @@ -147,6 +149,7 @@ export const SingleIntegrationCard: React.FC<Props> = observer(({ integration }) </Tooltip> ) : ( <Tooltip + isMobile={isMobile} disabled={isUserAdmin} tooltipContent={!isUserAdmin ? "You don't have permission to perform this" : null} > diff --git a/web/components/issues/attachment/attachment-detail.tsx b/web/components/issues/attachment/attachment-detail.tsx index 8ff2b9305..c1be0f355 100644 --- a/web/components/issues/attachment/attachment-detail.tsx +++ b/web/components/issues/attachment/attachment-detail.tsx @@ -2,6 +2,7 @@ import { FC, useState } from "react"; import Link from "next/link"; import { AlertCircle, X } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Tooltip } from "@plane/ui"; // components @@ -34,7 +35,7 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = (props) => { } = useIssueDetail(); // states const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false); - + const { isMobile } = usePlatformOS(); const attachment = attachmentId && getAttachmentById(attachmentId); if (!attachment) return <></>; @@ -56,10 +57,11 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = (props) => { <div className="h-7 w-7">{getFileIcon(getFileExtension(attachment.asset))}</div> <div className="flex flex-col gap-1"> <div className="flex items-center gap-2"> - <Tooltip tooltipContent={getFileName(attachment.attributes.name)}> + <Tooltip tooltipContent={getFileName(attachment.attributes.name)} isMobile={isMobile}> <span className="text-sm">{truncateText(`${getFileName(attachment.attributes.name)}`, 10)}</span> </Tooltip> <Tooltip + isMobile={isMobile} tooltipContent={`${ getUserDetails(attachment.updated_by)?.display_name ?? "" } uploaded on ${renderFormattedDate(attachment.updated_at)}`} diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx index 0097b65b6..389f9f875 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -4,6 +4,7 @@ import { Network } from "lucide-react"; import { Tooltip } from "@plane/ui"; import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper"; import { useIssueDetail } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // ui // components import { IssueUser } from "../"; @@ -25,7 +26,7 @@ export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (pr } = useIssueDetail(); const activity = getActivityById(activityId); - + const { isMobile } = usePlatformOS(); if (!activity) return <></>; return ( <div @@ -42,6 +43,7 @@ export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (pr <span> {children} </span> <span> <Tooltip + isMobile={isMobile} tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`} > <span className="whitespace-nowrap"> {calculateTimeAgo(activity.created_at)}</span> diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx index 49f813ec6..c622079e2 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx @@ -2,6 +2,7 @@ import { FC } from "react"; // hooks import { Tooltip } from "@plane/ui"; import { useIssueDetail } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // ui type TIssueLink = { @@ -14,12 +15,12 @@ export const IssueLink: FC<TIssueLink> = (props) => { const { activity: { getActivityById }, } = useIssueDetail(); - + const { isMobile } = usePlatformOS(); const activity = getActivityById(activityId); if (!activity) return <></>; return ( - <Tooltip tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"}> + <Tooltip tooltipContent={activity.issue_detail ? activity.issue_detail.name : "This issue has been deleted"} isMobile={isMobile}> <a aria-disabled={activity.issue === null} href={`${ diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx index fdf94be28..093556a34 100644 --- a/web/components/issues/issue-detail/label/label-list.tsx +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { observer } from "mobx-react"; // components import { useIssueDetail } from "hooks/store"; import { LabelListItem } from "./label-list-item"; @@ -14,7 +15,7 @@ type TLabelList = { disabled: boolean; }; -export const LabelList: FC<TLabelList> = (props) => { +export const LabelList: FC<TLabelList> = observer((props) => { const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props; // hooks const { @@ -40,4 +41,4 @@ export const LabelList: FC<TLabelList> = (props) => { ))} </> ); -}; +}); diff --git a/web/components/issues/issue-detail/links/link-detail.tsx b/web/components/issues/issue-detail/links/link-detail.tsx index 4504329f0..2772dc0c7 100644 --- a/web/components/issues/issue-detail/links/link-detail.tsx +++ b/web/components/issues/issue-detail/links/link-detail.tsx @@ -9,6 +9,7 @@ import { ExternalLinkIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import { calculateTimeAgo } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; import { useIssueDetail, useMember } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; import { IssueLinkCreateUpdateModal, TLinkOperationsModal } from "./create-update-link-modal"; export type TIssueLinkDetail = { @@ -33,7 +34,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => { toggleIssueLinkModalStore(modalToggle); setIsIssueLinkModalOpen(modalToggle); }; - + const { isMobile } = usePlatformOS(); const linkDetail = getLinkById(linkId); if (!linkDetail) return <></>; @@ -64,7 +65,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => { <span className="py-1"> <LinkIcon className="h-3 w-3 flex-shrink-0" /> </span> - <Tooltip tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}> + <Tooltip tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url} isMobile={isMobile}> <span className="truncate text-xs"> {linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url} </span> diff --git a/web/components/issues/issue-detail/module-select.tsx b/web/components/issues/issue-detail/module-select.tsx index f157ede86..91cda67d6 100644 --- a/web/components/issues/issue-detail/module-select.tsx +++ b/web/components/issues/issue-detail/module-select.tsx @@ -71,7 +71,6 @@ export const IssueModuleSelect: React.FC<TIssueModuleSelect> = observer((props) hideIcon dropdownArrow dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" - showTooltip multiple /> </div> diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index 0b6501027..60cb06664 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import Link from "next/link"; import { Pencil, X } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // components import { Tooltip } from "@plane/ui"; import { ParentIssuesListModal } from "components/issues"; @@ -35,7 +36,7 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props) const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined; const parentIssueProjectDetails = parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined; - + const { isMobile } = usePlatformOS(); const handleParentIssue = async (_issueId: string | null = null) => { try { await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); @@ -73,7 +74,7 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props) > {issue.parent_id && parentIssue ? ( <div className="flex items-center gap-1 bg-green-500/20 text-green-700 rounded px-1.5 py-1"> - <Tooltip tooltipHeading="Title" tooltipContent={parentIssue.name}> + <Tooltip tooltipHeading="Title" tooltipContent={parentIssue.name} isMobile={isMobile}> <Link href={`/${workspaceSlug}/projects/${projectId}/issues/${parentIssue?.id}`} target="_blank" @@ -86,7 +87,7 @@ export const IssueParentSelect: React.FC<TIssueParentSelect> = observer((props) </Tooltip> {!disabled && ( - <Tooltip tooltipContent="Remove" position="bottom"> + <Tooltip tooltipContent="Remove" position="bottom" isMobile={isMobile}> <span onClick={(e) => { e.preventDefault(); diff --git a/web/components/issues/issue-detail/reactions/issue-comment.tsx b/web/components/issues/issue-detail/reactions/issue-comment.tsx index 97c63a017..e26befe1b 100644 --- a/web/components/issues/issue-detail/reactions/issue-comment.tsx +++ b/web/components/issues/issue-detail/reactions/issue-comment.tsx @@ -1,10 +1,11 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; // components -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; import { renderEmoji } from "helpers/emoji.helper"; -import { useIssueDetail } from "hooks/store"; -// ui +import { useIssueDetail, useMember } from "hooks/store"; +// helper +import { formatTextList } from "helpers/issue.helper"; // types import { IUser } from "@plane/types"; import { ReactionSelector } from "./reaction-selector"; @@ -21,10 +22,11 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) // hooks const { - commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser }, + commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById }, createCommentReaction, removeCommentReaction, } = useIssueDetail(); + const { getUserDetails } = useMember(); const reactionIds = getCommentReactionsByCommentId(commentId); const userReactions = commentReactionsByUser(commentId, currentUser.id).map((r) => r.reaction); @@ -73,6 +75,17 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) [workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions] ); + const getReactionUsers = (reaction: string): string => { + const reactionUsers = (reactionIds?.[reaction] || []) + .map((reactionId) => { + const reactionDetails = getCommentReactionById(reactionId); + return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null; + }) + .filter((displayName): displayName is string => !!displayName); + const formattedUsers = formatTextList(reactionUsers); + return formattedUsers; + }; + return ( <div className="mt-4 relative flex items-center gap-1.5"> <ReactionSelector @@ -87,19 +100,21 @@ export const IssueCommentReaction: FC<TIssueCommentReaction> = observer((props) (reaction) => reactionIds[reaction]?.length > 0 && ( <> - <button - type="button" - onClick={() => issueCommentReactionOperations.react(reaction)} - key={reaction} - className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${ - userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80" - }`} - > - <span>{renderEmoji(reaction)}</span> - <span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}> - {(reactionIds || {})[reaction].length}{" "} - </span> - </button> + <Tooltip tooltipContent={getReactionUsers(reaction)}> + <button + type="button" + onClick={() => issueCommentReactionOperations.react(reaction)} + key={reaction} + className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${ + userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80" + }`} + > + <span>{renderEmoji(reaction)}</span> + <span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}> + {(reactionIds || {})[reaction].length}{" "} + </span> + </button> + </Tooltip> </> ) )} diff --git a/web/components/issues/issue-detail/reactions/issue.tsx b/web/components/issues/issue-detail/reactions/issue.tsx index 6f5610634..c21f92139 100644 --- a/web/components/issues/issue-detail/reactions/issue.tsx +++ b/web/components/issues/issue-detail/reactions/issue.tsx @@ -1,10 +1,12 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; -// components -import { TOAST_TYPE, setToast } from "@plane/ui"; -import { renderEmoji } from "helpers/emoji.helper"; -import { useIssueDetail } from "hooks/store"; +// hooks +import { useIssueDetail, useMember } from "hooks/store"; // ui +import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +// helpers +import { renderEmoji } from "helpers/emoji.helper"; +import { formatTextList } from "helpers/issue.helper"; // types import { IUser } from "@plane/types"; import { ReactionSelector } from "./reaction-selector"; @@ -20,10 +22,11 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => { const { workspaceSlug, projectId, issueId, currentUser } = props; // hooks const { - reaction: { getReactionsByIssueId, reactionsByUser }, + reaction: { getReactionsByIssueId, reactionsByUser, getReactionById }, createReaction, removeReaction, } = useIssueDetail(); + const { getUserDetails } = useMember(); const reactionIds = getReactionsByIssueId(issueId); const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction); @@ -72,6 +75,18 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => { [workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions] ); + const getReactionUsers = (reaction: string): string => { + const reactionUsers = (reactionIds?.[reaction] || []) + .map((reactionId) => { + const reactionDetails = getReactionById(reactionId); + return reactionDetails ? getUserDetails(reactionDetails.actor_id)?.display_name : null; + }) + .filter((displayName): displayName is string => !!displayName); + + const formattedUsers = formatTextList(reactionUsers); + return formattedUsers; + }; + return ( <div className="mt-4 relative flex items-center gap-1.5"> <ReactionSelector size="md" position="top" value={userReactions} onSelect={issueReactionOperations.react} /> @@ -81,19 +96,21 @@ export const IssueReaction: FC<TIssueReaction> = observer((props) => { (reaction) => reactionIds[reaction]?.length > 0 && ( <> - <button - type="button" - onClick={() => issueReactionOperations.react(reaction)} - key={reaction} - className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${ - userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80" - }`} - > - <span>{renderEmoji(reaction)}</span> - <span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}> - {(reactionIds || {})[reaction].length}{" "} - </span> - </button> + <Tooltip tooltipContent={getReactionUsers(reaction)}> + <button + type="button" + onClick={() => issueReactionOperations.react(reaction)} + key={reaction} + className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${ + userReactions.includes(reaction) ? "bg-custom-primary-100/10" : "bg-custom-background-80" + }`} + > + <span>{renderEmoji(reaction)}</span> + <span className={userReactions.includes(reaction) ? "text-custom-primary-100" : ""}> + {(reactionIds || {})[reaction].length}{" "} + </span> + </button> + </Tooltip> </> ) )} diff --git a/web/components/issues/issue-detail/relation-select.tsx b/web/components/issues/issue-detail/relation-select.tsx index 0fd0902c6..d609d1ddd 100644 --- a/web/components/issues/issue-detail/relation-select.tsx +++ b/web/components/issues/issue-detail/relation-select.tsx @@ -7,6 +7,7 @@ import { RelatedIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import { ExistingIssuesListModal } from "components/core"; import { cn } from "helpers/common.helper"; import { useIssueDetail, useIssues, useProject } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // ui // helpers @@ -59,7 +60,7 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro toggleRelationModal, } = useIssueDetail(); const { issueMap } = useIssues(); - + const { isMobile } = usePlatformOS(); const relationIssueIds = getRelationByIssueIdRelationType(issueId, relationKey); const onSubmit = async (data: ISearchIssueResponse[]) => { @@ -124,7 +125,7 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro key={relationIssueId} className={`group flex items-center gap-1 rounded px-1.5 pb-1 pt-1 leading-3 hover:bg-custom-background-90 ${issueRelationObject[relationKey].className}`} > - <Tooltip tooltipHeading="Title" tooltipContent={currentIssue.name}> + <Tooltip tooltipHeading="Title" tooltipContent={currentIssue.name} isMobile={isMobile}> <Link href={`/${workspaceSlug}/projects/${projectDetails?.id}/issues/${currentIssue.id}`} target="_blank" @@ -136,7 +137,7 @@ export const IssueRelationSelect: React.FC<TIssueRelationSelect> = observer((pro </Link> </Tooltip> {!disabled && ( - <Tooltip tooltipContent="Remove" position="bottom"> + <Tooltip tooltipContent="Remove" position="bottom" isMobile={isMobile}> <span onClick={(e) => { e.preventDefault(); diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 5e56170a8..25aa810cf 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -376,7 +376,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => { /> </div> <div - className="fixed right-0 z-[5] h-full w-full min-w-[300px] space-y-5 overflow-hidden border-l border-custom-border-200 bg-custom-sidebar-background-100 py-5 sm:w-1/2 md:relative md:w-1/3 lg:min-w-80 xl:min-w-96" + className="fixed right-0 z-[5] h-full w-full min-w-[300px] overflow-hidden border-l border-custom-border-200 bg-custom-sidebar-background-100 py-5 sm:w-1/2 md:relative md:w-1/3 lg:min-w-80 xl:min-w-96" style={themeStore.issueDetailSidebarCollapsed ? { right: `-${window?.innerWidth || 0}px` } : {}} > <IssueDetailsSidebar diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index 7f1a7a0d1..7eac649b3 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -15,6 +15,7 @@ import { CalendarCheck2, } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // components import { ArchiveIcon, @@ -78,7 +79,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { issue: { getIssueById }, } = useIssueDetail(); const { getStateById } = useProjectState(); - + const { isMobile } = usePlatformOS(); const issue = getIssueById(issueId); if (!issue) return <></>; @@ -138,7 +139,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> )} <div className="flex flex-wrap items-center gap-2.5 text-custom-text-300"> - <Tooltip tooltipContent="Copy link"> + <Tooltip tooltipContent="Copy link" isMobile={isMobile}> <button type="button" className="grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary" @@ -149,6 +150,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { </Tooltip> {isArchivingAllowed && ( <Tooltip + isMobile={isMobile} tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"} > <button @@ -170,7 +172,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => { </Tooltip> )} {is_editable && ( - <Tooltip tooltipContent="Delete"> + <Tooltip tooltipContent="Delete" isMobile={isMobile}> <button type="button" className="grid h-5 w-5 place-items-center rounded hover:text-custom-text-200 focus:outline-none focus:ring-2 focus:ring-custom-primary" diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index 595cc963c..df183ba3d 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -8,6 +8,7 @@ import { Tooltip, ControlLink } from "@plane/ui"; import { cn } from "helpers/common.helper"; import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // helpers // types import { TIssue, TIssueMap } from "@plane/types"; @@ -29,6 +30,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => { const { getProjectIdentifierById } = useProject(); const { getProjectStates } = useProjectState(); const { peekIssue, setPeekIssue } = useIssueDetail(); + const { isMobile } = usePlatformOS(); // states const [isMenuActive, setIsMenuActive] = useState(false); @@ -110,7 +112,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => { <div className="flex-shrink-0 text-xs text-custom-text-300"> {getProjectIdentifierById(issue?.project_id)}-{issue.sequence_id} </div> - <Tooltip tooltipContent={issue.name}> + <Tooltip tooltipContent={issue.name} isMobile={isMobile}> <div className="truncate text-xs">{issue.name}</div> </Tooltip> </div> diff --git a/web/components/issues/issue-layouts/empty-states/cycle.tsx b/web/components/issues/issue-layouts/empty-states/cycle.tsx index 350e4dbb4..1a49794c6 100644 --- a/web/components/issues/issue-layouts/empty-states/cycle.tsx +++ b/web/components/issues/issue-layouts/empty-states/cycle.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; +import isEmpty from "lodash/isEmpty"; // hooks -import { useApplication, useEventTracker, useIssues } from "hooks/store"; +import { useApplication, useCycle, useEventTracker, useIssues } from "hooks/store"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; import { ExistingIssuesListModal } from "components/core"; @@ -27,12 +28,15 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => { // states const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); // store hooks + const { getCycleById } = useCycle(); const { issues } = useIssues(EIssuesStoreType.CYCLE); const { commandPalette: { toggleCreateIssueModal }, } = useApplication(); const { setTrackElement } = useEventTracker(); + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId || !cycleId) return; @@ -56,8 +60,14 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => { ); }; - const emptyStateType = isEmptyFilters ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_CYCLE_NO_ISSUES; - const additionalPath = activeLayout ?? "list"; + const isCompletedCycleSnapshotAvailable = !isEmpty(cycleDetails?.progress_snapshot ?? {}); + + const emptyStateType = isCompletedCycleSnapshotAvailable + ? EmptyStateType.PROJECT_CYCLE_COMPLETED_NO_ISSUES + : isEmptyFilters + ? EmptyStateType.PROJECT_EMPTY_FILTER + : EmptyStateType.PROJECT_CYCLE_NO_ISSUES; + const additionalPath = isCompletedCycleSnapshotAvailable ? undefined : activeLayout ?? "list"; const emptyStateSize = isEmptyFilters ? "lg" : "sm"; return ( @@ -76,14 +86,18 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => { additionalPath={additionalPath} size={emptyStateSize} primaryButtonOnClick={ - isEmptyFilters - ? undefined - : () => { + !isCompletedCycleSnapshotAvailable && !isEmptyFilters + ? () => { setTrackElement("Cycle issue empty state"); toggleCreateIssueModal(true, EIssuesStoreType.CYCLE); } + : undefined + } + secondaryButtonOnClick={ + !isCompletedCycleSnapshotAvailable && isEmptyFilters + ? handleClearAllFilters + : () => setCycleIssuesListModal(true) } - secondaryButtonOnClick={isEmptyFilters ? handleClearAllFilters : () => setCycleIssuesListModal(true)} /> </div> </> diff --git a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx index fdaed4b9b..24a197c76 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite"; // icons import { X } from "lucide-react"; // helpers -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // constants @@ -18,7 +18,7 @@ export const AppliedDateFilters: React.FC<Props> = observer((props) => { const getDateLabel = (value: string): string => { let dateLabel = ""; - const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value); + const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value); if (dateDetails) dateLabel = dateDetails.name; else { diff --git a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx index 87def7e29..3c47eb286 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterStartDate: React.FC<Props> = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx index 9e0ce18a7..83a526351 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/issues/issue-layouts/filters/header/layout-selection.tsx b/web/components/issues/issue-layouts/filters/header/layout-selection.tsx index a69ead577..2b8df8edf 100644 --- a/web/components/issues/issue-layouts/filters/header/layout-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/layout-selection.tsx @@ -5,7 +5,8 @@ import { Tooltip } from "@plane/ui"; // types import { ISSUE_LAYOUTS } from "constants/issue"; import { TIssueLayouts } from "@plane/types"; -// constants +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { layouts: TIssueLayouts[]; @@ -15,11 +16,12 @@ type Props = { export const LayoutSelection: React.FC<Props> = (props) => { const { layouts, onChange, selectedLayout } = props; + const { isMobile } = usePlatformOS(); return ( <div className="flex items-center gap-1 rounded bg-custom-background-80 p-1"> {ISSUE_LAYOUTS.filter((l) => layouts.includes(l.key)).map((layout) => ( - <Tooltip key={layout.key} tooltipContent={layout.title}> + <Tooltip key={layout.key} tooltipContent={layout.title} isMobile={isMobile}> <button type="button" className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${ diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index 98b05d0ca..1676b1f34 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -1,5 +1,6 @@ import { observer } from "mobx-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Tooltip, StateGroupIcon, ControlLink } from "@plane/ui"; // helpers @@ -31,6 +32,7 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => { issueDetails && !issueDetails.tempId && setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); + const { isMobile } = usePlatformOS(); return ( <div @@ -42,6 +44,7 @@ export const IssueGanttBlock: React.FC<Props> = observer((props) => { > <div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" /> <Tooltip + isMobile={isMobile} tooltipContent={ <div className="space-y-1"> <h5>{issueDetails?.name}</h5> @@ -83,6 +86,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => { workspaceSlug && issueDetails && setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); + const { isMobile } = usePlatformOS(); return ( <ControlLink @@ -97,7 +101,7 @@ export const IssueGanttSidebarBlock: React.FC<Props> = observer((props) => { <div className="flex-shrink-0 text-xs text-custom-text-300"> {projectIdentifier} {issueDetails?.sequence_id} </div> - <Tooltip tooltipContent={issueDetails?.name}> + <Tooltip tooltipContent={issueDetails?.name} isMobile={isMobile}> <span className="flex-grow truncate text-sm font-medium">{issueDetails?.name}</span> </Tooltip> </div> diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index dabecc491..19a0a4173 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -6,6 +6,7 @@ import { Tooltip, ControlLink } from "@plane/ui"; import RenderIfVisible from "components/core/render-if-visible-HOC"; import { cn } from "helpers/common.helper"; import { useApplication, useIssueDetail, useProject } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // components import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { IssueProperties } from "../properties/all-properties"; @@ -41,6 +42,7 @@ interface IssueDetailsBlockProps { const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props: IssueDetailsBlockProps) => { const { issue, updateIssue, quickActions, isReadOnly, displayProperties } = props; // hooks + const { isMobile } = usePlatformOS(); const { getProjectIdentifierById } = useProject(); const { router: { workspaceSlug }, @@ -66,7 +68,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop </WithDisplayPropertiesHOC> {issue?.is_draft ? ( - <Tooltip tooltipContent={issue.name}> + <Tooltip tooltipContent={issue.name} isMobile={isMobile}> <span>{issue.name}</span> </Tooltip> ) : ( @@ -79,7 +81,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" disabled={!!issue?.tempId} > - <Tooltip tooltipContent={issue.name}> + <Tooltip tooltipContent={issue.name} isMobile={isMobile}> <span>{issue.name}</span> </Tooltip> </ControlLink> diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 099137348..bb7a9ee83 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -1,11 +1,12 @@ import { observer } from "mobx-react-lite"; // components // hooks +import { usePlatformOS } from "hooks/use-platform-os"; +import { useApplication, useIssueDetail, useProject } from "hooks/store"; // ui import { Spinner, Tooltip, ControlLink } from "@plane/ui"; // helper import { cn } from "helpers/common.helper"; -import { useApplication, useIssueDetail, useProject } from "hooks/store"; // types import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; import { IssueProperties } from "../properties/all-properties"; @@ -36,7 +37,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock setPeekIssue({ workspaceSlug, projectId: issue.project_id, issueId: issue.id }); const issue = issuesMap[issueId]; - + const { isMobile } = usePlatformOS(); if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); @@ -44,53 +45,62 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock return ( <div - className={cn("min-h-12 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm", { - "border border-custom-primary-70 hover:border-custom-primary-70": peekIssue && peekIssue.issueId === issue.id, - "last:border-b-transparent": peekIssue?.issueId !== issue.id, - })} + className={cn( + "min-h-12 relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 p-3 text-sm", + { + "border border-custom-primary-70 hover:border-custom-primary-70": peekIssue && peekIssue.issueId === issue.id, + "last:border-b-transparent": peekIssue?.issueId !== issue.id, + } + )} > - {displayProperties && displayProperties?.key && ( - <div className="flex-shrink-0 text-xs font-medium text-custom-text-300"> - {projectIdentifier}-{issue.sequence_id} + <div className="flex"> + <div className="flex flex-grow items-center gap-3"> + {displayProperties && displayProperties?.key && ( + <div className="flex-shrink-0 text-xs font-medium text-custom-text-300"> + {projectIdentifier}-{issue.sequence_id} + </div> + )} + + {issue?.tempId !== undefined && ( + <div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" /> + )} + + {issue?.is_draft ? ( + <Tooltip tooltipContent={issue.name} isMobile={isMobile}> + <span>{issue.name}</span> + </Tooltip> + ) : ( + <ControlLink + href={`/${workspaceSlug}/projects/${issue.project_id}/${ + issue.archived_at ? "archived-issues" : "issues" + }/${issue.id}`} + target="_blank" + onClick={() => handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} + > + <Tooltip tooltipContent={issue.name} isMobile={isMobile}> + <span>{issue.name}</span> + </Tooltip> + </ControlLink> + )} </div> - )} - - {issue?.tempId !== undefined && ( - <div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" /> - )} - - {issue?.is_draft ? ( - <Tooltip tooltipContent={issue.name}> - <span>{issue.name}</span> - </Tooltip> - ) : ( - <ControlLink - href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${ - issue.id - }`} - target="_blank" - onClick={() => handleIssuePeekOverview(issue)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - disabled={!!issue?.tempId} - > - <Tooltip tooltipContent={issue.name}> - <span>{issue.name}</span> - </Tooltip> - </ControlLink> - )} - - <div className="ml-auto flex flex-shrink-0 items-center gap-2"> + {!issue?.tempId && ( + <div className="block md:hidden border border-custom-border-300 rounded ">{quickActions(issue)}</div> + )} + </div> + <div className="ml-0 md:ml-auto flex flex-wrap md:flex-shrink-0 items-center gap-2"> {!issue?.tempId ? ( <> <IssueProperties - className="relative flex items-center gap-2 whitespace-nowrap" + className="relative flex flex-wrap md:flex-grow md:flex-shrink-0 items-center gap-2 whitespace-nowrap" issue={issue} isReadOnly={!canEditIssueProperties} updateIssue={updateIssue} displayProperties={displayProperties} activeLayout="List" /> - {quickActions(issue)} + <div className="hidden md:block">{quickActions(issue)}</div> </> ) : ( <div className="h-4 w-4"> diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 12179ee97..4009df6d2 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -123,7 +123,10 @@ const GroupByList: React.FC<IGroupByList> = (props) => { const isGroupByCreatedBy = group_by === "created_by"; return ( - <div ref={containerRef} className="vertical-scrollbar scrollbar-lg relative h-full w-full overflow-auto"> + <div + ref={containerRef} + className="vertical-scrollbar scrollbar-lg relative h-full w-full overflow-auto vertical-scrollbar-margin-top-md" + > {groups && groups.length > 0 && groups.map( diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 8c1e33b8c..12666561c 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -20,6 +20,7 @@ import { cn } from "helpers/common.helper"; import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "helpers/issue.helper"; import { useEventTracker, useEstimate, useLabel, useIssues, useProjectState } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // components import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; import { IssuePropertyLabels } from "../properties/labels"; @@ -50,6 +51,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { } = useIssues(EIssuesStoreType.CYCLE); const { areEstimatesEnabledForCurrentProject } = useEstimate(); const { getStateById } = useProjectState(); + const { isMobile } = usePlatformOS(); // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -391,7 +393,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { displayPropertyKey="sub_issue_count" shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!issue.sub_issues_count} > - <Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}> + <Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`} isMobile={isMobile}> <div onClick={issue.sub_issues_count ? redirectToIssueDetail : () => {}} className={cn( @@ -413,7 +415,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { displayPropertyKey="attachment_count" shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count} > - <Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}> + <Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`} isMobile={isMobile}> <div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"> <Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} /> <div className="text-xs">{issue.attachment_count}</div> @@ -427,7 +429,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => { displayPropertyKey="link" shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count} > - <Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}> + <Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`} isMobile={isMobile}> <div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"> <Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} /> <div className="text-xs">{issue.link_count}</div> diff --git a/web/components/issues/issue-layouts/properties/labels.tsx b/web/components/issues/issue-layouts/properties/labels.tsx index 090f0ce56..5889b52c5 100644 --- a/web/components/issues/issue-layouts/properties/labels.tsx +++ b/web/components/issues/issue-layouts/properties/labels.tsx @@ -9,6 +9,7 @@ import { Tooltip } from "@plane/ui"; import { useApplication, useLabel } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // types import { IIssueLabel } from "@plane/types"; @@ -62,7 +63,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro router: { workspaceSlug }, } = useApplication(); const { fetchProjectLabels, getProjectLabels } = useLabel(); - + const { isMobile } = usePlatformOS(); const storeLabels = getProjectLabels(projectId); const onOpen = () => { @@ -149,7 +150,13 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro {projectLabels ?.filter((l) => value.includes(l?.id)) .map((label) => ( - <Tooltip key={label.id} position="top" tooltipHeading="Labels" tooltipContent={label?.name ?? ""}> + <Tooltip + key={label.id} + position="top" + tooltipHeading="Labels" + tooltipContent={label?.name ?? ""} + isMobile={isMobile} + > <div key={label?.id} className={`flex overflow-hidden hover:bg-custom-background-80 ${ @@ -176,6 +183,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro }`} > <Tooltip + isMobile={isMobile} position="top" tooltipHeading="Labels" tooltipContent={projectLabels @@ -191,7 +199,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro </div> ) ) : ( - <Tooltip position="top" tooltipHeading="Labels" tooltipContent="None"> + <Tooltip position="top" tooltipHeading="Labels" tooltipContent="None" isMobile={isMobile}> <div className={`flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs hover:bg-custom-background-80 ${ noLabelBorder ? "" : "border-[0.5px] border-custom-border-300" @@ -224,8 +232,8 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro disabled ? "cursor-not-allowed text-custom-text-200" : value.length <= maxRender - ? "cursor-pointer" - : "cursor-pointer hover:bg-custom-background-80" + ? "cursor-pointer" + : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} onClick={handleOnClick} > diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 8a8ce29f4..b86d85723 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -14,6 +14,7 @@ import { cn } from "helpers/common.helper"; // hooks import { useIssueDetail, useProject } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // types import { IIssueDisplayProperties, TIssue } from "@plane/types"; // local components @@ -144,6 +145,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { //hooks const { getProjectIdentifierById } = useProject(); const { peekIssue, setPeekIssue } = useIssueDetail(); + const { isMobile } = usePlatformOS(); // states const [isMenuActive, setIsMenuActive] = useState(false); const menuActionRef = useRef<HTMLDivElement | null>(null); @@ -241,7 +243,7 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { disabled={!!issueDetail?.tempId} > <div className="w-full overflow-hidden"> - <Tooltip tooltipContent={issueDetail.name}> + <Tooltip tooltipContent={issueDetail.name} isMobile={isMobile}> <div className="h-full w-full cursor-pointer truncate px-4 text-left text-[0.825rem] text-custom-text-100 focus:outline-none" tabIndex={-1} diff --git a/web/components/issues/label.tsx b/web/components/issues/label.tsx index 1361ff7d1..e63850c20 100644 --- a/web/components/issues/label.tsx +++ b/web/components/issues/label.tsx @@ -1,14 +1,15 @@ import React from "react"; // components import { Tooltip } from "@plane/ui"; - +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { labelDetails: any[]; maxRender?: number; }; -export const ViewIssueLabel: React.FC<Props> = ({ labelDetails, maxRender = 1 }) => ( - <> +export const ViewIssueLabel: React.FC<Props> = ({ labelDetails, maxRender = 1 }) => { + const { isMobile } = usePlatformOS(); + return (<> {labelDetails?.length > 0 ? ( labelDetails.length <= maxRender ? ( <> @@ -17,7 +18,7 @@ export const ViewIssueLabel: React.FC<Props> = ({ labelDetails, maxRender = 1 }) key={label.id} className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm" > - <Tooltip position="top" tooltipHeading="Label" tooltipContent={label.name}> + <Tooltip position="top" tooltipHeading="Label" tooltipContent={label.name} isMobile={isMobile}> <div className="flex items-center gap-1.5 text-custom-text-200"> <span className="h-2 w-2 flex-shrink-0 rounded-full" @@ -33,7 +34,7 @@ export const ViewIssueLabel: React.FC<Props> = ({ labelDetails, maxRender = 1 }) </> ) : ( <div className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"> - <Tooltip position="top" tooltipHeading="Labels" tooltipContent={labelDetails.map((l) => l.name).join(", ")}> + <Tooltip position="top" tooltipHeading="Labels" tooltipContent={labelDetails.map((l) => l.name).join(", ")} isMobile={isMobile}> <div className="flex items-center gap-1.5 text-custom-text-200"> <span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" /> {`${labelDetails.length} Labels`} @@ -44,5 +45,5 @@ export const ViewIssueLabel: React.FC<Props> = ({ labelDetails, maxRender = 1 }) ) : ( "" )} - </> -); + </>) +}; diff --git a/web/components/issues/parent-issues-list-modal.tsx b/web/components/issues/parent-issues-list-modal.tsx index f5b804e74..7f5f4984b 100644 --- a/web/components/issues/parent-issues-list-modal.tsx +++ b/web/components/issues/parent-issues-list-modal.tsx @@ -8,6 +8,7 @@ import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui"; import useDebounce from "hooks/use-debounce"; import { ProjectService } from "services/project"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui // icons // types @@ -37,7 +38,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({ const [issues, setIssues] = useState<ISearchIssueResponse[]>([]); const [isSearching, setIsSearching] = useState(false); const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false); - + const { isMobile } = usePlatformOS(); const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const router = useRouter(); @@ -113,7 +114,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({ /> </div> <div className="flex p-2 sm:justify-end"> - <Tooltip tooltipContent="Toggle workspace level search"> + <Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}> <div className={`flex flex-shrink-0 cursor-pointer items-center gap-1 text-xs ${ isWorkspaceLevel ? "text-custom-text-100" : "text-custom-text-200" diff --git a/web/components/issues/peek-overview/header.tsx b/web/components/issues/peek-overview/header.tsx index b47551bc6..7e2142eb0 100644 --- a/web/components/issues/peek-overview/header.tsx +++ b/web/components/issues/peek-overview/header.tsx @@ -21,9 +21,8 @@ import { cn } from "helpers/common.helper"; import { copyUrlToClipboard } from "helpers/string.helper"; // store hooks import { useIssueDetail, useProjectState, useUser } from "hooks/store"; -// helpers -// components -// helpers +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; export type TPeekModes = "side-peek" | "modal" | "full-screen"; @@ -83,6 +82,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr issue: { getIssueById }, } = useIssueDetail(); const { getStateById } = useProjectState(); + const { isMobile } = usePlatformOS(); // derived values const issueDetails = getIssueById(issueId); const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined; @@ -160,13 +160,14 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr {currentUser && !isArchived && ( <IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} /> )} - <Tooltip tooltipContent="Copy link"> + <Tooltip tooltipContent="Copy link" isMobile={isMobile}> <button type="button" onClick={handleCopyText}> <Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" /> </button> </Tooltip> {isArchivingAllowed && ( <Tooltip + isMobile={isMobile} tooltipContent={isInArchivableGroup ? "Archive" : "Only completed or canceled issues can be archived"} > <button @@ -185,14 +186,14 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr </Tooltip> )} {isRestoringAllowed && ( - <Tooltip tooltipContent="Restore"> + <Tooltip tooltipContent="Restore" isMobile={isMobile}> <button type="button" onClick={handleRestoreIssue}> <RotateCcw className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> </button> </Tooltip> )} {!disabled && ( - <Tooltip tooltipContent="Delete"> + <Tooltip tooltipContent="Delete" isMobile={isMobile}> <button type="button" onClick={() => toggleDeleteIssueModal(true)}> <Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> </button> diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index c3ac1495a..47890c95c 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -93,7 +93,6 @@ export const IssueView: FC<IIssueView> = observer((props) => { isOpen={isDeleteIssueModalOpen} handleClose={() => { toggleDeleteIssueModal(false); - removeRoutePeekId(); }} data={issue} onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId)} diff --git a/web/components/issues/sub-issues/issue-list-item.tsx b/web/components/issues/sub-issues/issue-list-item.tsx index 5d7d19730..170bf622f 100644 --- a/web/components/issues/sub-issues/issue-list-item.tsx +++ b/web/components/issues/sub-issues/issue-list-item.tsx @@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } // components import { ControlLink, CustomMenu, Tooltip } from "@plane/ui"; import { useIssueDetail, useProject, useProjectState } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; import { TIssue } from "@plane/types"; import { IssueList } from "./issues-list"; import { IssueProperty } from "./properties"; @@ -46,7 +47,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => { } = useIssueDetail(); const project = useProject(); const { getProjectStates } = useProjectState(); - + const { isMobile } = usePlatformOS(); const issue = getIssueById(issueId); const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined; const currentIssueStateDetail = @@ -117,7 +118,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => { onClick={() => handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" > - <Tooltip tooltipContent={issue.name}> + <Tooltip tooltipContent={issue.name} isMobile={isMobile}> <span>{issue.name}</span> </Tooltip> </ControlLink> diff --git a/web/components/modules/applied-filters/date.tsx b/web/components/modules/applied-filters/date.tsx new file mode 100644 index 000000000..42494bdbd --- /dev/null +++ b/web/components/modules/applied-filters/date.tsx @@ -0,0 +1,56 @@ +import { observer } from "mobx-react-lite"; +// icons +import { X } from "lucide-react"; +// helpers +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; +import { renderFormattedDate } from "helpers/date-time.helper"; +import { capitalizeFirstLetter } from "helpers/string.helper"; +// constants + +type Props = { + editable: boolean | undefined; + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedDateFilters: React.FC<Props> = observer((props) => { + const { editable, handleRemove, values } = props; + + const getDateLabel = (value: string): string => { + let dateLabel = ""; + + const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value); + + if (dateDetails) dateLabel = dateDetails.name; + else { + const dateParts = value.split(";"); + + if (dateParts.length === 2) { + const [date, time] = dateParts; + + dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`; + } + } + + return dateLabel; + }; + + return ( + <> + {values.map((date) => ( + <div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs"> + <span className="normal-case">{getDateLabel(date)}</span> + {editable && ( + <button + type="button" + className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" + onClick={() => handleRemove(date)} + > + <X size={10} strokeWidth={2} /> + </button> + )} + </div> + ))} + </> + ); +}); diff --git a/web/components/modules/applied-filters/index.ts b/web/components/modules/applied-filters/index.ts new file mode 100644 index 000000000..cf34b6e69 --- /dev/null +++ b/web/components/modules/applied-filters/index.ts @@ -0,0 +1,4 @@ +export * from "./date"; +export * from "./members"; +export * from "./root"; +export * from "./status"; diff --git a/web/components/modules/applied-filters/members.tsx b/web/components/modules/applied-filters/members.tsx new file mode 100644 index 000000000..88f18ee0c --- /dev/null +++ b/web/components/modules/applied-filters/members.tsx @@ -0,0 +1,46 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// ui +import { Avatar } from "@plane/ui"; +// types +import { useMember } from "hooks/store"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedMembersFilters: React.FC<Props> = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + return ( + <> + {values.map((memberId) => { + const memberDetails = getWorkspaceMemberDetails(memberId)?.member; + + if (!memberDetails) return null; + + return ( + <div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs"> + <Avatar name={memberDetails.display_name} src={memberDetails.avatar} showTooltip={false} /> + <span className="normal-case">{memberDetails.display_name}</span> + {editable && ( + <button + type="button" + className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" + onClick={() => handleRemove(memberId)} + > + <X size={10} strokeWidth={2} /> + </button> + )} + </div> + ); + })} + </> + ); +}); diff --git a/web/components/modules/applied-filters/root.tsx b/web/components/modules/applied-filters/root.tsx new file mode 100644 index 000000000..2969ea715 --- /dev/null +++ b/web/components/modules/applied-filters/root.tsx @@ -0,0 +1,88 @@ +import { X } from "lucide-react"; +// components +import { AppliedDateFilters, AppliedMembersFilters, AppliedStatusFilters } from "components/modules"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TModuleFilters } from "@plane/types"; + +type Props = { + appliedFilters: TModuleFilters; + handleClearAllFilters: () => void; + handleRemoveFilter: (key: keyof TModuleFilters, value: string | null) => void; + alwaysAllowEditing?: boolean; +}; + +const MEMBERS_FILTERS = ["lead", "members"]; +const DATE_FILTERS = ["start_date", "target_date"]; + +export const ModuleAppliedFiltersList: React.FC<Props> = (props) => { + const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props; + + if (!appliedFilters) return null; + if (Object.keys(appliedFilters).length === 0) return null; + + const isEditingAllowed = alwaysAllowEditing; + + return ( + <div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100"> + {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TModuleFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( + <div + key={filterKey} + className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize" + > + <div className="flex flex-wrap items-center gap-1.5"> + <span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span> + {filterKey === "status" && ( + <AppliedStatusFilters + editable={isEditingAllowed} + handleRemove={(val) => handleRemoveFilter("status", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + <AppliedDateFilters + editable={isEditingAllowed} + handleRemove={(val) => handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {MEMBERS_FILTERS.includes(filterKey) && ( + <AppliedMembersFilters + editable={isEditingAllowed} + handleRemove={(val) => handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + <button + type="button" + className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" + onClick={() => handleRemoveFilter(filterKey, null)} + > + <X size={12} strokeWidth={2} /> + </button> + )} + </div> + </div> + ); + })} + {isEditingAllowed && ( + <button + type="button" + onClick={handleClearAllFilters} + className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200" + > + Clear all + <X size={12} strokeWidth={2} /> + </button> + )} + </div> + ); +}; diff --git a/web/components/modules/applied-filters/status.tsx b/web/components/modules/applied-filters/status.tsx new file mode 100644 index 000000000..ed5426cde --- /dev/null +++ b/web/components/modules/applied-filters/status.tsx @@ -0,0 +1,41 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// ui +import { ModuleStatusIcon } from "@plane/ui"; +// constants +import { MODULE_STATUS } from "constants/module"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedStatusFilters: React.FC<Props> = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const statusDetails = MODULE_STATUS?.find((s) => s.value === status); + if (!statusDetails) return null; + + return ( + <div key={status} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs"> + <ModuleStatusIcon status={statusDetails.value} height="12px" width="12px" /> + {statusDetails.label} + {editable && ( + <button + type="button" + className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" + onClick={() => handleRemove(status)} + > + <X size={10} strokeWidth={2} /> + </button> + )} + </div> + ); + })} + </> + ); +}); diff --git a/web/components/modules/dropdowns/filters/index.ts b/web/components/modules/dropdowns/filters/index.ts new file mode 100644 index 000000000..786fc5cec --- /dev/null +++ b/web/components/modules/dropdowns/filters/index.ts @@ -0,0 +1,6 @@ +export * from "./lead"; +export * from "./members"; +export * from "./root"; +export * from "./start-date"; +export * from "./status"; +export * from "./target-date"; diff --git a/web/components/modules/dropdowns/filters/lead.tsx b/web/components/modules/dropdowns/filters/lead.tsx new file mode 100644 index 000000000..ffd4f8a2e --- /dev/null +++ b/web/components/modules/dropdowns/filters/lead.tsx @@ -0,0 +1,96 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterLead: React.FC<Props> = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + }, [appliedFilters, getUserDetails, memberIds, , searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + <FilterHeader + title={`Lead${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + <FilterOption + key={`lead-${member.id}`} + isChecked={appliedFilters?.includes(member.id) ? true : false} + onClick={() => handleUpdate(member.id)} + icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + <button + type="button" + className="ml-8 text-xs font-medium text-custom-primary-100" + onClick={handleViewToggle} + > + {itemsToRender === sortedOptions.length ? "View less" : "View all"} + </button> + )} + </> + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + ) + ) : ( + <Loader className="space-y-2"> + <Loader.Item height="20px" /> + <Loader.Item height="20px" /> + <Loader.Item height="20px" /> + </Loader> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/modules/dropdowns/filters/members.tsx b/web/components/modules/dropdowns/filters/members.tsx new file mode 100644 index 000000000..0d2737227 --- /dev/null +++ b/web/components/modules/dropdowns/filters/members.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterMembers: React.FC<Props> = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + <FilterHeader + title={`Members${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + <FilterOption + key={`member-${member.id}`} + isChecked={appliedFilters?.includes(member.id) ? true : false} + onClick={() => handleUpdate(member.id)} + icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + <button + type="button" + className="ml-8 text-xs font-medium text-custom-primary-100" + onClick={handleViewToggle} + > + {itemsToRender === sortedOptions.length ? "View less" : "View all"} + </button> + )} + </> + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + ) + ) : ( + <Loader className="space-y-2"> + <Loader.Item height="20px" /> + <Loader.Item height="20px" /> + <Loader.Item height="20px" /> + </Loader> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/modules/dropdowns/filters/root.tsx b/web/components/modules/dropdowns/filters/root.tsx new file mode 100644 index 000000000..30841a43a --- /dev/null +++ b/web/components/modules/dropdowns/filters/root.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterLead, FilterMembers, FilterStartDate, FilterStatus, FilterTargetDate } from "components/modules"; +import { FilterOption } from "components/issues"; +// types +import { TModuleDisplayFilters, TModuleFilters } from "@plane/types"; +import { TModuleStatus } from "@plane/ui"; + +type Props = { + displayFilters: TModuleDisplayFilters; + filters: TModuleFilters; + handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TModuleDisplayFilters>) => void; + handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void; + memberIds?: string[] | undefined; +}; + +export const ModuleFiltersSelection: React.FC<Props> = observer((props) => { + const { displayFilters, filters, handleDisplayFiltersUpdate, handleFiltersUpdate, memberIds } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( + <div className="flex h-full w-full flex-col overflow-hidden"> + <div className="bg-custom-background-100 p-2.5 pb-0"> + <div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs"> + <Search className="text-custom-text-400" size={12} strokeWidth={2} /> + <input + type="text" + className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400" + placeholder="Search" + value={filtersSearchQuery} + onChange={(e) => setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + <button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}> + <X className="text-custom-text-300" size={12} strokeWidth={2} /> + </button> + )} + </div> + </div> + <div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm"> + <div className="py-2"> + <FilterOption + isChecked={!!displayFilters.favorites} + onClick={() => + handleDisplayFiltersUpdate({ + favorites: !displayFilters.favorites, + }) + } + title="Favorites" + /> + </div> + + {/* status */} + <div className="py-2"> + <FilterStatus + appliedFilters={(filters.status as TModuleStatus[]) ?? null} + handleUpdate={(val) => handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> + </div> + + {/* lead */} + <div className="py-2"> + <FilterLead + appliedFilters={filters.lead ?? null} + handleUpdate={(val) => handleFiltersUpdate("lead", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> + </div> + + {/* members */} + <div className="py-2"> + <FilterMembers + appliedFilters={filters.members ?? null} + handleUpdate={(val) => handleFiltersUpdate("members", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> + </div> + + {/* start date */} + <div className="py-2"> + <FilterStartDate + appliedFilters={filters.start_date ?? null} + handleUpdate={(val) => handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> + </div> + + {/* target date */} + <div className="py-2"> + <FilterTargetDate + appliedFilters={filters.target_date ?? null} + handleUpdate={(val) => handleFiltersUpdate("target_date", val)} + searchQuery={filtersSearchQuery} + /> + </div> + </div> + </div> + ); +}); diff --git a/web/components/modules/dropdowns/filters/start-date.tsx b/web/components/modules/dropdowns/filters/start-date.tsx new file mode 100644 index 000000000..3c47eb286 --- /dev/null +++ b/web/components/modules/dropdowns/filters/start-date.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; + +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterStartDate: React.FC<Props> = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <> + {isDateFilterModalOpen && ( + <DateFilterModal + handleClose={() => setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Start date" + /> + )} + <FilterHeader + title={`Start date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + <FilterOption + key={option.value} + isChecked={appliedFilters?.includes(option.value) ? true : false} + onClick={() => handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + <FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple /> + </> + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/modules/dropdowns/filters/status.tsx b/web/components/modules/dropdowns/filters/status.tsx new file mode 100644 index 000000000..f73db2554 --- /dev/null +++ b/web/components/modules/dropdowns/filters/status.tsx @@ -0,0 +1,52 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { ModuleStatusIcon } from "@plane/ui"; +// types +import { TModuleStatus } from "@plane/types"; +// constants +import { MODULE_STATUS } from "constants/module"; + +type Props = { + appliedFilters: TModuleStatus[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterStatus: React.FC<Props> = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = MODULE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase())); + + return ( + <> + <FilterHeader + title={`Status${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {filteredOptions.length > 0 ? ( + filteredOptions.map((status) => ( + <FilterOption + key={status.value} + isChecked={appliedFilters?.includes(status.value) ? true : false} + onClick={() => handleUpdate(status.value)} + icon={<ModuleStatusIcon status={status.value} />} + title={status.label} + /> + )) + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/modules/dropdowns/filters/target-date.tsx b/web/components/modules/dropdowns/filters/target-date.tsx new file mode 100644 index 000000000..d563dbe92 --- /dev/null +++ b/web/components/modules/dropdowns/filters/target-date.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; + +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterTargetDate: React.FC<Props> = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <> + {isDateFilterModalOpen && ( + <DateFilterModal + handleClose={() => setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Due date" + /> + )} + <FilterHeader + title={`Due date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + <FilterOption + key={option.value} + isChecked={appliedFilters?.includes(option.value) ? true : false} + onClick={() => handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + <FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple /> + </> + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/modules/dropdowns/index.ts b/web/components/modules/dropdowns/index.ts new file mode 100644 index 000000000..f6c42552f --- /dev/null +++ b/web/components/modules/dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./filters"; +export * from "./order-by"; diff --git a/web/components/modules/dropdowns/order-by.tsx b/web/components/modules/dropdowns/order-by.tsx new file mode 100644 index 000000000..a611d1ead --- /dev/null +++ b/web/components/modules/dropdowns/order-by.tsx @@ -0,0 +1,70 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react"; +// ui +import { CustomMenu, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TModuleOrderByOptions } from "@plane/types"; +// constants +import { MODULE_ORDER_BY_OPTIONS } from "constants/module"; + +type Props = { + onChange: (value: TModuleOrderByOptions) => void; + value: TModuleOrderByOptions | undefined; +}; + +export const ModuleOrderByDropdown: React.FC<Props> = (props) => { + const { onChange, value } = props; + + const orderByDetails = MODULE_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key)); + + const isDescending = value?.[0] === "-"; + + return ( + <CustomMenu + customButton={ + <div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}> + <ArrowDownWideNarrow className="h-3 w-3" /> + {orderByDetails?.label} + <ChevronDown className="h-3 w-3" strokeWidth={2} /> + </div> + } + placement="bottom-end" + maxHeight="lg" + closeOnSelect + > + {MODULE_ORDER_BY_OPTIONS.map((option) => ( + <CustomMenu.MenuItem + key={option.key} + className="flex items-center justify-between gap-2" + onClick={() => { + if (isDescending) onChange(`-${option.key}` as TModuleOrderByOptions); + else onChange(option.key); + }} + > + {option.label} + {value?.includes(option.key) && <Check className="h-3 w-3" />} + </CustomMenu.MenuItem> + ))} + <hr className="my-2" /> + <CustomMenu.MenuItem + className="flex items-center justify-between gap-2" + onClick={() => { + if (isDescending) onChange(value.slice(1) as TModuleOrderByOptions); + }} + > + Ascending + {!isDescending && <Check className="h-3 w-3" />} + </CustomMenu.MenuItem> + <CustomMenu.MenuItem + className="flex items-center justify-between gap-2" + onClick={() => { + if (!isDescending) onChange(`-${value}` as TModuleOrderByOptions); + }} + > + Descending + {isDescending && <Check className="h-3 w-3" />} + </CustomMenu.MenuItem> + </CustomMenu> + ); +}; diff --git a/web/components/modules/gantt-chart/blocks.tsx b/web/components/modules/gantt-chart/blocks.tsx index 073283df4..60af5d048 100644 --- a/web/components/modules/gantt-chart/blocks.tsx +++ b/web/components/modules/gantt-chart/blocks.tsx @@ -1,6 +1,8 @@ +import Link from "next/link"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Tooltip, ModuleStatusIcon } from "@plane/ui"; // helpers @@ -24,6 +26,8 @@ export const ModuleGanttBlock: React.FC<Props> = observer((props) => { const { getModuleById } = useModule(); // derived values const moduleDetails = getModuleById(moduleId); + // hooks + const { isMobile } = usePlatformOS(); return ( <div @@ -35,6 +39,7 @@ export const ModuleGanttBlock: React.FC<Props> = observer((props) => { > <div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" /> <Tooltip + isMobile={isMobile} tooltipContent={ <div className="space-y-1"> <h5>{moduleDetails?.name}</h5> @@ -54,8 +59,6 @@ export const ModuleGanttBlock: React.FC<Props> = observer((props) => { export const ModuleGanttSidebarBlock: React.FC<Props> = observer((props) => { const { moduleId } = props; - // router - const router = useRouter(); // store hooks const { router: { workspaceSlug }, @@ -65,14 +68,12 @@ export const ModuleGanttSidebarBlock: React.FC<Props> = observer((props) => { const moduleDetails = getModuleById(moduleId); return ( - <div + <Link className="relative flex h-full w-full items-center gap-2" - onClick={() => - router.push(`/${workspaceSlug}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`) - } + href={`/${workspaceSlug}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`} > <ModuleStatusIcon status={moduleDetails?.status ?? "backlog"} height="16px" width="16px" /> <h6 className="flex-grow truncate text-sm font-medium">{moduleDetails?.name}</h6> - </div> + </Link> ); }); diff --git a/web/components/modules/gantt-chart/modules-list-layout.tsx b/web/components/modules/gantt-chart/modules-list-layout.tsx index 0a9b433c5..6c680e1ca 100644 --- a/web/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/components/modules/gantt-chart/modules-list-layout.tsx @@ -11,10 +11,12 @@ import { IModule } from "@plane/types"; export const ModulesListGanttChartView: React.FC = observer(() => { // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; // store const { currentProjectDetails } = useProject(); - const { projectModuleIds, moduleMap, updateModuleDetails } = useModule(); + const { getFilteredModuleIds, moduleMap, updateModuleDetails } = useModule(); + // derived values + const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined; const handleModuleUpdate = async (module: IModule, data: IBlockUpdateData) => { if (!workspaceSlug || !module) return; @@ -44,7 +46,7 @@ export const ModulesListGanttChartView: React.FC = observer(() => { <GanttChartRoot title="Modules" loaderTitle="Modules" - blocks={projectModuleIds ? blockFormat(projectModuleIds) : null} + blocks={filteredModuleIds ? blockFormat(filteredModuleIds) : null} sidebarToRender={(props) => <ModuleGanttSidebar {...props} />} blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} blockToRender={(data: IModule) => <ModuleGanttBlock moduleId={data.id} />} diff --git a/web/components/modules/index.ts b/web/components/modules/index.ts index c87ea79d2..7bda973fa 100644 --- a/web/components/modules/index.ts +++ b/web/components/modules/index.ts @@ -1,3 +1,5 @@ +export * from "./applied-filters"; +export * from "./dropdowns"; export * from "./select"; export * from "./sidebar-select"; export * from "./delete-module-modal"; diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 4873e009c..8ef3fe024 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -12,6 +12,7 @@ import { EUserProjectRoles } from "constants/project"; import { renderFormattedDate } from "helpers/date-time.helper"; import { copyUrlToClipboard } from "helpers/string.helper"; import { useEventTracker, useMember, useModule, useUser } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // ui // helpers @@ -39,7 +40,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => { // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - + const { isMobile } = usePlatformOS(); const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); e.preventDefault(); @@ -179,7 +180,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => { <div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md"> <div> <div className="flex items-center justify-between gap-2"> - <Tooltip tooltipContent={moduleDetails.name} position="top"> + <Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}> <span className="truncate text-base font-medium">{moduleDetails.name}</span> </Tooltip> <div className="flex items-center gap-2"> @@ -208,7 +209,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => { <span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span> </div> {moduleDetails.member_ids?.length > 0 && ( - <Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`}> + <Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`} isMobile={isMobile}> <div className="flex cursor-default items-center gap-1"> <AvatarGroup showTooltip={false}> {moduleDetails.member_ids.map((member_id) => { @@ -222,6 +223,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => { </div> <Tooltip + isMobile={isMobile} tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`} position="top-left" > diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 7fe25b918..0b28712b0 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -21,6 +21,7 @@ import { EUserProjectRoles } from "constants/project"; import { renderFormattedDate } from "helpers/date-time.helper"; import { copyUrlToClipboard } from "helpers/string.helper"; import { useModule, useUser, useEventTracker, useMember } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // ui // helpers @@ -48,7 +49,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => { // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - + const { isMobile } = usePlatformOS(); const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); e.preventDefault(); @@ -194,7 +195,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => { )} </CircularProgressIndicator> </span> - <Tooltip tooltipContent={moduleDetails.name} position="top"> + <Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}> <span className="truncate text-base font-medium">{moduleDetails.name}</span> </Tooltip> </div> @@ -227,7 +228,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => { </div> <div className="relative flex flex-shrink-0 items-center gap-3"> - <Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`}> + <Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}> <div className="flex w-10 cursor-default items-center justify-center gap-1"> {moduleDetails.member_ids.length > 0 ? ( <AvatarGroup showTooltip={false}> diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index 78b4a6571..2998843e1 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -1,13 +1,16 @@ +import Image from "next/image"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks -import { useApplication, useEventTracker, useModule } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; +import { useApplication, useEventTracker, useModule, useModuleFilter } from "hooks/store"; // components import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules"; import { EmptyState } from "components/empty-state"; // ui import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui"; +// assets +import NameFilterImage from "public/empty-state/module/name-filter.svg"; +import AllFiltersImage from "public/empty-state/module/all-filters.svg"; // constants import { EmptyStateType } from "constants/empty-state"; @@ -18,29 +21,48 @@ export const ModulesListView: React.FC = observer(() => { // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); + const { getFilteredModuleIds, loader } = useModule(); + const { currentProjectDisplayFilters: displayFilters, searchQuery } = useModuleFilter(); + // derived values + const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined; - const { projectModuleIds, loader } = useModule(); - - const { storedValue: modulesView } = useLocalStorage("modules_view", "grid"); - - if (loader || !projectModuleIds) + if (loader || !filteredModuleIds) return ( <> - {modulesView === "list" && <CycleModuleListLayout />} - {modulesView === "grid" && <CycleModuleBoardLayout />} - {modulesView === "gantt_chart" && <GanttLayoutLoader />} + {displayFilters?.layout === "list" && <CycleModuleListLayout />} + {displayFilters?.layout === "board" && <CycleModuleBoardLayout />} + {displayFilters?.layout === "gantt" && <GanttLayoutLoader />} </> ); + if (filteredModuleIds.length === 0) + return ( + <div className="h-full w-full grid place-items-center"> + <div className="text-center"> + <Image + src={searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage} + className="h-36 sm:h-48 w-36 sm:w-48 mx-auto" + alt="No matching modules" + /> + <h5 className="text-xl font-medium mt-7 mb-1">No matching modules</h5> + <p className="text-custom-text-400 text-base"> + {searchQuery.trim() === "" + ? "Remove the filters to see all modules" + : "Remove the search criteria to see all modules"} + </p> + </div> + </div> + ); + return ( <> - {projectModuleIds.length > 0 ? ( + {filteredModuleIds.length > 0 ? ( <> - {modulesView === "list" && ( + {displayFilters?.layout === "list" && ( <div className="h-full overflow-y-auto"> <div className="flex h-full w-full justify-between"> <div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg"> - {projectModuleIds.map((moduleId) => ( + {filteredModuleIds.map((moduleId) => ( <ModuleListItem key={moduleId} moduleId={moduleId} /> ))} </div> @@ -51,7 +73,7 @@ export const ModulesListView: React.FC = observer(() => { </div> </div> )} - {modulesView === "grid" && ( + {displayFilters?.layout === "board" && ( <div className="h-full w-full"> <div className="flex h-full w-full justify-between"> <div @@ -61,7 +83,7 @@ export const ModulesListView: React.FC = observer(() => { : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" } auto-rows-max transition-all vertical-scrollbar scrollbar-lg`} > - {projectModuleIds.map((moduleId) => ( + {filteredModuleIds.map((moduleId) => ( <ModuleCardItem key={moduleId} moduleId={moduleId} /> ))} </div> @@ -72,7 +94,7 @@ export const ModulesListView: React.FC = observer(() => { </div> </div> )} - {modulesView === "gantt_chart" && <ModulesListGanttChartView />} + {displayFilters?.layout === "gantt" && <ModulesListGanttChartView />} </> ) : ( <EmptyState diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index 0e4904a7e..5535b4160 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -15,6 +15,7 @@ import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "help import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper"; // hooks import { useEventTracker } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // type import type { IUserNotification, NotificationType } from "@plane/types"; @@ -44,7 +45,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => { } = props; // store hooks const { captureEvent } = useEventTracker(); - + const { isMobile } = usePlatformOS(); const router = useRouter(); const { workspaceSlug } = router.query; // states @@ -358,7 +359,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => { }, }, ].map((item) => ( - <Tooltip tooltipContent={item.name} key={item.id}> + <Tooltip tooltipContent={item.name} key={item.id} isMobile={isMobile}> <button type="button" onClick={(e) => { @@ -373,7 +374,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => { </button> </Tooltip> ))} - <Tooltip tooltipContent="Snooze"> + <Tooltip tooltipContent="Snooze" isMobile={isMobile}> <CustomMenu className="flex items-center" customButton={ diff --git a/web/components/notifications/notification-header.tsx b/web/components/notifications/notification-header.tsx index ffe57fcbc..69a1086f9 100644 --- a/web/components/notifications/notification-header.tsx +++ b/web/components/notifications/notification-header.tsx @@ -13,6 +13,7 @@ import { } from "constants/event-tracker"; import { getNumberCount } from "helpers/string.helper"; import { useEventTracker } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // helpers // type import type { NotificationType, NotificationCount } from "@plane/types"; @@ -52,6 +53,8 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) => } = props; // store hooks const { captureEvent } = useEventTracker(); + // hooks + const { isMobile } = usePlatformOS(); const notificationTabs: Array<{ label: string; @@ -84,7 +87,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) => </div> <div className="flex items-center justify-center gap-x-4 text-custom-text-200"> - <Tooltip tooltipContent="Refresh"> + <Tooltip tooltipContent="Refresh" isMobile={isMobile}> <button type="button" onClick={() => { @@ -94,7 +97,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) => <RefreshCw className={`h-3.5 w-3.5 ${isRefreshing ? "animate-spin" : ""}`} /> </button> </Tooltip> - <Tooltip tooltipContent="Unread notifications"> + <Tooltip tooltipContent="Unread notifications" isMobile={isMobile}> <button type="button" onClick={() => { @@ -154,7 +157,7 @@ export const NotificationHeader: React.FC<NotificationHeaderProps> = (props) => </CustomMenu.MenuItem> </CustomMenu> <div className="hidden md:block"> - <Tooltip tooltipContent="Close"> + <Tooltip tooltipContent="Close" isMobile={isMobile}> <button type="button" onClick={() => closePopover()}> <X className="h-3.5 w-3.5" /> </button> diff --git a/web/components/notifications/notification-popover.tsx b/web/components/notifications/notification-popover.tsx index d7aa1b07d..c3e508688 100644 --- a/web/components/notifications/notification-popover.tsx +++ b/web/components/notifications/notification-popover.tsx @@ -11,6 +11,7 @@ import { getNumberCount } from "helpers/string.helper"; import { useApplication } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useUserNotification from "hooks/use-user-notifications"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // images import emptyNotification from "public/empty-state/notification.svg"; @@ -23,6 +24,8 @@ export const NotificationPopover = observer(() => { const { theme: themeStore } = useApplication(); // refs const notificationPopoverRef = React.useRef<HTMLDivElement | null>(null); + // hooks + const { isMobile } = usePlatformOS(); const { notifications, @@ -67,7 +70,7 @@ export const NotificationPopover = observer(() => { /> <Popover ref={notificationPopoverRef} className="md:relative w-full"> <> - <Tooltip tooltipContent="Notifications" position="right" className="ml-2" disabled={!isSidebarCollapsed}> + <Tooltip tooltipContent="Notifications" position="right" className="ml-2" disabled={!isSidebarCollapsed} isMobile={isMobile}> <button className={`group relative flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${ isActive diff --git a/web/components/pages/page-form.tsx b/web/components/pages/page-form.tsx index 97e881096..a48489e0a 100644 --- a/web/components/pages/page-form.tsx +++ b/web/components/pages/page-form.tsx @@ -6,6 +6,7 @@ import { Button, Input, Tooltip } from "@plane/ui"; import { PAGE_ACCESS_SPECIFIERS } from "constants/page"; import { IPageStore } from "store/page.store"; import { IPage } from "@plane/types"; +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { handleFormSubmit: (values: IPage) => Promise<void>; @@ -31,7 +32,7 @@ export const PageForm: React.FC<Props> = (props) => { ? { name: pageStore.name, description: pageStore.description, access: pageStore.access } : defaultValues, }); - + const { isMobile } = usePlatformOS(); const handleCreateUpdatePage = (formData: IPage) => handleFormSubmit(formData); return ( @@ -75,7 +76,7 @@ export const PageForm: React.FC<Props> = (props) => { <div className="flex items-center gap-2"> <div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1"> {PAGE_ACCESS_SPECIFIERS.map((access, index) => ( - <Tooltip key={access.key} tooltipContent={access.label}> + <Tooltip key={access.key} tooltipContent={access.label} isMobile={isMobile}> <button type="button" onClick={() => onChange(access.key)} diff --git a/web/components/pages/pages-list/list-item.tsx b/web/components/pages/pages-list/list-item.tsx index d4cb3c023..d9005b4d8 100644 --- a/web/components/pages/pages-list/list-item.tsx +++ b/web/components/pages/pages-list/list-item.tsx @@ -22,8 +22,10 @@ import { CreateUpdatePageModal, DeletePageModal } from "components/pages"; import { EUserProjectRoles } from "constants/project"; import { renderFormattedTime, renderFormattedDate } from "helpers/date-time.helper"; import { copyUrlToClipboard } from "helpers/string.helper"; +// hooks import { useMember, usePage, useUser } from "hooks/store"; import { useProjectPages } from "hooks/store/use-project-specific-pages"; +import { usePlatformOS } from "hooks/use-platform-os"; import { IIssueLabel } from "@plane/types"; export interface IPagesListItem { @@ -44,7 +46,7 @@ export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId } const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false); const [deletePageModal, setDeletePageModal] = useState(false); - + const { isMobile } = usePlatformOS(); const { currentUser, membership: { currentProjectRole }, @@ -182,6 +184,7 @@ export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId } <div className="flex items-center gap-2.5"> {archived_at ? ( <Tooltip + isMobile={isMobile} tooltipContent={`Archived at ${renderFormattedTime(archived_at)} on ${renderFormattedDate( archived_at )}`} @@ -190,6 +193,7 @@ export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId } </Tooltip> ) : ( <Tooltip + isMobile={isMobile} tooltipContent={`Last updated at ${renderFormattedTime(updated_at)} on ${renderFormattedDate( updated_at )}`} @@ -198,7 +202,10 @@ export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId } </Tooltip> )} {isEditingAllowed && ( - <Tooltip tooltipContent={`${is_favorite ? "Remove from favorites" : "Mark as favorite"}`}> + <Tooltip + tooltipContent={`${is_favorite ? "Remove from favorites" : "Mark as favorite"}`} + isMobile={isMobile} + > {is_favorite ? ( <button type="button" onClick={handleRemoveFromFavorites}> <Star className="h-3.5 w-3.5 fill-orange-400 text-orange-400" /> @@ -212,6 +219,7 @@ export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId } )} {userCanChangeAccess && ( <Tooltip + isMobile={isMobile} tooltipContent={`${ access ? "This page is only visible to you" : "This page can be viewed by anyone in the project" }`} @@ -228,6 +236,7 @@ export const PagesListItem: FC<IPagesListItem> = observer(({ pageId, projectId } </Tooltip> )} <Tooltip + isMobile={isMobile} position="top-right" tooltipContent={`Created by ${ownerDetails?.member?.display_name} on ${renderFormattedDate( created_at diff --git a/web/components/pages/pages-list/list-view.tsx b/web/components/pages/pages-list/list-view.tsx index 8c1a09e73..6b363b2f2 100644 --- a/web/components/pages/pages-list/list-view.tsx +++ b/web/components/pages/pages-list/list-view.tsx @@ -29,7 +29,7 @@ export const PagesListView: FC<IPagesListView> = (props) => { // here we are only observing the projectPageStore, so that we can re-render the component when the projectPageStore changes - const emptyStateType = pageTab ? `project-page-${pageTab}` : EmptyStateType.PROJECT_PAGE_ALL; + const emptyStateType = pageTab ? `project-page-${pageTab.toLowerCase()}` : EmptyStateType.PROJECT_PAGE_ALL; const isButtonVisible = pageTab !== "archived" && pageTab !== "favorites"; return ( diff --git a/web/components/profile/sidebar.tsx b/web/components/profile/sidebar.tsx index 4cab1a9f1..ef75abb29 100644 --- a/web/components/profile/sidebar.tsx +++ b/web/components/profile/sidebar.tsx @@ -16,6 +16,7 @@ import { renderFormattedDate } from "helpers/date-time.helper"; // hooks import { useApplication, useProject, useUser } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // services import { UserService } from "services/user.service"; // components @@ -35,7 +36,7 @@ export const ProfileSidebar = observer(() => { const { currentUser } = useUser(); const { theme: themeStore } = useApplication(); const { getProjectById } = useProject(); - + const { isMobile } = usePlatformOS(); const { data: userProjectsData } = useSWR( workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null, workspaceSlug && userId @@ -158,7 +159,7 @@ export const ProfileSidebar = observer(() => { </div> <div className="flex flex-shrink-0 items-center gap-2"> {project.assigned_issues > 0 && ( - <Tooltip tooltipContent="Completion percentage" position="left"> + <Tooltip tooltipContent="Completion percentage" position="left" isMobile={isMobile}> <div className={`rounded px-1 py-0.5 text-xs font-medium ${ completedIssuePercentage <= 35 diff --git a/web/components/project/applied-filters/access.tsx b/web/components/project/applied-filters/access.tsx new file mode 100644 index 000000000..bdb6ec053 --- /dev/null +++ b/web/components/project/applied-filters/access.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// constants +import { NETWORK_CHOICES } from "constants/project"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedAccessFilters: React.FC<Props> = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const accessDetails = NETWORK_CHOICES.find((s) => `${s.key}` === status); + return ( + <div key={status} className="flex items-center gap-1 rounded p-1 text-xs bg-custom-background-80"> + {accessDetails?.label} + {editable && ( + <button + type="button" + className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" + onClick={() => handleRemove(status)} + > + <X size={10} strokeWidth={2} /> + </button> + )} + </div> + ); + })} + </> + ); +}); diff --git a/web/components/project/applied-filters/date.tsx b/web/components/project/applied-filters/date.tsx new file mode 100644 index 000000000..aab0cf98a --- /dev/null +++ b/web/components/project/applied-filters/date.tsx @@ -0,0 +1,55 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; +import { capitalizeFirstLetter } from "helpers/string.helper"; +// constants +import { DATE_BEFORE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + editable: boolean | undefined; + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedDateFilters: React.FC<Props> = observer((props) => { + const { editable, handleRemove, values } = props; + + const getDateLabel = (value: string): string => { + let dateLabel = ""; + + const dateDetails = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === value); + + if (dateDetails) dateLabel = dateDetails.name; + else { + const dateParts = value.split(";"); + + if (dateParts.length === 2) { + const [date, time] = dateParts; + + dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`; + } + } + + return dateLabel; + }; + + return ( + <> + {values.map((date) => ( + <div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs"> + <span className="normal-case">{getDateLabel(date)}</span> + {editable && ( + <button + type="button" + className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" + onClick={() => handleRemove(date)} + > + <X size={10} strokeWidth={2} /> + </button> + )} + </div> + ))} + </> + ); +}); diff --git a/web/components/project/applied-filters/index.ts b/web/components/project/applied-filters/index.ts new file mode 100644 index 000000000..818aa6134 --- /dev/null +++ b/web/components/project/applied-filters/index.ts @@ -0,0 +1,4 @@ +export * from "./access"; +export * from "./date"; +export * from "./members"; +export * from "./root"; diff --git a/web/components/project/applied-filters/members.tsx b/web/components/project/applied-filters/members.tsx new file mode 100644 index 000000000..88f18ee0c --- /dev/null +++ b/web/components/project/applied-filters/members.tsx @@ -0,0 +1,46 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// ui +import { Avatar } from "@plane/ui"; +// types +import { useMember } from "hooks/store"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedMembersFilters: React.FC<Props> = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + return ( + <> + {values.map((memberId) => { + const memberDetails = getWorkspaceMemberDetails(memberId)?.member; + + if (!memberDetails) return null; + + return ( + <div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs"> + <Avatar name={memberDetails.display_name} src={memberDetails.avatar} showTooltip={false} /> + <span className="normal-case">{memberDetails.display_name}</span> + {editable && ( + <button + type="button" + className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" + onClick={() => handleRemove(memberId)} + > + <X size={10} strokeWidth={2} /> + </button> + )} + </div> + ); + })} + </> + ); +}); diff --git a/web/components/project/applied-filters/root.tsx b/web/components/project/applied-filters/root.tsx new file mode 100644 index 000000000..6e1cbf6a7 --- /dev/null +++ b/web/components/project/applied-filters/root.tsx @@ -0,0 +1,113 @@ +import { X } from "lucide-react"; +// components +import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "components/project"; +// ui +import { Tooltip } from "@plane/ui"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TProjectFilters } from "@plane/types"; + +type Props = { + appliedFilters: TProjectFilters; + handleClearAllFilters: () => void; + handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void; + alwaysAllowEditing?: boolean; + filteredProjects: number; + totalProjects: number; +}; + +const MEMBERS_FILTERS = ["lead", "members"]; +const DATE_FILTERS = ["created_at"]; + +export const ProjectAppliedFiltersList: React.FC<Props> = (props) => { + const { + appliedFilters, + handleClearAllFilters, + handleRemoveFilter, + alwaysAllowEditing, + filteredProjects, + totalProjects, + } = props; + + if (!appliedFilters) return null; + if (Object.keys(appliedFilters).length === 0) return null; + + const isEditingAllowed = alwaysAllowEditing; + + return ( + <div className="flex items-start justify-between gap-1.5"> + <div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100"> + {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TProjectFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( + <div + key={filterKey} + className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize" + > + <div className="flex flex-wrap items-center gap-1.5"> + <span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span> + {filterKey === "access" && ( + <AppliedAccessFilters + editable={isEditingAllowed} + handleRemove={(val) => handleRemoveFilter("access", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + <AppliedDateFilters + editable={isEditingAllowed} + handleRemove={(val) => handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {MEMBERS_FILTERS.includes(filterKey) && ( + <AppliedMembersFilters + editable={isEditingAllowed} + handleRemove={(val) => handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + <button + type="button" + className="grid place-items-center text-custom-text-300 hover:text-custom-text-200" + onClick={() => handleRemoveFilter(filterKey, null)} + > + <X size={12} strokeWidth={2} /> + </button> + )} + </div> + </div> + ); + })} + {isEditingAllowed && ( + <button + type="button" + onClick={handleClearAllFilters} + className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200" + > + Clear all + <X size={12} strokeWidth={2} /> + </button> + )} + </div> + <Tooltip + tooltipContent={ + <p> + <span className="font-semibold">{filteredProjects}</span> of{" "} + <span className="font-semibold">{totalProjects}</span> projects match the applied filters. + </p> + } + > + <span className="bg-custom-background-80 rounded-full text-sm font-medium py-1 px-2.5"> + {filteredProjects}/{totalProjects} + </span> + </Tooltip> + </div> + ); +}; diff --git a/web/components/project/card-list.tsx b/web/components/project/card-list.tsx index df63dfb73..3f23ed9a2 100644 --- a/web/components/project/card-list.tsx +++ b/web/components/project/card-list.tsx @@ -1,10 +1,14 @@ +import Image from "next/image"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useEventTracker, useProject } from "hooks/store"; +import { useApplication, useEventTracker, useProject, useProjectFilter } from "hooks/store"; // components import { EmptyState } from "components/empty-state"; import { ProjectCard } from "components/project"; import { ProjectsLoader } from "components/ui"; +// assets +import AllFiltersImage from "public/empty-state/project/all-filters.svg"; +import NameFilterImage from "public/empty-state/project/name-filter.svg"; // constants import { EmptyStateType } from "constants/empty-state"; @@ -12,38 +16,49 @@ export const ProjectCardList = observer(() => { // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); + const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject(); + const { searchQuery } = useProjectFilter(); - const { workspaceProjectIds, searchedProjects, getProjectById } = useProject(); + if (!filteredProjectIds) return <ProjectsLoader />; - if (!workspaceProjectIds) return <ProjectsLoader />; + if (workspaceProjectIds?.length === 0) + return ( + <EmptyState + type={EmptyStateType.WORKSPACE_PROJECTS} + primaryButtonOnClick={() => { + setTrackElement("Project empty state"); + commandPaletteStore.toggleCreateProjectModal(true); + }} + /> + ); + if (filteredProjectIds.length === 0) + return ( + <div className="h-full w-full grid place-items-center"> + <div className="text-center"> + <Image + src={searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage} + className="h-36 sm:h-48 w-36 sm:w-48 mx-auto" + alt="No matching projects" + /> + <h5 className="text-xl font-medium mt-7 mb-1">No matching projects</h5> + <p className="text-custom-text-400 text-base whitespace-pre-line"> + {searchQuery.trim() === "" + ? "Remove the filters to see all projects" + : "No projects detected with the matching\ncriteria. Create a new project instead"} + </p> + </div> + </div> + ); return ( - <> - {workspaceProjectIds.length > 0 ? ( - <div className="h-full w-full overflow-y-auto p-8 vertical-scrollbar scrollbar-lg"> - {searchedProjects.length == 0 ? ( - <div className="mt-10 w-full text-center text-custom-text-400">No matching projects</div> - ) : ( - <div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3"> - {searchedProjects.map((projectId) => { - const projectDetails = getProjectById(projectId); - - if (!projectDetails) return; - - return <ProjectCard key={projectDetails.id} project={projectDetails} />; - })} - </div> - )} - </div> - ) : ( - <EmptyState - type={EmptyStateType.WORKSPACE_PROJECTS} - primaryButtonOnClick={() => { - setTrackElement("Project empty state"); - commandPaletteStore.toggleCreateProjectModal(true); - }} - /> - )} - </> + <div className="h-full w-full overflow-y-auto p-8 vertical-scrollbar scrollbar-lg"> + <div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3"> + {filteredProjectIds.map((projectId) => { + const projectDetails = getProjectById(projectId); + if (!projectDetails) return; + return <ProjectCard key={projectDetails.id} project={projectDetails} />; + })} + </div> + </div> ); }); diff --git a/web/components/project/card.tsx b/web/components/project/card.tsx index 08aec43fa..976e7c896 100644 --- a/web/components/project/card.tsx +++ b/web/components/project/card.tsx @@ -2,36 +2,43 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { LinkIcon, Lock, Pencil, Star } from "lucide-react"; +import { Check, LinkIcon, Lock, Pencil, Star } from "lucide-react"; // ui import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; // components import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "components/project"; // helpers -import { copyTextToClipboard } from "helpers/string.helper"; +import { copyUrlToClipboard } from "helpers/string.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // hooks import { useProject } from "hooks/store"; // types import type { IProject } from "@plane/types"; -import { EUserProjectRoles } from "constants/project"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; // constants +import { EUserProjectRoles } from "constants/project"; -export type ProjectCardProps = { +type Props = { project: IProject; }; -export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { +export const ProjectCard: React.FC<Props> = observer((props) => { const { project } = props; - // router - const router = useRouter(); - const { workspaceSlug } = router.query; // states const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); const [joinProjectModalOpen, setJoinProjectModal] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug } = router.query; // store hooks const { addProjectToFavorites, removeProjectFromFavorites } = useProject(); - + // hooks + const { isMobile } = usePlatformOS(); project.member_role; + // derived values + const projectMembersIds = project.members?.map((member) => member.member_id); + // auth const isOwner = project.member_role === EUserProjectRoles.ADMIN; const isMember = project.member_role === EUserProjectRoles.MEMBER; @@ -53,7 +60,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { }; const handleRemoveFromFavorites = () => { - if (!workspaceSlug || !project) return; + if (!workspaceSlug) return; const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id); setPromiseToast(removeFromFavoritePromise, { @@ -69,23 +76,18 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { }); }; - const handleCopyText = () => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${project.id}/issues`).then(() => { + const handleCopyText = () => + copyUrlToClipboard(`${workspaceSlug}/projects/${project.id}/issues`).then(() => setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Project link copied to clipboard.", - }); - }); - }; - - const projectMembersIds = project.members?.map((member) => member.member_id); + }) + ); return ( <> - {/* Delete Project Modal */} + {/* Delete Project Modal */} <DeleteProjectModal project={project} isOpen={deleteProjectModalOpen} @@ -94,20 +96,22 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { {/* Join Project Modal */} {workspaceSlug && ( <JoinProjectModal - workspaceSlug={workspaceSlug?.toString()} + workspaceSlug={workspaceSlug.toString()} project={project} isOpen={joinProjectModalOpen} handleClose={() => setJoinProjectModal(false)} /> )} - - {/* Card Information */} - <div - onClick={() => { - if (project.is_member) router.push(`/${workspaceSlug?.toString()}/projects/${project.id}/issues`); - else setJoinProjectModal(true); + <Link + href={`/${workspaceSlug}/projects/${project.id}/issues`} + onClick={(e) => { + if (!project.is_member) { + e.preventDefault(); + e.stopPropagation(); + setJoinProjectModal(true); + } }} - className="flex cursor-pointer flex-col rounded border border-custom-border-200 bg-custom-background-100" + className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100" > <div className="relative h-[118px] w-full rounded-t "> <div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" /> @@ -121,12 +125,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { className="absolute left-0 top-0 h-full w-full rounded-t object-cover" /> - <div className="absolute bottom-4 z-10 flex h-10 w-full items-center justify-between gap-3 px-4"> + <div className="absolute bottom-4 z-[1] flex h-10 w-full items-center justify-between gap-3 px-4"> <div className="flex flex-grow items-center gap-2.5 truncate"> - <div className="flex item-center justify-center h-9 w-9 flex-shrink-0 rounded bg-white/90"> - <span className="grid place-items-center"> - <ProjectLogo logo={project.logo_props} /> - </span> + <div className="h-9 w-9 flex-shrink-0 grid place-items-center rounded bg-white/90"> + <ProjectLogo logo={project.logo_props} /> </div> <div className="flex w-full flex-col justify-between gap-0.5 truncate"> @@ -152,15 +154,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { <button className="flex h-6 w-6 items-center justify-center rounded bg-white/10" onClick={(e) => { - if (project.is_favorite) { - e.preventDefault(); - e.stopPropagation(); - handleRemoveFromFavorites(); - } else { - e.preventDefault(); - e.stopPropagation(); - handleAddToFavorites(); - } + e.preventDefault(); + e.stopPropagation(); + if (project.is_favorite) handleRemoveFromFavorites(); + else handleAddToFavorites(); }} > <Star @@ -172,9 +169,14 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { </div> <div className="flex h-[104px] w-full flex-col justify-between rounded-b p-4"> - <p className="line-clamp-2 break-words text-sm text-custom-text-300">{project.description}</p> + <p className="line-clamp-2 break-words text-sm text-custom-text-300"> + {project.description && project.description.trim() !== "" + ? project.description + : `Created on ${renderFormattedDate(project.created_at)}`} + </p> <div className="item-center flex justify-between"> <Tooltip + isMobile={isMobile} tooltipHeading="Members" tooltipContent={ project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member" @@ -197,19 +199,24 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { <span className="text-sm italic text-custom-text-400">No Member Yet</span> )} </Tooltip> - {(isOwner || isMember) && ( - <Link - className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200" - onClick={(e) => { - e.stopPropagation(); - }} - href={`/${workspaceSlug}/projects/${project.id}/settings`} - > - <Pencil className="h-3.5 w-3.5" /> - </Link> - )} - - {!project.is_member ? ( + {project.is_member && + (isOwner || isMember ? ( + <Link + className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200" + onClick={(e) => { + e.stopPropagation(); + }} + href={`/${workspaceSlug}/projects/${project.id}/settings`} + > + <Pencil className="h-3.5 w-3.5" /> + </Link> + ) : ( + <span className="flex items-center gap-1 text-custom-text-400 text-sm"> + <Check className="h-3.5 w-3.5" /> + Joined + </span> + ))} + {!project.is_member && ( <div className="flex items-center"> <Button variant="link-primary" @@ -223,10 +230,10 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => { Join </Button> </div> - ) : null} + )} </div> </div> - </div> + </Link> </> ); }); diff --git a/web/components/project/create-project-form.tsx b/web/components/project/create-project-form.tsx index 509cf310c..694bd8185 100644 --- a/web/components/project/create-project-form.tsx +++ b/web/components/project/create-project-form.tsx @@ -27,6 +27,7 @@ import { cn } from "helpers/common.helper"; import { projectIdentifierSanitizer } from "helpers/project.helper"; // hooks import { useEventTracker, useProject } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // types import { IProject } from "@plane/types"; @@ -71,7 +72,7 @@ export const CreateProjectForm: FC<Props> = observer((props) => { defaultValues, reValidateMode: "onChange", }); - + const { isMobile } = usePlatformOS(); const handleAddToFavorites = (projectId: string) => { if (!workspaceSlug) return; @@ -283,6 +284,7 @@ export const CreateProjectForm: FC<Props> = observer((props) => { )} /> <Tooltip + isMobile={isMobile} tooltipContent="Helps you identify issues in the project uniquely, (e.g. APP-123). Max 5 characters." className="text-sm" position="right-top" diff --git a/web/components/project/dropdowns/filters/access.tsx b/web/components/project/dropdowns/filters/access.tsx new file mode 100644 index 000000000..63303872b --- /dev/null +++ b/web/components/project/dropdowns/filters/access.tsx @@ -0,0 +1,48 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { NETWORK_CHOICES } from "constants/project"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterAccess: React.FC<Props> = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = NETWORK_CHOICES.filter((a) => a.label.includes(searchQuery.toLowerCase())); + + return ( + <> + <FilterHeader + title={`Access${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {filteredOptions.length > 0 ? ( + filteredOptions.map((access) => ( + <FilterOption + key={access.key} + isChecked={appliedFilters?.includes(`${access.key}`) ? true : false} + onClick={() => handleUpdate(`${access.key}`)} + icon={<access.icon className="h-3 w-3" />} + title={access.label} + /> + )) + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/project/dropdowns/filters/created-at.tsx b/web/components/project/dropdowns/filters/created-at.tsx new file mode 100644 index 000000000..3867ab148 --- /dev/null +++ b/web/components/project/dropdowns/filters/created-at.tsx @@ -0,0 +1,64 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_BEFORE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterCreatedDate: React.FC<Props> = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <> + {isDateFilterModalOpen && ( + <DateFilterModal + handleClose={() => setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Created date" + /> + )} + <FilterHeader + title={`Created date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + <FilterOption + key={option.value} + isChecked={appliedFilters?.includes(option.value) ? true : false} + onClick={() => handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + <FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple /> + </> + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/project/dropdowns/filters/index.ts b/web/components/project/dropdowns/filters/index.ts new file mode 100644 index 000000000..c04162e57 --- /dev/null +++ b/web/components/project/dropdowns/filters/index.ts @@ -0,0 +1,5 @@ +export * from "./access"; +export * from "./created-at"; +export * from "./lead"; +export * from "./members"; +export * from "./root"; diff --git a/web/components/project/dropdowns/filters/lead.tsx b/web/components/project/dropdowns/filters/lead.tsx new file mode 100644 index 000000000..02c257b9b --- /dev/null +++ b/web/components/project/dropdowns/filters/lead.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterLead: React.FC<Props> = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + <FilterHeader + title={`Lead${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + <FilterOption + key={`lead-${member.id}`} + isChecked={appliedFilters?.includes(member.id) ? true : false} + onClick={() => handleUpdate(member.id)} + icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + <button + type="button" + className="ml-8 text-xs font-medium text-custom-primary-100" + onClick={handleViewToggle} + > + {itemsToRender === sortedOptions.length ? "View less" : "View all"} + </button> + )} + </> + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + ) + ) : ( + <Loader className="space-y-2"> + <Loader.Item height="20px" /> + <Loader.Item height="20px" /> + <Loader.Item height="20px" /> + </Loader> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/project/dropdowns/filters/members.tsx b/web/components/project/dropdowns/filters/members.tsx new file mode 100644 index 000000000..0d2737227 --- /dev/null +++ b/web/components/project/dropdowns/filters/members.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterMembers: React.FC<Props> = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + <FilterHeader + title={`Members${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( + <div> + {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + <FilterOption + key={`member-${member.id}`} + isChecked={appliedFilters?.includes(member.id) ? true : false} + onClick={() => handleUpdate(member.id)} + icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + <button + type="button" + className="ml-8 text-xs font-medium text-custom-primary-100" + onClick={handleViewToggle} + > + {itemsToRender === sortedOptions.length ? "View less" : "View all"} + </button> + )} + </> + ) : ( + <p className="text-xs italic text-custom-text-400">No matches found</p> + ) + ) : ( + <Loader className="space-y-2"> + <Loader.Item height="20px" /> + <Loader.Item height="20px" /> + <Loader.Item height="20px" /> + </Loader> + )} + </div> + )} + </> + ); +}); diff --git a/web/components/project/dropdowns/filters/root.tsx b/web/components/project/dropdowns/filters/root.tsx new file mode 100644 index 000000000..e79fc8418 --- /dev/null +++ b/web/components/project/dropdowns/filters/root.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterAccess, FilterCreatedDate, FilterLead, FilterMembers } from "components/project"; +// types +import { TProjectDisplayFilters, TProjectFilters } from "@plane/types"; +import { FilterOption } from "components/issues"; + +type Props = { + displayFilters: TProjectDisplayFilters; + filters: TProjectFilters; + handleFiltersUpdate: (key: keyof TProjectFilters, value: string | string[]) => void; + handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TProjectDisplayFilters>) => void; + memberIds?: string[] | undefined; +}; + +export const ProjectFiltersSelection: React.FC<Props> = observer((props) => { + const { displayFilters, filters, handleFiltersUpdate, handleDisplayFiltersUpdate, memberIds } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( + <div className="flex h-full w-full flex-col overflow-hidden"> + <div className="bg-custom-background-100 p-2.5 pb-0"> + <div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs"> + <Search className="text-custom-text-400" size={12} strokeWidth={2} /> + <input + type="text" + className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400" + placeholder="Search" + value={filtersSearchQuery} + onChange={(e) => setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + <button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}> + <X className="text-custom-text-300" size={12} strokeWidth={2} /> + </button> + )} + </div> + </div> + <div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm"> + <div className="py-2"> + <FilterOption + isChecked={!!displayFilters.my_projects} + onClick={() => + handleDisplayFiltersUpdate({ + my_projects: !displayFilters.my_projects, + }) + } + title="My projects" + /> + </div> + + {/* access */} + <div className="py-2"> + <FilterAccess + appliedFilters={filters.access ?? null} + handleUpdate={(val) => handleFiltersUpdate("access", val)} + searchQuery={filtersSearchQuery} + /> + </div> + + {/* lead */} + <div className="py-2"> + <FilterLead + appliedFilters={filters.lead ?? null} + handleUpdate={(val) => handleFiltersUpdate("lead", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> + </div> + + {/* members */} + <div className="py-2"> + <FilterMembers + appliedFilters={filters.members ?? null} + handleUpdate={(val) => handleFiltersUpdate("members", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> + </div> + + {/* created date */} + <div className="py-2"> + <FilterCreatedDate + appliedFilters={filters.created_at ?? null} + handleUpdate={(val) => handleFiltersUpdate("created_at", val)} + searchQuery={filtersSearchQuery} + /> + </div> + </div> + </div> + ); +}); diff --git a/web/components/project/dropdowns/index.ts b/web/components/project/dropdowns/index.ts new file mode 100644 index 000000000..f6c42552f --- /dev/null +++ b/web/components/project/dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./filters"; +export * from "./order-by"; diff --git a/web/components/project/dropdowns/order-by.tsx b/web/components/project/dropdowns/order-by.tsx new file mode 100644 index 000000000..ceb61997e --- /dev/null +++ b/web/components/project/dropdowns/order-by.tsx @@ -0,0 +1,74 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react"; +// ui +import { CustomMenu, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TProjectOrderByOptions } from "@plane/types"; +// constants +import { PROJECT_ORDER_BY_OPTIONS } from "constants/project"; + +type Props = { + onChange: (value: TProjectOrderByOptions) => void; + value: TProjectOrderByOptions | undefined; +}; + +const DISABLED_ORDERING_OPTIONS = ["sort_order"]; + +export const ProjectOrderByDropdown: React.FC<Props> = (props) => { + const { onChange, value } = props; + + const orderByDetails = PROJECT_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key)); + + const isDescending = value?.[0] === "-"; + const isOrderingDisabled = !!value && DISABLED_ORDERING_OPTIONS.includes(value); + + return ( + <CustomMenu + customButton={ + <div className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}> + <ArrowDownWideNarrow className="h-3 w-3" /> + {orderByDetails?.label} + <ChevronDown className="h-3 w-3" strokeWidth={2} /> + </div> + } + placement="bottom-end" + closeOnSelect + > + {PROJECT_ORDER_BY_OPTIONS.map((option) => ( + <CustomMenu.MenuItem + key={option.key} + className="flex items-center justify-between gap-2" + onClick={() => { + if (isDescending) onChange(`-${option.key}` as TProjectOrderByOptions); + else onChange(option.key); + }} + > + {option.label} + {value?.includes(option.key) && <Check className="h-3 w-3" />} + </CustomMenu.MenuItem> + ))} + <hr className="my-2" /> + <CustomMenu.MenuItem + className="flex items-center justify-between gap-2" + onClick={() => { + if (isDescending) onChange(value.slice(1) as TProjectOrderByOptions); + }} + disabled={isOrderingDisabled} + > + Ascending + {!isOrderingDisabled && !isDescending && <Check className="h-3 w-3" />} + </CustomMenu.MenuItem> + <CustomMenu.MenuItem + className="flex items-center justify-between gap-2" + onClick={() => { + if (!isDescending) onChange(`-${value}` as TProjectOrderByOptions); + }} + disabled={isOrderingDisabled} + > + Descending + {!isOrderingDisabled && isDescending && <Check className="h-3 w-3" />} + </CustomMenu.MenuItem> + </CustomMenu> + ); +}; diff --git a/web/components/project/index.ts b/web/components/project/index.ts index 6dedb63d4..db51bc284 100644 --- a/web/components/project/index.ts +++ b/web/components/project/index.ts @@ -1,3 +1,5 @@ +export * from "./applied-filters"; +export * from "./dropdowns"; export * from "./publish-project"; export * from "./settings"; export * from "./card-list"; diff --git a/web/components/project/member-list-item.tsx b/web/components/project/member-list-item.tsx index 55b1b3c9a..10d27efbf 100644 --- a/web/components/project/member-list-item.tsx +++ b/web/components/project/member-list-item.tsx @@ -14,6 +14,7 @@ import { EUserProjectRoles } from "constants/project"; import { ROLE } from "constants/workspace"; // hooks import { useEventTracker, useMember, useProject, useUser } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { userId: string; @@ -36,7 +37,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => { project: { removeMemberFromProject, getProjectMemberDetails, updateMember }, } = useMember(); const { captureEvent } = useEventTracker(); - + const { isMobile } = usePlatformOS(); // derived values const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; const userDetails = getProjectMemberDetails(userId); @@ -171,7 +172,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => { })} </CustomSelect> {(isAdmin || userDetails.member?.id === currentUser?.id) && ( - <Tooltip tooltipContent={userDetails.member?.id === currentUser?.id ? "Leave project" : "Remove member"}> + <Tooltip tooltipContent={userDetails.member?.id === currentUser?.id ? "Leave project" : "Remove member"} isMobile={isMobile}> <button type="button" onClick={() => setRemoveMemberModal(true)} diff --git a/web/components/project/send-project-invitation-modal.tsx b/web/components/project/send-project-invitation-modal.tsx index 24fc36521..aa680598e 100644 --- a/web/components/project/send-project-invitation-modal.tsx +++ b/web/components/project/send-project-invitation-modal.tsx @@ -142,8 +142,9 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => { if (!memberDetails?.member) return; return { value: `${memberDetails?.member.id}`, - query: `${memberDetails?.member.first_name} ${memberDetails?.member - .last_name} ${memberDetails?.member.display_name.toLowerCase()}`, + query: `${memberDetails?.member.first_name} ${ + memberDetails?.member.last_name + } ${memberDetails?.member.display_name.toLowerCase()}`, content: ( <div className="flex w-full items-center gap-2"> <div className="flex-shrink-0 pt-0.5"> @@ -211,9 +212,6 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => { rules={{ required: "Please select a member" }} render={({ field: { value, onChange } }) => { const selectedMember = getWorkspaceMemberDetails(value); - - if (!selectedMember?.member) return <></>; - return ( <CustomSearchSelect value={value} diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 11ed01cd3..3b2117a0c 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -36,6 +36,7 @@ import { getNumberCount } from "helpers/string.helper"; // hooks import { useApplication, useEventTracker, useInbox, useProject } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // helpers // components @@ -95,6 +96,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => { const { setTrackElement } = useEventTracker(); const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject(); const { getInboxesByProjectId, getInboxById } = useInbox(); + const { isMobile } = usePlatformOS(); // states const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false); const [publishModalOpen, setPublishModal] = useState(false); @@ -185,6 +187,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => { > {provided && !disableDrag && ( <Tooltip + isMobile={isMobile} tooltipContent={project.sort_order === null ? "Join the project to rearrange" : "Drag to rearrange"} position="top-right" > @@ -205,7 +208,13 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => { </button> </Tooltip> )} - <Tooltip tooltipContent={`${project.name}`} position="right" className="ml-2" disabled={!isCollapsed}> + <Tooltip + tooltipContent={`${project.name}`} + position="right" + className="ml-2" + disabled={!isCollapsed} + isMobile={isMobile} + > <Disclosure.Button as="div" className={cn( @@ -353,6 +362,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => { <Link key={item.name} href={item.href} onClick={handleProjectClick}> <span className="block w-full"> <Tooltip + isMobile={isMobile} tooltipContent={`${project?.name}: ${item.name}`} position="right" className="ml-2" diff --git a/web/components/states/create-update-state-inline.tsx b/web/components/states/create-update-state-inline.tsx index 88c50a017..1bfc73f70 100644 --- a/web/components/states/create-update-state-inline.tsx +++ b/web/components/states/create-update-state-inline.tsx @@ -11,6 +11,7 @@ import { STATE_CREATED, STATE_UPDATED } from "constants/event-tracker"; import { GROUP_CHOICES } from "constants/project"; // hooks import { useEventTracker, useProjectState } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // types import type { IState } from "@plane/types"; @@ -38,6 +39,7 @@ export const CreateUpdateStateInline: React.FC<Props> = observer((props) => { // store hooks const { captureProjectStateEvent, setTrackElement } = useEventTracker(); const { createState, updateState } = useProjectState(); + const { isMobile } = usePlatformOS(); // form info const { handleSubmit, @@ -239,7 +241,7 @@ export const CreateUpdateStateInline: React.FC<Props> = observer((props) => { name="group" control={control} render={({ field: { value, onChange } }) => ( - <Tooltip tooltipContent={groupLength === 1 ? "Cannot have an empty group." : "Choose State"}> + <Tooltip tooltipContent={groupLength === 1 ? "Cannot have an empty group." : "Choose State"} isMobile={isMobile}> <div> <CustomSelect disabled={groupLength === 1} diff --git a/web/components/states/project-setting-state-list-item.tsx b/web/components/states/project-setting-state-list-item.tsx index 760c8501c..c57cfc995 100644 --- a/web/components/states/project-setting-state-list-item.tsx +++ b/web/components/states/project-setting-state-list-item.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Pencil, X, ArrowDown, ArrowUp } from "lucide-react"; import { Tooltip, StateGroupIcon } from "@plane/ui"; @@ -30,6 +31,7 @@ export const StatesListItem: React.FC<Props> = observer((props) => { // store hooks const { setTrackElement } = useEventTracker(); const { markStateAsDefault, moveStatePosition } = useProjectState(); + const { isMobile } = usePlatformOS(); // derived values const groupStates = statesList.filter((s) => s.group === state.group); const groupLength = groupStates.length; @@ -109,11 +111,11 @@ export const StatesListItem: React.FC<Props> = observer((props) => { disabled={state.default || groupLength === 1} > {state.default ? ( - <Tooltip tooltipContent="Cannot delete the default state."> + <Tooltip tooltipContent="Cannot delete the default state." isMobile={isMobile}> <X className={`h-4 w-4 ${groupLength < 1 ? "text-custom-sidebar-text-400" : "text-red-500"}`} /> </Tooltip> ) : groupLength === 1 ? ( - <Tooltip tooltipContent="Cannot have an empty group."> + <Tooltip tooltipContent="Cannot have an empty group." isMobile={isMobile}> <X className={`h-4 w-4 ${groupLength < 1 ? "text-custom-sidebar-text-400" : "text-red-500"}`} /> </Tooltip> ) : ( diff --git a/web/components/ui/labels-list.tsx b/web/components/ui/labels-list.tsx index fddea8478..8ebc19158 100644 --- a/web/components/ui/labels-list.tsx +++ b/web/components/ui/labels-list.tsx @@ -3,6 +3,8 @@ import { FC } from "react"; import { Tooltip } from "@plane/ui"; // types import { IIssueLabel } from "@plane/types"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type IssueLabelsListProps = { labels?: (IIssueLabel | undefined)[]; @@ -12,11 +14,12 @@ type IssueLabelsListProps = { export const IssueLabelsList: FC<IssueLabelsListProps> = (props) => { const { labels } = props; + const { isMobile } = usePlatformOS(); return ( <> {labels && ( <> - <Tooltip position="top" tooltipHeading="Labels" tooltipContent={labels.map((l) => l?.name).join(", ")}> + <Tooltip position="top" tooltipHeading="Labels" tooltipContent={labels.map((l) => l?.name).join(", ")} isMobile={isMobile}> <div className="flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs text-custom-text-200"> <span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" /> {`${labels.length} Labels`} diff --git a/web/components/views/view-list-item.tsx b/web/components/views/view-list-item.tsx index 29d5bac57..959bbe0dd 100644 --- a/web/components/views/view-list-item.tsx +++ b/web/components/views/view-list-item.tsx @@ -59,6 +59,7 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => { }); }; + // @ts-expect-error key types are not compatible const totalFilters = calculateTotalFilters(view.filters ?? {}); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; diff --git a/web/components/web-hooks/form/secret-key.tsx b/web/components/web-hooks/form/secret-key.tsx index 11129fb07..0e98bc3ff 100644 --- a/web/components/web-hooks/form/secret-key.tsx +++ b/web/components/web-hooks/form/secret-key.tsx @@ -14,6 +14,8 @@ import { useWebhook, useWorkspace } from "hooks/store"; import { IWebhook } from "@plane/types"; // utils import { getCurrentHookAsCSV } from "../utils"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { data: Partial<IWebhook>; @@ -30,7 +32,7 @@ export const WebhookSecretKey: FC<Props> = observer((props) => { // store hooks const { currentWorkspace } = useWorkspace(); const { currentWebhook, regenerateSecretKey, webhookSecretKey } = useWebhook(); - + const { isMobile } = usePlatformOS(); const handleCopySecretKey = () => { if (!webhookSecretKey) return; @@ -108,7 +110,7 @@ export const WebhookSecretKey: FC<Props> = observer((props) => { {webhookSecretKey && ( <div className="flex items-center gap-2"> {SECRET_KEY_OPTIONS.map((option) => ( - <Tooltip key={option.key} tooltipContent={option.label}> + <Tooltip key={option.key} tooltipContent={option.label} isMobile={isMobile}> <button type="button" className="grid flex-shrink-0 place-items-center" onClick={option.onClick}> <option.Icon className="h-3 w-3 text-custom-text-400" /> </button> diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index 210bbbd3a..2e113414d 100644 --- a/web/components/workspace/help-section.tsx +++ b/web/components/workspace/help-section.tsx @@ -10,6 +10,7 @@ import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; // hooks import { useApplication } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // assets import packageJson from "package.json"; @@ -41,6 +42,7 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = observe theme: { sidebarCollapsed, toggleSidebar }, commandPalette: { toggleShortcutModal }, } = useApplication(); + const { isMobile } = usePlatformOS(); // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // refs @@ -69,7 +71,7 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = observe </div> )} <div className={`flex items-center gap-1 ${isCollapsed ? "flex-col justify-center" : "w-1/2 justify-evenly"}`}> - <Tooltip tooltipContent="Shortcuts"> + <Tooltip tooltipContent="Shortcuts" isMobile={isMobile}> <button type="button" className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${ @@ -80,7 +82,7 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = observe <Zap className="h-3.5 w-3.5" /> </button> </Tooltip> - <Tooltip tooltipContent="Help"> + <Tooltip tooltipContent="Help" isMobile={isMobile}> <button type="button" className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${ @@ -100,7 +102,7 @@ export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = observe <MoveLeft className="h-3.5 w-3.5" /> </button> - <Tooltip tooltipContent={`${isCollapsed ? "Expand" : "Hide"}`}> + <Tooltip tooltipContent={`${isCollapsed ? "Expand" : "Hide"}`} isMobile={isMobile}> <button type="button" className={`hidden place-items-center rounded-md p-1.5 text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 md:grid ${ diff --git a/web/components/workspace/settings/invitations-list-item.tsx b/web/components/workspace/settings/invitations-list-item.tsx index 8c6de24b2..eba62f599 100644 --- a/web/components/workspace/settings/invitations-list-item.tsx +++ b/web/components/workspace/settings/invitations-list-item.tsx @@ -10,6 +10,7 @@ import { ConfirmWorkspaceMemberRemove } from "components/workspace"; import { EUserWorkspaceRoles, ROLE } from "constants/workspace"; // hooks import { useMember, useUser } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { invitationId: string; @@ -29,6 +30,7 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => { const { workspace: { updateMemberInvitation, deleteMemberInvitation, getWorkspaceInvitationDetails }, } = useMember(); + const { isMobile } = usePlatformOS(); // derived values const invitationDetails = getWorkspaceInvitationDetails(invitationId); @@ -134,7 +136,7 @@ export const WorkspaceInvitationsListItem: FC<Props> = observer((props) => { ); })} </CustomSelect> - <Tooltip tooltipContent="Remove member" disabled={!isAdmin}> + <Tooltip tooltipContent="Remove member" disabled={!isAdmin} isMobile={isMobile}> <button type="button" onClick={() => setRemoveMemberModal(true)} diff --git a/web/components/workspace/settings/members-list-item.tsx b/web/components/workspace/settings/members-list-item.tsx index f40d78bb0..6409963bb 100644 --- a/web/components/workspace/settings/members-list-item.tsx +++ b/web/components/workspace/settings/members-list-item.tsx @@ -13,6 +13,7 @@ import { WORKSPACE_MEMBER_lEAVE } from "constants/event-tracker"; import { EUserWorkspaceRoles, ROLE } from "constants/workspace"; // hooks import { useEventTracker, useMember, useUser } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { memberId: string; @@ -35,6 +36,7 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => { workspace: { updateMember, removeMemberFromWorkspace, getWorkspaceMemberDetails }, } = useMember(); const { captureEvent } = useEventTracker(); + const { isMobile } = usePlatformOS(); // derived values const memberDetails = getWorkspaceMemberDetails(memberId); @@ -185,6 +187,7 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => { })} </CustomSelect> <Tooltip + isMobile={isMobile} tooltipContent={isCurrentUser ? "Leave workspace" : "Remove member"} disabled={!isAdmin && !isCurrentUser} > diff --git a/web/components/workspace/sidebar-menu.tsx b/web/components/workspace/sidebar-menu.tsx index 2da7cd406..69860dea2 100644 --- a/web/components/workspace/sidebar-menu.tsx +++ b/web/components/workspace/sidebar-menu.tsx @@ -15,11 +15,13 @@ import { EUserWorkspaceRoles } from "constants/workspace"; import { cn } from "helpers/common.helper"; // hooks import { useApplication, useEventTracker, useUser } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; export const WorkspaceSidebarMenu = observer(() => { // store hooks const { theme: themeStore } = useApplication(); const { captureEvent } = useEventTracker(); + const { isMobile } = usePlatformOS(); const { membership: { currentWorkspaceRole }, } = useUser(); @@ -50,13 +52,13 @@ export const WorkspaceSidebarMenu = observer(() => { position="right" className="ml-2" disabled={!themeStore?.sidebarCollapsed} + isMobile={isMobile} > <div - className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${ - link.highlight(router.asPath, `/${workspaceSlug}`) + className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${link.highlight(router.asPath, `/${workspaceSlug}`) ? "bg-custom-primary-100/10 text-custom-primary-100" : "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80" - } ${themeStore?.sidebarCollapsed ? "justify-center" : ""}`} + } ${themeStore?.sidebarCollapsed ? "justify-center" : ""}`} > { <link.Icon diff --git a/web/constants/empty-state.ts b/web/constants/empty-state.ts index 495bff29f..dd6d76ef3 100644 --- a/web/constants/empty-state.ts +++ b/web/constants/empty-state.ts @@ -2,7 +2,7 @@ import { EUserProjectRoles } from "./project"; import { EUserWorkspaceRoles } from "./workspace"; export interface EmptyStateDetails { - key: string; + key: EmptyStateType; title?: string; description?: string; path?: string; @@ -26,8 +26,6 @@ export interface EmptyStateDetails { access?: EUserWorkspaceRoles | EUserProjectRoles; } -export type EmptyStateKeys = keyof typeof emptyStateDetails; - export enum EmptyStateType { WORKSPACE_DASHBOARD = "workspace-dashboard", WORKSPACE_ANALYTICS = "workspace-analytics", @@ -52,6 +50,7 @@ export enum EmptyStateType { PROJECT_CYCLE_NO_ISSUES = "project-cycle-no-issues", PROJECT_CYCLE_ACTIVE = "project-cycle-active", PROJECT_CYCLE_ALL = "project-cycle-all", + PROJECT_CYCLE_COMPLETED_NO_ISSUES = "project-cycle-completed-no-issues", PROJECT_EMPTY_FILTER = "project-empty-filter", PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter", PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter", @@ -67,7 +66,7 @@ export enum EmptyStateType { PROJECT_VIEW = "project-view", PROJECT_PAGE = "project-page", PROJECT_PAGE_ALL = "project-page-all", - PROJECT_PAGE_FAVORITE = "project-page-favorites", + PROJECT_PAGE_FAVORITES = "project-page-favorites", PROJECT_PAGE_PRIVATE = "project-page-private", PROJECT_PAGE_SHARED = "project-page-shared", PROJECT_PAGE_ARCHIVED = "project-page-archived", @@ -76,8 +75,8 @@ export enum EmptyStateType { const emptyStateDetails = { // workspace - "workspace-dashboard": { - key: "workspace-dashboard", + [EmptyStateType.WORKSPACE_DASHBOARD]: { + key: EmptyStateType.WORKSPACE_DASHBOARD, title: "Overview of your projects, activity, and metrics", description: " Welcome to Plane, we are excited to have you here. Create your first project and track your issues, and this page will transform into a space that helps you progress. Admins will also see items which help their team progress.", @@ -94,8 +93,8 @@ const emptyStateDetails = { accessType: "workspace", access: EUserWorkspaceRoles.MEMBER, }, - "workspace-analytics": { - key: "workspace-analytics", + [EmptyStateType.WORKSPACE_ANALYTICS]: { + key: EmptyStateType.WORKSPACE_ANALYTICS, title: "Track progress, workloads, and allocations. Spot trends, remove blockers, and move work faster", description: "See scope versus demand, estimates, and scope creep. Get performance by team members and teams, and make sure your project runs on time.", @@ -111,8 +110,8 @@ const emptyStateDetails = { accessType: "workspace", access: EUserWorkspaceRoles.MEMBER, }, - "workspace-projects": { - key: "workspace-projects", + [EmptyStateType.WORKSPACE_PROJECTS]: { + key: EmptyStateType.WORKSPACE_PROJECTS, title: "Start a Project", description: "Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal.", @@ -128,8 +127,8 @@ const emptyStateDetails = { access: EUserWorkspaceRoles.MEMBER, }, // all-issues - "workspace-all-issues": { - key: "workspace-all-issues", + [EmptyStateType.WORKSPACE_ALL_ISSUES]: { + key: EmptyStateType.WORKSPACE_ALL_ISSUES, title: "No issues in the project", description: "First project done! Now, slice your work into trackable pieces with issues. Let's go!", path: "/empty-state/all-issues/all-issues", @@ -139,8 +138,8 @@ const emptyStateDetails = { accessType: "workspace", access: EUserWorkspaceRoles.MEMBER, }, - "workspace-assigned": { - key: "workspace-assigned", + [EmptyStateType.WORKSPACE_ASSIGNED]: { + key: EmptyStateType.WORKSPACE_ASSIGNED, title: "No issues yet", description: "Issues assigned to you can be tracked from here.", path: "/empty-state/all-issues/assigned", @@ -150,8 +149,8 @@ const emptyStateDetails = { accessType: "workspace", access: EUserWorkspaceRoles.MEMBER, }, - "workspace-created": { - key: "workspace-created", + [EmptyStateType.WORKSPACE_CREATED]: { + key: EmptyStateType.WORKSPACE_CREATED, title: "No issues yet", description: "All issues created by you come here, track them here directly.", path: "/empty-state/all-issues/created", @@ -161,20 +160,20 @@ const emptyStateDetails = { accessType: "workspace", access: EUserWorkspaceRoles.MEMBER, }, - "workspace-subscribed": { - key: "workspace-subscribed", + [EmptyStateType.WORKSPACE_SUBSCRIBED]: { + key: EmptyStateType.WORKSPACE_SUBSCRIBED, title: "No issues yet", description: "Subscribe to issues you are interested in, track all of them here.", path: "/empty-state/all-issues/subscribed", }, - "workspace-custom-view": { - key: "workspace-custom-view", + [EmptyStateType.WORKSPACE_CUSTOM_VIEW]: { + key: EmptyStateType.WORKSPACE_CUSTOM_VIEW, title: "No issues yet", description: "Issues that applies to the filters, track all of them here.", path: "/empty-state/all-issues/custom-view", }, - "workspace-no-projects": { - key: "workspace-no-projects", + [EmptyStateType.WORKSPACE_NO_PROJECTS]: { + key: EmptyStateType.WORKSPACE_NO_PROJECTS, title: "No project", description: "To create issues or manage your work, you need to create a project or be a part of one.", path: "/empty-state/onboarding/projects", @@ -189,72 +188,72 @@ const emptyStateDetails = { access: EUserWorkspaceRoles.MEMBER, }, // workspace settings - "workspace-settings-api-tokens": { - key: "workspace-settings-api-tokens", + [EmptyStateType.WORKSPACE_SETTINGS_API_TOKENS]: { + key: EmptyStateType.WORKSPACE_SETTINGS_API_TOKENS, title: "No API tokens created", description: "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started.", path: "/empty-state/workspace-settings/api-tokens", }, - "workspace-settings-webhooks": { - key: "workspace-settings-webhooks", + [EmptyStateType.WORKSPACE_SETTINGS_WEBHOOKS]: { + key: EmptyStateType.WORKSPACE_SETTINGS_WEBHOOKS, title: "No webhooks added", description: "Create webhooks to receive real-time updates and automate actions.", path: "/empty-state/workspace-settings/webhooks", }, - "workspace-settings-export": { - key: "workspace-settings-export", + [EmptyStateType.WORKSPACE_SETTINGS_EXPORT]: { + key: EmptyStateType.WORKSPACE_SETTINGS_EXPORT, title: "No previous exports yet", description: "Anytime you export, you will also have a copy here for reference.", path: "/empty-state/workspace-settings/exports", }, - "workspace-settings-import": { - key: "workspace-settings-import", + [EmptyStateType.WORKSPACE_SETTINGS_IMPORT]: { + key: EmptyStateType.WORKSPACE_SETTINGS_IMPORT, title: "No previous imports yet", description: "Find all your previous imports here and download them.", path: "/empty-state/workspace-settings/imports", }, // profile - "profile-assigned": { - key: "profile-assigned", + [EmptyStateType.PROFILE_ASSIGNED]: { + key: EmptyStateType.PROFILE_ASSIGNED, title: "No issues are assigned to you", description: "Issues assigned to you can be tracked from here.", path: "/empty-state/profile/assigned", }, - "profile-created": { - key: "profile-created", + [EmptyStateType.PROFILE_CREATED]: { + key: EmptyStateType.PROFILE_CREATED, title: "No issues yet", description: "All issues created by you come here, track them here directly.", path: "/empty-state/profile/created", }, - "profile-subscribed": { - key: "profile-subscribed", + [EmptyStateType.PROFILE_SUBSCRIBED]: { + key: EmptyStateType.PROFILE_SUBSCRIBED, title: "No issues yet", description: "Subscribe to issues you are interested in, track all of them here.", path: "/empty-state/profile/subscribed", }, // project settings - "project-settings-labels": { - key: "project-settings-labels", + [EmptyStateType.PROJECT_SETTINGS_LABELS]: { + key: EmptyStateType.PROJECT_SETTINGS_LABELS, title: "No labels yet", description: "Create labels to help organize and filter issues in you project.", path: "/empty-state/project-settings/labels", }, - "project-settings-integrations": { - key: "project-settings-integrations", + [EmptyStateType.PROJECT_SETTINGS_INTEGRATIONS]: { + key: EmptyStateType.PROJECT_SETTINGS_INTEGRATIONS, title: "No integrations configured", description: "Configure GitHub and other integrations to sync your project issues.", path: "/empty-state/project-settings/integrations", }, - "project-settings-estimate": { - key: "project-settings-estimate", + [EmptyStateType.PROJECT_SETTINGS_ESTIMATE]: { + key: EmptyStateType.PROJECT_SETTINGS_ESTIMATE, title: "No estimates added", description: "Create a set of estimates to communicate the amount of work per issue.", path: "/empty-state/project-settings/estimates", }, // project cycles - "project-cycles": { - key: "project-cycles", + [EmptyStateType.PROJECT_CYCLES]: { + key: EmptyStateType.PROJECT_CYCLES, title: "Group and timebox your work in Cycles.", description: "Break work down by timeboxed chunks, work backwards from your project deadline to set dates, and make tangible progress as a team.", @@ -270,8 +269,8 @@ const emptyStateDetails = { accessType: "workspace", access: EUserWorkspaceRoles.MEMBER, }, - "project-cycle-no-issues": { - key: "project-cycle-no-issues", + [EmptyStateType.PROJECT_CYCLE_NO_ISSUES]: { + key: EmptyStateType.PROJECT_CYCLE_NO_ISSUES, title: "No issues added to the cycle", description: "Add or create issues you wish to timebox and deliver within this cycle", path: "/empty-state/cycle-issues/", @@ -284,23 +283,30 @@ const emptyStateDetails = { accessType: "project", access: EUserProjectRoles.MEMBER, }, - "project-cycle-active": { - key: "project-cycle-active", + [EmptyStateType.PROJECT_CYCLE_ACTIVE]: { + key: EmptyStateType.PROJECT_CYCLE_ACTIVE, title: "No active cycle", description: "An active cycle includes any period that encompasses today's date within its range. Find the progress and details of the active cycle here.", path: "/empty-state/cycle/active", }, - "project-cycle-all": { - key: "project-cycle-all", + [EmptyStateType.PROJECT_CYCLE_COMPLETED_NO_ISSUES]: { + key: EmptyStateType.PROJECT_CYCLE_COMPLETED_NO_ISSUES, + title: "No issues in the cycle", + description: + "No issues in the cycle. Issues are either transferred or hidden. To see hidden issues if any, update your display properties accordingly.", + path: "/empty-state/cycle/completed-no-issues", + }, + [EmptyStateType.PROJECT_CYCLE_ALL]: { + key: EmptyStateType.PROJECT_CYCLE_ALL, title: "No cycles", description: "An active cycle includes any period that encompasses today's date within its range. Find the progress and details of the active cycle here.", path: "/empty-state/cycle/active", }, // empty filters - "project-empty-filter": { - key: "project-empty-filter", + [EmptyStateType.PROJECT_EMPTY_FILTER]: { + key: EmptyStateType.PROJECT_EMPTY_FILTER, title: "No issues found matching the filters applied", path: "/empty-state/empty-filters/", secondaryButton: { @@ -309,8 +315,8 @@ const emptyStateDetails = { accessType: "project", access: EUserProjectRoles.MEMBER, }, - "project-archived-empty-filter": { - key: "project-archived-empty-filter", + [EmptyStateType.PROJECT_ARCHIVED_EMPTY_FILTER]: { + key: EmptyStateType.PROJECT_ARCHIVED_EMPTY_FILTER, title: "No issues found matching the filters applied", path: "/empty-state/empty-filters/", secondaryButton: { @@ -319,8 +325,8 @@ const emptyStateDetails = { accessType: "project", access: EUserProjectRoles.MEMBER, }, - "project-draft-empty-filter": { - key: "project-draft-empty-filter", + [EmptyStateType.PROJECT_DRAFT_EMPTY_FILTER]: { + key: EmptyStateType.PROJECT_DRAFT_EMPTY_FILTER, title: "No issues found matching the filters applied", path: "/empty-state/empty-filters/", secondaryButton: { @@ -330,8 +336,8 @@ const emptyStateDetails = { access: EUserProjectRoles.MEMBER, }, // project issues - "project-no-issues": { - key: "project-no-issues", + [EmptyStateType.PROJECT_NO_ISSUES]: { + key: EmptyStateType.PROJECT_NO_ISSUES, title: "Create an issue and assign it to someone, even yourself", description: "Think of issues as jobs, tasks, work, or JTBD. Which we like. An issue and its sub-issues are usually time-based actionables assigned to members of your team. Your team creates, assigns, and completes issues to move your project towards its goal.", @@ -347,8 +353,8 @@ const emptyStateDetails = { accessType: "project", access: EUserProjectRoles.MEMBER, }, - "project-archived-no-issues": { - key: "project-archived-no-issues", + [EmptyStateType.PROJECT_ARCHIVED_NO_ISSUES]: { + key: EmptyStateType.PROJECT_ARCHIVED_NO_ISSUES, title: "No archived issues yet", description: "Archived issues help you remove issues you completed or cancelled from focus. You can set automation to auto archive issues and find them here.", @@ -359,39 +365,39 @@ const emptyStateDetails = { accessType: "project", access: EUserProjectRoles.MEMBER, }, - "project-draft-no-issues": { - key: "project-draft-no-issues", + [EmptyStateType.PROJECT_DRAFT_NO_ISSUES]: { + key: EmptyStateType.PROJECT_DRAFT_NO_ISSUES, title: "No draft issues yet", description: "Quickly stepping away but want to keep your place? No worries – save a draft now. Your issues will be right here waiting for you.", path: "/empty-state/draft/draft-issues-empty", }, - "views-empty-search": { - key: "views-empty-search", + [EmptyStateType.VIEWS_EMPTY_SEARCH]: { + key: EmptyStateType.VIEWS_EMPTY_SEARCH, title: "No matching views", description: "No views match the search criteria. Create a new view instead.", path: "/empty-state/search/search", }, - "projects-empty-search": { - key: "projects-empty-search", + [EmptyStateType.PROJECTS_EMPTY_SEARCH]: { + key: EmptyStateType.PROJECTS_EMPTY_SEARCH, title: "No matching projects", description: "No projects detected with the matching criteria. Create a new project instead.", path: "/empty-state/search/project", }, - "commandK-empty-search": { - key: "commandK-empty-search", + [EmptyStateType.COMMANDK_EMPTY_SEARCH]: { + key: EmptyStateType.COMMANDK_EMPTY_SEARCH, title: "No results found. ", path: "/empty-state/search/search", }, - "members-empty-search": { - key: "members-empty-search", + [EmptyStateType.MEMBERS_EMPTY_SEARCH]: { + key: EmptyStateType.MEMBERS_EMPTY_SEARCH, title: "No matching members", description: "Add them to the project if they are already a part of the workspace", path: "/empty-state/search/member", }, // project module - "project-module-issues": { - key: "project-modules-issues", + [EmptyStateType.PROJECT_MODULE_ISSUES]: { + key: EmptyStateType.PROJECT_MODULE_ISSUES, title: "No issues in the module", description: "Create or add issues which you want to accomplish as part of this module", path: "/empty-state/module-issues/", @@ -404,8 +410,8 @@ const emptyStateDetails = { accessType: "project", access: EUserProjectRoles.MEMBER, }, - "project-module": { - key: "project-module", + [EmptyStateType.PROJECT_MODULE]: { + key: EmptyStateType.PROJECT_MODULE, title: "Map your project milestones to Modules and track aggregated work easily.", description: "A group of issues that belong to a logical, hierarchical parent form a module. Think of them as a way to track work by project milestones. They have their own periods and deadlines as well as analytics to help you see how close or far you are from a milestone.", @@ -421,8 +427,8 @@ const emptyStateDetails = { access: EUserProjectRoles.MEMBER, }, // project views - "project-view": { - key: "project-view", + [EmptyStateType.PROJECT_VIEW]: { + key: EmptyStateType.PROJECT_VIEW, title: "Save filtered views for your project. Create as many as you need", description: "Views are a set of saved filters that you use frequently or want easy access to. All your colleagues in a project can see everyone’s views and choose whichever suits their needs best.", @@ -438,8 +444,8 @@ const emptyStateDetails = { access: EUserProjectRoles.MEMBER, }, // project pages - "project-page": { - key: "pages", + [EmptyStateType.PROJECT_PAGE]: { + key: EmptyStateType.PROJECT_PAGE, title: "Write a note, a doc, or a full knowledge base. Get Galileo, Plane’s AI assistant, to help you get started", description: "Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your project’s context. To make short work of any doc, invoke Galileo, Plane’s AI, with a shortcut or the click of a button.", @@ -455,39 +461,39 @@ const emptyStateDetails = { accessType: "project", access: EUserProjectRoles.MEMBER, }, - "project-page-all": { - key: "project-page-all", + [EmptyStateType.PROJECT_PAGE_ALL]: { + key: EmptyStateType.PROJECT_PAGE_ALL, title: "Write a note, a doc, or a full knowledge base", description: "Pages help you organise your thoughts to create wikis, discussions or even document heated takes for your project. Use it wisely!", path: "/empty-state/pages/all", }, - "project-page-favorites": { - key: "project-page-favorites", + [EmptyStateType.PROJECT_PAGE_FAVORITES]: { + key: EmptyStateType.PROJECT_PAGE_FAVORITES, title: "No favorite pages yet", description: "Favorites for quick access? mark them and find them right here.", path: "/empty-state/pages/favorites", }, - "project-page-private": { - key: "project-page-private", + [EmptyStateType.PROJECT_PAGE_PRIVATE]: { + key: EmptyStateType.PROJECT_PAGE_PRIVATE, title: "No private pages yet", description: "Keep your private thoughts here. When you're ready to share, the team's just a click away.", path: "/empty-state/pages/private", }, - "project-page-shared": { - key: "project-page-shared", + [EmptyStateType.PROJECT_PAGE_SHARED]: { + key: EmptyStateType.PROJECT_PAGE_SHARED, title: "No shared pages yet", description: "See pages shared with everyone in your project right here.", path: "/empty-state/pages/shared", }, - "project-page-archived": { - key: "project-page-archived", + [EmptyStateType.PROJECT_PAGE_ARCHIVED]: { + key: EmptyStateType.PROJECT_PAGE_ARCHIVED, title: "No archived pages yet", description: "Archive pages not on your radar. Access them here when needed.", path: "/empty-state/pages/archived", }, - "project-page-recent": { - key: "project-page-recent", + [EmptyStateType.PROJECT_PAGE_RECENT]: { + key: EmptyStateType.PROJECT_PAGE_RECENT, title: "Write a note, a doc, or a full knowledge base", description: "Pages help you organise your thoughts to create wikis, discussions or even document heated takes for your project. Use it wisely! Pages will be sorted and grouped by last updated", @@ -500,4 +506,4 @@ const emptyStateDetails = { }, } as const; -export const EMPTY_STATE_DETAILS: Record<EmptyStateKeys, EmptyStateDetails> = emptyStateDetails; +export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails; diff --git a/web/constants/filters.ts b/web/constants/filters.ts index e4131e451..6ac961bb9 100644 --- a/web/constants/filters.ts +++ b/web/constants/filters.ts @@ -1,4 +1,4 @@ -export const DATE_FILTER_OPTIONS = [ +export const DATE_AFTER_FILTER_OPTIONS = [ { name: "1 week from now", value: "1_weeks;after;fromnow", @@ -16,3 +16,18 @@ export const DATE_FILTER_OPTIONS = [ value: "2_months;after;fromnow", }, ]; + +export const DATE_BEFORE_FILTER_OPTIONS = [ + { + name: "1 week ago", + value: "1_weeks;before;fromnow", + }, + { + name: "2 weeks ago", + value: "2_weeks;before;fromnow", + }, + { + name: "1 month ago", + value: "1_months;before;fromnow", + }, +]; diff --git a/web/constants/module.ts b/web/constants/module.ts index ef4a719da..544e810ac 100644 --- a/web/constants/module.ts +++ b/web/constants/module.ts @@ -1,6 +1,6 @@ import { GanttChartSquare, LayoutGrid, List } from "lucide-react"; // types -import { TModuleStatus } from "@plane/types"; +import { TModuleLayoutOptions, TModuleOrderByOptions, TModuleStatus } from "@plane/types"; export const MODULE_STATUS: { label: string; @@ -53,20 +53,43 @@ export const MODULE_STATUS: { }, ]; -export const MODULE_VIEW_LAYOUTS: { key: "list" | "grid" | "gantt_chart"; icon: any; title: string }[] = [ +export const MODULE_VIEW_LAYOUTS: { key: TModuleLayoutOptions; icon: any; title: string }[] = [ { key: "list", icon: List, title: "List layout", }, { - key: "grid", + key: "board", icon: LayoutGrid, title: "Grid layout", }, { - key: "gantt_chart", + key: "gantt", icon: GanttChartSquare, title: "Gantt layout", }, ]; + +export const MODULE_ORDER_BY_OPTIONS: { key: TModuleOrderByOptions; label: string }[] = [ + { + key: "name", + label: "Name", + }, + { + key: "progress", + label: "Progress", + }, + { + key: "issues_length", + label: "Number of issues", + }, + { + key: "target_date", + label: "Due date", + }, + { + key: "created_at", + label: "Created date", + }, +]; diff --git a/web/constants/project.ts b/web/constants/project.ts index 6073e96be..ba6b2c29c 100644 --- a/web/constants/project.ts +++ b/web/constants/project.ts @@ -3,6 +3,7 @@ import { Globe2, Lock, LucideIcon } from "lucide-react"; import { SettingIcon } from "components/icons"; // types import { Props } from "components/icons/types"; +import { TProjectOrderByOptions } from "@plane/types"; export enum EUserProjectRoles { GUEST = 5, @@ -39,23 +40,6 @@ export const GROUP_CHOICES = { cancelled: "Cancelled", }; -export const MONTHS = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - -export const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - export const PROJECT_AUTOMATION_MONTHS = [ { label: "1 month", value: 1 }, { label: "3 months", value: 3 }, @@ -156,3 +140,25 @@ export const PROJECT_SETTINGS_LINKS: { Icon: SettingIcon, }, ]; + +export const PROJECT_ORDER_BY_OPTIONS: { + key: TProjectOrderByOptions; + label: string; +}[] = [ + { + key: "sort_order", + label: "Manual", + }, + { + key: "name", + label: "Name", + }, + { + key: "created_at", + label: "Created date", + }, + { + key: "members_length", + label: "Number of members", + }, +]; diff --git a/web/helpers/filter.helper.ts b/web/helpers/filter.helper.ts index 3c34fa9da..d8804cabd 100644 --- a/web/helpers/filter.helper.ts +++ b/web/helpers/filter.helper.ts @@ -1,15 +1,22 @@ -import { differenceInCalendarDays } from "date-fns"; -// types -import { IIssueFilterOptions } from "@plane/types"; +import differenceInCalendarDays from "date-fns/differenceInCalendarDays"; -export const calculateTotalFilters = (filters: IIssueFilterOptions): number => +type TFilters = { + [key: string]: string[] | null; +}; + +/** + * @description calculates the total number of filters applied + * @param {TFilters} filters + * @returns {number} + */ +export const calculateTotalFilters = (filters: TFilters): number => filters && Object.keys(filters).length > 0 ? Object.keys(filters) .map((key) => - filters[key as keyof IIssueFilterOptions] !== null - ? isNaN((filters[key as keyof IIssueFilterOptions] as string[]).length) + filters[key as keyof TFilters] !== null + ? isNaN((filters[key as keyof TFilters] as string[]).length) ? 0 - : (filters[key as keyof IIssueFilterOptions] as string[]).length + : (filters[key as keyof TFilters] as string[]).length : 0 ) .reduce((curr, prev) => curr + prev, 0) @@ -30,6 +37,12 @@ export const satisfiesDateFilter = (date: Date, filter: string): boolean => { } if (from === "fromnow") { + if (operator === "before") { + if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) <= -7; + if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) <= -14; + if (value === "1_months") return differenceInCalendarDays(date, new Date()) <= -30; + } + if (operator === "after") { if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7; if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14; diff --git a/web/helpers/issue.helper.ts b/web/helpers/issue.helper.ts index 3e6689151..cba9ced8d 100644 --- a/web/helpers/issue.helper.ts +++ b/web/helpers/issue.helper.ts @@ -184,3 +184,21 @@ export function getChangedIssuefields(formData: Partial<TIssue>, dirtyFields: { return changedFields; } + +export const formatTextList = (TextArray: string[]): string => { + const count = TextArray.length; + switch (count) { + case 0: + return ""; + case 1: + return TextArray[0]; + case 2: + return `${TextArray[0]} and ${TextArray[1]}`; + case 3: + return `${TextArray.slice(0, 2).join(", ")}, and ${TextArray[2]}`; + case 4: + return `${TextArray.slice(0, 3).join(", ")}, and ${TextArray[3]}`; + default: + return `${TextArray.slice(0, 3).join(", ")}, and +${count - 3} more`; + } +}; diff --git a/web/helpers/module.helper.ts b/web/helpers/module.helper.ts new file mode 100644 index 000000000..7f7a523f6 --- /dev/null +++ b/web/helpers/module.helper.ts @@ -0,0 +1,80 @@ +import sortBy from "lodash/sortBy"; +// helpers +import { satisfiesDateFilter } from "helpers/filter.helper"; +// types +import { IModule, TModuleDisplayFilters, TModuleFilters, TModuleOrderByOptions } from "@plane/types"; + +/** + * @description orders modules based on their status + * @param {IModule[]} modules + * @param {TModuleOrderByOptions | undefined} orderByKey + * @returns {IModule[]} + */ +export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptions | undefined): IModule[] => { + let orderedModules: IModule[] = []; + if (modules.length === 0 || !orderByKey) return []; + + if (orderByKey === "name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]); + if (orderByKey === "-name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]).reverse(); + if (["progress", "-progress"].includes(orderByKey)) + orderedModules = sortBy(modules, [ + (m) => { + let progress = (m.completed_issues + m.cancelled_issues) / m.total_issues; + if (isNaN(progress)) progress = 0; + return orderByKey === "progress" ? progress : !progress; + }, + "name", + ]); + if (["issues_length", "-issues_length"].includes(orderByKey)) + orderedModules = sortBy(modules, [ + (m) => (orderByKey === "issues_length" ? m.total_issues : !m.total_issues), + "name", + ]); + if (orderByKey === "target_date") orderedModules = sortBy(modules, [(m) => m.target_date]); + if (orderByKey === "-target_date") orderedModules = sortBy(modules, [(m) => !m.target_date]); + if (orderByKey === "created_at") orderedModules = sortBy(modules, [(m) => m.created_at]); + if (orderByKey === "-created_at") orderedModules = sortBy(modules, [(m) => !m.created_at]); + + return orderedModules; +}; + +/** + * @description filters modules based on the filters + * @param {IModule} module + * @param {TModuleDisplayFilters} displayFilters + * @param {TModuleFilters} filters + * @returns {boolean} + */ +export const shouldFilterModule = ( + module: IModule, + displayFilters: TModuleDisplayFilters, + filters: TModuleFilters +): boolean => { + let fallsInFilters = true; + Object.keys(filters).forEach((key) => { + const filterKey = key as keyof TModuleFilters; + if (filterKey === "status" && filters.status && filters.status.length > 0) + fallsInFilters = fallsInFilters && filters.status.includes(module.status.toLowerCase()); + if (filterKey === "lead" && filters.lead && filters.lead.length > 0) + fallsInFilters = fallsInFilters && filters.lead.includes(`${module.lead_id}`); + if (filterKey === "members" && filters.members && filters.members.length > 0) { + const memberIds = module.member_ids; + fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId)); + } + if (filterKey === "start_date" && filters.start_date && filters.start_date.length > 0) { + filters.start_date.forEach((dateFilter) => { + fallsInFilters = + fallsInFilters && !!module.start_date && satisfiesDateFilter(new Date(module.start_date), dateFilter); + }); + } + if (filterKey === "target_date" && filters.target_date && filters.target_date.length > 0) { + filters.target_date.forEach((dateFilter) => { + fallsInFilters = + fallsInFilters && !!module.target_date && satisfiesDateFilter(new Date(module.target_date), dateFilter); + }); + } + }); + if (displayFilters.favorites && !module.is_favorite) fallsInFilters = false; + + return fallsInFilters; +}; diff --git a/web/helpers/project.helper.ts b/web/helpers/project.helper.ts index 441c14a42..ba0d52742 100644 --- a/web/helpers/project.helper.ts +++ b/web/helpers/project.helper.ts @@ -1,4 +1,8 @@ -import { IProject } from "@plane/types"; +import sortBy from "lodash/sortBy"; +// helpers +import { satisfiesDateFilter } from "helpers/filter.helper"; +// types +import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types"; /** * Updates the sort order of the project. @@ -46,3 +50,58 @@ export const orderJoinedProjects = ( export const projectIdentifierSanitizer = (identifier: string): string => identifier.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, ""); + +/** + * @description filters projects based on the filter + * @param {IProject} project + * @param {TProjectFilters} filters + * @param {TProjectDisplayFilters} displayFilters + * @returns {boolean} + */ +export const shouldFilterProject = ( + project: IProject, + displayFilters: TProjectDisplayFilters, + filters: TProjectFilters +): boolean => { + let fallsInFilters = true; + Object.keys(filters).forEach((key) => { + const filterKey = key as keyof TProjectFilters; + if (filterKey === "access" && filters.access && filters.access.length > 0) + fallsInFilters = fallsInFilters && filters.access.includes(`${project.network}`); + if (filterKey === "lead" && filters.lead && filters.lead.length > 0) + fallsInFilters = fallsInFilters && filters.lead.includes(`${project.project_lead}`); + if (filterKey === "members" && filters.members && filters.members.length > 0) { + const memberIds = project.members.map((member) => member.member_id); + fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId)); + } + if (filterKey === "created_at" && filters.created_at && filters.created_at.length > 0) { + filters.created_at.forEach((dateFilter) => { + fallsInFilters = fallsInFilters && satisfiesDateFilter(new Date(project.created_at), dateFilter); + }); + } + }); + if (displayFilters.my_projects && !project.is_member) fallsInFilters = false; + + return fallsInFilters; +}; + +/** + * @description orders projects based on the orderByKey + * @param {IProject[]} projects + * @param {TProjectOrderByOptions | undefined} orderByKey + * @returns {IProject[]} + */ +export const orderProjects = (projects: IProject[], orderByKey: TProjectOrderByOptions | undefined): IProject[] => { + let orderedProjects: IProject[] = []; + if (projects.length === 0) return orderedProjects; + + if (orderByKey === "sort_order") orderedProjects = sortBy(projects, [(p) => p.sort_order]); + if (orderByKey === "name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]); + if (orderByKey === "-name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]).reverse(); + if (orderByKey === "created_at") orderedProjects = sortBy(projects, [(p) => p.created_at]); + if (orderByKey === "-created_at") orderedProjects = sortBy(projects, [(p) => !p.created_at]); + if (orderByKey === "members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]); + if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]).reverse(); + + return orderedProjects; +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index 3ec5c97bf..906b473ff 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -10,7 +10,9 @@ export * from "./use-label"; export * from "./use-member"; export * from "./use-mention"; export * from "./use-module"; +export * from "./use-module-filter"; export * from "./use-page"; +export * from "./use-project-filter"; export * from "./use-project-publish"; export * from "./use-project-state"; export * from "./use-project-view"; diff --git a/web/hooks/store/use-module-filter.ts b/web/hooks/store/use-module-filter.ts new file mode 100644 index 000000000..783cea6d6 --- /dev/null +++ b/web/hooks/store/use-module-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { IModuleFilterStore } from "store/module_filter.store"; + +export const useModuleFilter = (): IModuleFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useModuleFilter must be used within StoreProvider"); + return context.moduleFilter; +}; diff --git a/web/hooks/store/use-project-filter.ts b/web/hooks/store/use-project-filter.ts new file mode 100644 index 000000000..9aebe55d9 --- /dev/null +++ b/web/hooks/store/use-project-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { IProjectFilterStore } from "store/project/project_filter.store"; + +export const useProjectFilter = (): IProjectFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useProjectFilter must be used within StoreProvider"); + return context.projectRoot.projectFilter; +}; diff --git a/web/hooks/use-platform-os.tsx b/web/hooks/use-platform-os.tsx new file mode 100644 index 000000000..8d23d7b27 --- /dev/null +++ b/web/hooks/use-platform-os.tsx @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export const usePlatformOS = () => { + const [isMobile, setIsMobile] = useState(false); + useEffect(() => { + const userAgent = window.navigator.userAgent; + const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent); + + if (isMobile) setIsMobile(isMobile); + }, []); + return {isMobile}; +} diff --git a/web/layouts/settings-layout/profile/layout.tsx b/web/layouts/settings-layout/profile/layout.tsx index 67f545a2d..ef778cacb 100644 --- a/web/layouts/settings-layout/profile/layout.tsx +++ b/web/layouts/settings-layout/profile/layout.tsx @@ -21,7 +21,7 @@ export const ProfileSettingsLayout: FC<IProfileSettingsLayout> = (props) => { <ProfileLayoutSidebar /> <main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100"> {header} - <div className="h-full w-full overflow-x-hidden overflow-y-scroll">{children}</div> + <div className="h-full w-full overflow-hidden">{children}</div> </main> </div> </UserAuthWrapper> diff --git a/web/layouts/settings-layout/profile/preferences/layout.tsx b/web/layouts/settings-layout/profile/preferences/layout.tsx index 71a1fdd85..09cbd9649 100644 --- a/web/layouts/settings-layout/profile/preferences/layout.tsx +++ b/web/layouts/settings-layout/profile/preferences/layout.tsx @@ -73,9 +73,7 @@ export const ProfilePreferenceSettingsLayout: FC<IProfilePreferenceSettingsLayou <ProfilePreferenceSettingsSidebar /> <main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100"> {header} - <div className="h-full w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md"> - {children} - </div> + <div className="h-full w-full overflow-hidden">{children}</div> </main> </div> </ProfileSettingsLayout> diff --git a/web/layouts/settings-layout/profile/sidebar.tsx b/web/layouts/settings-layout/profile/sidebar.tsx index 1bae51d8b..430b77179 100644 --- a/web/layouts/settings-layout/profile/sidebar.tsx +++ b/web/layouts/settings-layout/profile/sidebar.tsx @@ -7,6 +7,7 @@ import { mutate } from "swr"; import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react"; // hooks import { useApplication, useUser, useWorkspace } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; // constants @@ -41,7 +42,7 @@ export const ProfileLayoutSidebar = observer(() => { } = useApplication(); const { currentUser, currentUserSettings, signOut } = useUser(); const { workspaces } = useWorkspace(); - + const { isMobile } = usePlatformOS(); const workspacesList = Object.values(workspaces ?? {}); // redirect url for normal mode @@ -132,7 +133,7 @@ export const ProfileLayoutSidebar = observer(() => { return ( <Link key={link.key} href={link.href} className="block w-full" onClick={handleItemClick}> - <Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed}> + <Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed} isMobile={isMobile}> <div className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${ link.highlight(router.pathname) @@ -195,7 +196,7 @@ export const ProfileLayoutSidebar = observer(() => { <div className="mt-1.5"> {WORKSPACE_ACTION_LINKS.map((link) => ( <Link className="block w-full" key={link.key} href={link.href} onClick={handleItemClick}> - <Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed}> + <Tooltip tooltipContent={link.label} position="right" className="ml-2" disabled={!sidebarCollapsed} isMobile={isMobile}> <div className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium text-custom-sidebar-text-200 outline-none hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80 ${ sidebarCollapsed ? "justify-center" : "" diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx index 3648f5922..eb3c92044 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx @@ -1,30 +1,58 @@ -import { ReactElement } from "react"; +import { ReactElement, useCallback } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // layouts // components import { PageHead } from "components/core"; import { ModulesListHeader } from "components/headers"; -import { ModulesListView } from "components/modules"; +import { ModuleAppliedFiltersList, ModulesListView } from "components/modules"; // types // hooks -import { useProject } from "hooks/store"; +import { useModuleFilter, useProject } from "hooks/store"; import { AppLayout } from "layouts/app-layout"; import { NextPageWithLayout } from "lib/types"; +import { calculateTotalFilters } from "helpers/filter.helper"; +import { TModuleFilters } from "@plane/types"; const ProjectModulesPage: NextPageWithLayout = observer(() => { const router = useRouter(); const { projectId } = router.query; // store const { getProjectById } = useProject(); + const { currentProjectFilters, clearAllFilters, updateFilters } = useModuleFilter(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Modules` : undefined; + const handleRemoveFilter = useCallback( + (key: keyof TModuleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [currentProjectFilters, projectId, updateFilters] + ); + return ( <> <PageHead title={pageTitle} /> - <ModulesListView /> + <div className="h-full w-full flex flex-col"> + {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( + <div className="border-b border-custom-border-200 px-5 py-3"> + <ModuleAppliedFiltersList + appliedFilters={currentProjectFilters ?? {}} + handleClearAllFilters={() => clearAllFilters(`${projectId}`)} + handleRemoveFilter={handleRemoveFilter} + alwaysAllowEditing + /> + </div> + )} + <ModulesListView /> + </div> </> ); }); diff --git a/web/pages/[workspaceSlug]/projects/index.tsx b/web/pages/[workspaceSlug]/projects/index.tsx index 158e6577f..e941bd8cb 100644 --- a/web/pages/[workspaceSlug]/projects/index.tsx +++ b/web/pages/[workspaceSlug]/projects/index.tsx @@ -1,25 +1,60 @@ -import { ReactElement } from "react"; +import { ReactElement, useCallback } from "react"; import { observer } from "mobx-react"; // components import { PageHead } from "components/core"; import { ProjectsHeader } from "components/headers"; -import { ProjectCardList } from "components/project"; +import { ProjectAppliedFiltersList, ProjectCardList } from "components/project"; // layouts -import { useWorkspace } from "hooks/store"; +import { useApplication, useProject, useProjectFilter, useWorkspace } from "hooks/store"; import { AppLayout } from "layouts/app-layout"; -// type +// helpers +import { calculateTotalFilters } from "helpers/filter.helper"; +// types import { NextPageWithLayout } from "lib/types"; +import { TProjectFilters } from "@plane/types"; const ProjectsPage: NextPageWithLayout = observer(() => { // store + const { + router: { workspaceSlug }, + } = useApplication(); const { currentWorkspace } = useWorkspace(); + const { workspaceProjectIds, filteredProjectIds } = useProject(); + const { currentWorkspaceFilters, clearAllFilters, updateFilters } = useProjectFilter(); // derived values const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined; + const handleRemoveFilter = useCallback( + (key: keyof TProjectFilters, value: string | null) => { + if (!workspaceSlug) return; + let newValues = currentWorkspaceFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(workspaceSlug.toString(), { [key]: newValues }); + }, + [currentWorkspaceFilters, updateFilters, workspaceSlug] + ); + return ( <> <PageHead title={pageTitle} /> - <ProjectCardList /> + <div className="h-full w-full flex flex-col"> + {calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 && ( + <div className="border-b border-custom-border-200 px-5 py-3"> + <ProjectAppliedFiltersList + appliedFilters={currentWorkspaceFilters ?? {}} + handleClearAllFilters={() => clearAllFilters(`${workspaceSlug}`)} + handleRemoveFilter={handleRemoveFilter} + filteredProjects={filteredProjectIds?.length ?? 0} + totalProjects={workspaceProjectIds?.length ?? 0} + alwaysAllowEditing + /> + </div> + )} + <ProjectCardList /> + </div> </> ); }); diff --git a/web/pages/profile/activity.tsx b/web/pages/profile/activity.tsx index bda1295cf..17ad3292c 100644 --- a/web/pages/profile/activity.tsx +++ b/web/pages/profile/activity.tsx @@ -49,7 +49,7 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { <SidebarHamburgerToggle onClick={() => themeStore.toggleSidebar()} /> <h3 className="text-xl font-medium">Activity</h3> </div> - <div className="h-full flex flex-col overflow-y-auto"> + <div className="h-full flex flex-col overflow-y-auto vertical-scrollbar scrollbar-md"> {activityPages} {pageCount < totalPages && resultsCount !== 0 && ( <div className="flex items-center justify-center text-xs w-full"> diff --git a/web/pages/profile/preferences/email.tsx b/web/pages/profile/preferences/email.tsx index b34a493e5..923ba70d9 100644 --- a/web/pages/profile/preferences/email.tsx +++ b/web/pages/profile/preferences/email.tsx @@ -28,7 +28,7 @@ const ProfilePreferencesThemePage: NextPageWithLayout = () => { return ( <> <PageHead title="Profile - Email Preference" /> - <div className="mx-auto mt-8 h-full w-full px-6 lg:px-20 pb-8"> + <div className="mx-auto mt-8 h-full w-full px-6 lg:px-20 pb-8 vertical-scrollbar scrollbar-md"> <EmailNotificationForm data={data} /> </div> </> diff --git a/web/pages/profile/preferences/theme.tsx b/web/pages/profile/preferences/theme.tsx index e23e94c66..49aa2a777 100644 --- a/web/pages/profile/preferences/theme.tsx +++ b/web/pages/profile/preferences/theme.tsx @@ -54,7 +54,7 @@ const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { <> <PageHead title="Profile - Theme Prefrence" /> {currentUser ? ( - <div className="mx-auto mt-10 h-full w-full overflow-y-auto px-6 pb-8 md:mt-14 lg:px-20"> + <div className="mx-auto mt-10 h-full w-full overflow-y-auto px-6 pb-8 md:mt-14 lg:px-20 vertical-scrollbar scrollbar-md"> <div className="flex items-center border-b border-custom-border-100 pb-3.5"> <h3 className="text-xl font-medium">Preferences</h3> </div> diff --git a/web/public/empty-state/cycle/completed-no-issues-dark.webp b/web/public/empty-state/cycle/completed-no-issues-dark.webp new file mode 100644 index 000000000..a187dbf5c Binary files /dev/null and b/web/public/empty-state/cycle/completed-no-issues-dark.webp differ diff --git a/web/public/empty-state/cycle/completed-no-issues-light.webp b/web/public/empty-state/cycle/completed-no-issues-light.webp new file mode 100644 index 000000000..6009f60f4 Binary files /dev/null and b/web/public/empty-state/cycle/completed-no-issues-light.webp differ diff --git a/web/public/empty-state/module/all-filters.svg b/web/public/empty-state/module/all-filters.svg new file mode 100644 index 000000000..6ba0731fe --- /dev/null +++ b/web/public/empty-state/module/all-filters.svg @@ -0,0 +1,45 @@ +<svg width="205" height="217" viewBox="0 0 205 217" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="102.5" cy="102.5" r="102.5" fill="url(#paint0_linear_8097_110478)"/> +<path d="M146.167 47.5H59.8333C53.0218 47.5 47.5 53.0218 47.5 59.8333V146.167C47.5 152.978 53.0218 158.5 59.8333 158.5H146.167C152.978 158.5 158.5 152.978 158.5 146.167V59.8333C158.5 53.0218 152.978 47.5 146.167 47.5Z" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M83.1297 72.167H73.5371C72.7802 72.167 72.1667 72.7805 72.1667 73.5374V83.13C72.1667 83.8868 72.7802 84.5003 73.5371 84.5003H83.1297C83.8865 84.5003 84.5 83.8868 84.5 83.13V73.5374C84.5 72.7805 83.8865 72.167 83.1297 72.167Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M83.1297 121.5H73.5371C72.7802 121.5 72.1667 122.114 72.1667 122.87V132.463C72.1667 133.22 72.7802 133.833 73.5371 133.833H83.1297C83.8865 133.833 84.5 133.22 84.5 132.463V122.87C84.5 122.114 83.8865 121.5 83.1297 121.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M132.463 72.167H122.87C122.114 72.167 121.5 72.7805 121.5 73.5374V83.13C121.5 83.8868 122.114 84.5003 122.87 84.5003H132.463C133.22 84.5003 133.833 83.8868 133.833 83.13V73.5374C133.833 72.7805 133.22 72.167 132.463 72.167Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M132.463 121.5H122.87C122.114 121.5 121.5 122.114 121.5 122.87V132.463C121.5 133.22 122.114 133.833 122.87 133.833H132.463C133.22 133.833 133.833 133.22 133.833 132.463V122.87C133.833 122.114 133.22 121.5 132.463 121.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<g filter="url(#filter0_ddd_8097_110478)"> +<circle cx="103" cy="173.828" r="31" fill="#3A5BC7"/> +<path d="M91.9961 166.5H114.496" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M96.9961 174H109.496" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M100.746 181.5H105.746" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/> +</g> +<defs> +<filter id="filter0_ddd_8097_110478" x="64" y="137.828" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_8097_110478"/> +<feOffset dy="2"/> +<feGaussianBlur stdDeviation="1.5"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8097_110478"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_8097_110478"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="6"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_8097_110478" result="effect2_dropShadow_8097_110478"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_8097_110478"/> +<feOffset dy="4"/> +<feGaussianBlur stdDeviation="8"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/> +<feBlend mode="normal" in2="effect2_dropShadow_8097_110478" result="effect3_dropShadow_8097_110478"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_8097_110478" result="shape"/> +</filter> +<linearGradient id="paint0_linear_8097_110478" x1="102.5" y1="0" x2="102.5" y2="207.52" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F7F7F7"/> +<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/> +</linearGradient> +</defs> +</svg> diff --git a/web/public/empty-state/module/name-filter.svg b/web/public/empty-state/module/name-filter.svg new file mode 100644 index 000000000..0d9655b66 --- /dev/null +++ b/web/public/empty-state/module/name-filter.svg @@ -0,0 +1,44 @@ +<svg width="206" height="217" viewBox="0 0 206 217" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="103" cy="102.5" r="102.5" fill="url(#paint0_linear_8097_112031)"/> +<path d="M146.667 47.5H60.3333C53.5218 47.5 48 53.0218 48 59.8333V146.167C48 152.978 53.5218 158.5 60.3333 158.5H146.667C153.478 158.5 159 152.978 159 146.167V59.8333C159 53.0218 153.478 47.5 146.667 47.5Z" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M83.6297 72.167H74.0371C73.2803 72.167 72.6667 72.7805 72.6667 73.5374V83.13C72.6667 83.8868 73.2803 84.5003 74.0371 84.5003H83.6297C84.3865 84.5003 85.0001 83.8868 85.0001 83.13V73.5374C85.0001 72.7805 84.3865 72.167 83.6297 72.167Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M83.6297 121.5H74.0371C73.2803 121.5 72.6667 122.114 72.6667 122.87V132.463C72.6667 133.22 73.2803 133.833 74.0371 133.833H83.6297C84.3865 133.833 85.0001 133.22 85.0001 132.463V122.87C85.0001 122.114 84.3865 121.5 83.6297 121.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M132.963 72.167H123.37C122.614 72.167 122 72.7805 122 73.5374V83.13C122 83.8868 122.614 84.5003 123.37 84.5003H132.963C133.72 84.5003 134.333 83.8868 134.333 83.13V73.5374C134.333 72.7805 133.72 72.167 132.963 72.167Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M132.963 121.5H123.37C122.614 121.5 122 122.114 122 122.87V132.463C122 133.22 122.614 133.833 123.37 133.833H132.963C133.72 133.833 134.333 133.22 134.333 132.463V122.87C134.333 122.114 133.72 121.5 132.963 121.5Z" fill="#80838D" stroke="#80838D" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/> +<g filter="url(#filter0_ddd_8097_112031)"> +<circle cx="103.5" cy="174" r="31" fill="#3A5BC7"/> +<path d="M101.821 185.756C109.24 185.756 115.254 179.742 115.254 172.323C115.254 164.904 109.24 158.89 101.821 158.89C94.4015 158.89 88.3872 164.904 88.3872 172.323C88.3872 179.742 94.4015 185.756 101.821 185.756Z" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M118.612 189.114L111.392 181.894" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/> +</g> +<defs> +<filter id="filter0_ddd_8097_112031" x="64.5" y="138" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_8097_112031"/> +<feOffset dy="2"/> +<feGaussianBlur stdDeviation="1.5"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8097_112031"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_8097_112031"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="6"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_8097_112031" result="effect2_dropShadow_8097_112031"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_8097_112031"/> +<feOffset dy="4"/> +<feGaussianBlur stdDeviation="8"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/> +<feBlend mode="normal" in2="effect2_dropShadow_8097_112031" result="effect3_dropShadow_8097_112031"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_8097_112031" result="shape"/> +</filter> +<linearGradient id="paint0_linear_8097_112031" x1="103" y1="0" x2="103" y2="207.52" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F7F7F7"/> +<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/> +</linearGradient> +</defs> +</svg> diff --git a/web/public/empty-state/project/all-filters.svg b/web/public/empty-state/project/all-filters.svg new file mode 100644 index 000000000..0280bcf2d --- /dev/null +++ b/web/public/empty-state/project/all-filters.svg @@ -0,0 +1,42 @@ +<svg width="206" height="217" viewBox="0 0 206 217" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="103" cy="102.5" r="102.5" fill="url(#paint0_linear_8013_111910)"/> +<path d="M153 64.75H53C46.0964 64.75 40.5 70.3464 40.5 77.25V139.75C40.5 146.654 46.0964 152.25 53 152.25H153C159.904 152.25 165.5 146.654 165.5 139.75V77.25C165.5 70.3464 159.904 64.75 153 64.75Z" stroke="#B9BBC6" stroke-width="3.36" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M128 152.25V52.25C128 48.9348 126.683 45.7554 124.339 43.4112C121.995 41.067 118.815 39.75 115.5 39.75H90.5C87.1848 39.75 84.0054 41.067 81.6612 43.4112C79.317 45.7554 78 48.9348 78 52.25V152.25" stroke="#B9BBC6" stroke-width="3.36" stroke-linecap="round" stroke-linejoin="round"/> +<g filter="url(#filter0_ddd_8013_111910)"> +<circle cx="104" cy="174" r="31" fill="#3A5BC7"/> +<path d="M89 164H119" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M95.667 174H112.334" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M100.667 184H107.334" stroke="#F9F9FB" stroke-width="3.57143" stroke-linecap="round" stroke-linejoin="round"/> +</g> +<defs> +<filter id="filter0_ddd_8013_111910" x="65" y="138" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_8013_111910"/> +<feOffset dy="2"/> +<feGaussianBlur stdDeviation="1.5"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8013_111910"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_8013_111910"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="6"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_8013_111910" result="effect2_dropShadow_8013_111910"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_8013_111910"/> +<feOffset dy="4"/> +<feGaussianBlur stdDeviation="8"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/> +<feBlend mode="normal" in2="effect2_dropShadow_8013_111910" result="effect3_dropShadow_8013_111910"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_8013_111910" result="shape"/> +</filter> +<linearGradient id="paint0_linear_8013_111910" x1="103" y1="0" x2="103" y2="207.52" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F7F7F7"/> +<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/> +</linearGradient> +</defs> +</svg> diff --git a/web/public/empty-state/project/name-filter.svg b/web/public/empty-state/project/name-filter.svg new file mode 100644 index 000000000..a1e89c9a0 --- /dev/null +++ b/web/public/empty-state/project/name-filter.svg @@ -0,0 +1,41 @@ +<svg width="206" height="217" viewBox="0 0 206 217" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="103" cy="102.5" r="102.5" fill="url(#paint0_linear_8013_110289)"/> +<path d="M153 64.75H53C46.0964 64.75 40.5 70.3464 40.5 77.25V139.75C40.5 146.654 46.0964 152.25 53 152.25H153C159.904 152.25 165.5 146.654 165.5 139.75V77.25C165.5 70.3464 159.904 64.75 153 64.75Z" stroke="#B9BBC6" stroke-width="3.36" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M128 152.25V52.25C128 48.9348 126.683 45.7554 124.339 43.4112C121.995 41.067 118.815 39.75 115.5 39.75H90.5C87.1848 39.75 84.0054 41.067 81.6612 43.4112C79.317 45.7554 78 48.9348 78 52.25V152.25" stroke="#B9BBC6" stroke-width="3.36" stroke-linecap="round" stroke-linejoin="round"/> +<g filter="url(#filter0_ddd_8013_110289)"> +<circle cx="103.754" cy="173.828" r="31" fill="#3A5BC7"/> +<path d="M102.074 185.584C109.493 185.584 115.508 179.57 115.508 172.151C115.508 164.732 109.493 158.717 102.074 158.717C94.6553 158.717 88.641 164.732 88.641 172.151C88.641 179.57 94.6553 185.584 102.074 185.584Z" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M118.866 188.942L111.646 181.722" stroke="white" stroke-width="2.52049" stroke-linecap="round" stroke-linejoin="round"/> +</g> +<defs> +<filter id="filter0_ddd_8013_110289" x="64.7539" y="137.828" width="78" height="79" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="2" operator="erode" in="SourceAlpha" result="effect1_dropShadow_8013_110289"/> +<feOffset dy="2"/> +<feGaussianBlur stdDeviation="1.5"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0 0 0 0.051 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8013_110289"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect2_dropShadow_8013_110289"/> +<feOffset dy="3"/> +<feGaussianBlur stdDeviation="6"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.055 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_8013_110289" result="effect2_dropShadow_8013_110289"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="8" operator="erode" in="SourceAlpha" result="effect3_dropShadow_8013_110289"/> +<feOffset dy="4"/> +<feGaussianBlur stdDeviation="8"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.078 0"/> +<feBlend mode="normal" in2="effect2_dropShadow_8013_110289" result="effect3_dropShadow_8013_110289"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow_8013_110289" result="shape"/> +</filter> +<linearGradient id="paint0_linear_8013_110289" x1="103" y1="0" x2="103" y2="207.52" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F7F7F7"/> +<stop offset="1" stop-color="#F8F9FA" stop-opacity="0"/> +</linearGradient> +</defs> +</svg> diff --git a/web/store/dashboard.store.ts b/web/store/dashboard.store.ts index c8a07428e..4eaf325b2 100644 --- a/web/store/dashboard.store.ts +++ b/web/store/dashboard.store.ts @@ -15,6 +15,8 @@ import { } from "@plane/types"; export interface IDashboardStore { + // error states + widgetStatsError: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, any | null>> }; // observables homeDashboardId: string | null; widgetDetails: { [workspaceSlug: string]: Record<string, TWidget[]> }; @@ -36,6 +38,7 @@ export interface IDashboardStore { // computed actions getWidgetDetails: (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => TWidget | undefined; getWidgetStats: <T>(workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => T | undefined; + getWidgetStatsError: (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => any | null; // actions fetchHomeDashboardWidgets: (workspaceSlug: string) => Promise<THomeDashboardResponse>; fetchWidgetStats: ( @@ -58,6 +61,8 @@ export interface IDashboardStore { } export class DashboardStore implements IDashboardStore { + // error states + widgetStatsError: { [workspaceSlug: string]: Record<string, Record<TWidgetKeys, any>> } = {}; // observables homeDashboardId: string | null = null; widgetDetails: { [workspaceSlug: string]: Record<string, TWidget[]> } = {}; @@ -70,6 +75,8 @@ export class DashboardStore implements IDashboardStore { constructor(_rootStore: RootStore) { makeObservable(this, { + // error states + widgetStatsError: observable, // observables homeDashboardId: observable.ref, widgetDetails: observable, @@ -93,7 +100,7 @@ export class DashboardStore implements IDashboardStore { /** * @description get home dashboard widgets - * @returns home dashboard widgets + * @returns {TWidget[] | undefined} */ get homeDashboardWidgets() { const workspaceSlug = this.routerStore.workspaceSlug; @@ -104,10 +111,10 @@ export class DashboardStore implements IDashboardStore { /** * @description get widget details - * @param workspaceSlug - * @param dashboardId - * @param widgetId - * @returns widget details + * @param {string} workspaceSlug + * @param {string} dashboardId + * @param {TWidgetKeys} widgetKey + * @returns {TWidget | undefined} */ getWidgetDetails = computedFn((workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => { const widgets = this.widgetDetails?.[workspaceSlug]?.[dashboardId]; @@ -117,20 +124,30 @@ export class DashboardStore implements IDashboardStore { /** * @description get widget stats - * @param workspaceSlug - * @param dashboardId - * @param widgetKey - * @returns widget stats + * @param {string} workspaceSlug + * @param {string} dashboardId + * @param {TWidgetKeys} widgetKey + * @returns {T | undefined} */ getWidgetStats = <T>(workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys): T | undefined => (this.widgetStats?.[workspaceSlug]?.[dashboardId]?.[widgetKey] as unknown as T) ?? undefined; /** - * @description fetch home dashboard details and widgets - * @param workspaceSlug - * @returns home dashboard response + * @description get widget stats error + * @param {string} workspaceSlug + * @param {string} dashboardId + * @param {TWidgetKeys} widgetKey + * @returns {any | null} */ - fetchHomeDashboardWidgets = async (workspaceSlug: string) => { + getWidgetStatsError = (workspaceSlug: string, dashboardId: string, widgetKey: TWidgetKeys) => + this.widgetStatsError?.[workspaceSlug]?.[dashboardId]?.[widgetKey] ?? null; + + /** + * @description fetch home dashboard details and widgets + * @param {string} workspaceSlug + * @returns {Promise<THomeDashboardResponse>} + */ + fetchHomeDashboardWidgets = async (workspaceSlug: string): Promise<THomeDashboardResponse> => { try { const response = await this.dashboardService.getHomeDashboardWidgets(workspaceSlug); @@ -151,27 +168,36 @@ export class DashboardStore implements IDashboardStore { /** * @description fetch widget stats - * @param workspaceSlug - * @param dashboardId - * @param widgetKey + * @param {string} workspaceSlug + * @param {string} dashboardId + * @param {TWidgetStatsRequestParams} widgetKey * @returns widget stats */ fetchWidgetStats = async (workspaceSlug: string, dashboardId: string, params: TWidgetStatsRequestParams) => - this.dashboardService.getWidgetStats(workspaceSlug, dashboardId, params).then((res) => { - runInAction(() => { - // @ts-ignore - if (res.issues) this.issueStore.addIssue(res.issues); - set(this.widgetStats, [workspaceSlug, dashboardId, params.widget_key], res); - }); + this.dashboardService + .getWidgetStats(workspaceSlug, dashboardId, params) + .then((res) => { + runInAction(() => { + // @ts-ignore + if (res.issues) this.issueStore.addIssue(res.issues); + set(this.widgetStats, [workspaceSlug, dashboardId, params.widget_key], res); + set(this.widgetStatsError, [workspaceSlug, dashboardId, params.widget_key], null); + }); + return res; + }) + .catch((error) => { + runInAction(() => { + set(this.widgetStatsError, [workspaceSlug, dashboardId, params.widget_key], error); + }); - return res; - }); + throw error; + }); /** * @description update dashboard widget - * @param dashboardId - * @param widgetId - * @param data + * @param {string} dashboardId + * @param {string} widgetId + * @param {Partial<TWidget>} data * @returns updated widget */ updateDashboardWidget = async ( @@ -209,9 +235,9 @@ export class DashboardStore implements IDashboardStore { /** * @description update dashboard widget filters - * @param dashboardId - * @param widgetId - * @param data + * @param {string} dashboardId + * @param {string} widgetId + * @param {TWidgetFiltersFormData} data * @returns updated widget */ updateDashboardWidgetFilters = async ( diff --git a/web/store/module.store.ts b/web/store/module.store.ts index c7dcba79c..8b589a66f 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -5,6 +5,8 @@ import { computedFn } from "mobx-utils"; // services import { ModuleService } from "services/module.service"; import { ProjectService } from "services/project"; +// helpers +import { orderModules, shouldFilterModule } from "helpers/module.helper"; // types import { RootStore } from "store/root.store"; import { IModule, ILinkDetails } from "@plane/types"; @@ -18,6 +20,7 @@ export interface IModuleStore { // computed projectModuleIds: string[] | null; // computed actions + getFilteredModuleIds: (projectId: string) => string[] | null; getModuleById: (moduleId: string) => IModule | null; getModuleNameById: (moduleId: string) => string; getProjectModuleIds: (projectId: string) => string[] | null; @@ -108,6 +111,28 @@ export class ModulesStore implements IModuleStore { return projectModuleIds || null; } + /** + * @description returns filtered module ids based on display filters and filters + * @param {TModuleDisplayFilters} displayFilters + * @param {TModuleFilters} filters + * @returns {string[] | null} + */ + getFilteredModuleIds = computedFn((projectId: string) => { + const displayFilters = this.rootStore.moduleFilter.getDisplayFiltersByProjectId(projectId); + const filters = this.rootStore.moduleFilter.getFiltersByProjectId(projectId); + const searchQuery = this.rootStore.moduleFilter.searchQuery; + if (!this.fetchedMap[projectId]) return null; + let modules = Object.values(this.moduleMap ?? {}).filter( + (m) => + m.project_id === projectId && + m.name.toLowerCase().includes(searchQuery.toLowerCase()) && + shouldFilterModule(m, displayFilters ?? {}, filters ?? {}) + ); + modules = orderModules(modules, displayFilters?.order_by); + const moduleIds = modules.map((m) => m.id); + return moduleIds; + }); + /** * @description get module by id * @param moduleId diff --git a/web/store/module_filter.store.ts b/web/store/module_filter.store.ts new file mode 100644 index 000000000..52f8f1d4f --- /dev/null +++ b/web/store/module_filter.store.ts @@ -0,0 +1,146 @@ +import { action, computed, observable, makeObservable, runInAction, autorun } from "mobx"; +import { computedFn } from "mobx-utils"; +import set from "lodash/set"; +// types +import { RootStore } from "store/root.store"; +import { TModuleDisplayFilters, TModuleFilters } from "@plane/types"; + +export interface IModuleFilterStore { + // observables + displayFilters: Record<string, TModuleDisplayFilters>; + filters: Record<string, TModuleFilters>; + searchQuery: string; + // computed + currentProjectDisplayFilters: TModuleDisplayFilters | undefined; + currentProjectFilters: TModuleFilters | undefined; + // computed functions + getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined; + getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined; + // actions + updateDisplayFilters: (projectId: string, displayFilters: TModuleDisplayFilters) => void; + updateFilters: (projectId: string, filters: TModuleFilters) => void; + updateSearchQuery: (query: string) => void; + clearAllFilters: (projectId: string) => void; +} + +export class ModuleFilterStore implements IModuleFilterStore { + // observables + displayFilters: Record<string, TModuleDisplayFilters> = {}; + filters: Record<string, TModuleFilters> = {}; + searchQuery: string = ""; + // root store + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + displayFilters: observable, + filters: observable, + searchQuery: observable.ref, + // computed + currentProjectDisplayFilters: computed, + currentProjectFilters: computed, + // actions + updateDisplayFilters: action, + updateFilters: action, + updateSearchQuery: action, + clearAllFilters: action, + }); + // root store + this.rootStore = _rootStore; + // initialize display filters of the current project + autorun(() => { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + this.initProjectModuleFilters(projectId); + }); + } + + /** + * @description get display filters of the current project + */ + get currentProjectDisplayFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.displayFilters[projectId]; + } + + /** + * @description get filters of the current project + */ + get currentProjectFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.filters[projectId]; + } + + /** + * @description get display filters of a project by projectId + * @param {string} projectId + */ + getDisplayFiltersByProjectId = computedFn((projectId: string) => this.displayFilters[projectId]); + + /** + * @description get filters of a project by projectId + * @param {string} projectId + */ + getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]); + + /** + * @description initialize display filters and filters of a project + * @param {string} projectId + */ + initProjectModuleFilters = (projectId: string) => { + const displayFilters = this.getDisplayFiltersByProjectId(projectId); + runInAction(() => { + this.displayFilters[projectId] = { + favorites: displayFilters?.favorites || false, + layout: displayFilters?.layout || "list", + order_by: displayFilters?.order_by || "name", + }; + this.filters[projectId] = {}; + }); + }; + + /** + * @description update display filters of a project + * @param {string} projectId + * @param {TModuleDisplayFilters} displayFilters + */ + updateDisplayFilters = (projectId: string, displayFilters: TModuleDisplayFilters) => { + runInAction(() => { + Object.keys(displayFilters).forEach((key) => { + set(this.displayFilters, [projectId, key], displayFilters[key as keyof TModuleDisplayFilters]); + }); + }); + }; + + /** + * @description update filters of a project + * @param {string} projectId + * @param {TModuleFilters} filters + */ + updateFilters = (projectId: string, filters: TModuleFilters) => { + runInAction(() => { + Object.keys(filters).forEach((key) => { + set(this.filters, [projectId, key], filters[key as keyof TModuleFilters]); + }); + }); + }; + + /** + * @description update search query + * @param {string} query + */ + updateSearchQuery = (query: string) => (this.searchQuery = query); + + /** + * @description clear all filters of a project + * @param {string} projectId + */ + clearAllFilters = (projectId: string) => { + runInAction(() => { + this.filters[projectId] = {}; + }); + }; +} diff --git a/web/store/project/index.ts b/web/store/project/index.ts index dff0db175..87b0dac19 100644 --- a/web/store/project/index.ts +++ b/web/store/project/index.ts @@ -1,18 +1,22 @@ import { RootStore } from "store/root.store"; import { IProjectPublishStore, ProjectPublishStore } from "./project-publish.store"; import { IProjectStore, ProjectStore } from "./project.store"; +import { IProjectFilterStore, ProjectFilterStore } from "./project_filter.store"; export interface IProjectRootStore { project: IProjectStore; + projectFilter: IProjectFilterStore; publish: IProjectPublishStore; } export class ProjectRootStore { project: IProjectStore; + projectFilter: IProjectFilterStore; publish: IProjectPublishStore; constructor(_root: RootStore) { this.project = new ProjectStore(_root); + this.projectFilter = new ProjectFilterStore(_root); this.publish = new ProjectPublishStore(this); } } diff --git a/web/store/project/project.store.ts b/web/store/project/project.store.ts index 1b9220a2d..4f181ec34 100644 --- a/web/store/project/project.store.ts +++ b/web/store/project/project.store.ts @@ -1,4 +1,3 @@ -import { cloneDeep, update } from "lodash"; import set from "lodash/set"; import sortBy from "lodash/sortBy"; import { observable, action, computed, makeObservable, runInAction } from "mobx"; @@ -8,40 +7,38 @@ import { IssueLabelService, IssueService } from "services/issue"; import { ProjectService, ProjectStateService } from "services/project"; import { IProject } from "@plane/types"; import { RootStore } from "../root.store"; +import { orderProjects, shouldFilterProject } from "helpers/project.helper"; // services export interface IProjectStore { // observables - searchQuery: string; projectMap: { [projectId: string]: IProject; // projectId: project Info }; // computed - searchedProjects: string[]; - workspaceProjectIds: string[] | null; + filteredProjectIds: string[] | undefined; + workspaceProjectIds: string[] | undefined; joinedProjectIds: string[]; favoriteProjectIds: string[]; currentProjectDetails: IProject | undefined; // actions - setSearchQuery: (query: string) => void; getProjectById: (projectId: string) => IProject | null; getProjectIdentifierById: (projectId: string) => string; // fetch actions fetchProjects: (workspaceSlug: string) => Promise<IProject[]>; - fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<any>; + fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<IProject>; // favorites actions addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>; removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>; // project-view action updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise<any>; // CRUD actions - createProject: (workspaceSlug: string, data: any) => Promise<any>; - updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<any>; + createProject: (workspaceSlug: string, data: Partial<IProject>) => Promise<IProject>; + updateProject: (workspaceSlug: string, projectId: string, data: Partial<IProject>) => Promise<IProject>; deleteProject: (workspaceSlug: string, projectId: string) => Promise<void>; } export class ProjectStore implements IProjectStore { // observables - searchQuery: string = ""; projectMap: { [projectId: string]: IProject; // projectId: project Info } = {}; @@ -56,16 +53,13 @@ export class ProjectStore implements IProjectStore { constructor(_rootStore: RootStore) { makeObservable(this, { // observables - searchQuery: observable.ref, projectMap: observable, // computed - searchedProjects: computed, + filteredProjectIds: computed, workspaceProjectIds: computed, currentProjectDetails: computed, joinedProjectIds: computed, favoriteProjectIds: computed, - // actions - setSearchQuery: action.bound, // fetch actions fetchProjects: action, fetchProjectDetails: action, @@ -88,17 +82,24 @@ export class ProjectStore implements IProjectStore { } /** - * Returns searched projects based on search query + * @description returns filtered projects based on filters and search query */ - get searchedProjects() { + get filteredProjectIds() { const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; - if (!workspaceDetails) return []; - const workspaceProjects = Object.values(this.projectMap).filter( + const { + currentWorkspaceDisplayFilters: displayFilters, + currentWorkspaceFilters: filters, + searchQuery, + } = this.rootStore.projectRoot.projectFilter; + if (!workspaceDetails || !displayFilters || !filters) return; + let workspaceProjects = Object.values(this.projectMap).filter( (p) => p.workspace === workspaceDetails.id && - (p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) || - p.identifier.toLowerCase().includes(this.searchQuery.toLowerCase())) + (p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.identifier.toLowerCase().includes(searchQuery.toLowerCase())) && + shouldFilterProject(p, displayFilters, filters) ); + workspaceProjects = orderProjects(workspaceProjects, displayFilters.order_by); return workspaceProjects.map((p) => p.id); } @@ -107,7 +108,7 @@ export class ProjectStore implements IProjectStore { */ get workspaceProjectIds() { const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; - if (!workspaceDetails) return null; + if (!workspaceDetails) return; const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id); const projectIds = workspaceProjects.map((p) => p.id); return projectIds ?? null; @@ -153,14 +154,6 @@ export class ProjectStore implements IProjectStore { return projectIds; } - /** - * Sets search query - * @param query - */ - setSearchQuery = (query: string) => { - this.searchQuery = query; - }; - /** * get Workspace projects using workspace slug * @param workspaceSlug diff --git a/web/store/project/project_filter.store.ts b/web/store/project/project_filter.store.ts new file mode 100644 index 000000000..35eac1f62 --- /dev/null +++ b/web/store/project/project_filter.store.ts @@ -0,0 +1,144 @@ +import { action, computed, observable, makeObservable, runInAction, autorun } from "mobx"; +import { computedFn } from "mobx-utils"; +import set from "lodash/set"; +// types +import { RootStore } from "store/root.store"; +import { TProjectDisplayFilters, TProjectFilters } from "@plane/types"; + +export interface IProjectFilterStore { + // observables + displayFilters: Record<string, TProjectDisplayFilters>; + filters: Record<string, TProjectFilters>; + searchQuery: string; + // computed + currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined; + currentWorkspaceFilters: TProjectFilters | undefined; + // computed functions + getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined; + getFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectFilters | undefined; + // actions + updateDisplayFilters: (workspaceSlug: string, displayFilters: TProjectDisplayFilters) => void; + updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void; + updateSearchQuery: (query: string) => void; + clearAllFilters: (workspaceSlug: string) => void; +} + +export class ProjectFilterStore implements IProjectFilterStore { + // observables + displayFilters: Record<string, TProjectDisplayFilters> = {}; + filters: Record<string, TProjectFilters> = {}; + searchQuery: string = ""; + // root store + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + displayFilters: observable, + filters: observable, + searchQuery: observable.ref, + // computed + currentWorkspaceDisplayFilters: computed, + currentWorkspaceFilters: computed, + // actions + updateDisplayFilters: action, + updateFilters: action, + updateSearchQuery: action, + clearAllFilters: action, + }); + // root store + this.rootStore = _rootStore; + // initialize display filters of the current workspace + autorun(() => { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + this.initWorkspaceFilters(workspaceSlug); + }); + } + + /** + * @description get display filters of the current workspace + */ + get currentWorkspaceDisplayFilters() { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + return this.displayFilters[workspaceSlug]; + } + + /** + * @description get filters of the current workspace + */ + get currentWorkspaceFilters() { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + return this.filters[workspaceSlug]; + } + + /** + * @description get display filters of a workspace by workspaceSlug + * @param {string} workspaceSlug + */ + getDisplayFiltersByWorkspaceSlug = computedFn((workspaceSlug: string) => this.displayFilters[workspaceSlug]); + + /** + * @description get filters of a workspace by workspaceSlug + * @param {string} workspaceSlug + */ + getFiltersByWorkspaceSlug = computedFn((workspaceSlug: string) => this.filters[workspaceSlug]); + + /** + * @description initialize display filters and filters of a workspace + * @param {string} workspaceSlug + */ + initWorkspaceFilters = (workspaceSlug: string) => { + const displayFilters = this.getDisplayFiltersByWorkspaceSlug(workspaceSlug); + runInAction(() => { + this.displayFilters[workspaceSlug] = { + order_by: displayFilters?.order_by || "created_at", + }; + this.filters[workspaceSlug] = {}; + }); + }; + + /** + * @description update display filters of a workspace + * @param {string} workspaceSlug + * @param {TProjectDisplayFilters} displayFilters + */ + updateDisplayFilters = (workspaceSlug: string, displayFilters: TProjectDisplayFilters) => { + runInAction(() => { + Object.keys(displayFilters).forEach((key) => { + set(this.displayFilters, [workspaceSlug, key], displayFilters[key as keyof TProjectDisplayFilters]); + }); + }); + }; + + /** + * @description update filters of a workspace + * @param {string} workspaceSlug + * @param {TProjectFilters} filters + */ + updateFilters = (workspaceSlug: string, filters: TProjectFilters) => { + runInAction(() => { + Object.keys(filters).forEach((key) => { + set(this.filters, [workspaceSlug, key], filters[key as keyof TProjectFilters]); + }); + }); + }; + + /** + * @description update search query + * @param {string} query + */ + updateSearchQuery = (query: string) => (this.searchQuery = query); + + /** + * @description clear all filters of a workspace + * @param {string} workspaceSlug + */ + clearAllFilters = (workspaceSlug: string) => { + runInAction(() => { + this.filters[workspaceSlug] = {}; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 0390d7ce2..5dc3c2574 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -19,6 +19,7 @@ import { IUserRootStore, UserRootStore } from "./user"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store"; +import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store"; enableStaticRendering(typeof window === "undefined"); @@ -32,6 +33,7 @@ export class RootStore { cycle: ICycleStore; cycleFilter: ICycleFilterStore; module: IModuleStore; + moduleFilter: IModuleFilterStore; projectView: IProjectViewStore; globalView: IGlobalViewStore; issue: IIssueRootStore; @@ -54,6 +56,7 @@ export class RootStore { this.cycle = new CycleStore(this); this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); + this.moduleFilter = new ModuleFilterStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this); @@ -62,8 +65,8 @@ export class RootStore { this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); - this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); + this.projectPages = new ProjectPageStore(this); } resetOnSignout() { @@ -74,6 +77,7 @@ export class RootStore { this.cycle = new CycleStore(this); this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); + this.moduleFilter = new ModuleFilterStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this); @@ -82,7 +86,7 @@ export class RootStore { this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); - this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); + this.projectPages = new ProjectPageStore(this); } } diff --git a/web/styles/globals.css b/web/styles/globals.css index 6c51e75c4..5b24abc08 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -602,6 +602,9 @@ div.web-view-spinner div.bar12 { .horizontal-scrollbar::-webkit-scrollbar-corner { background-color: transparent; } +.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track { + margin-top: 44px; +} /* scrollbar sm size */ .scrollbar-sm::-webkit-scrollbar {