diff --git a/.github/fabricbot.json b/.github/fabricbot.json index dd4a25c756..1dfbf0f9d3 100644 --- a/.github/fabricbot.json +++ b/.github/fabricbot.json @@ -48,9 +48,7 @@ } ], "eventType": "issue", - "eventNames": [ - "issue_comment" - ] + "eventNames": ["issue_comment"] }, "id": "CIypIJG15L" }, @@ -92,10 +90,7 @@ } ], "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ] + "eventNames": ["issues", "project_card"] }, "id": "tfFb5MZkEN" }, @@ -109,206 +104,31 @@ "frequency": [ { "weekDay": 0, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 1, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 2, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 3, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 4, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 5, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 6, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] } ], "searchTerms": [ @@ -358,206 +178,31 @@ "frequency": [ { "weekDay": 0, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 1, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 2, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 3, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 4, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 5, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 6, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] } ], "searchTerms": [ @@ -615,206 +260,31 @@ "frequency": [ { "weekDay": 0, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 1, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 2, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 3, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 4, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 5, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] }, { "weekDay": 6, - "hours": [ - 0, - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18, - 19, - 20, - 21, - 22, - 23 - ] + "hours": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23] } ], "searchTerms": [ @@ -861,9 +331,7 @@ "version": "1.0", "config": { "eventType": "issue", - "eventNames": [ - "issue_comment" - ], + "eventNames": ["issue_comment"], "conditions": { "operator": "and", "operands": [ @@ -983,9 +451,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issue_comment" - ], + "eventNames": ["issue_comment"], "taskName": "For issues closed due to inactivity, re-open an issue if issue author posts a reply within 7 days.", "actions": [ { @@ -1062,9 +528,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issue_comment" - ], + "eventNames": ["issue_comment"], "taskName": "For issues closed with no activity over 7 days, ask non-contributor to consider opening a new issue instead.", "actions": [ { @@ -1086,66 +550,31 @@ "frequency": [ { "weekDay": 0, - "hours": [ - 0, - 6, - 12, - 18 - ] + "hours": [0, 6, 12, 18] }, { "weekDay": 1, - "hours": [ - 0, - 6, - 12, - 18 - ] + "hours": [0, 6, 12, 18] }, { "weekDay": 2, - "hours": [ - 0, - 6, - 12, - 18 - ] + "hours": [0, 6, 12, 18] }, { "weekDay": 3, - "hours": [ - 0, - 6, - 12, - 18 - ] + "hours": [0, 6, 12, 18] }, { "weekDay": 4, - "hours": [ - 0, - 6, - 12, - 18 - ] + "hours": [0, 6, 12, 18] }, { "weekDay": 5, - "hours": [ - 0, - 6, - 12, - 18 - ] + "hours": [0, 6, 12, 18] }, { "weekDay": 6, - "hours": [ - 0, - 6, - 12, - 18 - ] + "hours": [0, 6, 12, 18] } ], "searchTerms": [ @@ -1213,9 +642,7 @@ } ], "eventType": "pull_request", - "eventNames": [ - "pull_request_review" - ] + "eventNames": ["pull_request_review"] }, "id": "UoZhI9Xa_b" }, @@ -1265,11 +692,7 @@ } ], "eventType": "pull_request", - "eventNames": [ - "pull_request", - "issues", - "project_card" - ] + "eventNames": ["pull_request", "issues", "project_card"] }, "id": "OBnWQyDvQP" }, @@ -1308,9 +731,7 @@ } ], "eventType": "pull_request", - "eventNames": [ - "issue_comment" - ] + "eventNames": ["issue_comment"] }, "id": "-vTgwlrBFi" }, @@ -1349,9 +770,7 @@ } ], "eventType": "pull_request", - "eventNames": [ - "pull_request_review" - ] + "eventNames": ["pull_request_review"] }, "id": "n0PmRx-B7j" }, @@ -1393,11 +812,7 @@ } ], "eventType": "pull_request", - "eventNames": [ - "pull_request", - "issues", - "project_card" - ] + "eventNames": ["pull_request", "issues", "project_card"] }, "id": "wnHBiGRhLL" }, @@ -1428,9 +843,7 @@ } ], "eventType": "pull_request", - "eventNames": [ - "issue_comment" - ] + "eventNames": ["issue_comment"] }, "id": "zQ4HFBl0o8" }, @@ -1461,9 +874,7 @@ } ], "eventType": "pull_request", - "eventNames": [ - "pull_request_review" - ] + "eventNames": ["pull_request_review"] }, "id": "8Zyb0OGTkn" }, @@ -1477,66 +888,31 @@ "frequency": [ { "weekDay": 0, - "hours": [ - 4, - 10, - 16, - 22 - ] + "hours": [4, 10, 16, 22] }, { "weekDay": 1, - "hours": [ - 4, - 10, - 16, - 22 - ] + "hours": [4, 10, 16, 22] }, { "weekDay": 2, - "hours": [ - 4, - 10, - 16, - 22 - ] + "hours": [4, 10, 16, 22] }, { "weekDay": 3, - "hours": [ - 4, - 10, - 16, - 22 - ] + "hours": [4, 10, 16, 22] }, { "weekDay": 4, - "hours": [ - 4, - 10, - 16, - 22 - ] + "hours": [4, 10, 16, 22] }, { "weekDay": 5, - "hours": [ - 4, - 10, - 16, - 22 - ] + "hours": [4, 10, 16, 22] }, { "weekDay": 6, - "hours": [ - 4, - 10, - 16, - 22 - ] + "hours": [4, 10, 16, 22] } ], "searchTerms": [ @@ -1586,66 +962,31 @@ "frequency": [ { "weekDay": 0, - "hours": [ - 5, - 11, - 17, - 23 - ] + "hours": [5, 11, 17, 23] }, { "weekDay": 1, - "hours": [ - 5, - 11, - 17, - 23 - ] + "hours": [5, 11, 17, 23] }, { "weekDay": 2, - "hours": [ - 5, - 11, - 17, - 23 - ] + "hours": [5, 11, 17, 23] }, { "weekDay": 3, - "hours": [ - 5, - 11, - 17, - 23 - ] + "hours": [5, 11, 17, 23] }, { "weekDay": 4, - "hours": [ - 5, - 11, - 17, - 23 - ] + "hours": [5, 11, 17, 23] }, { "weekDay": 5, - "hours": [ - 5, - 11, - 17, - 23 - ] + "hours": [5, 11, 17, 23] }, { "weekDay": 6, - "hours": [ - 5, - 11, - 17, - 23 - ] + "hours": [5, 11, 17, 23] } ], "searchTerms": [ @@ -1734,10 +1075,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add \"work-in Progress\" label when moved to In progress column in the project", "actions": [ { @@ -1792,10 +1130,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add Blocked label when moved to blocked column in the project", "actions": [ { @@ -1862,10 +1197,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add needs triage label with welcome message", "actions": [ { @@ -1907,10 +1239,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add Completed label when moved to Done column in the project", "actions": [ { @@ -1964,10 +1293,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add Needs Triage label when moved to Need Triage column in the project", "actions": [ { @@ -2021,10 +1347,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add In Review label when moved to To do column in the project", "actions": [ { @@ -2073,10 +1396,7 @@ "operands": [] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add Completed label when moved to Completed column in the project" } }, @@ -2092,10 +1412,7 @@ "operands": [] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add Completed label when moved to Completed column in the project" } }, @@ -2111,10 +1428,7 @@ "operands": [] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ] + "eventNames": ["issues", "project_card"] } }, { @@ -2129,10 +1443,7 @@ "operands": [] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add Completed label when moved to Completed column in the project" } }, @@ -2148,10 +1459,7 @@ "operands": [] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add Completed label when moved to Completed column in the project" } }, @@ -2198,10 +1506,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add needs triage label to new issues", "actions": [ { @@ -2257,10 +1562,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add needs triage label to new issues", "actions": [ { @@ -2316,10 +1618,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add needs triage label to new issues", "actions": [ { @@ -2375,10 +1674,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add needs triage label to new issues", "actions": [ { @@ -2434,10 +1730,7 @@ ] }, "eventType": "issue", - "eventNames": [ - "issues", - "project_card" - ], + "eventNames": ["issues", "project_card"], "taskName": "Add needs triage label to new issues", "actions": [ { @@ -2469,11 +1762,7 @@ ] }, "eventType": "pull_request", - "eventNames": [ - "pull_request", - "issues", - "project_card" - ], + "eventNames": ["pull_request", "issues", "project_card"], "actions": [ { "name": "addReply", diff --git a/.github/policies/branch-protection.yml b/.github/policies/branch-protection.yml index 8f2258c180..b422fb7132 100644 --- a/.github/policies/branch-protection.yml +++ b/.github/policies/branch-protection.yml @@ -6,26 +6,26 @@ description: Organization branch protection policy for Microsoft Graph Toolkit r resource: repository configuration: branchProtectionRules: - - branchNamePattern: next/* - requiredApprovingReviewsCount: - min: 1 - # Must have a CODEOWNER approve for the PR to be merged. - requireCodeOwnersReview: true - - # Dismiss stale pull request approvals when new commits are pushed - dismissStaleReviews: true - - # Require conversation resolution before merging. Address all concerns, and resolve in the GitHub PR UI. - requiresConversationResolution: true + - branchNamePattern: next/* + requiredApprovingReviewsCount: + min: 1 + # Must have a CODEOWNER approve for the PR to be merged. + requireCodeOwnersReview: true - # Require status checks to pass before merging. TODO: this value should be true, we should work to support this. - # Used with the requiredStatusChecks setting to specify which checks must pass for the PR to be merged. - requiresStrictStatusChecks: true + # Dismiss stale pull request approvals when new commits are pushed + dismissStaleReviews: true - requiredStatusChecks: - - GitOps/AdvancedSecurity - - license/cla - - check-build-matrix - - # TODO: all commits should be signed. We need to get everyone signing their commits. - requiresCommitSignatures: false + # Require conversation resolution before merging. Address all concerns, and resolve in the GitHub PR UI. + requiresConversationResolution: true + + # Require status checks to pass before merging. TODO: this value should be true, we should work to support this. + # Used with the requiredStatusChecks setting to specify which checks must pass for the PR to be merged. + requiresStrictStatusChecks: true + + requiredStatusChecks: + - GitOps/AdvancedSecurity + - license/cla + - check-build-matrix + + # TODO: all commits should be signed. We need to get everyone signing their commits. + requiresCommitSignatures: false diff --git a/.github/workflows/project-automation.yml b/.github/workflows/project-automation.yml index 428aadf46b..f1408d369d 100644 --- a/.github/workflows/project-automation.yml +++ b/.github/workflows/project-automation.yml @@ -32,8 +32,8 @@ jobs: }' -f org=$PROJECT_ORGANIZATION -F number=$PROJECT_NUMBER > project_data.json echo 'PROJECT_ID='$(jq '.data.organization.projectNext.id' project_data.json) >> $GITHUB_ENV - - - name: Add issue to project + + - name: Add issue to project env: GITHUB_TOKEN: ${{ secrets.PROJECT_ACCESS_TOKEN }} ISSUE_ID: ${{ github.event.issue.node_id }} @@ -43,4 +43,4 @@ jobs: addProjectNextItem(input: {projectId: $project, contentId: $issue}) { projectNextItem { id } } - }' -f project=$PROJECT_ID -f issue=$ISSUE_ID \ No newline at end of file + }' -f project=$PROJECT_ID -f issue=$ISSUE_ID diff --git a/.vscode/launch.json b/.vscode/launch.json index aa5d14456f..41619e2ae0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "version": "0.2.0", "configurations": [ { - "type": "edge", + "type": "msedge", "request": "launch", "name": "Launch Edge Canary against localhost", "url": "http://localhost:3000", @@ -13,7 +13,7 @@ "version": "canary" }, { - "type": "edge", + "type": "msedge", "request": "launch", "name": "Launch Edge Dev against localhost", "url": "http://localhost:3000", @@ -28,6 +28,14 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "port": 9229 + }, + { + "name": "Tests debug (Edge)", + "request": "launch", + "type": "msedge", + "url": "http://localhost:8000/", + "webRoot": "${workspaceRoot}", + "sourceMaps": true } ] } diff --git a/assets/workbox-dae083bf.js b/assets/workbox-dae083bf.js index 6ed0c7ade9..99b53de717 100644 --- a/assets/workbox-dae083bf.js +++ b/assets/workbox-dae083bf.js @@ -1,2 +1,644 @@ -define(["exports"], (function (t) { "use strict"; try { self["workbox:core:6.4.1"] && _() } catch (t) { } const e = (t, ...e) => { let s = t; return e.length > 0 && (s += ` :: ${JSON.stringify(e)}`), s }; class s extends Error { constructor(t, s) { super(e(t, s)), this.name = t, this.details = s } } try { self["workbox:routing:6.4.1"] && _() } catch (t) { } const n = t => t && "object" == typeof t ? t : { handle: t }; class i { constructor(t, e, s = "GET") { this.handler = n(e), this.match = t, this.method = s } setCatchHandler(t) { this.catchHandler = n(t) } } class r extends i { constructor(t, e, s) { super((({ url: e }) => { const s = t.exec(e.href); if (s && (e.origin === location.origin || 0 === s.index)) return s.slice(1) }), e, s) } } class o { constructor() { this.t = new Map, this.i = new Map } get routes() { return this.t } addFetchListener() { self.addEventListener("fetch", (t => { const { request: e } = t, s = this.handleRequest({ request: e, event: t }); s && t.respondWith(s) })) } addCacheListener() { self.addEventListener("message", (t => { if (t.data && "CACHE_URLS" === t.data.type) { const { payload: e } = t.data, s = Promise.all(e.urlsToCache.map((e => { "string" == typeof e && (e = [e]); const s = new Request(...e); return this.handleRequest({ request: s, event: t }) }))); t.waitUntil(s), t.ports && t.ports[0] && s.then((() => t.ports[0].postMessage(!0))) } })) } handleRequest({ request: t, event: e }) { const s = new URL(t.url, location.href); if (!s.protocol.startsWith("http")) return; const n = s.origin === location.origin, { params: i, route: r } = this.findMatchingRoute({ event: e, request: t, sameOrigin: n, url: s }); let o = r && r.handler; const a = t.method; if (!o && this.i.has(a) && (o = this.i.get(a)), !o) return; let c; try { c = o.handle({ url: s, request: t, event: e, params: i }) } catch (t) { c = Promise.reject(t) } const h = r && r.catchHandler; return c instanceof Promise && (this.o || h) && (c = c.catch((async n => { if (h) try { return await h.handle({ url: s, request: t, event: e, params: i }) } catch (t) { t instanceof Error && (n = t) } if (this.o) return this.o.handle({ url: s, request: t, event: e }); throw n }))), c } findMatchingRoute({ url: t, sameOrigin: e, request: s, event: n }) { const i = this.t.get(s.method) || []; for (const r of i) { let i; const o = r.match({ url: t, sameOrigin: e, request: s, event: n }); if (o) return i = o, (Array.isArray(i) && 0 === i.length || o.constructor === Object && 0 === Object.keys(o).length || "boolean" == typeof o) && (i = void 0), { route: r, params: i } } return {} } setDefaultHandler(t, e = "GET") { this.i.set(e, n(t)) } setCatchHandler(t) { this.o = n(t) } registerRoute(t) { this.t.has(t.method) || this.t.set(t.method, []), this.t.get(t.method).push(t) } unregisterRoute(t) { if (!this.t.has(t.method)) throw new s("unregister-route-but-not-found-with-method", { method: t.method }); const e = this.t.get(t.method).indexOf(t); if (!(e > -1)) throw new s("unregister-route-route-not-registered"); this.t.get(t.method).splice(e, 1) } } let a; const c = { googleAnalytics: "googleAnalytics", precache: "precache-v2", prefix: "workbox", runtime: "runtime", suffix: "undefined" != typeof registration ? registration.scope : "" }, h = t => [c.prefix, t, c.suffix].filter((t => t && t.length > 0)).join("-"), u = t => t || h(c.precache), l = t => t || h(c.runtime); function f(t, e) { const s = e(); return t.waitUntil(s), s } try { self["workbox:precaching:6.4.1"] && _() } catch (t) { } function w(t) { if (!t) throw new s("add-to-cache-list-unexpected-type", { entry: t }); if ("string" == typeof t) { const e = new URL(t, location.href); return { cacheKey: e.href, url: e.href } } const { revision: e, url: n } = t; if (!n) throw new s("add-to-cache-list-unexpected-type", { entry: t }); if (!e) { const t = new URL(n, location.href); return { cacheKey: t.href, url: t.href } } const i = new URL(n, location.href), r = new URL(n, location.href); return i.searchParams.set("__WB_REVISION__", e), { cacheKey: i.href, url: r.href } } class d { constructor() { this.updatedURLs = [], this.notUpdatedURLs = [], this.handlerWillStart = async ({ request: t, state: e }) => { e && (e.originalRequest = t) }, this.cachedResponseWillBeUsed = async ({ event: t, state: e, cachedResponse: s }) => { if ("install" === t.type && e && e.originalRequest && e.originalRequest instanceof Request) { const t = e.originalRequest.url; s ? this.notUpdatedURLs.push(t) : this.updatedURLs.push(t) } return s } } } class p { constructor({ precacheController: t }) { this.cacheKeyWillBeUsed = async ({ request: t, params: e }) => { const s = (null == e ? void 0 : e.cacheKey) || this.h.getCacheKeyForURL(t.url); return s ? new Request(s, { headers: t.headers }) : t }, this.h = t } } let y; async function g(t, e) { let n = null; if (t.url) { n = new URL(t.url).origin } if (n !== self.location.origin) throw new s("cross-origin-copy-response", { origin: n }); const i = t.clone(), r = { headers: new Headers(i.headers), status: i.status, statusText: i.statusText }, o = e ? e(r) : r, a = function () { if (void 0 === y) { const t = new Response(""); if ("body" in t) try { new Response(t.body), y = !0 } catch (t) { y = !1 } y = !1 } return y }() ? i.body : await i.blob(); return new Response(a, o) } function R(t, e) { const s = new URL(t); for (const t of e) s.searchParams.delete(t); return s.href } class m { constructor() { this.promise = new Promise(((t, e) => { this.resolve = t, this.reject = e })) } } const v = new Set; try { self["workbox:strategies:6.4.1"] && _() } catch (t) { } function q(t) { return "string" == typeof t ? new Request(t) : t } class U { constructor(t, e) { this.u = {}, Object.assign(this, e), this.event = e.event, this.l = t, this.p = new m, this.g = [], this.R = [...t.plugins], this.m = new Map; for (const t of this.R) this.m.set(t, {}); this.event.waitUntil(this.p.promise) } async fetch(t) { const { event: e } = this; let n = q(t); if ("navigate" === n.mode && e instanceof FetchEvent && e.preloadResponse) { const t = await e.preloadResponse; if (t) return t } const i = this.hasCallback("fetchDidFail") ? n.clone() : null; try { for (const t of this.iterateCallbacks("requestWillFetch")) n = await t({ request: n.clone(), event: e }) } catch (t) { if (t instanceof Error) throw new s("plugin-error-request-will-fetch", { thrownErrorMessage: t.message }) } const r = n.clone(); try { let t; t = await fetch(n, "navigate" === n.mode ? void 0 : this.l.fetchOptions); for (const s of this.iterateCallbacks("fetchDidSucceed")) t = await s({ event: e, request: r, response: t }); return t } catch (t) { throw i && await this.runCallbacks("fetchDidFail", { error: t, event: e, originalRequest: i.clone(), request: r.clone() }), t } } async fetchAndCachePut(t) { const e = await this.fetch(t), s = e.clone(); return this.waitUntil(this.cachePut(t, s)), e } async cacheMatch(t) { const e = q(t); let s; const { cacheName: n, matchOptions: i } = this.l, r = await this.getCacheKey(e, "read"), o = Object.assign(Object.assign({}, i), { cacheName: n }); s = await caches.match(r, o); for (const t of this.iterateCallbacks("cachedResponseWillBeUsed")) s = await t({ cacheName: n, matchOptions: i, cachedResponse: s, request: r, event: this.event }) || void 0; return s } async cachePut(t, e) { const n = q(t); var i; await (i = 0, new Promise((t => setTimeout(t, i)))); const r = await this.getCacheKey(n, "write"); if (!e) throw new s("cache-put-with-no-response", { url: (o = r.url, new URL(String(o), location.href).href.replace(new RegExp(`^${location.origin}`), "")) }); var o; const a = await this.v(e); if (!a) return !1; const { cacheName: c, matchOptions: h } = this.l, u = await self.caches.open(c), l = this.hasCallback("cacheDidUpdate"), f = l ? await async function (t, e, s, n) { const i = R(e.url, s); if (e.url === i) return t.match(e, n); const r = Object.assign(Object.assign({}, n), { ignoreSearch: !0 }), o = await t.keys(e, r); for (const e of o) if (i === R(e.url, s)) return t.match(e, n) }(u, r.clone(), ["__WB_REVISION__"], h) : null; try { await u.put(r, l ? a.clone() : a) } catch (t) { if (t instanceof Error) throw "QuotaExceededError" === t.name && await async function () { for (const t of v) await t() }(), t } for (const t of this.iterateCallbacks("cacheDidUpdate")) await t({ cacheName: c, oldResponse: f, newResponse: a.clone(), request: r, event: this.event }); return !0 } async getCacheKey(t, e) { const s = `${t.url} | ${e}`; if (!this.u[s]) { let n = t; for (const t of this.iterateCallbacks("cacheKeyWillBeUsed")) n = q(await t({ mode: e, request: n, event: this.event, params: this.params })); this.u[s] = n } return this.u[s] } hasCallback(t) { for (const e of this.l.plugins) if (t in e) return !0; return !1 } async runCallbacks(t, e) { for (const s of this.iterateCallbacks(t)) await s(e) } *iterateCallbacks(t) { for (const e of this.l.plugins) if ("function" == typeof e[t]) { const s = this.m.get(e), n = n => { const i = Object.assign(Object.assign({}, n), { state: s }); return e[t](i) }; yield n } } waitUntil(t) { return this.g.push(t), t } async doneWaiting() { let t; for (; t = this.g.shift();)await t } destroy() { this.p.resolve(null) } async v(t) { let e = t, s = !1; for (const t of this.iterateCallbacks("cacheWillUpdate")) if (e = await t({ request: this.request, response: e, event: this.event }) || void 0, s = !0, !e) break; return s || e && 200 !== e.status && (e = void 0), e } } class L extends class { constructor(t = {}) { this.cacheName = l(t.cacheName), this.plugins = t.plugins || [], this.fetchOptions = t.fetchOptions, this.matchOptions = t.matchOptions } handle(t) { const [e] = this.handleAll(t); return e } handleAll(t) { t instanceof FetchEvent && (t = { event: t, request: t.request }); const e = t.event, s = "string" == typeof t.request ? new Request(t.request) : t.request, n = "params" in t ? t.params : void 0, i = new U(this, { event: e, request: s, params: n }), r = this.q(i, s, e); return [r, this.U(r, i, s, e)] } async q(t, e, n) { let i; await t.runCallbacks("handlerWillStart", { event: n, request: e }); try { if (i = await this.L(e, t), !i || "error" === i.type) throw new s("no-response", { url: e.url }) } catch (s) { if (s instanceof Error) for (const r of t.iterateCallbacks("handlerDidError")) if (i = await r({ error: s, event: n, request: e }), i) break; if (!i) throw s } for (const s of t.iterateCallbacks("handlerWillRespond")) i = await s({ event: n, request: e, response: i }); return i } async U(t, e, s, n) { let i, r; try { i = await t } catch (r) { } try { await e.runCallbacks("handlerDidRespond", { event: n, request: s, response: i }), await e.doneWaiting() } catch (t) { t instanceof Error && (r = t) } if (await e.runCallbacks("handlerDidComplete", { event: n, request: s, response: i, error: r }), e.destroy(), r) throw r } }{ constructor(t = {}) { t.cacheName = u(t.cacheName), super(t), this._ = !1 !== t.fallbackToNetwork, this.plugins.push(L.copyRedirectedCacheableResponsesPlugin) } async L(t, e) { const s = await e.cacheMatch(t); return s || (e.event && "install" === e.event.type ? await this.C(t, e) : await this.O(t, e)) } async O(t, e) { let n; const i = e.params || {}; if (!this._) throw new s("missing-precache-entry", { cacheName: this.cacheName, url: t.url }); { const s = i.integrity, r = t.integrity, o = !r || r === s; n = await e.fetch(new Request(t, { integrity: r || s })), s && o && (this.N(), await e.cachePut(t, n.clone())) } return n } async C(t, e) { this.N(); const n = await e.fetch(t); if (!await e.cachePut(t, n.clone())) throw new s("bad-precaching-response", { url: t.url, status: n.status }); return n } N() { let t = null, e = 0; for (const [s, n] of this.plugins.entries()) n !== L.copyRedirectedCacheableResponsesPlugin && (n === L.defaultPrecacheCacheabilityPlugin && (t = s), n.cacheWillUpdate && e++); 0 === e ? this.plugins.push(L.defaultPrecacheCacheabilityPlugin) : e > 1 && null !== t && this.plugins.splice(t, 1) } } L.defaultPrecacheCacheabilityPlugin = { cacheWillUpdate: async ({ response: t }) => !t || t.status >= 400 ? null : t }, L.copyRedirectedCacheableResponsesPlugin = { cacheWillUpdate: async ({ response: t }) => t.redirected ? await g(t) : t }; class b { constructor({ cacheName: t, plugins: e = [], fallbackToNetwork: s = !0 } = {}) { this.k = new Map, this.K = new Map, this.T = new Map, this.l = new L({ cacheName: u(t), plugins: [...e, new p({ precacheController: this })], fallbackToNetwork: s }), this.install = this.install.bind(this), this.activate = this.activate.bind(this) } get strategy() { return this.l } precache(t) { this.addToCacheList(t), this.W || (self.addEventListener("install", this.install), self.addEventListener("activate", this.activate), this.W = !0) } addToCacheList(t) { const e = []; for (const n of t) { "string" == typeof n ? e.push(n) : n && void 0 === n.revision && e.push(n.url); const { cacheKey: t, url: i } = w(n), r = "string" != typeof n && n.revision ? "reload" : "default"; if (this.k.has(i) && this.k.get(i) !== t) throw new s("add-to-cache-list-conflicting-entries", { firstEntry: this.k.get(i), secondEntry: t }); if ("string" != typeof n && n.integrity) { if (this.T.has(t) && this.T.get(t) !== n.integrity) throw new s("add-to-cache-list-conflicting-integrities", { url: i }); this.T.set(t, n.integrity) } if (this.k.set(i, t), this.K.set(i, r), e.length > 0) { const t = `Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`; console.warn(t) } } } install(t) { return f(t, (async () => { const e = new d; this.strategy.plugins.push(e); for (const [e, s] of this.k) { const n = this.T.get(s), i = this.K.get(e), r = new Request(e, { integrity: n, cache: i, credentials: "same-origin" }); await Promise.all(this.strategy.handleAll({ params: { cacheKey: s }, request: r, event: t })) } const { updatedURLs: s, notUpdatedURLs: n } = e; return { updatedURLs: s, notUpdatedURLs: n } })) } activate(t) { return f(t, (async () => { const t = await self.caches.open(this.strategy.cacheName), e = await t.keys(), s = new Set(this.k.values()), n = []; for (const i of e) s.has(i.url) || (await t.delete(i), n.push(i.url)); return { deletedURLs: n } })) } getURLsToCacheKeys() { return this.k } getCachedURLs() { return [...this.k.keys()] } getCacheKeyForURL(t) { const e = new URL(t, location.href); return this.k.get(e.href) } getIntegrityForCacheKey(t) { return this.T.get(t) } async matchPrecache(t) { const e = t instanceof Request ? t.url : t, s = this.getCacheKeyForURL(e); if (s) { return (await self.caches.open(this.strategy.cacheName)).match(s) } } createHandlerBoundToURL(t) { const e = this.getCacheKeyForURL(t); if (!e) throw new s("non-precached-url", { url: t }); return s => (s.request = new Request(t), s.params = Object.assign({ cacheKey: e }, s.params), this.strategy.handle(s)) } } let C; const E = () => (C || (C = new b), C); class O extends i { constructor(t, e) { super((({ request: s }) => { const n = t.getURLsToCacheKeys(); for (const i of function* (t, { ignoreURLParametersMatching: e = [/^utm_/, /^fbclid$/], directoryIndex: s = "index.html", cleanURLs: n = !0, urlManipulation: i } = {}) { const r = new URL(t, location.href); r.hash = "", yield r.href; const o = function (t, e = []) { for (const s of [...t.searchParams.keys()]) e.some((t => t.test(s))) && t.searchParams.delete(s); return t }(r, e); if (yield o.href, s && o.pathname.endsWith("/")) { const t = new URL(o.href); t.pathname += s, yield t.href } if (n) { const t = new URL(o.href); t.pathname += ".html", yield t.href } if (i) { const t = i({ url: r }); for (const e of t) yield e.href } }(s.url, e)) { const e = n.get(i); if (e) { return { cacheKey: e, integrity: t.getIntegrityForCacheKey(e) } } } }), t.strategy) } } function x(t) { const e = E(); !function (t, e, n) { let c; if ("string" == typeof t) { const s = new URL(t, location.href); c = new i((({ url: t }) => t.href === s.href), e, n) } else if (t instanceof RegExp) c = new r(t, e, n); else if ("function" == typeof t) c = new i(t, e, n); else { if (!(t instanceof i)) throw new s("unsupported-route-type", { moduleName: "workbox-routing", funcName: "registerRoute", paramName: "capture" }); c = t } (a || (a = new o, a.addFetchListener(), a.addCacheListener()), a).registerRoute(c) }(new O(e, t)) } t.precacheAndRoute = function (t, e) { !function (t) { E().precache(t) }(t), x(e) } })); +define(['exports'], function (t) { + 'use strict'; + try { + self['workbox:core:6.4.1'] && _(); + } catch (t) {} + const e = (t, ...e) => { + let s = t; + return e.length > 0 && (s += ` :: ${JSON.stringify(e)}`), s; + }; + class s extends Error { + constructor(t, s) { + super(e(t, s)), (this.name = t), (this.details = s); + } + } + try { + self['workbox:routing:6.4.1'] && _(); + } catch (t) {} + const n = t => (t && 'object' == typeof t ? t : { handle: t }); + class i { + constructor(t, e, s = 'GET') { + (this.handler = n(e)), (this.match = t), (this.method = s); + } + setCatchHandler(t) { + this.catchHandler = n(t); + } + } + class r extends i { + constructor(t, e, s) { + super( + ({ url: e }) => { + const s = t.exec(e.href); + if (s && (e.origin === location.origin || 0 === s.index)) return s.slice(1); + }, + e, + s + ); + } + } + class o { + constructor() { + (this.t = new Map()), (this.i = new Map()); + } + get routes() { + return this.t; + } + addFetchListener() { + self.addEventListener('fetch', t => { + const { request: e } = t, + s = this.handleRequest({ request: e, event: t }); + s && t.respondWith(s); + }); + } + addCacheListener() { + self.addEventListener('message', t => { + if (t.data && 'CACHE_URLS' === t.data.type) { + const { payload: e } = t.data, + s = Promise.all( + e.urlsToCache.map(e => { + 'string' == typeof e && (e = [e]); + const s = new Request(...e); + return this.handleRequest({ request: s, event: t }); + }) + ); + t.waitUntil(s), t.ports && t.ports[0] && s.then(() => t.ports[0].postMessage(!0)); + } + }); + } + handleRequest({ request: t, event: e }) { + const s = new URL(t.url, location.href); + if (!s.protocol.startsWith('http')) return; + const n = s.origin === location.origin, + { params: i, route: r } = this.findMatchingRoute({ event: e, request: t, sameOrigin: n, url: s }); + let o = r && r.handler; + const a = t.method; + if ((!o && this.i.has(a) && (o = this.i.get(a)), !o)) return; + let c; + try { + c = o.handle({ url: s, request: t, event: e, params: i }); + } catch (t) { + c = Promise.reject(t); + } + const h = r && r.catchHandler; + return ( + c instanceof Promise && + (this.o || h) && + (c = c.catch(async n => { + if (h) + try { + return await h.handle({ url: s, request: t, event: e, params: i }); + } catch (t) { + t instanceof Error && (n = t); + } + if (this.o) return this.o.handle({ url: s, request: t, event: e }); + throw n; + })), + c + ); + } + findMatchingRoute({ url: t, sameOrigin: e, request: s, event: n }) { + const i = this.t.get(s.method) || []; + for (const r of i) { + let i; + const o = r.match({ url: t, sameOrigin: e, request: s, event: n }); + if (o) + return ( + (i = o), + ((Array.isArray(i) && 0 === i.length) || + (o.constructor === Object && 0 === Object.keys(o).length) || + 'boolean' == typeof o) && + (i = void 0), + { route: r, params: i } + ); + } + return {}; + } + setDefaultHandler(t, e = 'GET') { + this.i.set(e, n(t)); + } + setCatchHandler(t) { + this.o = n(t); + } + registerRoute(t) { + this.t.has(t.method) || this.t.set(t.method, []), this.t.get(t.method).push(t); + } + unregisterRoute(t) { + if (!this.t.has(t.method)) throw new s('unregister-route-but-not-found-with-method', { method: t.method }); + const e = this.t.get(t.method).indexOf(t); + if (!(e > -1)) throw new s('unregister-route-route-not-registered'); + this.t.get(t.method).splice(e, 1); + } + } + let a; + const c = { + googleAnalytics: 'googleAnalytics', + precache: 'precache-v2', + prefix: 'workbox', + runtime: 'runtime', + suffix: 'undefined' != typeof registration ? registration.scope : '' + }, + h = t => [c.prefix, t, c.suffix].filter(t => t && t.length > 0).join('-'), + u = t => t || h(c.precache), + l = t => t || h(c.runtime); + function f(t, e) { + const s = e(); + return t.waitUntil(s), s; + } + try { + self['workbox:precaching:6.4.1'] && _(); + } catch (t) {} + function w(t) { + if (!t) throw new s('add-to-cache-list-unexpected-type', { entry: t }); + if ('string' == typeof t) { + const e = new URL(t, location.href); + return { cacheKey: e.href, url: e.href }; + } + const { revision: e, url: n } = t; + if (!n) throw new s('add-to-cache-list-unexpected-type', { entry: t }); + if (!e) { + const t = new URL(n, location.href); + return { cacheKey: t.href, url: t.href }; + } + const i = new URL(n, location.href), + r = new URL(n, location.href); + return i.searchParams.set('__WB_REVISION__', e), { cacheKey: i.href, url: r.href }; + } + class d { + constructor() { + (this.updatedURLs = []), + (this.notUpdatedURLs = []), + (this.handlerWillStart = async ({ request: t, state: e }) => { + e && (e.originalRequest = t); + }), + (this.cachedResponseWillBeUsed = async ({ event: t, state: e, cachedResponse: s }) => { + if ('install' === t.type && e && e.originalRequest && e.originalRequest instanceof Request) { + const t = e.originalRequest.url; + s ? this.notUpdatedURLs.push(t) : this.updatedURLs.push(t); + } + return s; + }); + } + } + class p { + constructor({ precacheController: t }) { + (this.cacheKeyWillBeUsed = async ({ request: t, params: e }) => { + const s = (null == e ? void 0 : e.cacheKey) || this.h.getCacheKeyForURL(t.url); + return s ? new Request(s, { headers: t.headers }) : t; + }), + (this.h = t); + } + } + let y; + async function g(t, e) { + let n = null; + if (t.url) { + n = new URL(t.url).origin; + } + if (n !== self.location.origin) throw new s('cross-origin-copy-response', { origin: n }); + const i = t.clone(), + r = { headers: new Headers(i.headers), status: i.status, statusText: i.statusText }, + o = e ? e(r) : r, + a = (function () { + if (void 0 === y) { + const t = new Response(''); + if ('body' in t) + try { + new Response(t.body), (y = !0); + } catch (t) { + y = !1; + } + y = !1; + } + return y; + })() + ? i.body + : await i.blob(); + return new Response(a, o); + } + function R(t, e) { + const s = new URL(t); + for (const t of e) s.searchParams.delete(t); + return s.href; + } + class m { + constructor() { + this.promise = new Promise((t, e) => { + (this.resolve = t), (this.reject = e); + }); + } + } + const v = new Set(); + try { + self['workbox:strategies:6.4.1'] && _(); + } catch (t) {} + function q(t) { + return 'string' == typeof t ? new Request(t) : t; + } + class U { + constructor(t, e) { + (this.u = {}), + Object.assign(this, e), + (this.event = e.event), + (this.l = t), + (this.p = new m()), + (this.g = []), + (this.R = [...t.plugins]), + (this.m = new Map()); + for (const t of this.R) this.m.set(t, {}); + this.event.waitUntil(this.p.promise); + } + async fetch(t) { + const { event: e } = this; + let n = q(t); + if ('navigate' === n.mode && e instanceof FetchEvent && e.preloadResponse) { + const t = await e.preloadResponse; + if (t) return t; + } + const i = this.hasCallback('fetchDidFail') ? n.clone() : null; + try { + for (const t of this.iterateCallbacks('requestWillFetch')) n = await t({ request: n.clone(), event: e }); + } catch (t) { + if (t instanceof Error) throw new s('plugin-error-request-will-fetch', { thrownErrorMessage: t.message }); + } + const r = n.clone(); + try { + let t; + t = await fetch(n, 'navigate' === n.mode ? void 0 : this.l.fetchOptions); + for (const s of this.iterateCallbacks('fetchDidSucceed')) t = await s({ event: e, request: r, response: t }); + return t; + } catch (t) { + throw ( + (i && + (await this.runCallbacks('fetchDidFail', { + error: t, + event: e, + originalRequest: i.clone(), + request: r.clone() + })), + t) + ); + } + } + async fetchAndCachePut(t) { + const e = await this.fetch(t), + s = e.clone(); + return this.waitUntil(this.cachePut(t, s)), e; + } + async cacheMatch(t) { + const e = q(t); + let s; + const { cacheName: n, matchOptions: i } = this.l, + r = await this.getCacheKey(e, 'read'), + o = Object.assign(Object.assign({}, i), { cacheName: n }); + s = await caches.match(r, o); + for (const t of this.iterateCallbacks('cachedResponseWillBeUsed')) + s = (await t({ cacheName: n, matchOptions: i, cachedResponse: s, request: r, event: this.event })) || void 0; + return s; + } + async cachePut(t, e) { + const n = q(t); + var i; + await ((i = 0), new Promise(t => setTimeout(t, i))); + const r = await this.getCacheKey(n, 'write'); + if (!e) + throw new s('cache-put-with-no-response', { + url: ((o = r.url), new URL(String(o), location.href).href.replace(new RegExp(`^${location.origin}`), '')) + }); + var o; + const a = await this.v(e); + if (!a) return !1; + const { cacheName: c, matchOptions: h } = this.l, + u = await self.caches.open(c), + l = this.hasCallback('cacheDidUpdate'), + f = l + ? await (async function (t, e, s, n) { + const i = R(e.url, s); + if (e.url === i) return t.match(e, n); + const r = Object.assign(Object.assign({}, n), { ignoreSearch: !0 }), + o = await t.keys(e, r); + for (const e of o) if (i === R(e.url, s)) return t.match(e, n); + })(u, r.clone(), ['__WB_REVISION__'], h) + : null; + try { + await u.put(r, l ? a.clone() : a); + } catch (t) { + if (t instanceof Error) + throw ( + ('QuotaExceededError' === t.name && + (await (async function () { + for (const t of v) await t(); + })()), + t) + ); + } + for (const t of this.iterateCallbacks('cacheDidUpdate')) + await t({ cacheName: c, oldResponse: f, newResponse: a.clone(), request: r, event: this.event }); + return !0; + } + async getCacheKey(t, e) { + const s = `${t.url} | ${e}`; + if (!this.u[s]) { + let n = t; + for (const t of this.iterateCallbacks('cacheKeyWillBeUsed')) + n = q(await t({ mode: e, request: n, event: this.event, params: this.params })); + this.u[s] = n; + } + return this.u[s]; + } + hasCallback(t) { + for (const e of this.l.plugins) if (t in e) return !0; + return !1; + } + async runCallbacks(t, e) { + for (const s of this.iterateCallbacks(t)) await s(e); + } + *iterateCallbacks(t) { + for (const e of this.l.plugins) + if ('function' == typeof e[t]) { + const s = this.m.get(e), + n = n => { + const i = Object.assign(Object.assign({}, n), { state: s }); + return e[t](i); + }; + yield n; + } + } + waitUntil(t) { + return this.g.push(t), t; + } + async doneWaiting() { + let t; + for (; (t = this.g.shift()); ) await t; + } + destroy() { + this.p.resolve(null); + } + async v(t) { + let e = t, + s = !1; + for (const t of this.iterateCallbacks('cacheWillUpdate')) + if (((e = (await t({ request: this.request, response: e, event: this.event })) || void 0), (s = !0), !e)) break; + return s || (e && 200 !== e.status && (e = void 0)), e; + } + } + class L extends class { + constructor(t = {}) { + (this.cacheName = l(t.cacheName)), + (this.plugins = t.plugins || []), + (this.fetchOptions = t.fetchOptions), + (this.matchOptions = t.matchOptions); + } + handle(t) { + const [e] = this.handleAll(t); + return e; + } + handleAll(t) { + t instanceof FetchEvent && (t = { event: t, request: t.request }); + const e = t.event, + s = 'string' == typeof t.request ? new Request(t.request) : t.request, + n = 'params' in t ? t.params : void 0, + i = new U(this, { event: e, request: s, params: n }), + r = this.q(i, s, e); + return [r, this.U(r, i, s, e)]; + } + async q(t, e, n) { + let i; + await t.runCallbacks('handlerWillStart', { event: n, request: e }); + try { + if (((i = await this.L(e, t)), !i || 'error' === i.type)) throw new s('no-response', { url: e.url }); + } catch (s) { + if (s instanceof Error) + for (const r of t.iterateCallbacks('handlerDidError')) + if (((i = await r({ error: s, event: n, request: e })), i)) break; + if (!i) throw s; + } + for (const s of t.iterateCallbacks('handlerWillRespond')) i = await s({ event: n, request: e, response: i }); + return i; + } + async U(t, e, s, n) { + let i, r; + try { + i = await t; + } catch (r) {} + try { + await e.runCallbacks('handlerDidRespond', { event: n, request: s, response: i }), await e.doneWaiting(); + } catch (t) { + t instanceof Error && (r = t); + } + if ((await e.runCallbacks('handlerDidComplete', { event: n, request: s, response: i, error: r }), e.destroy(), r)) + throw r; + } + } { + constructor(t = {}) { + (t.cacheName = u(t.cacheName)), + super(t), + (this._ = !1 !== t.fallbackToNetwork), + this.plugins.push(L.copyRedirectedCacheableResponsesPlugin); + } + async L(t, e) { + const s = await e.cacheMatch(t); + return s || (e.event && 'install' === e.event.type ? await this.C(t, e) : await this.O(t, e)); + } + async O(t, e) { + let n; + const i = e.params || {}; + if (!this._) throw new s('missing-precache-entry', { cacheName: this.cacheName, url: t.url }); + { + const s = i.integrity, + r = t.integrity, + o = !r || r === s; + (n = await e.fetch(new Request(t, { integrity: r || s }))), + s && o && (this.N(), await e.cachePut(t, n.clone())); + } + return n; + } + async C(t, e) { + this.N(); + const n = await e.fetch(t); + if (!(await e.cachePut(t, n.clone()))) throw new s('bad-precaching-response', { url: t.url, status: n.status }); + return n; + } + N() { + let t = null, + e = 0; + for (const [s, n] of this.plugins.entries()) + n !== L.copyRedirectedCacheableResponsesPlugin && + (n === L.defaultPrecacheCacheabilityPlugin && (t = s), n.cacheWillUpdate && e++); + 0 === e + ? this.plugins.push(L.defaultPrecacheCacheabilityPlugin) + : e > 1 && null !== t && this.plugins.splice(t, 1); + } + } + (L.defaultPrecacheCacheabilityPlugin = { + cacheWillUpdate: async ({ response: t }) => (!t || t.status >= 400 ? null : t) + }), + (L.copyRedirectedCacheableResponsesPlugin = { + cacheWillUpdate: async ({ response: t }) => (t.redirected ? await g(t) : t) + }); + class b { + constructor({ cacheName: t, plugins: e = [], fallbackToNetwork: s = !0 } = {}) { + (this.k = new Map()), + (this.K = new Map()), + (this.T = new Map()), + (this.l = new L({ + cacheName: u(t), + plugins: [...e, new p({ precacheController: this })], + fallbackToNetwork: s + })), + (this.install = this.install.bind(this)), + (this.activate = this.activate.bind(this)); + } + get strategy() { + return this.l; + } + precache(t) { + this.addToCacheList(t), + this.W || + (self.addEventListener('install', this.install), + self.addEventListener('activate', this.activate), + (this.W = !0)); + } + addToCacheList(t) { + const e = []; + for (const n of t) { + 'string' == typeof n ? e.push(n) : n && void 0 === n.revision && e.push(n.url); + const { cacheKey: t, url: i } = w(n), + r = 'string' != typeof n && n.revision ? 'reload' : 'default'; + if (this.k.has(i) && this.k.get(i) !== t) + throw new s('add-to-cache-list-conflicting-entries', { firstEntry: this.k.get(i), secondEntry: t }); + if ('string' != typeof n && n.integrity) { + if (this.T.has(t) && this.T.get(t) !== n.integrity) + throw new s('add-to-cache-list-conflicting-integrities', { url: i }); + this.T.set(t, n.integrity); + } + if ((this.k.set(i, t), this.K.set(i, r), e.length > 0)) { + const t = `Workbox is precaching URLs without revision info: ${e.join( + ', ' + )}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`; + console.warn(t); + } + } + } + install(t) { + return f(t, async () => { + const e = new d(); + this.strategy.plugins.push(e); + for (const [e, s] of this.k) { + const n = this.T.get(s), + i = this.K.get(e), + r = new Request(e, { integrity: n, cache: i, credentials: 'same-origin' }); + await Promise.all(this.strategy.handleAll({ params: { cacheKey: s }, request: r, event: t })); + } + const { updatedURLs: s, notUpdatedURLs: n } = e; + return { updatedURLs: s, notUpdatedURLs: n }; + }); + } + activate(t) { + return f(t, async () => { + const t = await self.caches.open(this.strategy.cacheName), + e = await t.keys(), + s = new Set(this.k.values()), + n = []; + for (const i of e) s.has(i.url) || (await t.delete(i), n.push(i.url)); + return { deletedURLs: n }; + }); + } + getURLsToCacheKeys() { + return this.k; + } + getCachedURLs() { + return [...this.k.keys()]; + } + getCacheKeyForURL(t) { + const e = new URL(t, location.href); + return this.k.get(e.href); + } + getIntegrityForCacheKey(t) { + return this.T.get(t); + } + async matchPrecache(t) { + const e = t instanceof Request ? t.url : t, + s = this.getCacheKeyForURL(e); + if (s) { + return (await self.caches.open(this.strategy.cacheName)).match(s); + } + } + createHandlerBoundToURL(t) { + const e = this.getCacheKeyForURL(t); + if (!e) throw new s('non-precached-url', { url: t }); + return s => ( + (s.request = new Request(t)), (s.params = Object.assign({ cacheKey: e }, s.params)), this.strategy.handle(s) + ); + } + } + let C; + const E = () => (C || (C = new b()), C); + class O extends i { + constructor(t, e) { + super(({ request: s }) => { + const n = t.getURLsToCacheKeys(); + for (const i of (function* ( + t, + { + ignoreURLParametersMatching: e = [/^utm_/, /^fbclid$/], + directoryIndex: s = 'index.html', + cleanURLs: n = !0, + urlManipulation: i + } = {} + ) { + const r = new URL(t, location.href); + (r.hash = ''), yield r.href; + const o = (function (t, e = []) { + for (const s of [...t.searchParams.keys()]) e.some(t => t.test(s)) && t.searchParams.delete(s); + return t; + })(r, e); + if ((yield o.href, s && o.pathname.endsWith('/'))) { + const t = new URL(o.href); + (t.pathname += s), yield t.href; + } + if (n) { + const t = new URL(o.href); + (t.pathname += '.html'), yield t.href; + } + if (i) { + const t = i({ url: r }); + for (const e of t) yield e.href; + } + })(s.url, e)) { + const e = n.get(i); + if (e) { + return { cacheKey: e, integrity: t.getIntegrityForCacheKey(e) }; + } + } + }, t.strategy); + } + } + function x(t) { + const e = E(); + !(function (t, e, n) { + let c; + if ('string' == typeof t) { + const s = new URL(t, location.href); + c = new i(({ url: t }) => t.href === s.href, e, n); + } else if (t instanceof RegExp) c = new r(t, e, n); + else if ('function' == typeof t) c = new i(t, e, n); + else { + if (!(t instanceof i)) + throw new s('unsupported-route-type', { + moduleName: 'workbox-routing', + funcName: 'registerRoute', + paramName: 'capture' + }); + c = t; + } + (a || ((a = new o()), a.addFetchListener(), a.addCacheListener()), a).registerRoute(c); + })(new O(e, t)); + } + t.precacheAndRoute = function (t, e) { + !(function (t) { + E().precache(t); + })(t), + x(e); + }; +}); //# sourceMappingURL=workbox-dae083bf.js.map diff --git a/gulpfile.js b/gulpfile.js index 6b4c8047df..eb2e49a5f9 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -39,7 +39,7 @@ export const PACKAGE_VERSION = '[VERSION]'; function runSass() { return ( gulp - .src('src/**/!(shared)*.scss') + .src(['src/**/!(shared)*.scss', '!src/styles/tailwind-styles.css']) .pipe(sass()) .pipe(cleanCSS()) // replacement to make office-ui-fabric-core icons work with lit-element @@ -60,6 +60,15 @@ function runSass() { ); } +function tailwind() { + return gulp + .src(['src/**/tailwind-styles.css']) + .pipe(gap.prependText(scssFileHeader)) + .pipe(gap.appendText(scssFileFooter)) + .pipe(rename({ extname: '-css.ts' })) + .pipe(gulp.dest('src/')); +} + function setLicense() { return gulp .src(['packages/**/src/**/*.{ts,js,scss}', '!packages/**/generated/**/*'], { base: './' }) @@ -73,11 +82,13 @@ function setVersion() { fs.writeFileSync('./src/utils/version.ts', versionFile.replace('[VERSION]', pkg.version)); } -gulp.task('sass', runSass); +gulp.task('sass', gulp.series(tailwind, runSass)); +gulp.task('tailwind', tailwind); gulp.task('setLicense', setLicense); gulp.task('setVersion', async () => setVersion()); gulp.task('watchSass', () => { + tailwind(); runSass(); - return gulp.watch('src/**/*.scss', gulp.series('sass')); + return gulp.watch('src/**/*.{scss,css}', gulp.series('sass')); }); diff --git a/index.html b/index.html index 46809e09be..2a013e3eca 100644 --- a/index.html +++ b/index.html @@ -31,44 +31,44 @@ + login-type="popup" + scopes="ExternalItem.Read.All,Files.Read.All,Sites.Read.All" + > + - -
- -
+ -

Developer test page

-
-

mgt-login

- - -

mgt-person me query two lines card on click with presence

- - -

mgt-people-picker

- - -

mgt-tasks

- - - -

mgt-todo

- - - - -
+ + + + + + + + diff --git a/package.json b/package.json index 9f54324003..9aeec224cc 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,9 @@ "storybook:deploy": "npm run storybook:build && storybook-to-ghpages -e storybook-static", "setLicense": "gulp setLicense", "test": "jest", - "version:tsc": "tsc -v" + "version:tsc": "tsc -v", + "wtr": "wtr", + "wtr:watch": "wtr --watch" }, "storybook-deployer": { "gitUsername": "@microsoft/mgt", @@ -101,6 +103,9 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/user-event": "^14.4.3", "@types/jest": "^29.2.4", + "@types/mocha": "10.0.1", + "@types/chai-dom": "^0.0.8", + "@types/lodash-es": "^4.17.6", "@types/node": "12.12.22", "@types/react": "^17.0.00", "@types/react-dom": "^17.0.0", @@ -109,6 +114,7 @@ "@typescript-eslint/parser": "^5.54.0", "@web/dev-server": "^0.1.10", "@webcomponents/webcomponentsjs": "^2.5.0", + "autoprefixer": "10.4.13", "babel-loader": "^8.2.1", "core-js": "^3.7.0", "cpx": "^1.5.0", @@ -161,7 +167,13 @@ "ts-jest": "^29.0.3", "typescript": "^4.9.4", "web-component-analyzer": "^1.1.6", - "whatwg-fetch": "^3.6.2" + "whatwg-fetch": "^3.6.2", + "tailwindcss": "3.2.4", + "sinon": "15.0.1", + "@open-wc/testing": "3.1.7", + "@web/test-runner": "0.15.0", + "@web/dev-server-esbuild": "0.3.3", + "@web/test-runner-playwright": "0.9.0" }, "husky": { "hooks": { diff --git a/packages/mgt-components/README.md b/packages/mgt-components/README.md index 29737cf2a0..691e7ea2c4 100644 --- a/packages/mgt-components/README.md +++ b/packages/mgt-components/README.md @@ -9,20 +9,21 @@ The [Microsoft Graph Toolkit (mgt)](https://aka.ms/mgt) web components package i [See docs for full documentation](https://aka.ms/mgt-docs) ## Components + You can explore components and samples with the [playground](https://mgt.dev) powered by storybook. The Toolkit currently includes the following components: -* [mgt-login](https://learn.microsoft.com/graph/toolkit/components/login) -* [mgt-person](https://learn.microsoft.com/graph/toolkit/components/person) -* [mgt-person-card](https://learn.microsoft.com/graph/toolkit/components/person-card) -* [mgt-people](https://learn.microsoft.com/graph/toolkit/components/people) -* [mgt-people-picker](https://learn.microsoft.com/graph/toolkit/components/people-picker) -* [mgt-agenda](https://learn.microsoft.com/graph/toolkit/components/agenda) -* [mgt-tasks](https://learn.microsoft.com/graph/toolkit/components/tasks) -* [mgt-todo](https://learn.microsoft.com/graph/toolkit/components/todo) -* [mgt-get](https://learn.microsoft.com/graph/toolkit/components/get) -* [mgt-teams-channel-picker](https://learn.microsoft.com/graph/toolkit/components/teams-channel-picker) +- [mgt-login](https://learn.microsoft.com/graph/toolkit/components/login) +- [mgt-person](https://learn.microsoft.com/graph/toolkit/components/person) +- [mgt-person-card](https://learn.microsoft.com/graph/toolkit/components/person-card) +- [mgt-people](https://learn.microsoft.com/graph/toolkit/components/people) +- [mgt-people-picker](https://learn.microsoft.com/graph/toolkit/components/people-picker) +- [mgt-agenda](https://learn.microsoft.com/graph/toolkit/components/agenda) +- [mgt-tasks](https://learn.microsoft.com/graph/toolkit/components/tasks) +- [mgt-todo](https://learn.microsoft.com/graph/toolkit/components/todo) +- [mgt-get](https://learn.microsoft.com/graph/toolkit/components/get) +- [mgt-teams-channel-picker](https://learn.microsoft.com/graph/toolkit/components/teams-channel-picker) The components work best when used with a [provider](https://learn.microsoft.com/graph/toolkit/providers). The provider handles authentication and the requests to the Microsoft Graph APIs used by the components. @@ -32,28 +33,28 @@ The components can be used on their own, but they are at their best when they ar 1. Install the packages - ```bash - npm install @microsoft/mgt-element @microsoft/mgt-components @microsoft/mgt-msal2-provider - ``` + ```bash + npm install @microsoft/mgt-element @microsoft/mgt-components @microsoft/mgt-msal2-provider + ``` 1. Use components in your code - ```html - + // initialize the auth provider globally + Providers.globalProvider = new Msal2Provider({clientId: 'clientId'}); + - - - - ``` + + + + ``` ## Disambiguation @@ -205,6 +206,7 @@ document.body.innerHTML = ''; > Note: it is not possible to use disambiguation with static imports. ## Sea also -* [Microsoft Graph Toolkit docs](https://aka.ms/mgt-docs) -* [Microsoft Graph Toolkit repository](https://aka.ms/mgt) -* [Microsoft Graph Toolkit playground](https://mgt.dev) \ No newline at end of file + +- [Microsoft Graph Toolkit docs](https://aka.ms/mgt-docs) +- [Microsoft Graph Toolkit repository](https://aka.ms/mgt) +- [Microsoft Graph Toolkit playground](https://mgt.dev) diff --git a/packages/mgt-components/src/components/components.ts b/packages/mgt-components/src/components/components.ts index cc425c3462..9d73324bbf 100644 --- a/packages/mgt-components/src/components/components.ts +++ b/packages/mgt-components/src/components/components.ts @@ -25,6 +25,9 @@ import './mgt-messages/mgt-messages'; import './mgt-organization/mgt-organization'; import './mgt-profile/mgt-profile'; import './mgt-theme-toggle/mgt-theme-toggle'; +import './mgt-search-results/mgt-search-results'; +import './mgt-search-verticals/mgt-search-verticals'; +import './mgt-search-filters/mgt-search-filters'; export * from './mgt-agenda/mgt-agenda'; export * from './mgt-file/mgt-file'; @@ -47,3 +50,6 @@ export * from './mgt-messages/mgt-messages'; export * from './mgt-organization/mgt-organization'; export * from './mgt-profile/mgt-profile'; export * from './mgt-theme-toggle/mgt-theme-toggle'; +export * from './mgt-search-results/mgt-search-results'; +export * from './mgt-search-verticals/mgt-search-verticals'; +export * from './mgt-search-filters/mgt-search-filters'; diff --git a/packages/mgt-components/src/components/mgt-contact/mgt-contact.scss b/packages/mgt-components/src/components/mgt-contact/mgt-contact.scss index 0fe1b102cc..48415973a0 100644 --- a/packages/mgt-components/src/components/mgt-contact/mgt-contact.scss +++ b/packages/mgt-components/src/components/mgt-contact/mgt-contact.scss @@ -5,164 +5,165 @@ * ------------------------------------------------------------------------------------------- */ -@import '../base-person-card-section'; -@import '../../styles/shared-styles'; -@import '../../styles/shared-sass-variables'; -@import './mgt-contact.theme'; - -:host { - position: relative; - user-select: none; - - .root { - .part { - display: grid; - grid-template-columns: auto 1fr auto; - - .part__icon { - display: flex; - min-width: 20px; - width: 20px; - height: 20px; - align-items: center; - justify-content: center; - margin-left: 20px; - margin-top: 10px; - line-height: 20px; - - svg { - fill: $contact-copy-icon-color; - } - } - - .part__details { - margin: 10px 14px; - overflow: hidden; - - .part__title { - font-size: 12px; - color: $contact-title-color; - line-height: 16px; - } - - .part__value { - grid-column: 2; - color: $contact-value-color; - font-size: 14px; - font-weight: 400; - line-height: 19px; - - .part__link { - color: $contact-link-color; - font-family: $font-family; - font-size: 14px; - cursor: pointer; - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - width: 100%; - display: inline-block; - - &:hover { - color: $contact-link-hover-color; - } - } - } - } - - .part__copy { - width: 32px; - height: 100%; - background-color: $contact-background-color; - visibility: hidden; - display: flex; - align-items: center; - justify-content: flex-start; - - svg { - fill: $contact-copy-icon-color; - cursor: pointer; - } - } - - &:hover { - .part__copy { - visibility: visible; - } - } - } - - &.compact { - padding: 0; - - .part { - height: 30px; - align-items: center; - } - - .part__details { - margin: 0; - } - - .part__title { - display: none; - } - - .part__icon { - margin-top: 0; - margin-right: 6px; - margin-bottom: 2px; - } - } - } -} - -[dir='rtl'] { - .part__link { - &.phone { - text-align: right; - direction: ltr; - } - } - - .part__icon { - margin: 10px 20px 0 0 !important; - } - - &.compact { - .part__icon { - margin-left: 6px !important; - margin-top: 0 !important; - } - } -} - -@media (forced-colors: active) and (prefers-color-scheme: dark) { - .root svg { - fill: rgb(255 255 255) !important; - fill-rule: nonzero !important; - clip-rule: nonzero !important; - - path, - rect { - fill: rgb(255 255 255) !important; - fill-rule: nonzero !important; - clip-rule: nonzero !important; - } - } -} - -@media (forced-colors: active) and (prefers-color-scheme: light) { - .root svg { - fill: rgb(0 0 0) !important; - fill-rule: nonzero !important; - clip-rule: nonzero !important; - - path, - rect { - fill: rgb(0 0 0) !important; - fill-rule: nonzero !important; - clip-rule: nonzero !important; - } - } -} + @import '../base-person-card-section'; + @import '../../styles/shared-styles'; + @import '../../styles/shared-sass-variables'; + @import './mgt-contact.theme'; + + :host { + position: relative; + user-select: none; + + .root { + .part { + display: grid; + grid-template-columns: auto 1fr auto; + + .part__icon { + display: flex; + min-width: 20px; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; + margin-left: 20px; + margin-top: 10px; + line-height: 20px; + + svg { + fill: $contact-copy-icon-color; + } + } + + .part__details { + margin: 10px 14px; + overflow: hidden; + + .part__title { + font-size: 12px; + color: $contact-title-color; + line-height: 16px; + } + + .part__value { + grid-column: 2; + color: $contact-value-color; + font-size: 14px; + font-weight: 400; + line-height: 19px; + + .part__link { + color: $contact-link-color; + font-family: $font-family; + font-size: 14px; + cursor: pointer; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; + display: inline-block; + + &:hover { + color: $contact-link-hover-color; + } + } + } + } + + .part__copy { + width: 32px; + height: 100%; + background-color: $contact-background-color; + visibility: hidden; + display: flex; + align-items: center; + justify-content: flex-start; + + svg { + fill: $contact-copy-icon-color; + cursor: pointer; + } + } + + &:hover { + .part__copy { + visibility: visible; + } + } + } + + &.compact { + padding: 0; + + .part { + height: 30px; + align-items: center; + } + + .part__details { + margin: 0; + } + + .part__title { + display: none; + } + + .part__icon { + margin-top: 0; + margin-right: 6px; + margin-bottom: 2px; + } + } + } + } + + [dir='rtl'] { + .part__link { + &.phone { + text-align: right; + direction: ltr; + } + } + + .part__icon { + margin: 10px 20px 0 0 !important; + } + + &.compact { + .part__icon { + margin-left: 6px !important; + margin-top: 0 !important; + } + } + } + + @media (forced-colors: active) and (prefers-color-scheme: dark) { + .root svg { + fill: rgb(255 255 255) !important; + fill-rule: nonzero !important; + clip-rule: nonzero !important; + + path, + rect { + fill: rgb(255 255 255) !important; + fill-rule: nonzero !important; + clip-rule: nonzero !important; + } + } + } + + @media (forced-colors: active) and (prefers-color-scheme: light) { + .root svg { + fill: rgb(0 0 0) !important; + fill-rule: nonzero !important; + clip-rule: nonzero !important; + + path, + rect { + fill: rgb(0 0 0) !important; + fill-rule: nonzero !important; + clip-rule: nonzero !important; + } + } + } + \ No newline at end of file diff --git a/packages/mgt-components/src/components/mgt-search-filters/mgt-base-filter.ts b/packages/mgt-components/src/components/mgt-search-filters/mgt-base-filter.ts new file mode 100644 index 0000000000..a2f66c01bf --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-filters/mgt-base-filter.ts @@ -0,0 +1,264 @@ +import { MgtConnectableComponent } from '@microsoft/mgt-element'; +import { property, state } from 'lit/decorators.js'; +import { html, PropertyValues, TemplateResult } from 'lit'; +import { isEqual, cloneDeep, sumBy, orderBy } from 'lodash-es'; +import { IDataFilterResult, IDataFilterValue, IDataFilterResultValue } from '@microsoft/mgt-element'; +import { IDataFilterConfiguration, FilterSortType, FilterSortDirection } from '@microsoft/mgt-element'; +import { fluentSelect, fluentOption, provideFluentDesignSystem, fluentListbox } from '@fluentui/web-components'; +import { styles as tailwindStyles } from '../../styles/tailwind-styles-css'; + +export enum DateFilterKeys { + From = 'from', + To = 'to' +} + +export abstract class MgtBaseFilterComponent extends MgtConnectableComponent { + /** + * Filter information to display + */ + @property() + filter: IDataFilterResult; + + /** + * Filter confguration + */ + @property() + filterConfiguration: IDataFilterConfiguration; + + /** + * Flag indicating if the filter should be disabled + */ + @property() + disabled: boolean; + + /** + * Callback function when a filter is selected + */ + @property() + onFilterUpdated: (filterName: string, filterValue: IDataFilterValue, selected: boolean) => void; + + /** + * Callback function when filters are submitted + */ + @property() + onApplyFilters: (filterName: string) => void; + + @state() + isExpanded: boolean; + + /** + * The current selected values in the component + */ + @state() + selectedValues: IDataFilterValue[] = []; + + /** + * The submitted filter values + */ + public declare submittedFilterValues: IDataFilterValue[]; + + /** + * Mutation observer for the fitler button + */ + declare buttonObserver: MutationObserver; + + protected get localizedFilterName(): string { + return this.getLocalizedString(this.filterConfiguration.displayName); + } + + /** + * Flag indicating if the selected values can be applied as filters + */ + protected get canApplyValues(): boolean { + return !isEqual(this.submittedFilterValues.map(v => v.value).sort(), this.selectedValues.map(v => v.value).sort()); + } + + public constructor() { + super(); + + this.submittedFilterValues = []; + + this.onItemUpdated = this.onItemUpdated.bind(this); + this.applyFilters = this.applyFilters.bind(this); + this.closeMenu = this.closeMenu.bind(this); + + // Register fluent tabs (as scoped elements) + provideFluentDesignSystem().register(fluentSelect(), fluentOption(), fluentListbox()); + } + + public render() { + let renderFilterName = html`${this.localizedFilterName}`; + + if (this.submittedFilterValues.length === 1) { + renderFilterName = html`${this.submittedFilterValues[0].name}`; + } + + if (this.submittedFilterValues.length > 1) { + // Display only filter values submitted and present in the available filter values + // Multiple refinement steps can lead initial selected values to not be included in the available values + const selectedValues = this.submittedFilterValues.filter(s => { + return ( + this.filter.values.map(v => v.key).indexOf(s.key) !== -1 || + s.key === DateFilterKeys.From || + s.key === DateFilterKeys.To + ); + }); + + renderFilterName = html` +
+
${this.localizedFilterName}
+
${selectedValues.length}
+
+ `; + } + + return html` + + +
+ ${renderFilterName} +
+ +
{ + e.stopPropagation(); + }}> + ${this.renderFilterContent()} +
+
+ `; + } + + public disconnectedCallback(): void { + // Could be already disconnected by an other filter instance + if (this.buttonObserver) { + this.buttonObserver.disconnect(); + } + + super.disconnectedCallback(); + } + + /** + * Reset all selected values for the current filter in the UI. + * Can be called from parent components + */ + protected resetSelectedValues() { + // Update parent state + this.selectedValues.forEach(v => { + this.onFilterUpdated(this.filter.filterName, v, false); + }); + + // Reset internal state + let newValues = [...this.selectedValues]; + newValues = []; + + this.selectedValues = newValues; + } + + /** + * Clear all selected values in the UI and submit empty filters to connected components + * @param preventApply Set true if you want to prevent new values to be submitted for that filter + */ + public clearSelectedValues(preventApply?: boolean) { + this.resetSelectedValues(); + + if (this.submittedFilterValues.length > 0) { + // Reset submitted filters + this.submittedFilterValues = []; + + if (!preventApply) this.applyFilters(); + } + } + + protected onItemUpdated(filterValue: IDataFilterValue, selected: boolean) { + if (selected) { + // Get the index of the filter value in the current selected values collection + const valueIdx = this.selectedValues.map(value => value.key).indexOf(filterValue.key); + const newValues = [...this.selectedValues]; + + if (valueIdx !== -1) { + // Update the existing value + newValues[valueIdx] = filterValue; + } else { + // Add the new value if doesn't exist + newValues.push(filterValue); + } + + this.selectedValues = newValues; + } else { + this.selectedValues = this.selectedValues.filter(v => v.key !== filterValue.key); + } + + this.onFilterUpdated(this.filter.filterName, filterValue, selected); + } + + protected isSelectedValue(key: string): boolean { + const isSelected = + this.selectedValues.filter(v => { + return v.key === key; + }).length > 0; + + return isSelected; + } + + /** + * The filter content to be implemented by concrete classes + */ + protected abstract renderFilterContent(): TemplateResult; + + protected applyFilters(): void { + this.submittedFilterValues = cloneDeep(this.selectedValues); + this.onApplyFilters(this.filter.filterName); + this.closeMenu(); + } + + /** + * Process manual filter aggregations according to matched values from configuration + * @param values the original filter values received from the results + * @returns the new aggregated filters values + */ + protected processAggregations(values: IDataFilterResultValue[]): IDataFilterResultValue[] { + let filteredValues = cloneDeep(values); + + if (this.filterConfiguration.aggregations) { + this.filterConfiguration.aggregations.forEach(aggregation => { + // Get all matching values + const matchingValues = filteredValues.filter(value => { + return aggregation.matchingValues.indexOf(value.name) > -1; + }); + + // Remove all values matching the aggregation + filteredValues = filteredValues.filter(value => { + return aggregation.matchingValues.indexOf(value.name) === -1; + }); + + if (matchingValues.length > 0) { + // A new aggregation + filteredValues.push({ + count: sumBy(matchingValues, 'count'), + key: this.getLocalizedString(aggregation.aggregationName), + name: this.getLocalizedString(aggregation.aggregationName), + value: aggregation.aggregationValue + }); + } + }); + } + + // Sort values according to the filter configuration + const sortProperty = this.filterConfiguration.sortBy === FilterSortType.ByCount ? 'count' : 'name'; + const sortDirection = this.filterConfiguration.sortDirection === FilterSortDirection.Ascending ? 'asc' : 'desc'; + filteredValues = orderBy(filteredValues, sortProperty, sortDirection); + + return filteredValues; + } + + // Close filter menu + public closeMenu() { + if (this.selectedValues.length > 0 && this.submittedFilterValues.length === 0) { + this.resetSelectedValues(); + } + } + + static get styles() { + return [tailwindStyles]; + } +} diff --git a/packages/mgt-components/src/components/mgt-search-filters/mgt-checkbox-filter/mgt-checkbox-filter.ts b/packages/mgt-components/src/components/mgt-search-filters/mgt-checkbox-filter/mgt-checkbox-filter.ts new file mode 100644 index 0000000000..ddd67bd8c8 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-filters/mgt-checkbox-filter/mgt-checkbox-filter.ts @@ -0,0 +1,251 @@ +import { html, PropertyValues } from 'lit'; +import { state } from 'lit/decorators.js'; +import { cloneDeep } from 'lodash-es'; +import { repeat } from 'lit/directives/repeat.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { strings } from './strings'; +import { MgtBaseFilterComponent } from '../mgt-base-filter'; +import { IDataFilterResultValue, IDataFilterAggregation, customElement } from '@microsoft/mgt-element'; + +export class MgtCheckboxFilterComponent extends MgtBaseFilterComponent { + @state() + searchKeyword: string; + + /** + * List of filtered values + */ + @state() + filteredValues: IDataFilterResultValue[] = []; + + declare startOffset: number; + + /** + * Number of items to be displayed in the menu. Limit this number to increase performances + */ + declare pageSize: number; + + constructor() { + super(); + + this.startOffset = 0; + this.pageSize = 50; + + this.onScroll = this.onScroll.bind(this); + } + + public renderFilterContent() { + // Display only filter values submitted and present in the available filter values + // Multiple refinement steps can lead initial selected values to not be included in the available values + const selectedValues = this.submittedFilterValues.filter(s => { + return this.filter.values.map(v => v.key).indexOf(s.key) !== -1; + }); + + const filterName = this.localizedFilterName ? this.localizedFilterName.toLowerCase() : null; + + const renderSearchBox = html` +
+ + { + this.filterValues(e.target.value); + }} + + /> +
+ `; + + return html` +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > +
+ + ${renderSearchBox} +
+ +
+ +
+ ${ + this.selectedValues.length > 0 || this.submittedFilterValues.length > 0 + ? html`` + : null + } +
+
+
+ ${repeat( + this.filteredValues, + filterValue => filterValue.key, + filterValue => { + return html` + { + this.onItemUpdated(filterValue, !this.isSelectedValue(filterValue.key)); + + if (!this.filterConfiguration.isMulti) { + this.applyFilters(); + } + }} + data-ref-value=${filterValue.value} + value=${filterValue.value} + data-ref-name=${filterValue.name} + .selected=${this.isSelectedValue(filterValue.key)} + > +
+
+ ${ + this.getFilterAggregation(filterValue.name) + ? html`` + : null + } +
+ ${ + this.searchKeyword + ? this.highlightMatches(filterValue.name) + : filterValue.name + } +
+
+ ${ + this.filterConfiguration.showCount + ? html` +
+ +
+ ` + : null + } +
+
+ `; + } + )} +
+ ${ + this.filterConfiguration.isMulti + ? html` +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + + +
+ ` + : null + } + `; + } + + public firstUpdated(changedProperties: PropertyValues): void { + // Return only a subset of values to manage performances + this.filter.values = this.processAggregations(this.filter.values); + this.filteredValues = cloneDeep(this.filter.values).slice(0, this.pageSize); + + this.startOffset = this.pageSize; + + const elt = this.renderRoot.querySelector('#filter-menu-content'); + elt.addEventListener('scroll', this.onScroll); + + super.firstUpdated(changedProperties); + } + + public updated(changedProperties: PropertyValues): void { + if (changedProperties.get('filter')) { + this.filter.values = this.processAggregations(this.filter.values); + this.filteredValues = this.sortBySelectedValues(cloneDeep(this.filter.values)).slice(0, this.pageSize); + } + + super.updated(changedProperties); + } + + public disconnectedCallback(): void { + const elt = this.renderRoot.querySelector('#filter-menu-content'); + elt.removeEventListener('scroll', this.onScroll); + super.disconnectedCallback(); + } + + protected get strings(): { [x: string]: string } { + return strings; + } + + public filterValues(value: string) { + if (!value) { + this.filteredValues = this.sortBySelectedValues(cloneDeep(this.filter.values)); + } else { + this.filteredValues = this.filter.values.filter(v => v.name.toLocaleLowerCase().indexOf(value) !== -1); + } + + // Return only a subset of values to manage performances + this.filteredValues = this.filteredValues.slice(0, this.pageSize); + + this.searchKeyword = value; + } + + private sortBySelectedValues(filters: IDataFilterResultValue[]) { + return filters.sort((x, y) => { + return Number(this.isSelectedValue(y.key)) - Number(this.isSelectedValue(x.key)); + }); + } + + private highlightMatches(value: string) { + const matchExpr = value.replace( + new RegExp(this.searchKeyword, 'gi'), + match => `${match}` + ); + return html`${unsafeHTML(matchExpr)}`; + } + + private clearSearchKeywords() { + (this.renderRoot.querySelector('#searchbox') as HTMLInputElement).value = null; + this.filterValues(null); + } + + private onScroll() { + const elt = this.renderRoot.querySelector('#filter-menu-content'); + if (elt.scrollTop + elt.clientHeight >= elt.scrollHeight - 50) { + const newOffset = this.startOffset + this.pageSize; + this.filteredValues = this.filter.values.slice(0, newOffset); + this.startOffset = newOffset; + } + } + + private getFilterAggregation(name: string): IDataFilterAggregation { + return this.filterConfiguration.aggregations?.filter(aggregation => { + return this.getLocalizedString(aggregation.aggregationName) === name; + })[0]; + } +} diff --git a/packages/mgt-components/src/components/mgt-search-filters/mgt-checkbox-filter/strings.ts b/packages/mgt-components/src/components/mgt-search-filters/mgt-checkbox-filter/strings.ts new file mode 100644 index 0000000000..98d2d6e943 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-filters/mgt-checkbox-filter/strings.ts @@ -0,0 +1,7 @@ +export const strings = { + reset: 'Reset', + searchPlaceholder: 'Search for values...', + apply: 'Apply', + cancel: 'Cancel', + selections: 'selection(s)' +}; diff --git a/packages/mgt-components/src/components/mgt-search-filters/mgt-date-filter/mgt-date-filter.ts b/packages/mgt-components/src/components/mgt-search-filters/mgt-date-filter/mgt-date-filter.ts new file mode 100644 index 0000000000..229df1a75c --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-filters/mgt-date-filter/mgt-date-filter.ts @@ -0,0 +1,302 @@ +import { + DateHelper, + FilterComparisonOperator, + IDataFilterValue, + LocalizationHelper, + customElement +} from '@microsoft/mgt-element'; +import { html, PropertyValues, nothing } from 'lit'; +import { MgtBaseFilterComponent, DateFilterKeys } from '../mgt-base-filter'; +import { strings } from './strings'; + +export enum DateFilterInterval { + AnyTime, + Today, + Past24, + PastWeek, + PastMonth, + Past3Months, + PastYear, + OlderThanAYear +} + +export class MgtDateFilterComponent extends MgtBaseFilterComponent { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private declare dayJs: any; + private declare dateHelper: DateHelper; + + private declare allIntervals: { [key in DateFilterInterval]: string }; + + public get fromDate(): string { + return this.getDateValue(DateFilterKeys.From); + } + + public get toDate(): string { + return this.getDateValue(DateFilterKeys.To); + } + + public constructor() { + super(); + + this.allIntervals = this.getAllIntervals(); + + this.dateHelper = new DateHelper(LocalizationHelper.strings?.language); + + this.onUpdateFromDate = this.onUpdateFromDate.bind(this); + this.onUpdateToDate = this.onUpdateToDate.bind(this); + this.applyDateFilters = this.applyDateFilters.bind(this); + } + + public renderFilterContent() { + // Intervals to display + const intervals = this.filter.values.filter(v => { + // If an interval is already selected, limit to only one to avoid selecting multiple matched intervals for the same items + // Ex: an item created last month will also match the past 3 months, and past year intervals. However, it does not make sense to propose all at once once user selects one. + if (this.selectedValues.length > 0) { + return this.selectedValues.some(s => s.key === v.key); + } + return v.count > 0; + }); + + return html` +
+
+ ${ + this.selectedValues.length > 0 + ? html`
+ this.clearSelectedValues()}> + ${strings.reset} +
` + : null + } +
+
+ ${ + !this.fromDate && !this.toDate + ? intervals.map(filterValue => { + return html` +
{ + this.onItemUpdated(filterValue, !this.isSelectedValue(filterValue.key)); + + if (!this.filterConfiguration.isMulti) { + // Apply filters immediately + this.applyFilters(); + } + }} + > +
+
+ +
+
+ +
+ ${ + this.filterConfiguration.showCount + ? html`
+ +
+ ` + : null + } +
+
+ `; + }) + : nothing + } +
+
+
+ ${strings.from}: + +
+ +
+ ${strings.to}: + +
+ +
+ `; + } + + public async connectedCallback(): Promise { + this.dayJs = await this.dateHelper.dayJs(); + super.connectedCallback(); + } + + protected get strings(): { [x: string]: string } { + return strings; + } + + protected updated(changedProperties: PropertyValues): void { + this.allIntervals = this.getAllIntervals(); + super.updated(changedProperties); + } + + private _getIntervalDate(unit: string, count: number): Date { + return this._getIntervalDateFromStartDate(new Date(), unit, count); + } + + private _getIntervalDateFromStartDate(startDate: Date, unit: string, count: number): Date { + return this.dayJs(startDate).subtract(count, unit); + } + + private _getIntervalForValue(filterValue: IDataFilterValue): string { + const dateAsString = filterValue.value; + + if (dateAsString && this.dayJs) { + let dateRanges = []; + // Value from Microaoft Search for date properties as FQL filter value + if (dateAsString.indexOf('range(') !== -1) { + const matches = dateAsString.match( + /(min|max)|(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)((-(\d{2}):(\d{2})|Z)?)/gi + ); + if (matches) { + // Return date range (i.e. dates between parenthesis) + dateRanges = matches; + } + } + + // To get it work, we need to submit equivalent aggregations at query time + const past24Date = this._getIntervalDate('days', 1); + const pastWeekDate = this._getIntervalDate('weeks', 1); + const pastMonthDate = this._getIntervalDate('months', 1); + const past3MonthsDate = this._getIntervalDate('months', 3); + const pastYearDate = this._getIntervalDate('years', 1); + + // Mutate the original object to get the correct name when submitted + if (dateRanges.indexOf('min') !== -1) { + filterValue.name = this.allIntervals[DateFilterInterval.OlderThanAYear]; + } else if (dateRanges.indexOf('max') !== -1) { + filterValue.name = this.allIntervals[DateFilterInterval.Today]; + } else if (this.dayJs(dateRanges[0]).isSame(past24Date, 'day')) { + filterValue.name = this.allIntervals[DateFilterInterval.Past24]; + } else if (this.dayJs(dateRanges[0]).isSame(pastWeekDate, 'day')) { + filterValue.name = this.allIntervals[DateFilterInterval.PastWeek]; + } else if (this.dayJs(dateRanges[0]).isSame(pastMonthDate, 'day')) { + filterValue.name = this.allIntervals[DateFilterInterval.PastMonth]; + } else if (this.dayJs(dateRanges[0]).isSame(past3MonthsDate, 'day')) { + filterValue.name = this.allIntervals[DateFilterInterval.Past3Months]; + } else if (this.dayJs(dateRanges[0]).isSame(pastYearDate, 'day')) { + filterValue.name = this.allIntervals[DateFilterInterval.PastYear]; + } + } else { + filterValue.name = this.allIntervals[DateFilterInterval.AnyTime]; + } + + return filterValue.name; + } + + private onUpdateFromDate(e: InputEvent) { + let date = (e.target as HTMLInputElement).value; + + // In the case user enter manually a date later than today + if (this.dayJs(date).isValid() && this.dayJs(date).isAfter(this.dayJs())) { + date = new Date().toISOString().split('T')[0]; + } + + const filterName = `${strings.from} ${this.dayJs(date).format('ll')}`; + this.onItemUpdated( + { + key: DateFilterKeys.From, + name: filterName, + value: date ? new Date(date).toISOString() : null, + operator: FilterComparisonOperator.Geq + }, + !!date // No value = unselected + ); + + if (!date && this.submittedFilterValues.length > 0) { + this.applyFilters(); + } + } + + private onUpdateToDate(e) { + let date = (e.target as HTMLInputElement).value; + + // In the case user enter manually a date later than today + if (this.dayJs(date).isValid() && this.dayJs(date).isAfter(this.dayJs())) { + date = new Date().toISOString().split('T')[0]; + } + + const filterName = `${strings.to} ${this.dayJs(date).format('ll')}`; + this.onItemUpdated( + { + key: DateFilterKeys.To, + name: filterName, + value: date ? new Date(date).toISOString() : null, + operator: FilterComparisonOperator.Lt + }, + !!date // No value = unselected + ); + + if (!date && this.submittedFilterValues.length > 0) { + this.applyFilters(); + } + } + + private applyDateFilters() { + // Don't apply filters if no value is selected + if (this.toDate || this.fromDate) { + // Keep only static filter values 'From' and 'To' and unselect the others if any + this.selectedValues + .filter(v => v.key !== DateFilterKeys.To && v.key !== DateFilterKeys.From) + .forEach(v => { + this.onFilterUpdated(this.filter.filterName, v, false); + }); + + this.selectedValues = this.selectedValues.filter( + v => v.key === DateFilterKeys.To || v.key === DateFilterKeys.From + ); + + this.applyFilters(); + } + } + + private getDateValue(dateKey: DateFilterKeys) { + const date = this.selectedValues.filter(v => v.key === dateKey)[0]; + if (date) { + return date.value.split('T')[0]; + } + + return ''; + } + + private getAllIntervals() { + return { + [DateFilterInterval.AnyTime]: strings.anyTime, + [DateFilterInterval.Today]: strings.today, + [DateFilterInterval.Past24]: strings.past24, + [DateFilterInterval.PastWeek]: strings.pastWeek, + [DateFilterInterval.PastMonth]: strings.pastMonth, + [DateFilterInterval.Past3Months]: strings.past3Months, + [DateFilterInterval.PastYear]: strings.pastYear, + [DateFilterInterval.OlderThanAYear]: strings.olderThanAYear + }; + } +} diff --git a/packages/mgt-components/src/components/mgt-search-filters/mgt-date-filter/strings.ts b/packages/mgt-components/src/components/mgt-search-filters/mgt-date-filter/strings.ts new file mode 100644 index 0000000000..1c0ace3ce8 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-filters/mgt-date-filter/strings.ts @@ -0,0 +1,15 @@ +export const strings = { + anyTime: 'Any time', + today: 'Today', + past24: 'Past 24 hours', + pastWeek: 'Past week', + pastMonth: 'Past month', + past3Months: 'Past 3 months', + pastYear: 'Past year', + olderThanAYear: 'Older than a year', + reset: 'Reset', + from: 'From', + to: 'To', + applyDates: 'Apply dates', + selections: 'selection(s)' +}; diff --git a/packages/mgt-components/src/components/mgt-search-filters/mgt-search-filters.ts b/packages/mgt-components/src/components/mgt-search-filters/mgt-search-filters.ts new file mode 100644 index 0000000000..2197aa24b9 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-filters/mgt-search-filters.ts @@ -0,0 +1,536 @@ +import { + BuiltinFilterTemplates, + ComponentElements, + EventConstants, + FilterConditionOperator, + FilterSortDirection, + FilterSortType, + IDataFilter, + IDataFilterConfiguration, + IDataFilterResult, + IDataFilterResultValue, + IDataFilterValue, + ISearchFiltersEventData, + ISearchResultsEventData, + ISearchSortEventData, + ISearchSortProperty, + MgtConnectableComponent, + customElement +} from '@microsoft/mgt-element'; +import { html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { isEmpty, cloneDeep, sortBy } from 'lodash-es'; +import { repeat } from 'lit/directives/repeat.js'; +import { strings } from './strings'; +import { styles as tailwindStyles } from '../../styles/tailwind-styles-css'; +import { MgtBaseFilterComponent } from './mgt-base-filter'; +import { ScopedElementsMixin } from '@open-wc/scoped-elements'; +import { MgtCheckboxFilterComponent } from './mgt-checkbox-filter/mgt-checkbox-filter'; +import { MgtDateFilterComponent } from './mgt-date-filter/mgt-date-filter'; + +export class MgtSearchFiltersComponentBase extends MgtConnectableComponent {} + +@customElement('search-filters') +export class MgtSearchFiltersComponent extends ScopedElementsMixin(MgtSearchFiltersComponentBase) { + /** + * The connected search results component ids + */ + @property({ + type: Array, + attribute: 'search-results-ids', + converter: { + fromAttribute: value => { + return value.split(','); + } + } + }) + searchResultsComponentIds: string[] = []; + + /** + * The filters configration + */ + @property({ + type: Object, + attribute: 'settings', + converter: { + fromAttribute: value => { + return JSON.parse(value) as IDataFilterConfiguration[]; + } + } + }) + filterConfiguration: IDataFilterConfiguration[] = []; + + /** + * The default logical operatorto use sbetween filters + */ + @property({ type: String, attribute: 'operator' }) + operator: FilterConditionOperator = FilterConditionOperator.AND; + + /** + * Available filters received from connected search results component + */ + @state() + availableFilters: IDataFilterResult[] = []; + + /** + * All selected values from all filters combined (not necessarily submitted) + */ + @state() + allSelectedFilters: IDataFilter[] = []; + + /** + * The list of disabled filters + */ + private declare disabledFilters: string[]; + + /** + * All submitted values from all filters combined + */ + public declare allSubmittedFilters: IDataFilter[]; + + /** + * The previous applied filters + */ + private declare previousAvailableFilters: IDataFilterResult[]; + + private declare submittedQueryText: string; + + private declare searchResultsEventData: ISearchResultsEventData; + constructor() { + super(); + + this.allSelectedFilters = []; + this.allSubmittedFilters = []; + this.disabledFilters = []; + + this.handleSearchResultsFilters = this.handleSearchResultsFilters.bind(this); + this.onFilterUpdated = this.onFilterUpdated.bind(this); + this.onApplyFilters = this.onApplyFilters.bind(this); + this.onSort = this.onSort.bind(this); + } + + public render() { + let renderSort = nothing; + + const renderCheckbox = (availableFilter: IDataFilterResult) => { + return html` -1} + .filter=${availableFilter} + .filterConfiguration=${ + this.filterConfiguration.filter(c => c.filterName === availableFilter.filterName)[0] + } + .onFilterUpdated=${this.onFilterUpdated} + .onApplyFilters=${this.onApplyFilters} + > + `; + }; + + const renderDate = (availableFilter: IDataFilterResult) => { + return html` -1} + .filter=${availableFilter} + .filterConfiguration=${ + this.filterConfiguration.filter(c => c.filterName === availableFilter.filterName)[0] + } + .onFilterUpdated=${this.onFilterUpdated} + .onApplyFilters=${this.onApplyFilters} + > + `; + }; + + const renderShimmers = html` +
+ ${repeat( + this.filterConfiguration, + configuration => configuration.filterName, + () => { + return html`
`; + } + )} +
+ `; + + if (this.searchResultsEventData?.sortFieldsConfiguration && this.availableFilters.length > 0) { + renderSort = html` + + + `; + } + + let renderFilters = html`${repeat( + // https://lit.dev/docs/templates/lists/#when-to-use-map-or-repeat + this.availableFilters, + filter => filter.filterName, + availableFilter => { + // Only display the filter component is values are present + if (availableFilter.values.length > 0) { + const filterConfiguration = this.getFilterConfiguration(availableFilter.filterName); + + switch (filterConfiguration.template) { + case BuiltinFilterTemplates.CheckBox: + return renderCheckbox(availableFilter); + + case BuiltinFilterTemplates.Date: + return renderDate(availableFilter); + + default: + return renderCheckbox(availableFilter); + } + } else { + return nothing; + } + } + )}`; + + if (this.availableFilters.length === 0 && this.filterConfiguration.length > 0) { + if (isEmpty(this.submittedQueryText)) { + renderFilters = renderShimmers; + } else { + renderFilters = html`${strings.noFilters}`; + } + } + + return html` +
+
+
+
+ + ${renderFilters} + ${ + (this.availableFilters.length > 0 && this.allSelectedFilters.length > 0) || + this.allSubmittedFilters.length > 0 + ? html`` + : null + } +
+
+ ${renderSort} +
+
+
+
+ `; + } + + static get styles() { + return [tailwindStyles]; + } + + static get scopedElements() { + return { + 'mgt-filter-checkbox': MgtCheckboxFilterComponent, + 'mgt-filter-date': MgtDateFilterComponent + //"mgt-search-sort": MgtSearchSortComponent + }; + } + + protected get strings(): { [x: string]: string } { + return strings; + } + + public connectedCallback(): Promise { + const bindings = this.searchResultsComponentIds.map(componentId => { + return { + id: componentId, + eventName: EventConstants.SEARCH_RESULTS_EVENT, + callbackFunction: this.handleSearchResultsFilters + }; + }); + + this.bindComponents(bindings); + + return Promise.resolve(super.connectedCallback()); + } + + public handleSearchResultsFilters(e: CustomEvent) { + this.searchResultsEventData = e.detail; + this.availableFilters = cloneDeep(e.detail.availableFilters); + this.submittedQueryText = e.detail.submittedQueryText; + + // Handle filters with zero value + if (e.detail.availableFilters.length === 0 && this.allSelectedFilters.length > 0) { + // Existing selected filters + const selectedFilterNames = this.allSelectedFilters.map(filter => filter.filterName); + + this.availableFilters = cloneDeep( + this.previousAvailableFilters.map(f => { + f.values = []; + return f; + }) + ); + + // Disable non selected filters + this.previousAvailableFilters.forEach(p => { + if (selectedFilterNames.indexOf(p.filterName) === -1) { + this.disabledFilters.push(p.filterName); + } + }); + } else { + this.disabledFilters = []; + } + + // Merge filters with the same field name + // This scenario happens when multiple sources have the same alias as refiner. In this case, the API returns duplicate fields instead of merging them. + this.availableFilters = this.mergeFilters(this.availableFilters); + + // Sort filters by configuration if any + this.availableFilters = this.availableFilters.sort((a, b) => { + const aSortIdx = this.filterConfiguration.filter(f => f.filterName === a.filterName)[0].sortIdx; + const bSortIdx = this.filterConfiguration.filter(f => f.filterName === b.filterName)[0].sortIdx; + return aSortIdx - bSortIdx; + }); + + // Save available filters for subsequent usage + this.previousAvailableFilters = cloneDeep(this.availableFilters); + } + + /** + * Handler when a value is updated (selected/unselected)from a specific filter + * @param filterName the filter name from where values has been applied + * @param filterValue the filter value that has been updated + */ + private onFilterUpdated(filterName: string, filterValue: IDataFilterValue, selected: boolean) { + if (selected) { + // Get the index of the filter in the current selected filters collection + const filterIdx = this.allSelectedFilters + .map(filter => { + return filter.filterName; + }) + .indexOf(filterName); + const newFilters = [...this.allSelectedFilters]; + + if (filterIdx !== -1) { + const valueIdx = this.allSelectedFilters[filterIdx].values.map(v => v.key).indexOf(filterValue.key); + + if (valueIdx === -1) { + // If the value does not exist yet, we add it to the selected values + + newFilters[filterIdx].values.push(filterValue); + this.allSelectedFilters = newFilters; + } else { + // Otherwise, we update the value in selected values + newFilters[filterIdx].values[valueIdx] = filterValue; + } + } else { + const newFilter: IDataFilter = { + filterName: filterName, + values: [filterValue] + }; + + newFilters.push(newFilter); + } + + this.allSelectedFilters = newFilters; + } else { + // Remove the filter value + this.allSelectedFilters = this.allSelectedFilters.map(selectedFilter => { + selectedFilter.values = selectedFilter.values.filter(value => value.key != filterValue.key); + + if (selectedFilter.values.length > 0) { + return selectedFilter; + } else { + return null; + } + }); + + // Remove null values + this.allSelectedFilters = this.allSelectedFilters.filter(f => f); + } + } + + /** + * Handler when values are applied from a specific filter + * @param filterName the filter name from where values has been applied + */ + private onApplyFilters(filterName: string) { + // Update the list of submitted filters to sent to the search engine + const selectedFilter = this.allSelectedFilters.filter(f => f.filterName === filterName)[0]; + + // If filter is 'null', it means no values are currently selected for that filter + if (selectedFilter) { + const filterIdx = this.allSubmittedFilters + .map(filter => { + return filter.filterName; + }) + .indexOf(filterName); + const newFilters = [...this.allSubmittedFilters]; + + if (filterIdx === -1) { + newFilters.push(selectedFilter); + } else { + newFilters[filterIdx] = selectedFilter; + } + + this.allSubmittedFilters = newFilters; + } else { + this.allSubmittedFilters = this.allSubmittedFilters.filter(s => s.filterName !== filterName); + } + + // Reset selected values from other filters that are not already submitted + const otherFiltersWithSelectedValues = this.allSelectedFilters + .filter(f => { + return ( + f.filterName !== filterName && + this.allSubmittedFilters.filter(a => a.filterName === f.filterName).length === 0 + ); + }) + .map(f => f.filterName); + + otherFiltersWithSelectedValues.forEach(filterName => { + const filterComponent = this.getFilterComponents(filterName); + if (filterComponent) { + filterComponent[0].clearSelectedValues(true); + } + }); + + this.applyFilters(); + } + + /** + * Handler when sort properties are updated + * @param sortProperties the sort properties + */ + private onSort(sortProperties: ISearchSortProperty[]) { + this.fireCustomEvent( + EventConstants.SEARCH_SORT_EVENT, + { + sortProperties: sortProperties + } as ISearchSortEventData, + true + ); + } + + /** + * Send filters to connected search results component + */ + private applyFilters() { + // Update filters information before sending them to source so they can be processed according their specificities + // i.e Merge selected filter with relavant information from its configuration + const updatedFilters = this.allSubmittedFilters.map(f => { + const confguration = this.getFilterConfiguration(f.filterName); + if (confguration) { + f.operator = confguration.operator; + } + return f; + }); + + this.fireCustomEvent(EventConstants.SEARCH_FILTER_EVENT, { + selectedFilters: updatedFilters, + filterOperator: this.operator ? this.operator : FilterConditionOperator.AND + } as ISearchFiltersEventData); + } + + private getFilterConfiguration(filterName: string): IDataFilterConfiguration { + return this.filterConfiguration.filter(c => c.filterName === filterName)[0]; + } + + /** + * Merges filter values having the same filter name + * @param availableFilters the available filters returned from the search response + * @returns the merged filters + */ + private mergeFilters(availableFilters: IDataFilterResult[]): IDataFilterResult[] { + let allMergedFilters: IDataFilterResult[] = []; + + availableFilters.forEach(filterResult => { + const mergedFilterIdx = allMergedFilters.map(m => m.filterName).indexOf(filterResult.filterName); + + if (mergedFilterIdx === -1) { + allMergedFilters.push(filterResult); + } else { + const allMergedValues: IDataFilterResultValue[] = []; + const allValues = allMergedFilters[mergedFilterIdx].values.concat(filterResult.values); + + // 3. Sum counts for similar value names + allValues.forEach(value => { + const mergedValueIdx = allMergedValues.map(v => v.name).indexOf(value.name); + + if (mergedValueIdx === -1) { + allMergedValues.push(value); + } else { + allMergedValues[mergedValueIdx].count = allMergedValues[mergedValueIdx].count + value.count; + } + }); + + allMergedFilters[mergedFilterIdx].values = allMergedValues; + } + }); + + // Sort values according to the filter configuration + allMergedFilters = allMergedFilters.map(filter => { + let sortByField = 'name'; + let sortDirection = FilterSortDirection.Ascending; + + const filterConfigurationIdx = this.filterConfiguration + .map(configuration => configuration.filterName) + .indexOf(filter.filterName); + if (filterConfigurationIdx !== -1) { + const filterConfiguration = this.filterConfiguration[filterConfigurationIdx]; + if (filterConfiguration.sortBy === FilterSortType.ByCount) { + sortByField = 'count'; + } + + if (filterConfiguration.sortDirection === FilterSortDirection.Descending) { + sortDirection = FilterSortDirection.Descending; + } + } + + filter.values = + sortDirection === FilterSortDirection.Ascending + ? sortBy(filter.values, sortByField) + : sortBy(filter.values, sortByField).reverse(); + + return filter; + }); + + return allMergedFilters; + } + + public clearAllSelectedValues(preventApply?: boolean) { + // Reset selected values for all child components + const filterComponents = this.getFilterComponents(); + + // Reset all filters from the UI (whithout submitting values) + filterComponents.forEach(component => component.clearSelectedValues(true)); + + if (this.allSubmittedFilters.length > 0) { + this.allSubmittedFilters = []; + + // Apply empty filters + if (!preventApply) { + this.applyFilters(); + } + } + } + + /** + * Retrieved the list of child filters + * @param filterName Optionnal. A specific filter name to retrieve + * @returns the list of child filter components + */ + private getFilterComponents(filterName?: string): MgtBaseFilterComponent[] { + // Reset all values from sub components. List all valid sub filter components + const filterComponents: MgtBaseFilterComponent[] = Array.prototype.slice.call( + this.renderRoot.querySelectorAll(` + [data-tag-name='${ComponentElements.MgtDateFilterComponentElement}'], + [data-tag-name='${ComponentElements.MgtCheckboxFilterComponentElement}'] + `) + ); + + return filterName + ? filterComponents.filter(component => component.filter.filterName === filterName) + : filterComponents; + } +} diff --git a/packages/mgt-components/src/components/mgt-search-filters/strings.ts b/packages/mgt-components/src/components/mgt-search-filters/strings.ts new file mode 100644 index 0000000000..9f425d2c9a --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-filters/strings.ts @@ -0,0 +1,4 @@ +export const strings = { + resetAllFilters: 'Reset filters', + noFilters: 'No filter to display' +}; diff --git a/packages/mgt-components/src/components/mgt-search-results/loc/strings.default.ts b/packages/mgt-components/src/components/mgt-search-results/loc/strings.default.ts new file mode 100644 index 0000000000..c466a10583 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-results/loc/strings.default.ts @@ -0,0 +1,105 @@ +import { html } from 'lit-html'; +import { unsafeHTML } from 'lit-html/directives/unsafe-html'; + +export const MgtSearchResultsStrings = { + seeAllLink: 'See all', + results: 'results' +}; + +export const MgtPaginationStrings = { + nextBtn: 'Next', + previousBtn: 'Previous', + tooManyPages: 'Too many pages!', + screenTipContent: () => html` +
It seems your search returned a lot of pages!
+

Try to narrow down your scope by specifying more precise keywords🙏

+ ` +}; + +export const MgtFilterDateStrings = { + anyTime: 'Any time', + today: 'Today', + past24: 'Past 24 hours', + pastWeek: 'Past week', + pastMonth: 'Past month', + past3Months: 'Past 3 months', + pastYear: 'Past year', + olderThanAYear: 'Older than a year', + reset: 'Reset', + from: 'From', + to: 'To', + applyDates: 'Apply dates', + selections: 'selection(s)' +}; + +export const MgtFilterCheckboxStrings = { + reset: 'Reset', + searchPlaceholder: 'Search for values...', + apply: 'Apply', + cancel: 'Cancel', + selections: 'selection(s)' +}; + +export const MgtSearchFiltersStrings = { + resetAllFilters: 'Reset filters', + noFilters: 'No filter to display' +}; + +export const MgtSearchSortStrings = { + sortedByRelevance: 'Sorted by relevance', + sortDefault: 'Relevance', + sortAscending: 'Sort ascending', + sortDescending: 'Sort descending' +}; + +export const MgtSearchInputStrings = { + searchPlaceholder: 'Search for values...', + clearSearch: 'Clear searchbox', + previousSearches: 'Previous searches' +}; + +export const MgtLanguageProviderStrings = {}; + +export const MgtSearchInfosStrings = { + searchQueryResultText: (keywords): string => `Here's what we found for "${keywords}"`, + resultCountText: (count): string => `Found ${count} results`, + notFoundSuggestions: keywords => html` +

Your search for "${keywords}" did not match any content.

+

Some suggestions:

+
    +
  • Make sure all words are spelled correctly
  • +
  • Try entering different keywords, more general keywords or less keywords
  • +
  • Maybe what you were looking for is not in the scope of the Enterprise Search? Check all the sources we index in our FAQ page.
  • +
  • ... Or ask for help by submitting a SOS ticket and our colleagues will try their best to help you.
  • +
+ `, + didYouMean: (handlerFunction, updatedQueryString) => html` +

Did you mean: "${unsafeHTML(updatedQueryString)}"?

+ ` +}; + +export const MgtErrorMessageStrings = { + errorMessage: 'Error' +}; + +export const MgtExportProfilesStrings = { + exportBtn: 'Export results', + exportBtnLoading: 'Exporting' +}; + +export const strings = { + language: 'en-us', + _components: { + 'mgt-search-results': { ...MgtSearchResultsStrings }, + 'mgt-pagination': { ...MgtPaginationStrings }, + 'mgt-filter-date': { ...MgtFilterDateStrings }, + 'mgt-filter-checkbox': { ...MgtFilterCheckboxStrings }, + 'mgt-search-filters': { ...MgtSearchFiltersStrings }, + 'mgt-search-input': { ...MgtSearchInputStrings }, + 'mgt-input-autocomplete': { ...MgtSearchInputStrings }, + 'mgt-language-provider': { ...MgtLanguageProviderStrings }, + 'mgt-search-infos': { ...MgtSearchInfosStrings }, + 'mgt-error-message': { ...MgtErrorMessageStrings }, + 'mgt-sort': { ...MgtSearchSortStrings } + } +}; diff --git a/packages/mgt-components/src/components/mgt-search-results/mgt-adaptive-card/mgt-adaptive-card.ts b/packages/mgt-components/src/components/mgt-search-results/mgt-adaptive-card/mgt-adaptive-card.ts new file mode 100644 index 0000000000..1296ba8838 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-results/mgt-adaptive-card/mgt-adaptive-card.ts @@ -0,0 +1,220 @@ +import { FileFormat, MgtConnectableComponent, MgtTemplatedComponent, TemplateService } from '@microsoft/mgt-element'; +import { css, customElement, html, property, PropertyValues, state } from 'lit-element'; +import { unsafeHTML } from 'lit-html/directives/unsafe-html'; +import { styles as tailwindStyles } from '../../../styles/tailwind-styles-css'; + +/** + * Process adaptive card content from an external file + */ +@customElement('mgt-adaptive-card') +export class MgtAdaptiveCardComponent extends MgtConnectableComponent { + /** + * The file URL to fetch + */ + @property({ type: String, attribute: 'url' }) + fileUrl: string; + + /** + * The file format to load + */ + @property({ type: String, attribute: 'format' }) + fileFormat: FileFormat = FileFormat.Json; + + /** + * The fallback image URL + */ + @property({ type: String, attribute: 'fallback-img-url' }) + fallbackImageUrl: string; + + /** + * The data context to use to render the card + */ + @property({ + type: Object, + attribute: 'context', + converter: { + fromAttribute: value => { + try { + return JSON.parse(value); + } catch { + return null; + } + } + } + }) + cardContext: object; + + /** + * The raw adaptive card content as string (i.e. JSON stringified) + */ + @property({ type: String, attribute: 'content' }) + cardContent: string; + + /** + * The file content to display + */ + @state() + content: string; + + constructor() { + super(); + this.onImageError = this.onImageError.bind(this); + } + + render() { + if (this.content) { + return html`
${unsafeHTML(this.content)}
`; + } else { + return html` +
+
+
+
+
+ `; + } + } + + static get styles() { + return [ + css` + :host { + + > .ac-container{ + padding: 8px !important; + } + + .ac-anchor { + color: var(--text-color); + text-decoration: none; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + } + .ac-textBlock{ + white-space: normal !important; + overflow-wrap: break-word !important; + } + + .ac-anchor:hover{ + color: var(--ubi365-colorPrimary); + } + + .ac-anchor:focus, .ac-anchor:focus-visible{ + color: var(--ubi365-colorPrimary); + } + + .ac-container#ubiSearchTitle > .ac-textBlock{ + overflow: unset !important; + } + + .ac-container#ubiSearchTitle > .ac-textBlock > p{ + overflow: unset !important; + } + + .ac-container#ubiSearchSite > .ac-textBlock{ + overflow: unset !important; + } + .ac-container#ubiSearchSite > .ac-textBlock > p{ + overflow: unset !important; + } + + .ac-container#ubiSearchSite > .ac-textBlock > p > a{ + display: inline-block; + padding-left: 16px!important; + padding-right: 16px!important; + padding-top: 4px!important; + padding-bottom: 4px!important; + border-radius: 25px; + background-color: var(--topic-background-color) !important; + text-decoration: none; + color: var(--text-color); + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + } + + .ac-container#ubiSearchSite > .ac-textBlock > p > a:hover{ + background-color: var(--topic-hover-color) !important; + } + .ac-container#ubiSearchSite > .ac-textBlock > p > a:focus, .ac-container#ubiSearchSite > .ac-textBlock > p > a:focus-visible{ + background-color: var(--topic-focus-color) !important; + } + #ubiSearchSummary{ + font-size: 1rem !important; + line-height: 1.5rem !important; + } + } + `, + tailwindStyles + ]; + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected updated(changedProperties: PropertyValues): void { + // Set default fallback image if not found + if (this.fallbackImageUrl) { + this.renderRoot.querySelectorAll('img').forEach(el => { + if (el && !this._eventHanlders.get(JSON.stringify(el))) { + this._eventHanlders.set(JSON.stringify(el), { component: el, event: 'error', handler: this.onImageError }); + el.addEventListener('error', this.onImageError); + } + }); + } + } + + private onImageError(event: Event) { + (event.target as HTMLImageElement).src = this.fallbackImageUrl; + + // To avoid endless loop + event.target.removeEventListener('error', this.onImageError); + this._eventHanlders.delete(JSON.stringify(event.target)); + (event.target as HTMLImageElement).onerror = null; + } + + public async connectedCallback(): Promise { + const io = new IntersectionObserver(async data => { + if (data[0].isIntersecting) { + await this._processAdaptiveCard(); + io.disconnect(); + } + }); + + io.observe(this); + + super.connectedCallback(); + } + + private async _processAdaptiveCard() { + const templateService = new TemplateService(); + await templateService.loadAdaptiveCardsResources(); + + let contentToProcess: string; + + if (this.cardContent) { + contentToProcess = this.cardContent; + } else if (this.fileUrl) { + // Get the file raw content according to the type + const fileContent = await templateService.getFileContent(this.fileUrl); + + if (this.fileFormat === FileFormat.Json) { + contentToProcess = fileContent; + } + } + + if (this.cardContext) { + const htmlContent: HTMLElement = templateService.processAdaptiveCardTemplate( + contentToProcess, + this.cardContext, + null + ); + this.content = htmlContent.innerHTML; + } else { + this.content = this.cardContent; + } + } +} diff --git a/packages/mgt-components/src/components/mgt-search-results/mgt-search-results.ts b/packages/mgt-components/src/components/mgt-search-results/mgt-search-results.ts new file mode 100644 index 0000000000..320a0516bc --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-results/mgt-search-results.ts @@ -0,0 +1,1264 @@ +import { + LocalizationHelper, + Providers, + ProviderState, + MgtConnectableComponent, + BuiltinFilterTemplates, + BuiltinTokenNames, + DateHelper, + EntityType, + FilterSortDirection, + FilterSortType, + IDataSourceData, + ILocalizedString, + IMicrosoftSearchQuery, + IMicrosoftSearchService, + ISearchFiltersEventData, + ISearchInputEventData, + ISearchRequestAggregation, + ISearchResultsEventData, + ISearchSortEventData, + ISearchSortProperty, + ISearchVerticalEventData, + ISortFieldConfiguration, + ITemplateService, + ITokenService, + MicrosoftSearchService, + SearchAggregationSortBy, + SearchResultsHelper, + SortFieldDirection, + TemplateService, + TokenService, + UrlHelper, + DataFilterHelper, + customElement, + mgtHtml +} from '@microsoft/mgt-element'; +import { property, state } from 'lit/decorators.js'; +import { css, html, PropertyValues } from 'lit'; +import { isEmpty } from 'lodash-es'; +//import { MgtSearchFiltersComponent } from "../Mgt-search-filters/Mgt-search-filters"; +import { HTMLTemplateResult, TemplateResult } from 'lit'; +//import { MgtPaginationComponent } from "../../internal/Mgt-pagination/Mgt-pagination"; +//import { MgtSearchInputComponent } from "../Mgt-search-input/Mgt-search-input"; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +//import { MgtSearchVerticalsComponent } from "../Mgt-search-verticals/Mgt-search-verticals"; +import { repeat } from 'lit/directives/repeat.js'; +import { MgtSearchResultsStrings as strings } from './loc/strings.default'; +import { nothing } from 'lit'; +//import { MgtErrorMessageComponent } from "../../../components/internal/Mgt-error-message/Mgt-error-message"; +import { styles as tailwindStyles } from '../../styles/tailwind-styles-css'; +import { EventConstants } from '@microsoft/mgt-element'; +import { SvgIcon, getSvg } from '../../utils/SvgHelper'; +import { SearchHit } from '@microsoft/microsoft-graph-types'; +import { getNameFromUrl, getRelativeDisplayDate, sanitizeSummary, trimFileExtension } from '../../utils/Utils'; + +@customElement('search-results') +export class MgtSearchResultsComponent extends MgtConnectableComponent { + //#region Attributes + + /** + * Flag indicating if the beta endpoint for Microsoft Graph API should be used + */ + @property({ type: Boolean, attribute: 'use-beta' }) + useBetaEndpoint = false; + + /** + * The Microsoft Search entity types to query + */ + @property({ + type: String, + attribute: 'entity-types', + converter: { + fromAttribute: value => { + return value.split(',') as EntityType[]; + } + } + }) + entityTypes: EntityType[] = [EntityType.ListItem]; + + /** + * The default query text to apply. + * Query string parameter and search box have priority over this value during first load + */ + @property({ type: String, attribute: 'query-text' }) + defaultQueryText: string; + + /** + * The search query template to use. Support tokens https://learn.microsoft.com/en-us/graph/search-concept-query-template + */ + @property({ type: String, attribute: 'query-template' }) + queryTemplate: string; + + /** + * If specified, get the default query text from this query string parameter name + */ + @property({ type: String, attribute: 'default-query-string-parameter' }) + defaultQueryStringParameter: string; + + /** + * Search managed properties to retrieve for results and usable in the results template. + * Comma separated. Refer to the [Microsoft Search API documentation](https://learn.microsoft.com/en-us/graph/api/resources/search-api-overview?view=graph-rest-1.0&preserve-view=true#scope-search-based-on-entity-types) to know what properties can be used according to entity types. + */ + @property({ + type: Array, + attribute: 'fields', + converter: { + fromAttribute: value => { + return value.split(','); + } + } + }) + selectedFields: string[] = [ + 'name', + 'title', + 'summary', + 'created', + 'createdBy', + 'filetype', + 'defaultEncodingURL', + 'lastModifiedTime', + 'modifiedBy', + 'path', + 'hitHighlightedSummary', + 'SPSiteURL', + 'SiteTitle' + ]; + + /** + * Sort properties for the request + */ + @property({ + type: String, + attribute: 'sort-properties', + converter: { + fromAttribute: value => { + try { + return JSON.parse(value); + } catch { + return null; + } + } + } + }) + sortFieldsConfiguration: ISortFieldConfiguration[] = []; + + /** + * Flag indicating if the pagniation control should be displayed + */ + @property({ type: Boolean, attribute: 'show-paging' }) + showPaging: boolean; + + /** + * The number of results to show per results page + */ + @property({ type: Number, attribute: 'page-size' }) + pageSize = 10; + + /** + * The number of pages to display in the pagination control + */ + @property({ type: Number, attribute: 'pages-number' }) + numberOfPagesToDisplay = 5; + + /** + * Flag indicating if Micrsoft Search result types should be applied in results + */ + @property({ type: Boolean, attribute: 'enable-result-types' }) + enableResultTypes: boolean; + + /** + * If "entityTypes" contains "externalItem", specify the connection id of the external source + */ + @property({ + type: String, + attribute: 'connections', + converter: { + fromAttribute: value => { + return value.split(',').map(v => `/external/connections/${v}`) as string[]; + } + } + }) + connectionIds: string[]; + + /** + * Indicates whether spelling modifications are enabled. If enabled, the user will get the search results for the corrected query in case of no results for the original query with typos. + */ + @property({ type: Boolean, attribute: 'enable-modification' }) + enableModification = false; + + /** + * Indicates whether spelling suggestions are enabled. If enabled, the user will get the search results for the original search query and suggestions for spelling correction + */ + @property({ type: Boolean, attribute: 'enable-suggestion' }) + enableSuggestion = false; + + /** + * If specified, shows the title on top of the results + */ + @property({ + type: String, + attribute: 'comp-title', + converter: { + fromAttribute: value => { + try { + return JSON.parse(value); + } catch { + return value; + } + } + } + }) + componentTitle: string | ILocalizedString; + + /** + * If specified, shows a "See all" link at top top of the results + */ + @property({ type: String, attribute: 'see-all-link' }) + seeAllLink: string; + + /** + * If specified, show the results count at the top of the results + */ + @property({ type: Boolean, attribute: 'show-count' }) + showCount: boolean; + + /** + * The search filters component ID if connected to a search filters + */ + @property({ type: String, attribute: 'search-filters-id' }) + searchFiltersComponentId: string; + + /** + * The search input component ID if connected to a search input + */ + @property({ type: String, attribute: 'search-input-id' }) + searchInputComponentId: string; + + /** + * The search verticals component ID if connected to a search verticals + */ + @property({ type: String, attribute: 'search-verticals-id' }) + searchVerticalsComponentId: string; + + /** + * The search sort component ID if connected to a search sort component + */ + @property({ type: String, attribute: 'search-sort-id' }) + searchSortComponentId: string; + + /** + * If connected to a search verticals component on the same page, determines on which keys this component should be displayed + */ + @property({ + type: Array, + attribute: 'verticals-keys', + converter: { + fromAttribute: value => { + return value.split(','); + } + } + }) + selectedVerticalKeys: string[]; + + /** + * Flag indicating if the loading indication (spinner/shimmers) should be displayed when fectching the data + */ + @property({ type: Boolean, attribute: 'no-loading' }) + noLoadingIndicator: boolean; + + //#endregion + + //#region State properties + + @state() + data: IDataSourceData = { items: [] }; + + @state() + private isLoading = true; + + @state() + private shouldRender: boolean; + + @state() + error: Error = null; + + //#endregion + + //#region Class properties + public declare searchQuery: IMicrosoftSearchQuery; + public declare msSearchService: IMicrosoftSearchService; + private declare templateService: ITemplateService; + private declare tokenService: ITokenService; + private declare dateHelper: DateHelper; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private declare dayJs: any; + private declare currentLanguage: string; + declare sortProperties: ISearchSortProperty[]; + //#endregion + + //#region MGT/Lit Lifecycle methods + + constructor() { + super(); + this.msSearchService = new MicrosoftSearchService(); + this.templateService = new TemplateService(); + this.tokenService = new TokenService(); + + this.dateHelper = new DateHelper(LocalizationHelper.strings?.language); + + this.searchQuery = { + requests: [] + }; + + this.addEventListener('templateRendered', (e: CustomEvent) => { + const element = e.detail.element as HTMLElement; + + if (this.enableResultTypes) { + // Process result types and replace part of HTML with item id + /*const newElement = this.templateService.processResultTypesFromHtml(this.data, element, this.getTheme()); + element.replaceWith(newElement);*/ + } + }); + + this.handleSearchVertical = this.handleSearchVertical.bind(this); + this.handleSearchFilters = this.handleSearchFilters.bind(this); + this.handleSearchInput = this.handleSearchInput.bind(this); + this.handleSearchSort = this.handleSearchSort.bind(this); + + this.goToPage = this.goToPage.bind(this); + } + + public render() { + if (this.shouldRender) { + let renderHeader; + let renderItems; + let renderOverlay; + let renderPagination; + + // Render shimmers + if (this.hasTemplate('shimmers') && !this.noLoadingIndicator && !this.renderedOnce) { + renderItems = this.renderTemplate('shimmers', { items: Array(this.pageSize) }); + } else { + // Render loading overlay + if (this.isLoading && !this.noLoadingIndicator) { + renderOverlay = html` +
+
+ + + + + +
+
+ `; + } + } + + // Render error + if (this.error) { + return html``; + } + + // Render header + if (this.componentTitle || this.seeAllLink || this.showCount) { + renderHeader = html` +
+
+ ${ + this.componentTitle + ? html` +
${this.getLocalizedString( + this.componentTitle + )}
+ ` + : null + } + ${ + this.showCount && !this.isLoading + ? html` +
${this.data.totalCount} ${strings.results}
+ ` + : null + } +
+ ${ + this.seeAllLink && this.data.totalCount > 0 + ? html` + + ` + : null + } +
+ `; + } + + if (this.renderedOnce) { + // Render items + if (this.hasTemplate('items')) { + renderItems = this.renderTemplate('items', this.data); + } else { + // Default template for all items + renderItems = html` +
    + ${repeat( + this.data.items, + item => item.hitId, + item => { + return this.renderResult(item); + } + )} +
+ `; + } + + // Render pagination + if (this.showPaging && this.data.items.length > 0) { + renderPagination = html` + + `; + } + } + + return html` + ${renderHeader} +
+ ${renderOverlay} + ${renderItems} + ${renderPagination} +
+ `; + } + + return nothing; + } + + /** + * Render the result item. + * + * @protected + * @returns + * @memberof MgtSearchResults + */ + protected renderResult(result: SearchHit): TemplateResult { + const type = this.getResourceType(result.resource); + if (this.hasTemplate(`result-${type}`)) { + return this.renderTemplate(`result-${type}`, result, result.hitId); + } else { + switch (result.resource['@odata.type']) { + case '#microsoft.graph.driveItem': + return this.renderDriveItem(result); + case '#microsoft.graph.site': + return this.renderSite(result); + case '#microsoft.graph.person': + return this.renderPerson(result); + case '#microsoft.graph.drive': + case '#microsoft.graph.list': + return this.renderList(result); + case '#microsoft.graph.listItem': + return this.renderListItem(result); + case '#microsoft.graph.search.bookmark': + return this.renderBookmark(result); + case '#microsoft.graph.search.acronym': + return this.renderAcronym(result); + case '#microsoft.graph.search.qna': + return this.renderQnA(result); + default: + return this.renderDefault(result); + } + } + } + + /** + * Gets the resource type (entity) of a search result + * + * @param resource + */ + private getResourceType(resource: any) { + return resource['@odata.type'].split('.').pop(); + } + + /** + * Renders a driveItem entity + * + * @param result + */ + private renderDriveItem(result: SearchHit) { + const resource = result.resource as any; + return mgtHtml` +
+
+ + +
+
+ +
+
+ + +
+
+   ${strings.modified} ${getRelativeDisplayDate(new Date(resource.lastModifiedDateTime))} +
+
+
+
+ ${ + resource.thumbnail?.url && + html` +
+ ${resource.name} +
` + } + +
+ + `; + } + + /** + * Renders a site entity + * + * @param result + * @returns + */ + private renderSite(result: SearchHit): HTMLTemplateResult { + const resource = result.resource as any; + return html` +
+
+ ${this.getResourceIcon(resource)} +
+ +
+ + `; + } + + /** + * Renders a list entity + * + * @param result + * @returns + */ + private renderList(result: SearchHit): HTMLTemplateResult { + const resource = result.resource as any; + return mgtHtml` + + + `; + } + + /** + * Renders a listItem entity + * + * @param result + * @returns + */ + private renderListItem(result: SearchHit): HTMLTemplateResult { + const resource = result.resource as any; + return mgtHtml` +
+
+ ${resource.webUrl.endsWith('.aspx') ? getSvg(SvgIcon.News) : getSvg(SvgIcon.FileOuter)} +
+
+ +
+
+ + +
+
+   ${strings.modified} ${getRelativeDisplayDate(new Date(resource.lastModifiedDateTime))} +
+
+
+
+ ${ + resource.thumbnail?.url && + html` +
+ ${trimFileExtension(
+            resource.name || getNameFromUrl(resource.webUrl)
+          )} +
` + } +
+ + `; + } + + /** + * Renders a person entity + * + * @param result + * @returns + */ + private renderPerson(result: SearchHit): HTMLTemplateResult { + const resource = result.resource as any; + return mgtHtml` +
+ + +
+ + `; + } + + /** + * Renders a bookmark entity + * + * @param result + */ + private renderBookmark(result: SearchHit) { + return this.renderAnswer(result, SvgIcon.DoubleBookmark); + } + + /** + * Renders an acronym entity + * + * @param result + */ + private renderAcronym(result: SearchHit) { + return this.renderAnswer(result, SvgIcon.BookOpen); + } + + /** + * Renders a qna entity + * + * @param result + */ + private renderQnA(result: SearchHit) { + return this.renderAnswer(result, SvgIcon.BookQuestion); + } + + /** + * Renders an answer entity + * + * @param result + */ + private renderAnswer(result: SearchHit, icon: SvgIcon) { + const resource = result.resource as any; + return html` +
+
+ ${getSvg(icon)} +
+
+ +
${resource.description}
+
+
+ + `; + } + + /** + * Renders any entity + * + * @param result + */ + private renderDefault(result: SearchHit) { + const resource = result.resource as any; + const resourceUrl = this.getResourceUrl(resource); + return html` +
+
+ ${this.getResourceIcon(resource)} +
+
+
+ ${ + resourceUrl + ? html` + ${this.getResourceName(resource)} + ` + : html` + ${this.getResourceName(resource)} + ` + } +
+
+
+
+ + `; + } + + /** + * Gets default resource URLs + * + * @param resource + */ + private getResourceUrl(resource: any) { + return resource.webUrl || /* resource.url ||*/ resource.webLink || null; + } + + /** + * Gets default resource Names + * + * @param resource + */ + private getResourceName(resource: any) { + return resource.displayName || resource.subject || trimFileExtension(resource.name); + } + + /** + * Gets default result summary + * + * @param resource + */ + private getResultSummary(result: SearchHit) { + return sanitizeSummary(result.summary || (result.resource as any)?.description || null); + } + + /** + * Gets default resource icon + * + * @param resource + */ + private getResourceIcon(resource: any) { + switch (resource['@odata.type']) { + case '#microsoft.graph.site': + return getSvg(SvgIcon.Globe); + case '#microsoft.graph.message': + return getSvg(SvgIcon.Email); + case '#microsoft.graph.event': + return getSvg(SvgIcon.Event); + case 'microsoft.graph.chatMessage': + return getSvg(SvgIcon.SmallChat); + default: + return getSvg(SvgIcon.FileOuter); + } + } + + public async connectedCallback(): Promise { + // 'setTimeout' is used here to make sure the initialization logic for the search results components occurs after other component initialization routine. + // This way, we ensure other component properties will be accessible. + // connectedCallback events on other components will execute before according to the JS event loop + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop + setTimeout(async () => { + this.msSearchService.useBetaEndPoint = this.useBetaEndpoint; + this.dayJs = await this.dateHelper.dayJs(); + + // Bind connected components + const bindings = [ + { + id: this.searchVerticalsComponentId, + eventName: EventConstants.SEARCH_VERTICAL_EVENT, + callbackFunction: this.handleSearchVertical + }, + { + id: this.searchFiltersComponentId, + eventName: EventConstants.SEARCH_FILTER_EVENT, + callbackFunction: this.handleSearchFilters + }, + { + id: this.searchInputComponentId, + eventName: EventConstants.SEARCH_INPUT_EVENT, + callbackFunction: this.handleSearchInput + }, + { + id: this.searchSortComponentId, + eventName: EventConstants.SEARCH_SORT_EVENT, + callbackFunction: this.handleSearchSort + } + ]; + + this.bindComponents(bindings); + + if (this.enableResultTypes) { + // Only load adaptive cards bundle if result types are enabled for performance purpose + await this.templateService.loadAdaptiveCardsResources(); + } + + if (this.searchVerticalsComponentId) { + // Check if the current component should be displayed at first + const verticalsComponent = document.getElementById(this.searchVerticalsComponentId) as any; // MgtSearchVerticalsComponent; + if (verticalsComponent) { + // Reead the default value directly from the attribute + const selectedVerticalKey = verticalsComponent.selectedVerticalKey; + if (selectedVerticalKey) { + this.shouldRender = this.selectedVerticalKeys.indexOf(selectedVerticalKey) !== -1; + } + } + } else { + this.shouldRender = true; + } + + // Set default sort properties according to configuration + this.initSortProperties(); + + // Build the search query + this.buildSearchQuery(); + + // Set tokens + this.tokenService.setTokenValue(BuiltinTokenNames.searchTerms, this.getDefaultQueryText()); + + return super.connectedCallback(); + }); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public updated(changedProperties: PropertyValues): void { + // Process result types on default Lit template of this component + if (this.enableResultTypes && !this.hasTemplate('items')) { + this.templateService.processResultTypesFromHtml(this.data, this.renderRoot as HTMLElement); + } + + // Properties trigerring a new search + // Mainly use for Storybook demo scenario + if ( + changedProperties.get('defaultQueryText') || + changedProperties.get('selectedFields') || + changedProperties.get('pageSize') || + changedProperties.get('entityTypes') || + changedProperties.get('enableResultTypes') || + changedProperties.get('connectionIds') || + changedProperties.get('numberOfPagesToDisplay') + ) { + // Update the search query + this.buildSearchQuery(); + this._search(this.searchQuery); + } + + this.currentLanguage = LocalizationHelper.strings?.language; + } + + /** + * Only calls when the provider is in ProviderState.SignedIn state + * @returns + */ + public async loadState(): Promise { + if (this.shouldRender && this.getDefaultQueryText()) { + await this._search(this.searchQuery); + } else { + this.isLoading = false; + } + } + + //#endregion + + //#region Static properties accessors + static get styles() { + return [ + css` + :host { + + .itemInfo + .itemInfo::before { + content: " • "; + font-size: 18px; + line-height: 1; + transform: translateY(2px); + display: inline-block; + margin: 0 5px; + } + + .itemInfo + .itemInfo::after { + content: " • "; + font-size: 18px; + line-height: 1; + transform: translateY(2px); + display: inline-block; + margin: 0 5px; + } + } + `, + tailwindStyles + ]; + } + + protected get strings() { + return strings; + } + + //#endregion + + //#region Data related methods + + private async _search(searchQuery: IMicrosoftSearchQuery): Promise { + const provider = Providers.globalProvider; + if (!provider || provider.state !== ProviderState.SignedIn) { + return; + } + + try { + // Reset error + this.error = null; + + this.isLoading = true; + const queryLanguage = LocalizationHelper.strings?.language; + + const results = await this.msSearchService.search(searchQuery, queryLanguage); + this.data = results; + + // Enhance results + this.data.items = SearchResultsHelper.enhanceResults(this.data.items); + this.isLoading = false; + + this.renderedOnce = true; + + // Notify subscribers new filters are available + this.fireCustomEvent(EventConstants.SEARCH_RESULTS_EVENT, { + availableFilters: results.filters, + sortFieldsConfiguration: this.sortFieldsConfiguration.filter(s => s.isUserSort), + submittedQueryText: this.searchQuery.requests[0].query.queryString, + resultsCount: results.totalCount, + queryAlterationResponse: results.queryAlterationResponse, + from: this.searchQuery.requests[0].from + } as ISearchResultsEventData); + } catch (error) { + this.error = error; + } + } + + private buildAggregationsFromFiltersConfig(): ISearchRequestAggregation[] { + let aggregations: ISearchRequestAggregation[] = []; + const filterComponent = document.getElementById(this.searchFiltersComponentId) as any; //MgtSearchFiltersComponent; + if (filterComponent && filterComponent.filterConfiguration) { + // Build aggregations from filters configuration (i.e. refiners) + aggregations = filterComponent.filterConfiguration.map(filterConfiguration => { + const aggregation: ISearchRequestAggregation = { + field: filterConfiguration.filterName, + bucketDefinition: { + isDescending: filterConfiguration.sortDirection === FilterSortDirection.Ascending ? false : true, + minimumCount: 0, + sortBy: + filterConfiguration.sortBy === FilterSortType.ByCount + ? SearchAggregationSortBy.Count + : SearchAggregationSortBy.KeyAsString + }, + size: filterConfiguration && filterConfiguration.maxBuckets ? filterConfiguration.maxBuckets : 10 + }; + + if (filterConfiguration.template === BuiltinFilterTemplates.Date) { + const pastYear = this.dayJs(new Date()).subtract(1, 'years').subtract(1, 'minutes').toISOString(); + const past3Months = this.dayJs(new Date()).subtract(3, 'months').subtract(1, 'minutes').toISOString(); + const pastMonth = this.dayJs(new Date()).subtract(1, 'months').subtract(1, 'minutes').toISOString(); + const pastWeek = this.dayJs(new Date()).subtract(1, 'week').subtract(1, 'minutes').toISOString(); + const past24hours = this.dayJs(new Date()).subtract(24, 'hours').subtract(1, 'minutes').toISOString(); + const today = new Date().toISOString(); + + aggregation.bucketDefinition.ranges = [ + { + to: pastYear + }, + { + from: pastYear, + to: today + }, + { + from: past3Months, + to: today + }, + { + from: pastMonth, + to: today + }, + { + from: pastWeek, + to: today + }, + { + from: past24hours, + to: today + }, + { + from: today + } + ]; + } + + return aggregation; + }); + } + + return aggregations; + } + + /** + * Builds the search query according to the current componetn parameters and context + */ + private buildSearchQuery() { + // Build base search query from parameters + this.searchQuery = { + requests: [ + { + entityTypes: this.entityTypes, + contentSources: this.connectionIds, + fields: this.selectedFields, + query: { + queryString: this.getDefaultQueryText(), + queryTemplate: this.queryTemplate + }, + from: 0, + size: this.pageSize, + queryAlterationOptions: { + enableModification: this.enableModification, + enableSuggestion: this.enableSuggestion + }, + resultTemplateOptions: { + enableResultTemplate: this.enableResultTypes + } + } + ] + }; + + // Sort properties + if (this.sortProperties && this.sortProperties.length > 0) { + this.searchQuery.requests[0].sortProperties = this.sortProperties; + } + + // If a filter component is connected, get the configuration directly from connected component + if (this.searchFiltersComponentId) { + this.searchQuery.requests[0].aggregations = this.buildAggregationsFromFiltersConfig(); + } + } + + //#endregion + + //#region Event handlers from connected components + + private async handleSearchFilters(e: CustomEvent): Promise { + if (this.shouldRender) { + let aggregationFilters: string[] = []; + + const selectedFilters = e.detail.selectedFilters; + + // Build aggregation filters + if (selectedFilters.some(f => f.values.length > 0)) { + // Bind to current context to be able to refernce "dayJs" + const buildFqlRefinementString = DataFilterHelper.buildFqlRefinementString.bind(this); + + // Make sure, if we have multiple filters, at least two filters have values to avoid apply an operator ("or","and") on only one condition failing the query. + if ( + selectedFilters.length > 1 && + selectedFilters.filter(selectedFilter => selectedFilter.values.length > 0).length > 1 + ) { + const refinementString = buildFqlRefinementString(selectedFilters).join(','); + if (!isEmpty(refinementString)) { + aggregationFilters = aggregationFilters.concat([`${e.detail.filterOperator}(${refinementString})`]); + } + } else { + aggregationFilters = aggregationFilters.concat(buildFqlRefinementString(selectedFilters)); + } + } else { + delete this.searchQuery.requests[0].aggregationFilters; + } + + if (aggregationFilters.length > 0) { + this.searchQuery.requests[0].aggregationFilters = aggregationFilters; + } + + this.resetPagination(); + + await this._search(this.searchQuery); + } + } + + private async handleSearchInput(e: CustomEvent): Promise { + if (this.shouldRender) { + // Remove any query string parameter if used as default + if (this.defaultQueryStringParameter) { + const url = UrlHelper.removeQueryStringParam(this.defaultQueryStringParameter, window.location.href); + if (url !== window.location.href) { + window.history.pushState({}, '', url); + } + } + + // If empty keywords, reset to the default state + const searchKeywords = !isEmpty(e.detail.keywords) ? e.detail.keywords : this.getDefaultQueryText(); + + if (searchKeywords && searchKeywords !== this.searchQuery.requests[0].query.queryString) { + // Update token + this.tokenService.setTokenValue(BuiltinTokenNames.searchTerms, searchKeywords); + + this.searchQuery.requests[0].query.queryString = searchKeywords; + + this.resetFilters(); + this.resetPagination(); + + await this._search(this.searchQuery); + } + } + } + + private async handleSearchVertical(e: CustomEvent): Promise { + this.shouldRender = this.selectedVerticalKeys.indexOf(e.detail.selectedVertical.key) !== -1; + + if (this.shouldRender) { + // Reinitialize search context + this.resetQueryText(); + this.resetFilters(); + this.resetPagination(); + this.initSortProperties(); + + // Update the query when the new tab is selected + await this._search(this.searchQuery); + } else { + // If a query is currently performed, we cancel it to avoid new filters getting populated once one an other tab + if (this.isLoading) { + this.msSearchService.abortRequest(); + } + + // Reset available filters for connected search filter components + this.fireCustomEvent(EventConstants.SEARCH_RESULTS_EVENT, { + availableFilters: [] + } as ISearchResultsEventData); + } + } + + private async handleSearchSort(e: CustomEvent): Promise { + if (this.shouldRender) { + this.sortProperties = e.detail.sortProperties; + + this.buildSearchQuery(); + + // Update the query when new sort is defined + await this._search(this.searchQuery); + } + } + + //#endregion + + //#region Utility methods + + private getDefaultQueryText(): string { + // 1) Look connected search box if any + const inputComponent = document.getElementById(this.searchInputComponentId) as any; // MgtSearchInputComponent; + if (inputComponent && inputComponent.searchKeywords) { + return inputComponent.searchKeywords; + } + + // 2) Look query string parameters if any + if ( + this.defaultQueryStringParameter && + !isEmpty(UrlHelper.getQueryStringParam(this.defaultQueryStringParameter, window.location.href)) + ) { + return UrlHelper.getQueryStringParam(this.defaultQueryStringParameter, window.location.href); + } + + // 3) Look default hard coded value if any + if (this.defaultQueryText) { + return this.defaultQueryText; + } + } + + private goToPage(pageNumber: number) { + if (pageNumber > 0) { + // "-1" is to calculate the correct index. Ex page "1" with page size "10" means items from start index 0 to index 10. + this.searchQuery.requests[0].from = (pageNumber - 1) * this.pageSize; + this._search(this.searchQuery); + } + } + + private resetPagination() { + // Reset to first page + /* this.searchQuery.requests[0].from = 0; + const paginationComponent = this.renderRoot.querySelector(`[data-tag-name='${ComponentElements.MgtPaginationElement}']`); + if (paginationComponent) { + paginationComponent.initPagination(); + }*/ + } + + private resetFilters() { + // Reset existing filters if any selected + /* if (this.searchFiltersComponentId) { + const filterComponent = document.getElementById(this.searchFiltersComponentId) as MgtSearchFiltersComponent; + if (filterComponent) { + filterComponent.clearAllSelectedValues(true); + } + } + + delete this.searchQuery.requests[0].aggregationFilters;*/ + } + + private initSortProperties() { + this.sortProperties = this.sortFieldsConfiguration + .filter(s => s.isDefaultSort) + .map(s => { + return { + isDescending: s.sortDirection === SortFieldDirection.Descending, + name: s.sortField + }; + }); + } + + private resetQueryText() { + const queryText = this.getDefaultQueryText(); + this.searchQuery.requests[0].query.queryString = queryText; + } + //#endregion +} diff --git a/packages/mgt-components/src/components/mgt-search-verticals/mgt-search-verticals.scss b/packages/mgt-components/src/components/mgt-search-verticals/mgt-search-verticals.scss new file mode 100644 index 0000000000..03d3bf166d --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-verticals/mgt-search-verticals.scss @@ -0,0 +1 @@ +@import '../../../../../node_modules/office-ui-fabric-core/dist/sass/References'; diff --git a/packages/mgt-components/src/components/mgt-search-verticals/mgt-search-verticals.ts b/packages/mgt-components/src/components/mgt-search-verticals/mgt-search-verticals.ts new file mode 100644 index 0000000000..f093c45df1 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-verticals/mgt-search-verticals.ts @@ -0,0 +1,179 @@ +import { EventConstants, customElement } from '@microsoft/mgt-element'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit'; +import { IDataVerticalConfiguration } from '@microsoft/mgt-element/src/models/IDataVerticalConfiguration'; +import { ISearchVerticalEventData, MgtConnectableComponent, PageOpenBehavior } from '@microsoft/mgt-element'; +import { repeat } from 'lit/directives/repeat.js'; +import { isEmpty, isEqual } from 'lodash-es'; +import { styles } from './mgt-search-verticals-css'; +import { styles as tailwindStyles } from '../../styles/tailwind-styles-css'; +import { fluentTab, fluentTabPanel, fluentTabs, provideFluentDesignSystem } from '@fluentui/web-components'; +import { UrlHelper } from '@microsoft/mgt-element'; + +@customElement('search-verticals') +export class MgtSearchVerticalsComponent extends MgtConnectableComponent { + /** + * The configured search verticals + */ + @property({ + type: Object, + attribute: 'settings', + converter: { + fromAttribute: value => { + return JSON.parse(value) as IDataVerticalConfiguration[]; + } + } + }) + public verticals: IDataVerticalConfiguration[]; + + /** + * The query string parameter name to use to select a vertical tab by default + */ + @property({ type: String, attribute: 'default-query-string-param' }) + public defaultVerticalQueryStringParam = 'v'; + + @property({ type: String, attribute: 'selected-key', reflect: true }) + public selectedVerticalKey: string; + + constructor() { + super(); + this.onVerticalSelected = this.onVerticalSelected.bind(this); + + // Register fluent tabs (as scoped elements) + provideFluentDesignSystem().register(fluentTab(), fluentTabs(), fluentTabPanel()); + } + + public render() { + return html` +
+
+ + ${repeat( + this.verticals, + vertical => vertical.key, + vertical => { + return html` + { + this.onVerticalSelected(vertical); + }} + > + ${this.getLocalizedString(vertical.tabName)} + + + + `; + } + )} + +
+
+ `; + } + + static get styles() { + return [ + styles, // Allow component to use them CSS variables from design. The component is a first level component so it is OK to define theme variables here + tailwindStyles // Use Tailwind CSS classes + ]; + } + + public connectedCallback(): void { + this.handleQueryStringChange(); + this.initializeDefaultValue(); + + return super.connectedCallback(); + } + + /** + * Select the vertical and notifies all subscribers + * @param verticalKey the vertical key to select + */ + public selectVertical(verticalKey: string) { + if (!isEmpty(verticalKey)) { + this.selectedVerticalKey = verticalKey; + + // Update the query string parameter if already present in the URL + const verticalKeyParam = UrlHelper.getQueryStringParam( + this.defaultVerticalQueryStringParam, + window.location.href + ); + if ( + this.defaultVerticalQueryStringParam && + verticalKeyParam && + !isEqual(this.selectedVerticalKey, verticalKeyParam) + ) { + window.history.pushState( + {}, + '', + UrlHelper.addOrReplaceQueryStringParam( + window.location.href, + this.defaultVerticalQueryStringParam, + this.selectedVerticalKey + ) + ); + } + + this.fireCustomEvent(EventConstants.SEARCH_VERTICAL_EVENT, { + selectedVertical: this.verticals.filter(v => v.key === this.selectedVerticalKey)[0] + } as ISearchVerticalEventData); + } + } + + /** + * Handler when a vertical is slected by an user + * @param vertical the current selected vertical + */ + private onVerticalSelected(vertical: IDataVerticalConfiguration): void { + if (vertical.isLink && vertical.linkUrl) { + window.open(vertical.linkUrl, PageOpenBehavior.NewTab ? '_blank' : ''); + } else { + this.selectVertical(vertical.key); + } + } + + /** + * Initialize the default vertical value according to settings + */ + private initializeDefaultValue() { + if (!this.defaultVerticalQueryStringParam) { + this.selectedVerticalKey = this.selectedVerticalKey ? this.selectedVerticalKey : this.verticals[0].key; + } else { + // Get vertical corresponding to the query string URL parameter + const defaultQueryVal = UrlHelper.getQueryStringParam( + this.defaultVerticalQueryStringParam, + window.location.href.toLowerCase() + ); + + if (defaultQueryVal) { + const defaultSelected: IDataVerticalConfiguration[] = this.verticals.filter( + v => v.key.toLowerCase() === decodeURIComponent(defaultQueryVal.toLowerCase()) + ); + if (defaultSelected.length === 1) { + this.selectedVerticalKey = defaultSelected[0].key; + } + } else { + this.selectedVerticalKey = this.selectedVerticalKey ? this.selectedVerticalKey : this.verticals[0].key; + } + } + } + + /** + * Subscribes to URL query string change events using windows state + */ + private handleQueryStringChange() { + if (this.defaultVerticalQueryStringParam) { + // Will fire on browser back/forward + window.onpopstate = () => { + const verticalKeyParam = UrlHelper.getQueryStringParam( + this.defaultVerticalQueryStringParam, + window.location.href + ); + if (this.selectedVerticalKey !== verticalKeyParam) this.selectVertical(verticalKeyParam); + }; + } + } +} diff --git a/packages/mgt-components/src/components/mgt-search-verticals/strings.default.ts b/packages/mgt-components/src/components/mgt-search-verticals/strings.default.ts new file mode 100644 index 0000000000..0eb42c7c17 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-verticals/strings.default.ts @@ -0,0 +1,3 @@ +export const strings = { + language: 'en-us' +}; diff --git a/packages/mgt-components/src/components/mgt-search-verticals/strings.fr-fr.ts b/packages/mgt-components/src/components/mgt-search-verticals/strings.fr-fr.ts new file mode 100644 index 0000000000..b97df8f21c --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-verticals/strings.fr-fr.ts @@ -0,0 +1,3 @@ +export const strings = { + language: 'fr-fr' +}; diff --git a/packages/mgt-components/src/components/mgt-search-verticals/tests/mgt-search-verticals.test.ts b/packages/mgt-components/src/components/mgt-search-verticals/tests/mgt-search-verticals.test.ts new file mode 100644 index 0000000000..6d5c1b8c5b --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-verticals/tests/mgt-search-verticals.test.ts @@ -0,0 +1,125 @@ +import { assert, elementUpdated, expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { baseVerticalSettings } from './mocks'; +import sinon from 'sinon'; +import { ISearchVerticalEventData, LocalizationHelper, UrlHelper } from '@microsoft/mgt-element'; +import { strings as stringsFr } from '../strings.fr-fr'; +import { strings as stringsEn } from '../strings.default'; +import { MgtSearchVerticalsComponent } from '../mgt-search-verticals'; +import { EventConstants } from '@microsoft/mgt-element'; + +//#region Selectors +const getVerticals = (component: MgtSearchVerticalsComponent) => + component.shadowRoot.querySelector('fluent-tabs').querySelectorAll('fluent-tab'); +const getVerticalByName = (component: MgtSearchVerticalsComponent, name: string) => + component.shadowRoot + .querySelector('fluent-tabs') + .querySelector(`fluent-tab[data-name='${name}']`); +const getVerticalByKey = (component: MgtSearchVerticalsComponent, key: string) => + component.shadowRoot + .querySelector('fluent-tabs') + .querySelector(`fluent-tab[data-key='${key}']`); +//#endregion + +describe('mgt-search-verticals', async () => { + describe('common', async () => { + beforeEach(async () => { + const newUrl = UrlHelper.removeQueryStringParam('v', window.location.href); + window.history.pushState({ path: newUrl }, '', newUrl); + + LocalizationHelper.strings = stringsEn; + }); + + it('should be defined', async () => { + const el = document.createElement('mgt-search-verticals'); + assert.instanceOf(el, MgtSearchVerticalsComponent); + }); + + it('should display verticals according to the configuration', async () => { + const el: MgtSearchVerticalsComponent = await fixture( + html` + + + ` + ); + + // Check UI + assert.equal(getVerticals(el).length, 2); + assert.isNotNull(getVerticalByKey(el, 'tab1')); + assert.isNotNull(getVerticalByKey(el, 'tab2')); + + // Check data + assert.equal(el.verticals.length, 2); + }); + + it('should support localization for vertical name', async () => { + const el: MgtSearchVerticalsComponent = await fixture( + html` + + + ` + ); + + assert.isNotNull(getVerticalByName(el, 'Tab 1')); + assert.isNotNull(getVerticalByName(el, 'Tab 2')); + + // Load fr-fr strings + LocalizationHelper.strings = stringsFr; + await el.requestUpdate(); + + assert.isNotNull(getVerticalByName(el, 'Onglet 1')); + assert.isNotNull(getVerticalByName(el, 'Tab 2')); // Shouldn't change as it is not a localized string + }); + + it('shoud trigger an event with selected vertical data when user clicks on the tab', async () => { + const el: MgtSearchVerticalsComponent = await fixture( + html` + + + ` + ); + + assert.equal(el.getAttribute('selected-key'), 'tab1'); + + const listener = oneEvent(el, EventConstants.SEARCH_VERTICAL_EVENT); + getVerticalByKey(el, 'tab2').click(); + await elementUpdated(el); + + assert.equal(el.getAttribute('selected-key'), 'tab2'); + const { detail } = await listener; + + assert.equal((detail as ISearchVerticalEventData).selectedVertical.key, 'tab2'); + }); + }); + + describe('default query string parameter', async () => { + before(() => { + const newUrl = UrlHelper.addOrReplaceQueryStringParam(window.location.href, 'v', 'tab2'); + window.history.pushState({ path: newUrl }, '', newUrl); + }); + + it('should select default tab according to query string parameter', async () => { + const el: MgtSearchVerticalsComponent = await fixture( + html` + + + ` + ); + + const selectVerticalSpy = sinon.spy(el, 'selectVertical'); + assert.equal(el.getAttribute('selected-key'), 'tab2'); + expect(selectVerticalSpy.callCount).to.equal(0); + }); + + after(() => { + // Do not revert window.location.href to avoid infinite loop + }); + }); +}); diff --git a/packages/mgt-components/src/components/mgt-search-verticals/tests/mocks.ts b/packages/mgt-components/src/components/mgt-search-verticals/tests/mocks.ts new file mode 100644 index 0000000000..f51d549783 --- /dev/null +++ b/packages/mgt-components/src/components/mgt-search-verticals/tests/mocks.ts @@ -0,0 +1,23 @@ +//#region Mock data +export const baseVerticalSettings = [ + { + key: 'tab1', + tabName: { + default: 'Tab 1', + 'fr-fr': 'Onglet 1' + }, + tabValue: '', + isLink: false, + linkUrl: null, + openBehavior: 0 + }, + { + key: 'tab2', + tabName: 'Tab 2', + tabValue: '', + isLink: false, + linkUrl: null, + openBehavior: 0 + } +]; +//#endregion diff --git a/packages/mgt-components/src/styles/tailwind-styles.css b/packages/mgt-components/src/styles/tailwind-styles.css new file mode 100644 index 0000000000..af588d1be9 --- /dev/null +++ b/packages/mgt-components/src/styles/tailwind-styles.css @@ -0,0 +1,333 @@ +*,::after,::before{ + border:0 solid #e5e7eb; + box-sizing:border-box; +} + +::after,::before{ + --tw-content:""; +} + +html{ + -webkit-text-size-adjust:100%; + font-feature-settings:normal; + font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji; + line-height:1.5; + -moz-tab-size:4; + -o-tab-size:4; + tab-size:4; +} + +body{ + line-height:inherit; + margin:0; +} + +hr{ + border-top-width:1px; + color:inherit; + height:0; +} + +abbr:where([title]){ + -webkit-text-decoration:underline dotted; + text-decoration:underline dotted; +} + +h1,h2,h3,h4,h5,h6{ + font-size:inherit; + font-weight:inherit; +} + +a{ + color:inherit; + text-decoration:inherit; +} + +b,strong{ + font-weight:bolder; +} + +code,kbd,pre,samp{ + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace; + font-size:1em; +} + +small{ + font-size:80%; +} + +sub,sup{ + font-size:75%; + line-height:0; + position:relative; + vertical-align:baseline; +} + +sub{ + bottom:-.25em; +} + +sup{ + top:-.5em; +} + +table{ + border-collapse:collapse; + border-color:inherit; + text-indent:0; +} + +button,input,optgroup,select,textarea{ + color:inherit; + font-family:inherit; + font-size:100%; + font-weight:inherit; + line-height:inherit; + margin:0; + padding:0; +} + +button,select{ + text-transform:none; +} + +[type="button"],[type="reset"],[type="submit"],button{ + -webkit-appearance:button; + background-color:transparent; + background-image:none; +} + +:-moz-focusring{ + outline:auto; +} + +:-moz-ui-invalid{ + box-shadow:none; +} + +progress{ + vertical-align:baseline; +} + +::-webkit-inner-spin-button,::-webkit-outer-spin-button{ + height:auto; +} + +[type="search"]{ + -webkit-appearance:textfield; + outline-offset:-2px; +} + +::-webkit-search-decoration{ + -webkit-appearance:none; +} + +::-webkit-file-upload-button{ + -webkit-appearance:button; + font:inherit; +} + +summary{ + display:list-item; +} + +blockquote,dd,dl,fieldset,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{ + margin:0; +} + +fieldset,legend{ + padding:0; +} + +menu,ol,ul{ + list-style:none; + margin:0; + padding:0; +} + +textarea{ + resize:vertical; +} + +input::-moz-placeholder,textarea::-moz-placeholder{ + color:#9ca3af; + opacity:1; +} + +input::placeholder,textarea::placeholder{ + color:#9ca3af; + opacity:1; +} + +[role="button"],button{ + cursor:pointer; +} +:disabled{ + cursor:default; +} + +audio,canvas,embed,iframe,img,object,svg,video{ + display:block; + vertical-align:middle; +} + +img,video{ + height:auto; + max-width:100%; +} +[hidden]{ + display:none; +}*,::after,::before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x:;--tw-pan-y:;--tw-pinch-zoom:;--tw-scroll-snap-strictness:proximity;--tw-ordinal:;--tw-slashed-zero:;--tw-numeric-figure:;--tw-numeric-spacing:;--tw-numeric-fraction:;--tw-ring-inset:;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur:;--tw-brightness:;--tw-contrast:;--tw-grayscale:;--tw-hue-rotate:;--tw-invert:;--tw-saturate:;--tw-sepia:;--tw-drop-shadow:;--tw-backdrop-blur:;--tw-backdrop-brightness:;--tw-backdrop-contrast:;--tw-backdrop-grayscale:;--tw-backdrop-hue-rotate:;--tw-backdrop-invert:;--tw-backdrop-opacity:;--tw-backdrop-saturate:;--tw-backdrop-sepia:;}::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x:;--tw-pan-y:;--tw-pinch-zoom:;--tw-scroll-snap-strictness:proximity;--tw-ordinal:;--tw-slashed-zero:;--tw-numeric-figure:;--tw-numeric-spacing:;--tw-numeric-fraction:;--tw-ring-inset:;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur:;--tw-brightness:;--tw-contrast:;--tw-grayscale:;--tw-hue-rotate:;--tw-invert:;--tw-saturate:;--tw-sepia:;--tw-drop-shadow:;--tw-backdrop-blur:;--tw-backdrop-brightness:;--tw-backdrop-contrast:;--tw-backdrop-grayscale:;--tw-backdrop-hue-rotate:;--tw-backdrop-invert:;--tw-backdrop-opacity:;--tw-backdrop-saturate:;--tw-backdrop-sepia:;} +.container{width:100%;} +@media (min-width: 640px){.container{max-width:640px;}} +@media (min-width: 768px){.container{max-width:768px;}} +@media (min-width: 1024px){.container{max-width:1024px;}} +@media (min-width: 1280px){.container{max-width:1280px;}} +@media (min-width: 1536px){.container{max-width:1536px;}} +.pointer-events-none{pointer-events:none;} +.visible{visibility:visible;} +.collapse{visibility:collapse;} +.static{position:static;} +.fixed{position:fixed;} +.absolute{position:absolute;} +.relative{position:relative;} +.sticky{position:sticky;} +.top-0{top:0;} +.bottom-0{bottom:0;} +.top-\[10px\]{top:10px;} +.left-\[10px\]{left:10px;} +.right-\[10px\]{right:10px;} +.z-10{z-index:10;} +.ml-auto{margin-left:auto;} +.mr-auto{margin-right:auto;} +.mb-8{margin-bottom:2rem;} +.mb-4{margin-bottom:1rem;} +.ml-4{margin-left:1rem;} +.mb-2{margin-bottom:.5rem;} +.mt-6{margin-top:1.5rem;} +.mb-6{margin-bottom:1.5rem;} +.\!mt-0{margin-top:0 !important;} +.\!mb-8{margin-bottom:2rem !important;} +.block{display:block;} +.inline-block{display:inline-block;} +.inline{display:inline;} +.flex{display:flex;} +.table{display:table;} +.contents{display:contents;} +.hidden{display:none;} +.h-3{height:.75rem;} +.h-full{height:100%;} +.h-8{height:2rem;} +.h-1{height:.25rem;} +.h-\[22px\]{height:22px;} +.min-h-\[48px\]{min-height:48px;} +.w-14{width:3.5rem;} +.w-full{width:100%;} +.w-8{width:2rem;} +.w-1{width:.25rem;} +.w-\[22px\]{width:22px;} +.w-\[24px\]{width:24px;} +.min-w-full{min-width:100%;} +.min-w-\[140px\]{min-width:140px;} +.max-w-7xl{max-width:80rem;} +.border-collapse{border-collapse:collapse;} +.transform{transform:translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));} +@keyframes spin{to{transform:rotate(1turn);}} +.animate-spin{animation:spin 1s linear infinite;} +@keyframes bounce{0%,100%{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%);}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none;}} +.animate-bounce{animation:bounce 1s infinite;} +.cursor-pointer{cursor:pointer;} +.cursor-not-allowed{cursor:not-allowed;} +.resize{resize:both;} +.list-disc{list-style-type:disc;} +.flex-col{flex-direction:column;} +.flex-wrap{flex-wrap:wrap;} +.place-items-center{place-items:center;} +.items-end{align-items:flex-end;} +.items-center{align-items:center;} +.justify-center{justify-content:center;} +.justify-between{justify-content:space-between;} +.justify-around{justify-content:space-around;} +.space-x-2 > :not([hidden]) ~ :not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse));} +.space-x-1 > :not([hidden]) ~ :not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.25rem*var(--tw-space-x-reverse));} +.space-y-4 > :not([hidden]) ~ :not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));} +.space-y-2 > :not([hidden]) ~ :not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));} +.space-y-1 > :not([hidden]) ~ :not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));} +.space-x-4 > :not([hidden]) ~ :not([hidden]){--tw-space-x-reverse:0;margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(1rem*var(--tw-space-x-reverse));} +.space-x-3 > :not([hidden]) ~ :not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.75rem*var(--tw-space-x-reverse));} +.overflow-hidden{overflow:hidden;} +.rounded{border-radius:.25rem;} +.rounded-lg{border-radius:.5rem;} +.rounded-sm{border-radius:.125rem;} +.rounded-full{border-radius:9999px;} +.rounded-\[50\%\]{border-radius:50%;} +.rounded-\[13px\]{border-radius:13px;} +.border{border-width:1px;} +.border-2{border-width:2px;} +.border-b{border-bottom-width:1px;} +.border-t{border-top-width:1px;} +.border-black{--tw-border-opacity:1;border-color:rgb(0 0 0 / var(--tw-border-opacity));} +.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175 / var(--tw-border-opacity));} +.border-black\/\[0\.04\]{border-color:rgba(0,0,0,.04);} +.border-opacity-25{--tw-border-opacity:0.25;} +.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240 / var(--tw-bg-opacity));} +.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255 / var(--tw-bg-opacity));} +.bg-black{--tw-bg-opacity:1;background-color:rgb(0 0 0 / var(--tw-bg-opacity));} +.bg-black\/\[0\.02\]{background-color:rgba(0,0,0,.02);} +.bg-opacity-60{--tw-bg-opacity:0.6;} +.bg-gradient-to-r{background-image:linear-gradient(to right, var(--tw-gradient-stops));} +.bg-clip-text{ + -webkit-background-clip:text; + background-clip:text;} +.fill-current{fill:currentColor;} +.p-2{padding:.5rem;} +.p-8{padding:2rem;} +.p-1{padding:.25rem;} +.px-2{padding-left:.5rem;padding-right:.5rem;} +.py-1{padding-bottom:.25rem;padding-top:.25rem;} +.px-4{padding-left:1rem;padding-right:1rem;} +.px-6{padding-left:1.5rem;padding-right:1.5rem;} +.py-3{padding-bottom:.75rem;padding-top:.75rem;} +.py-2{padding-bottom:.5rem;padding-top:.5rem;} +.px-2\.5{padding-left:.625rem;padding-right:.625rem;} +.py-\[16px\]{padding-bottom:16px;padding-top:16px;} +.px-\[32px\]{padding-left:32px;padding-right:32px;} +.pl-9{padding-left:2.25rem;} +.pr-9{padding-right:2.25rem;} +.pl-8{padding-left:2rem;} +.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;} +.text-sm{font-size:.875rem;line-height:1.25rem;} +.text-3xl{font-size:1.875rem;line-height:2.25rem;} +.text-2xl{font-size:1.5rem;line-height:2rem;} +.text-xs{font-size:.75rem;line-height:1rem;} +.text-base{font-size:1rem;line-height:1.5rem;} +.font-bold{font-weight:700;} +.font-normal{font-weight:400;} +.font-medium{font-weight:500;} +.uppercase{text-transform:uppercase;} +.text-white{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity));} +.text-transparent{color:transparent;} +.text-black{--tw-text-opacity:1;color:rgb(0 0 0 / var(--tw-text-opacity));} +.text-black\/\[0\.6\]{color:rgba(0,0,0,.6);} +.underline{text-decoration-line:underline;} +.opacity-75{opacity:.75;} +.opacity-25{opacity:.25;} +.opacity-50{opacity:.5;} +.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1), 0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);} +.outline{outline-style:solid;} +.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);} +.blur{--tw-blur:blur(8px);} +.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);} +.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4, 0, .2, 1);} +.ease-in-out{transition-timing-function:cubic-bezier(.4, 0, .2, 1);} +.selection\:tracking-\[0\.0012em\] *::-moz-selection{letter-spacing:.0012em;} +.selection\:tracking-\[0\.0012em\] *::selection{letter-spacing:.0012em;} +.selection\:tracking-\[0\.0012em\]::-moz-selection{letter-spacing:.0012em;} +.selection\:tracking-\[0\.0012em\]::selection{letter-spacing:.0012em;} +.hover\:rounded-lg:hover{border-radius:.5rem;} +.hover\:underline:hover{text-decoration-line:underline;} +.focus\:opacity-75:focus{opacity:.75;} +.focus\:outline:focus{outline-style:solid;} +.focus\:outline-2:focus{outline-width:2px;} +.focus-visible\:outline:focus-visible{outline-style:solid;} +.focus-visible\:outline-2:focus-visible{outline-width:2px;} diff --git a/packages/mgt-element/README.md b/packages/mgt-element/README.md index c90d9b643d..011018b52f 100644 --- a/packages/mgt-element/README.md +++ b/packages/mgt-element/README.md @@ -16,33 +16,33 @@ This example illustrates how to instantiate a new provider (MsalProvider in this 1. Install the packages - ```bash - npm install @microsoft/mgt-element @microsoft/mgt-msal2-provider - ``` + ```bash + npm install @microsoft/mgt-element @microsoft/mgt-msal2-provider + ``` 1. Create the provider - ```ts - import {Providers} from '@microsoft/mgt-element'; - import {Msal2Provider} from '@microsoft/mgt-msal2-provider'; + ```ts + import {Providers} from '@microsoft/mgt-element'; + import {Msal2Provider} from '@microsoft/mgt-msal2-provider'; - // initialize the auth provider globally - Providers.globalProvider = new Msal2Provider({clientId: 'clientId'}); - ``` + // initialize the auth provider globally + Providers.globalProvider = new Msal2Provider({clientId: 'clientId'}); + ``` 1. Use the provider to sign in and call the graph: - ```ts - import {Providers, ProviderState} from '@microsoft/mgt-element'; + ```ts + import {Providers, ProviderState} from '@microsoft/mgt-element'; - const handleLoginClicked = async () => { - await Providers.globalProvider.login(); + const handleLoginClicked = async () => { + await Providers.globalProvider.login(); - if (Providers.globalProvider.state === ProviderState.SignedIn) { - let me = await Provider.globalProvider.graph.client.api('/me').get(); - } - } - ``` + if (Providers.globalProvider.state === ProviderState.SignedIn) { + let me = await Provider.globalProvider.graph.client.api('/me').get(); + } + } + ``` You can learn more about how to use the providers in the [documentation](https://learn.microsoft.com/graph/toolkit/providers). @@ -84,10 +84,10 @@ Provider.globalProvider = new SimpleProvider(getAccessToken, login, logout); You can extend the IProvider abstract class to create your own provider. The IProvider is similar to the SimpleProvider in that it requires the developer to implement the `getAccessToken()` function. - See the [custom provider documentation](https://learn.microsoft.com/graph/toolkit/providers/custom) for more details on both ways to create custom providers. ## Sea also -* [Microsoft Graph Toolkit docs](https://aka.ms/mgt-docs) -* [Microsoft Graph Toolkit repository](https://aka.ms/mgt) -* [Microsoft Graph Toolkit playground](https://mgt.dev) + +- [Microsoft Graph Toolkit docs](https://aka.ms/mgt-docs) +- [Microsoft Graph Toolkit repository](https://aka.ms/mgt) +- [Microsoft Graph Toolkit playground](https://mgt.dev) diff --git a/packages/mgt-element/package.json b/packages/mgt-element/package.json index 19d3815304..1061321e74 100644 --- a/packages/mgt-element/package.json +++ b/packages/mgt-element/package.json @@ -33,7 +33,14 @@ "dependencies": { "@microsoft/microsoft-graph-client": "3.0.2", "lit": "^2.3.1", - "idb": "6.0.0" + "idb": "6.0.0", + "lodash-es": "4.17.21", + "jspath": "0.4.0", + "adaptive-expressions": "4.18.0", + "adaptivecards": "2.7.1", + "adaptivecards-templating": "2.3.1", + "dayjs": "^1.11.7", + "markdown-it": "13.0.1" }, "publishConfig": { "directory": "dist" diff --git a/packages/mgt-element/src/Constants.ts b/packages/mgt-element/src/Constants.ts new file mode 100644 index 0000000000..2487046ede --- /dev/null +++ b/packages/mgt-element/src/Constants.ts @@ -0,0 +1,69 @@ +export class EventConstants { + /** + * Event name when filters are submitted + */ + public static readonly SEARCH_FILTER_EVENT = 'Mgt:Components:Search:Filter'; + + /** + * Event name when results are retrieved + */ + public static readonly SEARCH_RESULTS_EVENT = 'Mgt:Components:Search:Results'; + /** + * Event name when input is sent + */ + public static readonly SEARCH_INPUT_EVENT = 'Mgt:Components:Search:Input'; + + /** + * Event name when tab change is retrieved + */ + public static readonly SEARCH_VERTICAL_EVENT = 'Mgt:Components:Search:Verticals'; + /** + * Event name when sort field is updated + */ + public static readonly SEARCH_SORT_EVENT = 'Mgt:Components:Search:Sort'; +} + +export enum ComponentElements { + MgtSearchResultsElement = 'mgt-search-results', + MgtSearchFiltersElement = 'mgt-search-filters', + MgtCheckboxFilterComponentElement = 'mgt-filter-checkbox', + MgtDateFilterComponentElement = 'mgt-filter-date', + MgtSearchSortComponentElement = 'mgt-search-sort', + MgtPaginationElement = 'mgt-pagination', + MgtSearchInputComponent = 'mgt-search-input', + MgtSearchVerticalsComponent = 'mgt-search-verticals', + MgtAdaptiveCard = 'mgt-adaptive-card' +} + +export enum ThemeCSSVariables { + fontFamilyPrimary = '--ubi365-fontFamilyPrimary', + fontFamilySecondary = '--ubi365-fontFamilySecondary', + colorPrimary = '--ubi365-colorPrimary', + colorSecondary = '--ubi365-colorSecondary', + colorLight = '--ubi365-colorLight', + textColor = '--text-color', + //Grays + light100 = '--gray100', + light300 = '--gray300', + //Tabs + tabShadowHover = '--tab-border-color', + borderTabs = '--tabs-border-colors', + //Results + topicBackground = '--topic-background-color', + topicHover = '--topic-hover-color', + topicFocus = '--topic-focus-color', + topicDisable = '--topic-disable-color', + //Recommended + recommendedBorder = '--recommended-border', + recommendedBackground = '--recommended-background' +} + +export class CSSVariables { + public static readonly ThemeVariables = [ + ThemeCSSVariables.fontFamilyPrimary, + ThemeCSSVariables.fontFamilySecondary, + ThemeCSSVariables.colorLight, + ThemeCSSVariables.colorSecondary, + ThemeCSSVariables.colorPrimary + ]; +} diff --git a/packages/mgt-element/src/components/connectableComponent.ts b/packages/mgt-element/src/components/connectableComponent.ts new file mode 100644 index 0000000000..763f84dc3f --- /dev/null +++ b/packages/mgt-element/src/components/connectableComponent.ts @@ -0,0 +1,71 @@ +import { state } from 'lit/decorators.js'; +import { MgtTemplatedComponent } from './templatedComponent'; +import { EventHandler } from '../utils/EventDispatcher'; +import { isObjectLike } from 'lodash-es'; +import { IComponentBinding } from '../utils/IComponentBinding'; +import { LocalizationHelper } from '../utils/LocalizationHelper'; +import { ILocalizedString } from '../utils/ILocalizedString'; + +export abstract class MgtConnectableComponent extends MgtTemplatedComponent { + /** + * Flag indicating if data have been rendered at least once + */ + @state() + renderedOnce = false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected declare _eventHanlders: Map }>; + + constructor() { + super(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this._eventHanlders = new Map }>(); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const [key, value] of this._eventHanlders.entries()) { + value.component.removeEventListener(value.event, value.handler); + } + } + + protected bindComponents(bindings: IComponentBinding[]) { + const bindElement = (componentElement, binding) => { + this._eventHanlders.set(`${binding.id}-${binding.eventName}`, { + component: componentElement, + event: binding.eventName, + handler: binding.callbackFunction + }); + componentElement.addEventListener(binding.eventName, binding.callbackFunction); + }; + + bindings.forEach(binding => { + const eventKey = `${binding.id}-${binding.eventName}`; + + // The components should be already available in the DOM as they are statically defined in the page + const componentElement = document.getElementById(binding.id); + + if (componentElement && !this._eventHanlders.get(eventKey)) { + bindElement(componentElement, binding); + } + }); + } + + protected getLocalizedString(string: ILocalizedString | string) { + if (isObjectLike(string)) { + const localizedValue = string[LocalizationHelper.strings?.language]; + + if (!localizedValue) { + const defaultLabel = (string as ILocalizedString).default; + return defaultLabel ? defaultLabel : '{{Translation not found}}'; + } + + return localizedValue; + } + + return string; + } +} diff --git a/packages/mgt-element/src/events/ISearchFiltersEventData.ts.ts b/packages/mgt-element/src/events/ISearchFiltersEventData.ts.ts new file mode 100644 index 0000000000..61815b38c3 --- /dev/null +++ b/packages/mgt-element/src/events/ISearchFiltersEventData.ts.ts @@ -0,0 +1,13 @@ +import { FilterConditionOperator, IDataFilter } from '../models/IDataFilter'; + +export interface ISearchFiltersEventData { + /** + * List of filters to apply + */ + selectedFilters: IDataFilter[]; + + /** + * Operator to use between filters + */ + filterOperator: FilterConditionOperator; +} diff --git a/packages/mgt-element/src/events/ISearchInputEventData.ts b/packages/mgt-element/src/events/ISearchInputEventData.ts new file mode 100644 index 0000000000..5f89eb411d --- /dev/null +++ b/packages/mgt-element/src/events/ISearchInputEventData.ts @@ -0,0 +1,3 @@ +export interface ISearchInputEventData { + keywords: string; +} diff --git a/packages/mgt-element/src/events/ISearchResultsEventData.ts b/packages/mgt-element/src/events/ISearchResultsEventData.ts new file mode 100644 index 0000000000..3a45a6bafd --- /dev/null +++ b/packages/mgt-element/src/events/ISearchResultsEventData.ts @@ -0,0 +1,12 @@ +import { IDataFilterResult } from '../models/IDataFilter'; +import { IQueryAlterationResponse } from '../models/IMicrosoftSearchResponse'; +import { ISortFieldConfiguration } from '../models/ISortFieldConfiguration'; + +export interface ISearchResultsEventData { + availableFilters?: IDataFilterResult[]; + sortFieldsConfiguration?: ISortFieldConfiguration[]; + submittedQueryText?: string; + resultsCount?: number; + queryAlterationResponse?: IQueryAlterationResponse; + from?: number; +} diff --git a/packages/mgt-element/src/events/ISearchSortEventData.ts b/packages/mgt-element/src/events/ISearchSortEventData.ts new file mode 100644 index 0000000000..e47f09176b --- /dev/null +++ b/packages/mgt-element/src/events/ISearchSortEventData.ts @@ -0,0 +1,8 @@ +import { ISearchSortProperty } from '../models/IMicrosoftSearchRequest'; + +export interface ISearchSortEventData { + /** + * The Microsoft Search properties + */ + sortProperties: ISearchSortProperty[]; +} diff --git a/packages/mgt-element/src/events/ISearchVerticalEventData.ts b/packages/mgt-element/src/events/ISearchVerticalEventData.ts new file mode 100644 index 0000000000..bd7b37cd47 --- /dev/null +++ b/packages/mgt-element/src/events/ISearchVerticalEventData.ts @@ -0,0 +1,8 @@ +import { IDataVerticalConfiguration } from '../models/IDataVerticalConfiguration'; + +export class ISearchVerticalEventData { + /** + * Current selected vertical key + */ + selectedVertical: IDataVerticalConfiguration; +} diff --git a/packages/mgt-element/src/helpers/DataFilterHelper.ts b/packages/mgt-element/src/helpers/DataFilterHelper.ts new file mode 100644 index 0000000000..53ad2d23f0 --- /dev/null +++ b/packages/mgt-element/src/helpers/DataFilterHelper.ts @@ -0,0 +1,120 @@ +import isEmpty from 'lodash-es/isEmpty'; +import { + IDataFilter, + FilterConditionOperator, + IDataFilterValue, + FilterComparisonOperator +} from '../models/IDataFilter'; + +export class DataFilterHelper { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static dayJs: any; + + /** + * Build the refinement condition in FQL format + * @param selectedFilters The selected filter array + * @param dayJs The dayJs instance to resolve dates + * @param encodeTokens If true, encodes the taxonomy refinement tokens in UTF-8 to work with GET requests. Javascript encodes natively in UTF-16 by default. + */ + public static buildFqlRefinementString(selectedFilters: IDataFilter[], encodeTokens?: boolean): string[] { + const refinementQueryConditions: string[] = []; + + selectedFilters.forEach(filter => { + // Default operator is OR if not provided + const operator: FilterConditionOperator = filter.operator ? filter.operator : FilterConditionOperator.OR; + + // Mutli values + if (filter.values.length > 1) { + let startDate: string = null; + let endDate: string = null; + + // A refiner can have multiple values selected in a multi or mon multi selection scenario + // The correct operator is determined by the refiner display template according to its behavior + const conditions = filter.values + .map((filterValue: IDataFilterValue) => { + let value = filterValue.value; + + if (this.dayJs(value, this.dayJs.ISO_8601, true).isValid()) { + if ( + !startDate && + (filterValue.operator === FilterComparisonOperator.Geq || + filterValue.operator === FilterComparisonOperator.Gt) + ) { + startDate = value; + } + + if ( + !endDate && + (filterValue.operator === FilterComparisonOperator.Lt || + filterValue.operator === FilterComparisonOperator.Leq) + ) { + endDate = value; + } + } + + // If the value is null or undefined, we replace it by the FQL expression string('') + // Otherwise the query syntax won't be vaild resuting of to an HTTP 500 + if (isEmpty(value)) { + value = "string('')"; + } + + // Enclose the expression with quotes if the value contains spaces + if (/\s/.test(value) && value.indexOf('range') === -1) { + value = `"${value}"`; + } + + return /ǂǂ/.test(value) && encodeTokens ? encodeURIComponent(value) : value; + }) + .filter(c => c); + + if (startDate && endDate) { + refinementQueryConditions.push(`${filter.filterName}:range(${startDate},${endDate})`); + } else { + refinementQueryConditions.push(`${filter.filterName}:${operator}(${conditions.join(',')})`); + } + } else { + // Single value + if (filter.values.length === 1) { + const filterValue = filter.values[0]; + + // See https://sharepoint.stackexchange.com/questions/258081/how-to-hex-encode-refiners/258161 + let refinementToken = + /ǂǂ/.test(filterValue.value) && encodeTokens ? encodeURIComponent(filterValue.value) : filterValue.value; + + // https://docs.microsoft.com/en-us/sharepoint/dev/general-development/fast-query-language-fql-syntax-reference#fql_range_operator + if (this.dayJs(refinementToken, this.dayJs.ISO_8601, true).isValid()) { + if ( + filterValue.operator === FilterComparisonOperator.Gt || + filterValue.operator === FilterComparisonOperator.Geq + ) { + refinementToken = `range(${refinementToken},max)`; + } + + // Ex: scenario ('older than a year') + if ( + filterValue.operator === FilterComparisonOperator.Leq || + filterValue.operator === FilterComparisonOperator.Lt + ) { + refinementToken = `range(min,${refinementToken})`; + } + } + + // If the value is null or undefined, we replace it by the FQL expression string('') + // Otherwise the query syntax won't be vaild resuting of to an HTTP 500 + if (isEmpty(refinementToken)) { + refinementToken = "string('')"; + } + + // Enclose the expression with quotes if the value contains spaces + if (/\s/.test(refinementToken) && refinementToken.indexOf('range') === -1) { + refinementToken = `"${refinementToken}"`; + } + + refinementQueryConditions.push(`${filter.filterName}:${refinementToken}`); + } + } + }); + + return refinementQueryConditions; + } +} diff --git a/packages/mgt-element/src/helpers/DateHelper.ts b/packages/mgt-element/src/helpers/DateHelper.ts new file mode 100644 index 0000000000..cfc02d2470 --- /dev/null +++ b/packages/mgt-element/src/helpers/DateHelper.ts @@ -0,0 +1,144 @@ +export class DateHelper { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private dayJsLibrary: any; + + private culture: string; + + public constructor(culture?: string) { + this.culture = culture ? culture.toLocaleLowerCase() : 'en-us'; + } + + public async dayJs(): Promise { + if (this.dayJsLibrary) { + return Promise.resolve(this.dayJsLibrary); + } else { + const dayjs = await import('dayjs/esm'); + + const localizedFormat = await import('dayjs/esm/plugin/localizedFormat'); + + this.dayJsLibrary = dayjs.default; + this.dayJsLibrary.extend(localizedFormat.default); + + const twoLetterLanguageName = [ + 'af', + 'az', + 'be', + 'bg', + 'bm', + 'bo', + 'br', + 'bs', + 'ca', + 'cs', + 'cv', + 'cy', + 'da', + 'de-de', + 'dv', + 'el', + 'eo', + 'es-es', + 'et', + 'eu', + 'fa', + 'fi', + 'fil', + 'fo', + 'fy', + 'fr-fr', + 'ga', + 'gd', + 'gl', + 'gu', + 'he', + 'hi', + 'hr', + 'hu', + 'id', + 'is', + 'it-it', + 'ja', + 'jv', + 'ka', + 'kk', + 'km', + 'kn', + 'ko', + 'ku', + 'ky', + 'lb', + 'lo', + 'lt', + 'lv', + 'me', + 'mi', + 'mk', + 'ml', + 'mn', + 'mr', + 'mt', + 'my', + 'nb', + 'ne', + 'nn', + 'nl-nl', + 'pl', + 'pt-pt', + 'ro', + 'ru', + 'sd', + 'se', + 'si', + 'sk', + 'sl', + 'sq', + 'ss', + 'sv', + 'sw', + 'ta', + 'te', + 'tet', + 'tg', + 'th', + 'tk', + 'tlh', + 'tr', + 'tzl', + 'uk', + 'ur', + 'vi', + 'yo' + ]; + + // DayJs is by default "en-us" + if (!this.culture.startsWith('en-us')) { + for (let i = 0; i < twoLetterLanguageName.length; i++) + if (this.culture.startsWith(twoLetterLanguageName[i])) { + this.culture = this.culture.split('-')[0]; + break; + } + + await import(`dayjs/esm/locale/${this.culture}.js`); + } + + // Set default locale + this.dayJsLibrary.locale(this.culture); + return this.dayJsLibrary; + } + } + + public isDST() { + const today = new Date(); + const jan = new Date(today.getFullYear(), 0, 1); + const jul = new Date(today.getFullYear(), 6, 1); + const stdTimeZoneOffset = Math.max(jan.getTimezoneOffset(), jul.getTimezoneOffset()); + return today.getTimezoneOffset() < stdTimeZoneOffset; + } + + public addMinutes(isDst: boolean, date: Date, minutes: number, dst: number) { + if (isDst) { + minutes += dst; + } + return new Date(date.getTime() + minutes * 60000); + } +} diff --git a/packages/mgt-element/src/helpers/ObjectHelper.ts b/packages/mgt-element/src/helpers/ObjectHelper.ts new file mode 100644 index 0000000000..849df72f51 --- /dev/null +++ b/packages/mgt-element/src/helpers/ObjectHelper.ts @@ -0,0 +1,54 @@ +import * as jspath from 'jspath/lib/jspath.js'; +import { isEmpty } from 'lodash-es'; + +export class ObjectHelper { + /** + * Get object proeprty value by its deep path. + * @param object the object containg the property path + * @param path the property path to get + * @param delimiter if multiple matches are found, sepcifiy the delimiter character to use to separate values in the returned string + * @returns the property value as string if found, 'undefined' otherwise + */ + public static byPath(object: object, path: string, delimiter?: string): string { + let isValidPredicate = true; + + // Test if the provided path is a valid predicate https://www.npmjs.com/package/jspath#documentation + try { + jspath.compile(`.${path}`); + } catch (e) { + isValidPredicate = false; + } + + if (path && object && isValidPredicate) { + try { + // jsPath always returns an array. See https://www.npmjs.com/package/jspath#result + const value: object[] = jspath.apply(`.${path}`, object); + + // Empty array returned by jsPath + if (isEmpty(value)) { + // i.e the value to look for does not exist in the provided object + return undefined; + } + + // Check if value is an object + // - Arrays of objects will return '[object Object],[object Object]' etc. + if (value.toString().indexOf('[object Object]') !== -1) { + // Returns the stringified array + return JSON.stringify(value); + } + + if (delimiter && value.length > 1) { + return value.join(delimiter); + } + + // Use the default behavior of the toString() method. Arrays of simple values (string, integer, etc.) will be separated by a comma (',') + return value.toString(); + } catch (error) { + // Case when unexpected string or tokens are passed in the path + return null; + } + } else { + return undefined; + } + } +} diff --git a/packages/mgt-element/src/helpers/SearchResultsHelper.ts b/packages/mgt-element/src/helpers/SearchResultsHelper.ts new file mode 100644 index 0000000000..1c895716f4 --- /dev/null +++ b/packages/mgt-element/src/helpers/SearchResultsHelper.ts @@ -0,0 +1,91 @@ +import { ThemeCSSVariables } from '../Constants'; + +/** + * Fields to be added to results + */ +enum SearchResponseEnhancedFields { + FileTypeFamily = 'filefamily', + OpenInBrowserUrl = 'previewurl' +} + +/** + * Well known SharePoint managed properties (lower cased) + */ +enum WellKnownSearchProperties { + FileType = 'filetype', + Title = 'title', + Summary = 'summary' +} + +export class SearchResultsHelper { + public static readonly FileTypeAssociations = { + word: ['doc', 'docx', 'docm', 'dot', 'dotx', 'dotm'], + excel: ['xls', 'xlsx', 'csv', 'xlsm', 'xlsb', 'xlx', 'xml', 'csv', 'xltm', 'xlt', 'xltx'], + powerpoint: ['ppt', 'pptx', 'pptm', 'pps', 'ppsm', 'ppsx', 'potx', 'potm', 'pot'], + onenote: ['one'], + text: ['txt', 'rtf'], + visio: ['vsd', 'vsdx', 'vsdm'], + webpage: ['aspx', 'html'], + pdf: ['pdf'], + archive: ['zip', '7z', 'rar'] + }; + + /** + * Get the file famnily for its extension + * @param fileExtension the file extension + * @returns the file family corresponding to this extensions + */ + private static getFileIconType(fileExtension: string): string { + let fileType = 'generic'; + if (fileExtension) { + fileType = Object.keys(SearchResultsHelper.FileTypeAssociations).filter(key => { + return SearchResultsHelper.FileTypeAssociations[key].indexOf(fileExtension) !== -1; + })[0]; + } + + return fileType; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public static enhanceResults(items: any[]): any[] { + return items.map(item => { + // Title + if (item[WellKnownSearchProperties.Title]) + item[WellKnownSearchProperties.Title] = item[WellKnownSearchProperties.Title].replace(/\r|\t|\n/g, ''); + + // File family + if (item[WellKnownSearchProperties.FileType]) + item[SearchResponseEnhancedFields.FileTypeFamily] = this.getFileIconType( + item[WellKnownSearchProperties.FileType] + ); + + // Summary + if (item[WellKnownSearchProperties.Summary]) + item[WellKnownSearchProperties.Summary] = this.getItemSummary(item[WellKnownSearchProperties.Summary]); + + return item; + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public static getItemTitle(item: any) { + if (item.resource?.fields?.name) { + return item.resource?.fields?.name; + } else if (item.resource?.fields?.title) { + return item.resource?.fields?.title; + } else if (item.resource?.name) { + return item.resource?.name; + } + + return ''; + } + + public static getItemSummary(summary: string) { + // Special case with HitHighlightedSummary field + // eslint-disable-next-line no-useless-escape + return summary + .replace(//g, ``) + .replace(/<\/c0\>/g, '') + .replace(//g, '…'); + } +} diff --git a/packages/mgt-element/src/helpers/StringHelper.ts b/packages/mgt-element/src/helpers/StringHelper.ts new file mode 100644 index 0000000000..0fe5fab3cf --- /dev/null +++ b/packages/mgt-element/src/helpers/StringHelper.ts @@ -0,0 +1,6 @@ +export class StringHelper { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + public static escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + } +} diff --git a/packages/mgt-element/src/helpers/UrlHelper.ts b/packages/mgt-element/src/helpers/UrlHelper.ts new file mode 100644 index 0000000000..34a99fd7ac --- /dev/null +++ b/packages/mgt-element/src/helpers/UrlHelper.ts @@ -0,0 +1,114 @@ +export class UrlHelper { + /** + * Test if the provided string is a valid URL + * @param url the URL to check + */ + public static isValidUrl(url: string): boolean { + // eslint-disable-next-line no-useless-escape + return /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=\+\$,\w]+@)?[A-Za-z0-9\.\-]+|(?:www\.|[\-;:&=\+\$,\w]+@)[A-Za-z0-9\.\-]+)((?:\/[\+~%\/\.\w\-_]*)?\??(?:[\-\+=&;%@\.\w_]*)#?(?:[\.\!\/\\\w]*))?)/.test( + url + ); + } + + /** + * Get the value of a querystring + * @param {String} field The field to get the value of + * @param {String} url The URL to get the value from (optional) + * @return {String} The field value + */ + public static getQueryStringParam(field: string, url: string): string { + const href = url ? url : window.location.href; + const reg = new RegExp('[?&#]' + field + '=([^&#]*)', 'i'); + const qs = reg.exec(href); + return qs ? qs[1] : null; + } + + /** + * @param {String} field The field name of the query string to remove + * @param {String} sourceURL The source URL + * @return {String} The updated URL + */ + public static removeQueryStringParam(field: string, sourceURL: string): string { + let rtn = sourceURL.split('?')[0]; + let param = null; + let paramsArr = []; + const hash = window.location.hash; + const queryString = sourceURL.indexOf('?') !== -1 ? sourceURL.split('?')[1] : ''; + + if (queryString !== '') { + paramsArr = queryString.split('&'); + for (let i = paramsArr.length - 1; i >= 0; i -= 1) { + param = paramsArr[i].split('=')[0]; + if (param === field) { + paramsArr.splice(i, 1); + } + } + + if (paramsArr.length > 0) { + rtn = rtn + '?' + paramsArr.join('&').replace(hash, '') + hash; + } + } + return rtn; + } + + /** + * Add or replace a query string parameter + * @param url The current URL + * @param param The query string parameter to add or replace + * @param value The new value + */ + public static addOrReplaceQueryStringParam(url: string, param: string, value: string): string { + param = param.replace(/[.~*()]/g, ''); // // Ensure param is safe from DOS attacks - so we strip away RegEx special characters + const re = new RegExp('[\\?&]' + param + '=([^&#]*)'); + const match = re.exec(url); + let delimiter; + let newString; + + if (match === null) { + // Append new param + const hash = window.location.hash && window.location.hash !== '' ? window.location.hash : '#'; + const hasQuestionMark = /\?/.test(url); + delimiter = hasQuestionMark ? '&' : '?'; + newString = url.replace(hash, '') + delimiter + param + '=' + encodeURIComponent(value) + hash; + } else { + delimiter = match[0].charAt(0); + newString = url.replace(re, delimiter + param + '=' + encodeURIComponent(value)); + } + + return newString; + } + + /** + * Gets the current query string parameters + * @returns query string parameters as object + */ + public static getQueryStringParams(): { [parameter: string]: string } { + const queryStringParameters: { [parameter: string]: string } = {}; + const urlParams = new URLSearchParams(window.location.search); + urlParams.forEach((value, key) => { + queryStringParameters[key] = value; + }); + + return queryStringParameters; + } + + /** + * Decodes a provided string + * @param encodedStr the string to decode + */ + public static decode(encodedStr: string) { + const domParser = new DOMParser(); + const htmlContent: Document = domParser.parseFromString(`${encodedStr}`, 'text/html'); + return htmlContent.body.textContent; + } +} + +export enum PageOpenBehavior { + 'Self' = 'self', + 'NewTab' = 'newTab' +} + +export enum QueryPathBehavior { + 'URLFragment' = 'fragment', + 'QueryParameter' = 'queryparameter' +} diff --git a/packages/mgt-element/src/index.ts b/packages/mgt-element/src/index.ts index 196ffb39e2..322e143869 100644 --- a/packages/mgt-element/src/index.ts +++ b/packages/mgt-element/src/index.ts @@ -14,6 +14,7 @@ export * from './components/baseComponent'; export * from './components/baseProvider'; export * from './components/templatedComponent'; export * from './components/customElementHelper'; +export * from './components/connectableComponent'; export * from './providers/IProvider'; export * from './providers/Providers'; @@ -31,6 +32,42 @@ export * from './utils/GraphPageIterator'; export * from './utils/LocalizationHelper'; export * from './utils/mgtHtml'; export * from './utils/CustomElement'; + +export * from './services/IMicrosoftSearchService'; +export * from './services/MicrosoftSearchService'; +export * from './services/ITemplateService'; +export * from './services/TemplateService'; +export * from './services/TokenService'; +export * from './services/ITokenService'; + +export * from './models/BuiltinTemplate'; +export * from './models/IComponentBinding'; +export * from './models/IDataFilter'; +export * from './models/IDataFilterConfiguration'; +export * from './models/IDataSourceData'; +export * from './models/IDataVerticalConfiguration'; +export * from './models/ILocalizedString'; +export * from './models/IMicrosoftSearchDataSourceData'; +export * from './models/IMicrosoftSearchRequest'; +export * from './models/IMicrosoftSearchResponse'; +export * from './models/IResultTemplates'; +export * from './models/ISortFieldConfiguration'; +export * from './models/IThemeDefinition'; + +export * from './events/ISearchFiltersEventData.ts'; +export * from './events/ISearchInputEventData'; +export * from './events/ISearchResultsEventData'; +export * from './events/ISearchSortEventData'; +export * from './events/ISearchVerticalEventData'; + +export * from './Constants'; +export * from './helpers/DateHelper'; +export * from './helpers/ObjectHelper'; +export * from './helpers/SearchResultsHelper'; +export * from './helpers/StringHelper'; +export * from './helpers/UrlHelper'; +export * from './helpers/DataFilterHelper'; + export { PACKAGE_VERSION } from './utils/version'; export * from './CollectionResponse'; diff --git a/packages/mgt-element/src/models/BuiltinTemplate.ts b/packages/mgt-element/src/models/BuiltinTemplate.ts new file mode 100644 index 0000000000..de88a2555c --- /dev/null +++ b/packages/mgt-element/src/models/BuiltinTemplate.ts @@ -0,0 +1,4 @@ +export enum BuiltinFilterTemplates { + CheckBox = 'checkbox', + Date = 'date' +} diff --git a/packages/mgt-element/src/models/IComponentBinding.ts b/packages/mgt-element/src/models/IComponentBinding.ts new file mode 100644 index 0000000000..860dd123cd --- /dev/null +++ b/packages/mgt-element/src/models/IComponentBinding.ts @@ -0,0 +1,17 @@ +export interface IComponentBinding { + /** + * The DOM element ID to bind + */ + id: string; + + /** + * The event name to bind + */ + eventName: string; + + /** + * Function to call when the event is fired + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callbackFunction: (e: CustomEvent) => void; +} diff --git a/packages/mgt-element/src/models/IDataFilter.ts b/packages/mgt-element/src/models/IDataFilter.ts new file mode 100644 index 0000000000..10b799f952 --- /dev/null +++ b/packages/mgt-element/src/models/IDataFilter.ts @@ -0,0 +1,81 @@ +/** + * Represents a filter value to be send to a data source + */ +export interface IDataFilterValue { + /** + * An unique identifier for that filter value + */ + key: string; + + /** + * The filter value display name + */ + name: string; + + /** + * Inner value to use when the value is selected + */ + value: string; + + /** + * The comparison operator to use with this value. If not provided, the 'Equals' operator will be used. + */ + operator?: FilterComparisonOperator; +} + +/** + * Represents a filter value returned by a data source + */ +export interface IDataFilterResultValue extends IDataFilterValue { + /** + * The number of results with this value + */ + count: number; +} +/** + * Represents a filter to be send to the data source + */ +export interface IDataFilter { + /** + * The filter internal name + */ + filterName: string; + + /** + * Values available in this filter + */ + values: IDataFilterValue[]; + + /** + * The logical operator to use between values + */ + operator?: FilterConditionOperator; +} + +/** + * Represents a filter returned from a data source + */ +export interface IDataFilterResult { + /** + * The filter display name + */ + filterName: string; + + /** + * Values available in this filter + */ + values: IDataFilterResultValue[]; +} +export enum FilterConditionOperator { + OR = 'or', + AND = 'and' +} +export enum FilterComparisonOperator { + Eq = 0, + Neq = 1, + Gt = 2, + Lt = 3, + Geq = 4, + Leq = 5, + Contains = 6 +} diff --git a/packages/mgt-element/src/models/IDataFilterConfiguration.ts b/packages/mgt-element/src/models/IDataFilterConfiguration.ts new file mode 100644 index 0000000000..ce75817266 --- /dev/null +++ b/packages/mgt-element/src/models/IDataFilterConfiguration.ts @@ -0,0 +1,111 @@ +import { BuiltinFilterTemplates } from './BuiltinTemplate'; +import { FilterConditionOperator } from './IDataFilter'; +import { ILocalizedString } from './ILocalizedString'; + +export enum FilterType { + /** + * A 'Refiner' filter means the filter gets the filters values from the data source and sends back the selected ones the data source. + */ + Refiner = 'refiner', + + /** + * An 'Static' filter means the filter doesn't care about filter values sent by the data source and provides its own arbitrary values regardless of input values. + * A date picker or a taxonomy picker (or any picker) are good examples of what an 'Out' filter is. + */ + StaticFilter = 'staticFilter' +} + +export enum FilterSortType { + /** + * Sort filter values by their count + */ + ByCount = 'byCount', + /** + * Sort filter values by their display name + */ + ByName = 'byName' +} + +export enum FilterSortDirection { + Ascending = 'ascending', + Descending = 'descending' +} + +export interface IDataFilterConfiguration { + /** + * The internal filter name (ex: corresponding either to data source field name or refinable search managed property in the case of SharePoint) + */ + filterName: string; + + /** + * The flter name to display in the UI + */ + displayName: string | ILocalizedString; + + /** + * The template to use to show filters + */ + template: BuiltinFilterTemplates; + + /** + * Specifies if the filter should show values count + */ + showCount: boolean; + + /** + * The operator to use between filter values + */ + operator: FilterConditionOperator; + + /** + * Indicates if the filter allows multi values + */ + isMulti: boolean; + + /** + * If the filter should be sorted by name or by count + */ + sortBy: FilterSortType; + + /** + * The filter values sort direction (ascending/descending) + */ + sortDirection: FilterSortDirection; + + /** + * The index of this filter in the configuration + */ + sortIdx: number; + + /** + * Number of buckets to fetch + */ + maxBuckets: number; + + /** + * Aggregations to use for filter values + */ + aggregations?: IDataFilterAggregation[]; +} + +export interface IDataFilterAggregation { + /** + * The friendly name to display as filter value for user (ex: "Word document") + */ + aggregationName: string | ILocalizedString; + + /** + * The values matching the aggreagation (ex: ["docx","doc"]) + */ + matchingValues: string[]; + + /** + * FQL value to apply when clicked: ex: or("value1","value2") + */ + aggregationValue: string; + + /** + * The icon URL to display for that value. Optional + */ + aggregationValueIconUrl?: string; +} diff --git a/packages/mgt-element/src/models/IDataSourceData.ts b/packages/mgt-element/src/models/IDataSourceData.ts new file mode 100644 index 0000000000..c481ee1e15 --- /dev/null +++ b/packages/mgt-element/src/models/IDataSourceData.ts @@ -0,0 +1,25 @@ +import { IResultTemplates } from './IResultTemplates'; +import { IDataFilterResult } from './IDataFilter'; + +export interface IDataSourceData { + /** + * Items returned by the data source. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: any[]; + + /** + * The count of items returned by the datasource + */ + totalCount?: number; + + /** + * The available filters provided by the data source according to the filters configuration provided from the data context (if applicable). + */ + filters?: IDataFilterResult[]; + + /** + * Result templates available for items provided by the data source + */ + resultTemplates?: IResultTemplates; +} diff --git a/packages/mgt-element/src/models/IDataVerticalConfiguration.ts b/packages/mgt-element/src/models/IDataVerticalConfiguration.ts new file mode 100644 index 0000000000..afddefdbd8 --- /dev/null +++ b/packages/mgt-element/src/models/IDataVerticalConfiguration.ts @@ -0,0 +1,34 @@ +import { PageOpenBehavior } from '../helpers/UrlHelper'; +import { ILocalizedString } from './ILocalizedString'; + +export interface IDataVerticalConfiguration { + /** + * Unique key for the vertical + */ + key: string; + + /** + * The vertical tab name + */ + tabName: string | ILocalizedString; + + /** + * The vertical tab value that will be sent to connected components + */ + tabValue: string; + + /** + * Specifes if the vertical is a link + */ + isLink: boolean; + + /** + * The link URL + */ + linkUrl: string; + + /** + * The link open behavior + */ + openBehavior: PageOpenBehavior; +} diff --git a/packages/mgt-element/src/models/ILocalizedString.ts b/packages/mgt-element/src/models/ILocalizedString.ts new file mode 100644 index 0000000000..1892bf8baa --- /dev/null +++ b/packages/mgt-element/src/models/ILocalizedString.ts @@ -0,0 +1,11 @@ +export interface ILocalizedString { + /** + * The default label to use + */ + default: string; + + /** + * Any other locales for the string (ex: 'fr-fr') + */ + [locale: string]: string; +} diff --git a/packages/mgt-element/src/models/IMicrosoftSearchDataSourceData.ts b/packages/mgt-element/src/models/IMicrosoftSearchDataSourceData.ts new file mode 100644 index 0000000000..761fad71b5 --- /dev/null +++ b/packages/mgt-element/src/models/IMicrosoftSearchDataSourceData.ts @@ -0,0 +1,11 @@ +import { IDataSourceData } from './IDataSourceData'; +import { IQueryAlterationResponse } from './IMicrosoftSearchResponse'; + +export interface IMicrosoftSearchDataSourceData extends IDataSourceData { + /** + * The alteration response by Microsft Search + * see https://docs.microsoft.com/en-us/graph/api/resources/alterationResponse?view=graph-rest-beta&preserve-view=true + * Spelling correction is only supported for the following resources: message, event, site, drive, driveItem, list, listItem and externalItem. + */ + queryAlterationResponse?: IQueryAlterationResponse; +} diff --git a/packages/mgt-element/src/models/IMicrosoftSearchRequest.ts b/packages/mgt-element/src/models/IMicrosoftSearchRequest.ts new file mode 100644 index 0000000000..132c27a47b --- /dev/null +++ b/packages/mgt-element/src/models/IMicrosoftSearchRequest.ts @@ -0,0 +1,74 @@ +export enum EntityType { + Message = 'message', + Event = 'event', + Drive = 'drive', + DriveItem = 'driveItem', + ExternalItem = 'externalItem', + List = 'list', + ListItem = 'listItem', + Site = 'site', + Person = 'person', + Bookmark = 'bookmark' +} + +export interface IMicrosoftSearchQuery { + requests: IMicrosoftSearchRequest[]; +} + +/** + * https://docs.microsoft.com/en-us/graph/api/resources/searchrequest?view=graph-rest-beta + */ +export interface IMicrosoftSearchRequest { + entityTypes: EntityType[]; + query: { + queryString: string; + queryTemplate?: string; + }; + fields?: string[]; + aggregations?: ISearchRequestAggregation[]; + aggregationFilters?: string[]; + from?: number; + size?: number; + enableTopResults?: boolean; + sortProperties?: ISearchSortProperty[]; + contentSources?: string[]; + queryAlterationOptions?: IQueryAlterationOptions; + resultTemplateOptions?: { + enableResultTemplate: boolean; + }; + trimDuplicates?: boolean; +} + +export interface ISearchSortProperty { + name: string; + isDescending: boolean; +} + +export interface ISearchRequestAggregation { + field: string; + size?: number; + bucketDefinition: IBucketDefinition; +} + +export interface IBucketDefinition { + sortBy: SearchAggregationSortBy; + isDescending: boolean; + minimumCount: number; + ranges?: IBucketRangeDefinition[]; +} + +export enum SearchAggregationSortBy { + Count = 'count', + KeyAsNumber = 'keyAsNumber', + KeyAsString = 'keyAsString' +} + +export interface IBucketRangeDefinition { + from?: number | string; + to?: number | string; +} + +export interface IQueryAlterationOptions { + enableSuggestion: boolean; + enableModification: boolean; +} diff --git a/packages/mgt-element/src/models/IMicrosoftSearchResponse.ts b/packages/mgt-element/src/models/IMicrosoftSearchResponse.ts new file mode 100644 index 0000000000..6297800b21 --- /dev/null +++ b/packages/mgt-element/src/models/IMicrosoftSearchResponse.ts @@ -0,0 +1,73 @@ +import { IResultTemplates } from './IResultTemplates'; + +// https://docs.microsoft.com/en-us/graph/api/resources/searchresponse?view=graph-rest-beta +export interface IMicrosoftSearchResponse { + value: IMicrosoftSearchResultSet; +} + +export interface IMicrosoftSearchResultSet { + hitsContainers: ISearchHitsContainer[]; + searchTerms: string[]; + queryAlterationResponse?: IQueryAlterationResponse; + resultTemplates?: IResultTemplates; +} + +export interface ISearchHitsContainer { + hits: ISearchHit[]; + moreResultsAvailable: boolean; + total: number; + aggregations: ISearchResponseAggregation[]; +} + +export interface ISearchHit { + hitId: string; + rank: number; + summary: string; + contentSource: string; + resource: ISearchResponseResource; + resultTemplateId?: string; +} + +export interface ISearchResponseAggregation { + field: string; + size?: number; + buckets: IBucket[]; +} + +export interface IBucket { + key: string; + count: number; + aggregationFilterToken: string; +} + +export interface ISearchResponseResource { + '@odata.type': string; + // listItem + fields?: { + [fieldName: string]: string; + }; + // properties + properties?: { + [fieldName: string]: string; + }; +} + +// Query alteration response +// https://docs.microsoft.com/en-us/graph/api/resources/alterationresponse +export interface IQueryAlterationResponse { + originalQueryString: string; + queryAlteration: ISearchAlteration; + queryAlterationType: 'suggestion' | 'modification'; +} + +export interface ISearchAlteration { + alteredQueryString: string; + alteredHighlightedQueryString: string; + alteredQueryTokens: IAlteredQueryTokens[]; +} + +export interface IAlteredQueryTokens { + offset: number; + length: number; + suggestion: string; +} diff --git a/packages/mgt-element/src/models/IResultTemplates.ts b/packages/mgt-element/src/models/IResultTemplates.ts new file mode 100644 index 0000000000..870f52ca48 --- /dev/null +++ b/packages/mgt-element/src/models/IResultTemplates.ts @@ -0,0 +1,6 @@ +export interface IResultTemplates { + [resultTemplateId: string]: { + body: string; + displayName: string; + }; +} diff --git a/packages/mgt-element/src/models/ISortFieldConfiguration.ts b/packages/mgt-element/src/models/ISortFieldConfiguration.ts new file mode 100644 index 0000000000..c0ee67dbb9 --- /dev/null +++ b/packages/mgt-element/src/models/ISortFieldConfiguration.ts @@ -0,0 +1,33 @@ +import { ILocalizedString } from './ILocalizedString'; + +export enum SortFieldDirection { + Ascending = 'asc', + Descending = 'desc' +} + +export interface ISortFieldConfiguration { + /** + * The sort search field to use + */ + sortField: string; + + /** + * The default sort direction + */ + sortDirection: SortFieldDirection; + + /** + * If the field is sorted by default (without use action) + */ + isDefaultSort: boolean; + + /** + * If the field should be available for users to sort + */ + isUserSort: boolean; + + /** + * If sortable by users, the display name to use in the control + */ + sortFieldDisplayName: ILocalizedString; +} diff --git a/packages/mgt-element/src/models/IThemeDefinition.ts b/packages/mgt-element/src/models/IThemeDefinition.ts new file mode 100644 index 0000000000..0e60e0957e --- /dev/null +++ b/packages/mgt-element/src/models/IThemeDefinition.ts @@ -0,0 +1,3 @@ +export interface IThemeDefinition { + [key: string]: string; +} diff --git a/packages/mgt-element/src/services/IMicrosoftSearchService.ts b/packages/mgt-element/src/services/IMicrosoftSearchService.ts new file mode 100644 index 0000000000..e3b3285847 --- /dev/null +++ b/packages/mgt-element/src/services/IMicrosoftSearchService.ts @@ -0,0 +1,19 @@ +import { IMicrosoftSearchDataSourceData } from '../models/IMicrosoftSearchDataSourceData'; +import { IMicrosoftSearchQuery } from '../models/IMicrosoftSearchRequest'; + +export interface IMicrosoftSearchService { + useBetaEndPoint: boolean; + + /** + * Performs a search query against Microsoft Search + * @param searchQuery The search query in KQL forma + * @param culture The language cutlure to query (ex: 'fr-fr'). If not set, default is 'en-US') + * @return The search results + */ + search(searchQuery: IMicrosoftSearchQuery, culture?: string): Promise; + + /** + * Abort the current HTTP request + */ + abortRequest(); +} diff --git a/packages/mgt-element/src/services/ITemplateService.ts b/packages/mgt-element/src/services/ITemplateService.ts new file mode 100644 index 0000000000..9b17e844f5 --- /dev/null +++ b/packages/mgt-element/src/services/ITemplateService.ts @@ -0,0 +1,48 @@ +import { IDataSourceData } from '../models/IDataSourceData'; +import { IThemeDefinition } from '../models/IThemeDefinition'; + +export enum FileFormat { + Text = 'text', + Json = 'json' +} + +export interface ITemplateService { + /** + * Update the HTML element with corresponding result types for items (i.e. node with id attribute equals to "hitId") + * @param data the data source data containing the items + * @param templateContent the template content as HTML + * @param theme the theme to apply + * @returns the updated HTML element with result types + */ + processResultTypesFromHtml( + data: IDataSourceData, + templateContent: HTMLElement, + theme?: IThemeDefinition + ): HTMLElement; + + /** + * Process the adaptive card with data from the context + * @param templateContent the card content as stringified JSON + * @param templateContext the card context for data binding + * @param theme the theme to apply + * @returns the processed HTML as raw string + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + processAdaptiveCardTemplate( + templateContent: string, + templateContext: { [key: string]: any }, + theme?: IThemeDefinition + ): HTMLElement; + + /** + * Load adaptive cards resources on the page dynamically + */ + loadAdaptiveCardsResources(): Promise; + + /** + * Gets the external file content from the specified URL using Microsoft Graph + * @param fileUrl + * @returns the file raw content as string + */ + getFileContent(fileUrl: string): Promise; +} diff --git a/packages/mgt-element/src/services/ITokenService.ts b/packages/mgt-element/src/services/ITokenService.ts new file mode 100644 index 0000000000..27ed8535c4 --- /dev/null +++ b/packages/mgt-element/src/services/ITokenService.ts @@ -0,0 +1,20 @@ +export interface ITokenService { + /** + * Sets the value for a specific token + * @param token the token name + * @param value the value to set + */ + setTokenValue(token: string, value: string): void; + + /** + * Gets the value of a specific token + * @param token the token name to retrieve + */ + getTokenValue(token: string): string; + + /** + * Resolves tokens for the specified input string. + * @param string + */ + resolveTokens(string: string): string; +} diff --git a/packages/mgt-element/src/services/MicrosoftSearchService.ts b/packages/mgt-element/src/services/MicrosoftSearchService.ts new file mode 100644 index 0000000000..f35f90c832 --- /dev/null +++ b/packages/mgt-element/src/services/MicrosoftSearchService.ts @@ -0,0 +1,141 @@ +import { IMicrosoftSearchService } from './IMicrosoftSearchService'; +import { IMicrosoftSearchDataSourceData } from '../models/IMicrosoftSearchDataSourceData'; +import { EntityType, IMicrosoftSearchQuery } from '../models/IMicrosoftSearchRequest'; +import { IMicrosoftSearchResponse, IMicrosoftSearchResultSet } from '../models/IMicrosoftSearchResponse'; +import { IDataFilterResult, IDataFilterResultValue, FilterComparisonOperator } from '../models/IDataFilter'; +import { Client, ClientOptions, FetchOptions } from '@microsoft/microsoft-graph-client'; +import { Providers } from '../providers/Providers'; + +export const EntityTypesValidCombination = [ + EntityType.Drive, + EntityType.DriveItem, + EntityType.Site, + EntityType.List, + EntityType.ListItem +]; + +export class MicrosoftSearchService implements IMicrosoftSearchService { + private _useBetaEndPoint: boolean; + public get useBetaEndPoint(): boolean { + return this._useBetaEndPoint; + } + public set useBetaEndPoint(value: boolean) { + this._useBetaEndPoint = value; + this._microsoftSearchUrl = `https://graph.microsoft.com/${value ? 'beta' : 'v1.0'}/search/query`; + } + + private controller: AbortController; + + private _microsoftSearchUrl = 'https://graph.microsoft.com/v1.0/search/query'; + + public async search(searchQuery: IMicrosoftSearchQuery, culture?: string): Promise { + let itemsCount = 0; + const response: IMicrosoftSearchDataSourceData = { + items: [], + filters: [] + }; + const aggregationResults: IDataFilterResult[] = []; + + // To be able to cancel requests individually, a new controller needs to be instanciated every time + this.controller = new AbortController(); + + const fetchOptions: FetchOptions = { + signal: this.controller.signal + }; + + const clientOptions: ClientOptions = { + authProvider: Providers.globalProvider, + fetchOptions: fetchOptions + }; + + const client = Client.initWithMiddleware(clientOptions); + + try { + const jsonResponse: IMicrosoftSearchResponse = await client + .api(this._microsoftSearchUrl) + .headers({ + 'accept-language': culture ? culture : 'en-US' + }) + .post(searchQuery); + + if (jsonResponse.value && Array.isArray(jsonResponse.value)) { + jsonResponse.value.forEach((value: IMicrosoftSearchResultSet) => { + // Map results + value.hitsContainers.forEach(hitContainer => { + itemsCount += hitContainer.total; + + if (hitContainer.hits) { + const hits = hitContainer.hits.map(hit => { + // "externalItem" will contain resource.properties but "listItem" will be resource.fields + const propertiesFieldName = hit.resource.properties + ? 'properties' + : hit.resource.fields + ? 'fields' + : null; + + if (propertiesFieldName) { + // Flatten "fields" to be usable with the Search Fitler WP as refiners + Object.keys(hit.resource[propertiesFieldName]).forEach(field => { + // If the property already exists, keep it. Otherwise create it. + if (!hit[field.toLocaleLowerCase()]) { + hit[field.toLocaleLowerCase()] = hit.resource[propertiesFieldName][field]; + } + }); + } + + return hit; + }); + + response.items = response.items.concat(hits); + } + + if (hitContainer.aggregations) { + // Map refinement results + hitContainer.aggregations.forEach(aggregation => { + const values: IDataFilterResultValue[] = []; + aggregation.buckets.forEach(bucket => { + values.push({ + key: bucket.aggregationFilterToken, + count: bucket.count, + name: bucket.key, + value: bucket.aggregationFilterToken, + operator: FilterComparisonOperator.Contains + } as IDataFilterResultValue); + }); + + aggregationResults.push({ + filterName: aggregation.field, + values: values + }); + }); + + response.filters = aggregationResults; + } + }); + + if (value?.queryAlterationResponse) { + response.queryAlterationResponse = value.queryAlterationResponse; + } + + if (value?.resultTemplates) { + response.resultTemplates = value.resultTemplates; + } + }); + + response.totalCount = itemsCount; + } + } catch (error) { + if (this.controller.signal.aborted) { + console.log('Graph request was aborted'); + } + + throw new Error(`${error.code} - ${error.message}`); + } + + return response; + } + + public abortRequest() { + this.controller.abort(); + } +} diff --git a/packages/mgt-element/src/services/TemplateService.ts b/packages/mgt-element/src/services/TemplateService.ts new file mode 100644 index 0000000000..61bb1aa99e --- /dev/null +++ b/packages/mgt-element/src/services/TemplateService.ts @@ -0,0 +1,208 @@ +import { ComponentElements, ThemeCSSVariables } from '../Constants'; +import { IDataSourceData } from '../models/IDataSourceData'; +import { IThemeDefinition } from '../models/IThemeDefinition'; +import { ITemplateService } from './ITemplateService'; + +/** + * Custom attributes that can be used to display result types + */ +const ResultTypesAttributes = { + FallbackImageUrl: 'fallback-img-url' +}; + +export class TemplateService implements ITemplateService { + private _adaptiveCardsNS; + private _markdownIt; + private _adaptiveCardsTemplating; + private _serializationContext; + + public async loadAdaptiveCardsResources(): Promise { + if (!this._adaptiveCardsNS) { + // Load dynamic resources + /*this._adaptiveCardsNS = await import( + 'adaptivecards' + );*/ + + // Initialize the serialization context for the Adaptive Cards, if needed + if (!this._serializationContext) { + /* const { CardObjectRegistry, GlobalRegistry, SerializationContext } = await import( + 'adaptivecards' + );*/ + + //this._serializationContext = new SerializationContext(); + + const CardElementType = this._adaptiveCardsNS.CardElement; + const ActionElementType = this._adaptiveCardsNS.Action; + + /* const elementRegistry = new CardObjectRegistry>(); + const actionRegistry = new CardObjectRegistry>(); + + GlobalRegistry.populateWithDefaultElements(elementRegistry); + GlobalRegistry.populateWithDefaultActions(actionRegistry); + + this._serializationContext.setElementRegistry(elementRegistry); + this._serializationContext.setActionRegistry(actionRegistry);*/ + } + + this._adaptiveCardsNS.AdaptiveCard.onProcessMarkdown = (text: string, result) => { + // We use Markdown here to render HTML and use web components + const rawHtml = this._markdownIt.render(text).replace(/</g, '<').replace(/>/g, '>'); + + result.outputHtml = rawHtml; + result.didProcess = true; + }; + + /* await import( + 'adaptive-expressions' + );*/ + + /* this._adaptiveCardsTemplating = await import( + 'adaptivecards-templating' + );*/ + + const MarkdownIt = await import('markdown-it'); + + this._markdownIt = new MarkdownIt.default(); + this._markdownIt = new MarkdownIt(); + } + } + + public async getFileContent(fileAbsoluteUrl: string): Promise { + const response: Response = await fetch(fileAbsoluteUrl, { + method: 'GET', + mode: 'cors' + }); + + if (response.ok) { + switch (response.headers.get('Content-Type')) { + case 'text/html' || 'text/plain': + return await response.text(); + + case 'application/json': + return JSON.stringify(await response.json()); + + default: + return await response.text(); + } + } else { + throw response.statusText; + } + } + + public processAdaptiveCardTemplate( + templateContent: string, + templateContext: object, + theme?: IThemeDefinition + ): HTMLElement { + const template = new this._adaptiveCardsTemplating.Template(JSON.parse(templateContent)); + const hostConfiguration = new this._adaptiveCardsNS.HostConfig(this._getHostConfiguration(theme)); + + // The root context will be available in the the card implicitly + const context = { + $root: templateContext + }; + + const card = template.expand(context); + const adaptiveCard = new this._adaptiveCardsNS.AdaptiveCard(); + adaptiveCard.hostConfig = hostConfiguration; + + adaptiveCard.parse(card, this._serializationContext); + return adaptiveCard.render(); + } + + public processResultTypesFromHtml(data: IDataSourceData, templateContent: HTMLElement): HTMLElement { + if (data?.resultTemplates) { + // Build dictionary of available result template + const templateDictionary = new Map(Object.entries(data.resultTemplates)); + + for (const item of data.items) { + const templateId = item.resultTemplateId; + + if (templateId) { + const templatePayload = templateDictionary.get(templateId).body; + + // Check if item should use a result template + if (templatePayload && templateId !== 'connectordefault') { + // Partial match as we can"t use the complete ID due to special characters "/" and "=="" + const defaultItem: HTMLElement = templateContent.querySelector(`[id^="${item.hitId.substring(0, 15)}"]`); + + // Replace the HTML element corresponding to the item by its result type + if (defaultItem) { + const adaptiveCardComponent = document.createElement(ComponentElements.MgtAdaptiveCard) as any; //MgtAdaptiveCardComponent; + adaptiveCardComponent.cardContent = JSON.stringify(templatePayload); + adaptiveCardComponent.cardContext = item; + + // Get other settings from placeholder attribute specified in the template + adaptiveCardComponent.fallbackImageUrl = defaultItem.getAttribute(ResultTypesAttributes.FallbackImageUrl); + defaultItem.replaceWith(adaptiveCardComponent); + } + } + } + } + } + + return templateContent; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _getHostConfiguration(theme?: IThemeDefinition): { [key: string]: any } { + return { + separator: { + lineThickness: 1, + lineColor: '#1e252b' + }, + supportsInteractivity: true, + fontTypes: { + default: { + fontFamily: theme ? theme[ThemeCSSVariables.fontFamilyPrimary] : '', + fontSizes: { + small: 12, + medium: 14, + large: 24 + }, + fontWeights: { + bolder: 700 + } + }, + monospace: { + fontFamily: theme ? theme[ThemeCSSVariables.fontFamilySecondary] : '', + fontSizes: { + small: 12, + medium: 14, + large: 24 + }, + fontWeights: { + bolder: 700 + } + } + }, + containerStyles: { + default: { + backgroundColor: '#FFFFFF', + foregroundColors: { + default: { + default: '#000000' + }, + accent: { + default: theme ? theme[ThemeCSSVariables.colorPrimary] : '' + }, + light: { + default: theme ? theme[ThemeCSSVariables.colorLight] : '' + } + } + }, + emphasis: { + backgroundColor: '#DCE2E5' + } + }, + adaptiveCard: { + allowCustomStyle: true + }, + spacing: { + default: 6, + medium: 8, + large: 16 + } + }; + } +} diff --git a/packages/mgt-element/src/services/TokenService.ts b/packages/mgt-element/src/services/TokenService.ts new file mode 100644 index 0000000000..2b1ff6d565 --- /dev/null +++ b/packages/mgt-element/src/services/TokenService.ts @@ -0,0 +1,77 @@ +import { ObjectHelper } from '../helpers/ObjectHelper'; +import { StringHelper } from '../helpers/StringHelper'; +import { ITokenService } from './ITokenService'; + +export enum BuiltinTokenNames { + /** + * The input query text configured in the search results Web Part + */ + inputQueryText = 'inputQueryText', + + /** + * Similar as 'inputQueryText' to match the SharePoint search token + */ + searchTerms = 'searchTerms' +} + +export class TokenService implements ITokenService { + /** + * This regex only matches expressions enclosed with single, not escaped, curly braces '{}' + */ + private genericTokenRegexp = /{[^{]+?[^\\]}/gi; + + /** + * The list of static tokens values set by the Web Part as context + */ + private tokenValuesList: { [key: string]: string } = { + [BuiltinTokenNames.inputQueryText]: undefined, + [BuiltinTokenNames.searchTerms]: undefined + }; + + public setTokenValue(token: string, value: string) { + // Check if the token is in the whitelist + if (Object.keys(this.tokenValuesList).indexOf(token) !== -1) { + this.tokenValuesList[token] = value; + } else { + console.log(`The token '${token}' not allowed.`); + } + } + + public getTokenValue(token: string): string { + return this.tokenValuesList[token]; + } + + public resolveTokens(inputString: string): string { + if (inputString) { + // Look for static tokens in the specified string + const tokens = inputString.match(this.genericTokenRegexp); + + if (tokens !== null && tokens.length > 0) { + tokens.forEach(token => { + // Take the expression inside curly brackets + const tokenName = token.substr(1).slice(0, -1); + + // Check if the property exists in the object + // 'undefined' => token hasn't been initialized in the TokenService instance. We left the token expression untouched (ex: {token}). Ex: no filters component connected, etc. + // 'null' => token has been initialized but set with a null value. We replace by an empty string as we don't want the string 'null' litterally in the output. + // '' (empty string) => replaced in the original string with an empty string as well. + const tokenValue = ObjectHelper.byPath(this.tokenValuesList, tokenName); + + if (tokenValue !== undefined) { + if (tokenValue !== null) { + inputString = inputString.replace(new RegExp(StringHelper.escapeRegExp(token), 'gi'), tokenValue); + } else { + // If the property value is 'null', we replace by an empty string. 'null' means it has been already set but resolved as empty. + inputString = inputString.replace(new RegExp(StringHelper.escapeRegExp(token), 'gi'), ''); + } + } + }); + } + + // Replace manually escaped curly braces + inputString = inputString.replace(/\\({|})/gi, '$1'); + } + + return inputString; + } +} diff --git a/packages/mgt-element/src/utils/IComponentBinding.ts b/packages/mgt-element/src/utils/IComponentBinding.ts new file mode 100644 index 0000000000..860dd123cd --- /dev/null +++ b/packages/mgt-element/src/utils/IComponentBinding.ts @@ -0,0 +1,17 @@ +export interface IComponentBinding { + /** + * The DOM element ID to bind + */ + id: string; + + /** + * The event name to bind + */ + eventName: string; + + /** + * Function to call when the event is fired + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callbackFunction: (e: CustomEvent) => void; +} diff --git a/packages/mgt-element/src/utils/ILocalizedString.ts b/packages/mgt-element/src/utils/ILocalizedString.ts new file mode 100644 index 0000000000..1892bf8baa --- /dev/null +++ b/packages/mgt-element/src/utils/ILocalizedString.ts @@ -0,0 +1,11 @@ +export interface ILocalizedString { + /** + * The default label to use + */ + default: string; + + /** + * Any other locales for the string (ex: 'fr-fr') + */ + [locale: string]: string; +} diff --git a/packages/mgt-spfx/.eslintrc.js b/packages/mgt-spfx/.eslintrc.js new file mode 100644 index 0000000000..6b3a0e0295 --- /dev/null +++ b/packages/mgt-spfx/.eslintrc.js @@ -0,0 +1,5 @@ +require('@rushstack/eslint-config/patch/modern-module-resolution'); +module.exports = { + extends: ['@microsoft/eslint-config-spfx/lib/profiles/default'], + parserOptions: { tsconfigRootDir: __dirname } +}; diff --git a/packages/mgt-spfx/config/write-manifests.json b/packages/mgt-spfx/config/write-manifests.json index bad3526054..11dca67041 100644 --- a/packages/mgt-spfx/config/write-manifests.json +++ b/packages/mgt-spfx/config/write-manifests.json @@ -1,4 +1,4 @@ { "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json", "cdnBasePath": "" -} \ No newline at end of file +} diff --git a/packages/mgt-spfx/src/libraries/mgt/loc/en-us.js b/packages/mgt-spfx/src/libraries/mgt/loc/en-us.js index c3b1eaf678..6c55df4fcb 100644 --- a/packages/mgt-spfx/src/libraries/mgt/loc/en-us.js +++ b/packages/mgt-spfx/src/libraries/mgt/loc/en-us.js @@ -5,7 +5,6 @@ * ------------------------------------------------------------------------------------------- */ -define([], function() { - return { - } -}); \ No newline at end of file +define([], function () { + return {}; +}); diff --git a/packages/mgt/package.json b/packages/mgt/package.json index b05e82dc80..6cd85a7b29 100644 --- a/packages/mgt/package.json +++ b/packages/mgt/package.json @@ -27,8 +27,8 @@ "scripts": { "build": "npm-run-all clean build:compile build:bundle", "build:bundle": "npm-run-all copy:loader copy:wc sass rollup", - "build:compile": "npm-run-all sass compile", - "build:watch": "npm-run-all -p sass:watch compile:watch", + "build:compile": "npm-run-all tailwind sass compile", + "build:watch": "npm-run-all -p tailwind:watch sass:watch compile:watch", "clean": "shx rm -rf ./dist && shx rm -rf ./tsconfig.tsbuildinfo", "compile": "tsc -b", "compile:watch": "tsc -w", @@ -37,6 +37,8 @@ "lint": "eslint -c ../../.eslintrc.js 'src/**/*.ts'", "postpack": "cpx *.tgz ../../artifacts", "rollup": "rollup -c", + "tailwind": "cross-env postcss ./tailwind.css -o ../mgt-components/src/styles/tailwind-styles.css", + "tailwind:watch": "cross-env TAILWIND_MODE=watch postcss ./tailwind.css -o ../mgt-components/src/styles/tailwind-styles.css --watch", "sass": "gulp sass --cwd .", "sass:watch": "gulp watchSass --cwd .", "test": "jest", @@ -51,7 +53,11 @@ "@microsoft/mgt-mock-provider": "*" }, "devDependencies": { - "@webcomponents/webcomponentsjs": "^2.5.0" + "@webcomponents/webcomponentsjs": "^2.5.0", + "postcss": "8.4.19", + "postcss-cli": "10.1.0", + "cross-env": "7.0.3", + "cssnano": "6.0.1" }, "jest-junit": { "suiteName": "jest tests", diff --git a/packages/mgt/postcss.config.js b/packages/mgt/postcss.config.js new file mode 100644 index 0000000000..15a9264f15 --- /dev/null +++ b/packages/mgt/postcss.config.js @@ -0,0 +1,20 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + cssnano: { + preset: [ + 'default', + { + discardComments: { + removeAll: true + }, + minifySelectors: false, + minifyGradients: false, + minifyParams: false, + normalizeWhitespace: false + } + ] + } + } +}; diff --git a/packages/mgt/rollup.config.js b/packages/mgt/rollup.config.js index 73e1cec7cb..9f30de0c07 100644 --- a/packages/mgt/rollup.config.js +++ b/packages/mgt/rollup.config.js @@ -17,7 +17,7 @@ const getBabelConfig = isEs5 => { include: [ 'src/**/*', 'node_modules/lit-element/**/*', - 'node_modules/lit-html/**/*', + 'node_modules/lit-element/**/*', 'node_modules/@microsoft/microsoft-graph-client/lib/es/**/*', 'node_modules/msal/lib-es6/**/*' ] @@ -39,7 +39,8 @@ const es6Bundle = { entryFileNames: 'mgt.es6.js', format: 'iife', name: 'mgt', - sourcemap: false + sourcemap: false, + inlineDynamicImports: true }, plugins: [ babel({ diff --git a/packages/mgt/tailwind.config.js b/packages/mgt/tailwind.config.js new file mode 100644 index 0000000000..ff05e34970 --- /dev/null +++ b/packages/mgt/tailwind.config.js @@ -0,0 +1,4 @@ +module.exports = { + mode: 'jit', + content: ['../mgt-components/**/*.ts'] +}; diff --git a/packages/mgt/tailwind.css b/packages/mgt/tailwind.css new file mode 100644 index 0000000000..66505e467f --- /dev/null +++ b/packages/mgt/tailwind.css @@ -0,0 +1,4 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; +@tailwind screens; diff --git a/samples/angular-app/e2e/protractor.conf.js b/samples/angular-app/e2e/protractor.conf.js index 7c798cfff0..c92a1940d6 100644 --- a/samples/angular-app/e2e/protractor.conf.js +++ b/samples/angular-app/e2e/protractor.conf.js @@ -9,9 +9,7 @@ const { SpecReporter } = require('jasmine-spec-reporter'); */ exports.config = { allScriptsTimeout: 11000, - specs: [ - './src/**/*.e2e-spec.ts' - ], + specs: ['./src/**/*.e2e-spec.ts'], capabilities: { browserName: 'chrome' }, @@ -21,7 +19,7 @@ exports.config = { jasmineNodeOpts: { showColors: true, defaultTimeoutInterval: 30000, - print: function() {} + print: function () {} }, onPrepare() { require('ts-node').register({ @@ -29,4 +27,4 @@ exports.config = { }); jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); } -}; \ No newline at end of file +}; diff --git a/samples/angular-app/e2e/src/app.e2e-spec.ts b/samples/angular-app/e2e/src/app.e2e-spec.ts index c5499142d8..d6d0ded33f 100644 --- a/samples/angular-app/e2e/src/app.e2e-spec.ts +++ b/samples/angular-app/e2e/src/app.e2e-spec.ts @@ -16,8 +16,10 @@ describe('workspace-project App', () => { afterEach(async () => { // Assert that there are no errors emitted from the browser const logs = await browser.manage().logs().get(logging.Type.BROWSER); - expect(logs).not.toContain(jasmine.objectContaining({ - level: logging.Level.SEVERE, - } as logging.Entry)); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: logging.Level.SEVERE + } as logging.Entry) + ); }); }); diff --git a/samples/angular-app/e2e/tsconfig.json b/samples/angular-app/e2e/tsconfig.json index 39b800f789..677f30ff8a 100644 --- a/samples/angular-app/e2e/tsconfig.json +++ b/samples/angular-app/e2e/tsconfig.json @@ -4,10 +4,6 @@ "outDir": "../out-tsc/e2e", "module": "commonjs", "target": "es5", - "types": [ - "jasmine", - "jasminewd2", - "node" - ] + "types": ["jasmine", "jasminewd2", "node"] } } diff --git a/samples/angular-app/src/app/angular-agenda/angular-agenda.component.html b/samples/angular-app/src/app/angular-agenda/angular-agenda.component.html index 1d1b4526f6..22b7d42e03 100644 --- a/samples/angular-app/src/app/angular-agenda/angular-agenda.component.html +++ b/samples/angular-app/src/app/angular-agenda/angular-agenda.component.html @@ -1,20 +1,24 @@ - - + + - diff --git a/samples/angular-app/src/app/angular-agenda/angular-agenda.component.spec.ts b/samples/angular-app/src/app/angular-agenda/angular-agenda.component.spec.ts index 3983fb6c1c..5e5eb0f469 100644 --- a/samples/angular-app/src/app/angular-agenda/angular-agenda.component.spec.ts +++ b/samples/angular-app/src/app/angular-agenda/angular-agenda.component.spec.ts @@ -5,12 +5,13 @@ describe('AngularAgendaComponent', () => { let component: AngularAgendaComponent; let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ AngularAgendaComponent ] + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [AngularAgendaComponent] + }).compileComponents(); }) - .compileComponents(); - })); + ); beforeEach(() => { fixture = TestBed.createComponent(AngularAgendaComponent); diff --git a/samples/angular-app/src/app/angular-agenda/angular-agenda.component.ts b/samples/angular-app/src/app/angular-agenda/angular-agenda.component.ts index ce2a7c6ddb..ecacd2ce67 100644 --- a/samples/angular-app/src/app/angular-agenda/angular-agenda.component.ts +++ b/samples/angular-app/src/app/angular-agenda/angular-agenda.component.ts @@ -7,24 +7,25 @@ import { MgtAgenda } from '@microsoft/mgt'; styleUrls: ['./angular-agenda.component.scss'] }) export class AngularAgendaComponent implements AfterViewInit { - @ViewChild('myagenda', {static: true}) + @ViewChild('myagenda', { static: true }) agendaElement: ElementRef; - constructor() { } + constructor() {} ngAfterViewInit() { this.agendaElement.nativeElement.templateContext = { - openWebLink: (e: any, context: { event: { webLink: string | undefined; }; }, root: any) => { - window.open(context.event.webLink, '_blank'); + openWebLink: (e: any, context: { event: { webLink: string | undefined } }, root: any) => { + window.open(context.event.webLink, '_blank'); }, getDate: (dateString: string) => { - const dateObject = new Date(dateString); - return dateObject.setHours(0, 0, 0, 0); + const dateObject = new Date(dateString); + return dateObject.setHours(0, 0, 0, 0); }, getTime: (dateString: string) => { - const dateObject = new Date(dateString); - return dateObject.getHours().toString().padEnd(2, '0') - + ':' + dateObject.getMinutes().toString().padEnd(2, '0'); + const dateObject = new Date(dateString); + return ( + dateObject.getHours().toString().padEnd(2, '0') + ':' + dateObject.getMinutes().toString().padEnd(2, '0') + ); } }; } diff --git a/samples/angular-app/src/app/app-routing.module.ts b/samples/angular-app/src/app/app-routing.module.ts index b68f0e2fea..17115b30aa 100644 --- a/samples/angular-app/src/app/app-routing.module.ts +++ b/samples/angular-app/src/app/app-routing.module.ts @@ -5,33 +5,33 @@ import { HomeComponent } from './pages/home/home.component'; import { ProfileComponent } from './pages/profile/profile.component'; const routes: Routes = [ - { - path: 'profile', - component: ProfileComponent, - canActivate: [ - MsalGuard - ] - }, - { - /** - * Needed for login on page load for PathLocationStrategy. - * See FAQ for details: - * https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-angular/docs/FAQ.md - */ - path: 'auth', - component: MsalRedirectComponent - }, - { - path: '', - component: HomeComponent - } + { + path: 'profile', + component: ProfileComponent, + canActivate: [MsalGuard] + }, + { + /** + * Needed for login on page load for PathLocationStrategy. + * See FAQ for details: + * https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-angular/docs/FAQ.md + */ + path: 'auth', + component: MsalRedirectComponent + }, + { + path: '', + component: HomeComponent + } ]; @NgModule({ - imports: [RouterModule.forRoot(routes, { - useHash: false, - relativeLinkResolution: 'legacy' -})], - exports: [RouterModule] + imports: [ + RouterModule.forRoot(routes, { + useHash: false, + relativeLinkResolution: 'legacy' + }) + ], + exports: [RouterModule] }) -export class AppRoutingModule { } +export class AppRoutingModule {} diff --git a/samples/angular-app/src/app/app.component.html b/samples/angular-app/src/app/app.component.html index fa27d7969f..74d32d21a8 100644 --- a/samples/angular-app/src/app/app.component.html +++ b/samples/angular-app/src/app/app.component.html @@ -1,12 +1,12 @@
- -
\ No newline at end of file + + diff --git a/samples/angular-app/src/app/app.component.spec.ts b/samples/angular-app/src/app/app.component.spec.ts index 7b2e372b5b..67d3187b20 100644 --- a/samples/angular-app/src/app/app.component.spec.ts +++ b/samples/angular-app/src/app/app.component.spec.ts @@ -2,13 +2,13 @@ import { TestBed, waitForAsync } from '@angular/core/testing'; import { AppComponent } from './app.component'; describe('AppComponent', () => { - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ - AppComponent - ], - }).compileComponents(); - })); + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [AppComponent] + }).compileComponents(); + }) + ); it('should create the app', () => { const fixture = TestBed.createComponent(AppComponent); diff --git a/samples/angular-app/src/app/app.component.ts b/samples/angular-app/src/app/app.component.ts index e80737c804..9ecbec0c38 100644 --- a/samples/angular-app/src/app/app.component.ts +++ b/samples/angular-app/src/app/app.component.ts @@ -20,7 +20,7 @@ export class AppComponent implements OnInit, OnDestroy { private msalService: MsalService, private cd: ChangeDetectorRef, private msalBroadcastService: MsalBroadcastService - ) { } + ) {} public ngOnInit() { this.msalBroadcastService.inProgress$ diff --git a/samples/angular-app/src/app/app.module.ts b/samples/angular-app/src/app/app.module.ts index 7682a02709..c25751c523 100644 --- a/samples/angular-app/src/app/app.module.ts +++ b/samples/angular-app/src/app/app.module.ts @@ -12,12 +12,7 @@ import { AppRoutingModule } from 'src/app/app-routing.module'; import { ProfileComponent } from './pages/profile/profile.component'; @NgModule({ - declarations: [ - AppComponent, - NavBarComponent, - AngularAgendaComponent, - HomeComponent, - ProfileComponent], + declarations: [AppComponent, NavBarComponent, AngularAgendaComponent, HomeComponent, ProfileComponent], imports: [ BrowserModule, MsalModule.forRoot(new PublicClientApplication(MsalConfig), MsalGuardConfig, MsalInterceptorConfig), @@ -28,4 +23,4 @@ import { ProfileComponent } from './pages/profile/profile.component'; providers: [], bootstrap: [AppComponent, MsalRedirectComponent] }) -export class AppModule { } +export class AppModule {} diff --git a/samples/angular-app/src/app/nav-bar/nav-bar.component.html b/samples/angular-app/src/app/nav-bar/nav-bar.component.html index 7c12e27974..8f3934af33 100644 --- a/samples/angular-app/src/app/nav-bar/nav-bar.component.html +++ b/samples/angular-app/src/app/nav-bar/nav-bar.component.html @@ -1,19 +1,30 @@ \ No newline at end of file + + diff --git a/samples/angular-app/src/app/nav-bar/nav-bar.component.spec.ts b/samples/angular-app/src/app/nav-bar/nav-bar.component.spec.ts index 5bb7b3b44f..a74eb490d1 100644 --- a/samples/angular-app/src/app/nav-bar/nav-bar.component.spec.ts +++ b/samples/angular-app/src/app/nav-bar/nav-bar.component.spec.ts @@ -6,12 +6,13 @@ describe('NavBarComponent', () => { let component: NavBarComponent; let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ NavBarComponent ] + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [NavBarComponent] + }).compileComponents(); }) - .compileComponents(); - })); + ); beforeEach(() => { fixture = TestBed.createComponent(NavBarComponent); diff --git a/samples/angular-app/src/app/nav-bar/nav-bar.component.ts b/samples/angular-app/src/app/nav-bar/nav-bar.component.ts index 6082d5b395..0f5b2d8778 100644 --- a/samples/angular-app/src/app/nav-bar/nav-bar.component.ts +++ b/samples/angular-app/src/app/nav-bar/nav-bar.component.ts @@ -6,10 +6,7 @@ import { Component, OnInit } from '@angular/core'; styleUrls: ['./nav-bar.component.scss'] }) export class NavBarComponent implements OnInit { + constructor() {} - constructor() { } - - ngOnInit(): void { - } - + ngOnInit(): void {} } diff --git a/samples/angular-app/src/app/pages/home/home.component.html b/samples/angular-app/src/app/pages/home/home.component.html index 7545c1bfa0..6b8e1e0188 100644 --- a/samples/angular-app/src/app/pages/home/home.component.html +++ b/samples/angular-app/src/app/pages/home/home.component.html @@ -1 +1 @@ -Welcome to msal-angular v2 demo \ No newline at end of file +Welcome to msal-angular v2 demo diff --git a/samples/angular-app/src/app/pages/home/home.component.spec.ts b/samples/angular-app/src/app/pages/home/home.component.spec.ts index b19cfbd139..57bb07383a 100644 --- a/samples/angular-app/src/app/pages/home/home.component.spec.ts +++ b/samples/angular-app/src/app/pages/home/home.component.spec.ts @@ -6,12 +6,13 @@ describe('HomeComponent', () => { let component: HomeComponent; let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ HomeComponent ] + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [HomeComponent] + }).compileComponents(); }) - .compileComponents(); - })); + ); beforeEach(() => { fixture = TestBed.createComponent(HomeComponent); diff --git a/samples/angular-app/src/app/pages/home/home.component.ts b/samples/angular-app/src/app/pages/home/home.component.ts index 73acf06f0d..8be5b7dfeb 100644 --- a/samples/angular-app/src/app/pages/home/home.component.ts +++ b/samples/angular-app/src/app/pages/home/home.component.ts @@ -6,10 +6,7 @@ import { Component, OnInit } from '@angular/core'; styleUrls: ['./home.component.scss'] }) export class HomeComponent implements OnInit { + constructor() {} - constructor() { } - - ngOnInit(): void { - } - + ngOnInit(): void {} } diff --git a/samples/angular-app/src/app/pages/profile/profile.component.spec.ts b/samples/angular-app/src/app/pages/profile/profile.component.spec.ts index 4c3c8ab46d..1800ff185b 100644 --- a/samples/angular-app/src/app/pages/profile/profile.component.spec.ts +++ b/samples/angular-app/src/app/pages/profile/profile.component.spec.ts @@ -6,12 +6,13 @@ describe('ProfileComponent', () => { let component: ProfileComponent; let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ ProfileComponent ] + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ProfileComponent] + }).compileComponents(); }) - .compileComponents(); - })); + ); beforeEach(() => { fixture = TestBed.createComponent(ProfileComponent); diff --git a/samples/angular-app/src/app/pages/profile/profile.component.ts b/samples/angular-app/src/app/pages/profile/profile.component.ts index 29ea4ff3fb..1e7b410295 100644 --- a/samples/angular-app/src/app/pages/profile/profile.component.ts +++ b/samples/angular-app/src/app/pages/profile/profile.component.ts @@ -6,10 +6,7 @@ import { Component, OnInit } from '@angular/core'; styleUrls: ['./profile.component.scss'] }) export class ProfileComponent implements OnInit { + constructor() {} - constructor() { } - - ngOnInit(): void { - } - + ngOnInit(): void {} } diff --git a/samples/angular-app/src/index.html b/samples/angular-app/src/index.html index c6429eec42..cb1ff61033 100644 --- a/samples/angular-app/src/index.html +++ b/samples/angular-app/src/index.html @@ -1,14 +1,14 @@ - + - - - DemoMgtAngular - - - - - - - - + + + DemoMgtAngular + + + + + + + + diff --git a/samples/angular-app/src/main.ts b/samples/angular-app/src/main.ts index c7b673cf44..fa4e0aef33 100644 --- a/samples/angular-app/src/main.ts +++ b/samples/angular-app/src/main.ts @@ -8,5 +8,6 @@ if (environment.production) { enableProdMode(); } -platformBrowserDynamic().bootstrapModule(AppModule) +platformBrowserDynamic() + .bootstrapModule(AppModule) .catch(err => console.error(err)); diff --git a/samples/angular-app/src/polyfills.ts b/samples/angular-app/src/polyfills.ts index dcd18eaceb..4efb94c71b 100644 --- a/samples/angular-app/src/polyfills.ts +++ b/samples/angular-app/src/polyfills.ts @@ -45,8 +45,7 @@ /*************************************************************************************************** * Zone JS is required by default for Angular itself. */ -import 'zone.js'; // Included with Angular CLI. - +import 'zone.js'; // Included with Angular CLI. /*************************************************************************************************** * APPLICATION IMPORTS diff --git a/samples/angular-app/src/styles.scss b/samples/angular-app/src/styles.scss index 5be59b4cd4..a31e444117 100644 --- a/samples/angular-app/src/styles.scss +++ b/samples/angular-app/src/styles.scss @@ -1,3 +1,3 @@ @import 'tailwindcss/base'; @import 'tailwindcss/components'; -@import 'tailwindcss/utilities'; \ No newline at end of file +@import 'tailwindcss/utilities'; diff --git a/samples/angular-app/src/test.ts b/samples/angular-app/src/test.ts index 4bf4afba63..672e545618 100644 --- a/samples/angular-app/src/test.ts +++ b/samples/angular-app/src/test.ts @@ -2,25 +2,30 @@ import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; -import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting -} from '@angular/platform-browser-dynamic/testing'; +import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; declare const require: { - context(path: string, deep?: boolean, filter?: RegExp): { + context( + path: string, + deep?: boolean, + filter?: RegExp + ): { keys(): string[]; (id: string): T; }; }; // First, initialize the Angular testing environment. +<<<<<<< HEAD getTestBed().initTestEnvironment( BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { teardown: { destroyAfterEach: false } } ); +======= +getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting()); +>>>>>>> feature/search-components // Then we find all the tests. const context = require.context('./', true, /\.spec\.ts$/); // And load the modules. diff --git a/samples/angular-app/tailwind.config.js b/samples/angular-app/tailwind.config.js index 58e3582f80..f169f297d2 100644 --- a/samples/angular-app/tailwind.config.js +++ b/samples/angular-app/tailwind.config.js @@ -1,9 +1,7 @@ module.exports = { - content: [ - "./src/**/*.{html,ts}", - ], + content: ['./src/**/*.{html,ts}'], theme: { - extend: {}, + extend: {} }, - plugins: [], -} + plugins: [] +}; diff --git a/samples/angular-app/tsconfig.app.json b/samples/angular-app/tsconfig.app.json index f758d9820d..29f5f5864e 100644 --- a/samples/angular-app/tsconfig.app.json +++ b/samples/angular-app/tsconfig.app.json @@ -4,11 +4,6 @@ "outDir": "./out-tsc/app", "types": [] }, - "files": [ - "src/main.ts", - "src/polyfills.ts" - ], - "include": [ - "src/**/*.d.ts" - ] + "files": ["src/main.ts", "src/polyfills.ts"], + "include": ["src/**/*.d.ts"] } diff --git a/samples/angular-app/tsconfig.json b/samples/angular-app/tsconfig.json index 81f37ebd14..073a5a633f 100644 --- a/samples/angular-app/tsconfig.json +++ b/samples/angular-app/tsconfig.json @@ -11,10 +11,7 @@ "moduleResolution": "node", "importHelpers": true, "target": "es2020", - "lib": [ - "es2018", - "dom" - ] + "lib": ["es2018", "dom"] }, "angularCompilerOptions": { "fullTemplateTypeCheck": true, diff --git a/samples/angular-app/tsconfig.spec.json b/samples/angular-app/tsconfig.spec.json index 6400fde7d5..430cf757ce 100644 --- a/samples/angular-app/tsconfig.spec.json +++ b/samples/angular-app/tsconfig.spec.json @@ -2,17 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": [ - "jasmine", - "node" - ] + "types": ["jasmine", "node"] }, - "files": [ - "src/test.ts", - "src/polyfills.ts" - ], - "include": [ - "src/**/*.spec.ts", - "src/**/*.d.ts" - ] + "files": ["src/test.ts", "src/polyfills.ts"], + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] } diff --git a/samples/angular-app/tslint.json b/samples/angular-app/tslint.json index d92ff5d1b1..ac2147f164 100644 --- a/samples/angular-app/tslint.json +++ b/samples/angular-app/tslint.json @@ -2,10 +2,7 @@ "extends": "tslint:recommended", "rules": { "align": { - "options": [ - "parameters", - "statements" - ] + "options": ["parameters", "statements"] }, "array-type": false, "arrow-return-shorthand": true, @@ -16,74 +13,33 @@ "component-class-suffix": true, "contextual-lifecycle": true, "directive-class-suffix": true, - "directive-selector": [ - true, - "attribute", - "app", - "camelCase" - ], - "component-selector": [ - true, - "element", - "app", - "kebab-case" - ], + "directive-selector": [true, "attribute", "app", "camelCase"], + "component-selector": [true, "element", "app", "kebab-case"], "eofline": true, - "import-blacklist": [ - true, - "rxjs/Rx" - ], + "import-blacklist": [true, "rxjs/Rx"], "import-spacing": true, "indent": { - "options": [ - "spaces" - ] + "options": ["spaces"] }, "max-classes-per-file": false, - "max-line-length": [ - true, - 140 - ], + "max-line-length": [true, 140], "member-ordering": [ true, { - "order": [ - "static-field", - "instance-field", - "static-method", - "instance-method" - ] + "order": ["static-field", "instance-field", "static-method", "instance-method"] } ], - "no-console": [ - true, - "debug", - "info", - "time", - "timeEnd", - "trace" - ], + "no-console": [true, "debug", "info", "time", "timeEnd", "trace"], "no-empty": false, - "no-inferrable-types": [ - true, - "ignore-params" - ], + "no-inferrable-types": [true, "ignore-params"], "no-non-null-assertion": true, "no-redundant-jsdoc": true, "no-switch-case-fall-through": true, "no-var-requires": false, - "object-literal-key-quotes": [ - true, - "as-needed" - ], - "quotemark": [ - true, - "single" - ], + "object-literal-key-quotes": [true, "as-needed"], + "quotemark": [true, "single"], "semicolon": { - "options": [ - "always" - ] + "options": ["always"] }, "space-before-function-paren": { "options": { @@ -113,21 +69,10 @@ ] }, "variable-name": { - "options": [ - "ban-keywords", - "check-format", - "allow-pascal-case" - ] + "options": ["ban-keywords", "check-format", "allow-pascal-case"] }, "whitespace": { - "options": [ - "check-branch", - "check-decl", - "check-operator", - "check-separator", - "check-type", - "check-typecast" - ] + "options": ["check-branch", "check-decl", "check-operator", "check-separator", "check-type", "check-typecast"] }, "no-conflicting-lifecycle": true, "no-host-metadata-property": true, @@ -142,7 +87,5 @@ "use-lifecycle-interface": true, "use-pipe-transform-interface": true }, - "rulesDirectory": [ - "codelyzer" - ] -} \ No newline at end of file + "rulesDirectory": ["codelyzer"] +} diff --git a/samples/angular-app/webpack.config.js b/samples/angular-app/webpack.config.js new file mode 100644 index 0000000000..639ccd849c --- /dev/null +++ b/samples/angular-app/webpack.config.js @@ -0,0 +1,15 @@ +module.exports = { + module: { + rules: [ + { + test: /\.scss$/, + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: ['postcss-import', 'tailwindcss', 'autoprefixer'] + } + } + } + ] + } +}; diff --git a/samples/examples/file-upload.html b/samples/examples/file-upload.html index 3162084a19..4f006ae2c5 100644 --- a/samples/examples/file-upload.html +++ b/samples/examples/file-upload.html @@ -1,68 +1,70 @@ - - - - - - - - - -

Default:

- - -

Limit upload to 5 files:

- - -

Limit upload size to 10000 KB:

- - -

Exclude files with file extensions ".doc,.pdf":

- - -

Custom CSS included by class name:

- - -

Dark CSS in class name:

- - - - + --> diff --git a/samples/msal2provider-asp-net-core-sso/wwwroot/sso.html b/samples/msal2provider-asp-net-core-sso/wwwroot/sso.html index 1a5fafc777..b93f6d1f2c 100644 --- a/samples/msal2provider-asp-net-core-sso/wwwroot/sso.html +++ b/samples/msal2provider-asp-net-core-sso/wwwroot/sso.html @@ -1,10 +1,10 @@  - + - - + + Hello world - - \ No newline at end of file + + diff --git a/samples/proxy-provider-asp-net-core/Properties/launchSettings.json b/samples/proxy-provider-asp-net-core/Properties/launchSettings.json index 3981c1415b..90562b93b4 100644 --- a/samples/proxy-provider-asp-net-core/Properties/launchSettings.json +++ b/samples/proxy-provider-asp-net-core/Properties/launchSettings.json @@ -25,4 +25,4 @@ "applicationUrl": "https://localhost:44334" } } -} \ No newline at end of file +} diff --git a/samples/proxy-provider-asp-net-core/bundleconfig.json b/samples/proxy-provider-asp-net-core/bundleconfig.json index 6d3f9a57ae..9149a7853f 100644 --- a/samples/proxy-provider-asp-net-core/bundleconfig.json +++ b/samples/proxy-provider-asp-net-core/bundleconfig.json @@ -4,15 +4,11 @@ { "outputFileName": "wwwroot/css/site.min.css", // An array of relative input file paths. Globbing patterns supported - "inputFiles": [ - "wwwroot/css/site.css" - ] + "inputFiles": ["wwwroot/css/site.css"] }, { "outputFileName": "wwwroot/js/site.min.js", - "inputFiles": [ - "wwwroot/js/site.js" - ], + "inputFiles": ["wwwroot/js/site.js"], // Optionally specify minification options "minify": { "enabled": true, diff --git a/samples/proxy-provider-asp-net-core/wwwroot/css/site.css b/samples/proxy-provider-asp-net-core/wwwroot/css/site.css index ef19693235..63b2591536 100644 --- a/samples/proxy-provider-asp-net-core/wwwroot/css/site.css +++ b/samples/proxy-provider-asp-net-core/wwwroot/css/site.css @@ -1,43 +1,43 @@ body { - padding-top: 50px; - padding-bottom: 20px; + padding-top: 50px; + padding-bottom: 20px; } /* Wrapping element */ /* Set some basic padding to keep content from hitting the edges */ .body-content { - padding-left: 15px; - padding-right: 15px; + padding-left: 15px; + padding-right: 15px; } /* Set widths on the form inputs since otherwise they're 100% wide */ input, select, textarea { - max-width: 280px; + max-width: 280px; } /* Carousel */ .carousel-caption p { - font-size: 20px; - line-height: 1.4; + font-size: 20px; + line-height: 1.4; } /* Make .svg files in the carousel display properly in older browsers */ -.carousel-inner .item img[src$=".svg"] { - width: 100%; +.carousel-inner .item img[src$='.svg'] { + width: 100%; } mgt-login { - --button-color: #9d9d9d; - --button-background-color--hover: transparent; - --button-color--hover: white; + --button-color: #9d9d9d; + --button-background-color--hover: transparent; + --button-color--hover: white; } /* Hide/rearrange for smaller screens */ @media screen and (max-width: 767px) { - /* Hide captions */ - .carousel-caption { - display: none; - } + /* Hide captions */ + .carousel-caption { + display: none; + } } diff --git a/samples/proxy-provider-asp-net-core/wwwroot/css/site.min.css b/samples/proxy-provider-asp-net-core/wwwroot/css/site.min.css index 3beb45f52f..28972607f4 100644 --- a/samples/proxy-provider-asp-net-core/wwwroot/css/site.min.css +++ b/samples/proxy-provider-asp-net-core/wwwroot/css/site.min.css @@ -1 +1,25 @@ -body{padding-top:50px;padding-bottom:20px}.body-content{padding-left:15px;padding-right:15px}input,select,textarea{max-width:280px}.carousel-caption p{font-size:20px;line-height:1.4}.carousel-inner .item img[src$=".svg"]{width:100%}@media screen and (max-width:767px){.carousel-caption{display:none}} \ No newline at end of file +body { + padding-top: 50px; + padding-bottom: 20px; +} +.body-content { + padding-left: 15px; + padding-right: 15px; +} +input, +select, +textarea { + max-width: 280px; +} +.carousel-caption p { + font-size: 20px; + line-height: 1.4; +} +.carousel-inner .item img[src$='.svg'] { + width: 100%; +} +@media screen and (max-width: 767px) { + .carousel-caption { + display: none; + } +} diff --git a/samples/proxy-provider-asp-net-core/wwwroot/email_template.html b/samples/proxy-provider-asp-net-core/wwwroot/email_template.html index c7384170f3..5a149032e2 100644 --- a/samples/proxy-provider-asp-net-core/wwwroot/email_template.html +++ b/samples/proxy-provider-asp-net-core/wwwroot/email_template.html @@ -3,45 +3,67 @@ - - + + - - + +

Congratulations!

-

This is a message from the Microsoft Graph Connect Sample. You are well on your way to incorporating Microsoft Graph endpoints in your apps.

-

What's next?