diff --git a/common/config/src/main/resources/gui.conf b/common/config/src/main/resources/gui.conf index d58d94ac7b9..f0742d17968 100644 --- a/common/config/src/main/resources/gui.conf +++ b/common/config/src/main/resources/gui.conf @@ -112,6 +112,12 @@ gui { copilot-enabled = false copilot-enabled = ${?GUI_WORKFLOW_WORKSPACE_COPILOT_ENABLED} + # whether the guided tutorial / Argus chat is enabled. Disable to remove + # the trophy launcher button, the welcome popover, and the in-tour Argus + # chat from the workspace entirely. + tutorial-enabled = true + tutorial-enabled = ${?GUI_WORKFLOW_WORKSPACE_TUTORIAL_ENABLED} + # the limit of columns to be displayed in the result table limit-columns = 15 limit-columns = ${?GUI_WORKFLOW_WORKSPACE_LIMIT_COLUMNS} diff --git a/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala b/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala index adc789c9843..d5725420366 100644 --- a/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala +++ b/common/config/src/main/scala/org/apache/texera/config/GuiConfig.scala @@ -71,6 +71,8 @@ object GuiConfig { conf.getInt("gui.workflow-workspace.active-time-in-minutes") val guiWorkflowWorkspaceCopilotEnabled: Boolean = conf.getBoolean("gui.workflow-workspace.copilot-enabled") + val guiWorkflowWorkspaceTutorialEnabled: Boolean = + conf.getBoolean("gui.workflow-workspace.tutorial-enabled") val guiWorkflowWorkspaceLimitColumns: Int = conf.getInt("gui.workflow-workspace.limit-columns") } diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala index b7517d81eb7..55c6de5ed4b 100644 --- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala +++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala @@ -57,6 +57,7 @@ class ConfigResource { ), "activeTimeInMinutes" -> GuiConfig.guiWorkflowWorkspaceActiveTimeInMinutes, "copilotEnabled" -> GuiConfig.guiWorkflowWorkspaceCopilotEnabled, + "tutorialEnabled" -> GuiConfig.guiWorkflowWorkspaceTutorialEnabled, "limitColumns" -> GuiConfig.guiWorkflowWorkspaceLimitColumns, // flags from the auth.conf if needed "expirationTimeInMinutes" -> AuthConfig.jwtExpirationMinutes diff --git a/frontend/angular.json b/frontend/angular.json index b9e9961d027..8a7243ef354 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -29,6 +29,7 @@ "node_modules/jointjs/css/themes/default.css", "node_modules/ng-zorro-antd/ng-zorro-antd.min.css", "node_modules/ng-zorro-antd/resizable/style/index.min.css", + "node_modules/driver.js/dist/driver.css", "src/styles.scss" ], "scripts": ["./node_modules/marked/lib/marked.umd.js"], diff --git a/frontend/package.json b/frontend/package.json index 08b298260e3..58314ac54c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,9 +43,11 @@ "@ngx-formly/ng-zorro-antd": "6.3.12", "ai": "5.0.93", "ajv": "8.10.0", + "canvas-confetti": "^1.9.4", "concaveman": "2.0.0", "d3-shape": "2.1.0", "dagre": "0.8.5", + "driver.js": "^1.4.0", "file-saver": "2.0.5", "fuse.js": "6.5.3", "html2canvas": "1.4.1", @@ -101,6 +103,7 @@ "@nx/angular": "22.7.0", "@schematics/angular": "21.2.8", "@types/backbone": "1.4.15", + "@types/canvas-confetti": "^1.9.0", "@types/concaveman": "1.1.6", "@types/d3-shape": "2.1.2", "@types/dagre": "0.7.47", diff --git a/frontend/src/_tutorial-driver-overrides.scss b/frontend/src/_tutorial-driver-overrides.scss new file mode 100644 index 00000000000..26538e51caa --- /dev/null +++ b/frontend/src/_tutorial-driver-overrides.scss @@ -0,0 +1,470 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* + * Global styles for the tutorial mode (driver.js popovers, body.driver-active + * overrides, sparkle-burst animation injected into , etc.). + * + * Every rule here MUST live at the global root rather than inside a component + * stylesheet because the elements they target sit outside the Angular + * component tree: + * - driver.js appends its `.driver-popover` and `.driver-overlay` to + * - ng-zorro / CDK portal nz-select dropdowns into `.cdk-overlay-container`, + * also at level + * - the sparkle burst is created via `document.body.appendChild(...)` + * in tutorial.service.ts + * + * Angular's emulated view encapsulation rewrites component-scoped selectors + * with `[_ngcontent-xyz]` attributes, which match nothing on those + * outside-the-tree elements — so component styles cannot reach them. + * + * All rules are gated by either `body.driver-active` (only active during a + * tour) or the `.tutorial-*` / `.driver-popover.tutorial-*` namespace. + * When the tutorial flag is off and no tour is running, none of these rules + * apply to anything. + */ + +/* driver.js — purely cosmetic. DO NOT override z-index here; driver.js + uses z-index: 1000000000 by design so the spotlight always wins. */ +.driver-popover { + max-width: 360px; +} +.driver-popover-title { + font-weight: 600; +} +.driver-popover-description b { + color: #1890ff; +} + +/* Welcome popover — a centered, fancier variant of the standard driver.js + popover used for the first-time greeting. Wider, with a soft gradient and + a primary-coloured CTA so "Start building your first workflow" reads as + the obvious next step. */ +.driver-popover.tutorial-welcome-popover { + max-width: 460px; + background: linear-gradient(135deg, #ffffff 0%, #f0f5ff 100%); + border: 2px solid #1890ff; + box-shadow: 0 16px 48px rgba(24, 144, 255, 0.32); + animation: tutorial-welcome-pop 0.5s cubic-bezier(0.18, 1.25, 0.4, 1); + + .driver-popover-title { + font-size: 20px; + color: #1890ff; + text-align: center; + } + + .driver-popover-footer button.driver-popover-next-btn { + background: #1890ff; + border-color: #1890ff; + color: #fff; + font-weight: 600; + padding: 6px 18px; + border-radius: 8px; + box-shadow: 0 4px 14px rgba(24, 144, 255, 0.45); + text-shadow: none; + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + + &:hover { + background: #40a9ff; + border-color: #40a9ff; + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(24, 144, 255, 0.6); + } + } + + .tutorial-welcome-emoji { + font-size: 54px; + margin-bottom: 8px; + display: inline-block; + animation: tutorial-welcome-wave 1.4s ease-in-out infinite; + transform-origin: 70% 80%; + } +} + +@keyframes tutorial-welcome-pop { + 0% { + opacity: 0; + transform: scale(0.85); + } + 60% { + opacity: 1; + transform: scale(1.04); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes tutorial-welcome-wave { + 0%, + 100% { + transform: rotate(-14deg); + } + 50% { + transform: rotate(14deg); + } +} + +/* Floating sparkle burst (fired on each step advance). Anchored in the + top-right corner so the celebration never overlaps the spotlight content + the user is reading. Sparkles fly down-left into the workspace; the XP + chip rises a few pixels and fades. */ +.tutorial-sparkle-burst { + position: fixed; + top: 56px; + right: 56px; + width: 0; + height: 0; + z-index: 1000000001; /* above driver.js's overlay z-index */ + pointer-events: none; + + .sparkle-particle { + position: absolute; + left: 0; + top: 0; + font-size: 12px; + color: #f5c542; + text-shadow: 0 0 6px rgba(245, 197, 66, 0.9); + transform: translate(-50%, -50%); + animation: tutorial-sparkle-fly 0.9s ease-out forwards; + } + + .xp-float { + position: absolute; + left: 0; + top: 0; + background: linear-gradient(135deg, #faad14, #f5c542); + color: #fff; + font-weight: 700; + font-size: 10px; + letter-spacing: 0.3px; + padding: 2px 7px; + border-radius: 10px; + box-shadow: 0 3px 8px rgba(250, 173, 20, 0.5); + transform: translate(-50%, -50%); + white-space: nowrap; + animation: tutorial-xp-rise 1.1s ease-out forwards; + } +} + +@keyframes tutorial-sparkle-fly { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.4) rotate(0deg); + } + 30% { + opacity: 1; + transform: translate(calc(-50% + var(--dx, 0px)), calc(-50% + var(--dy, 0px))) scale(1.3) rotate(180deg); + } + 100% { + opacity: 0; + transform: translate(calc(-50% + var(--dx, 0px) * 1.6), calc(-50% + var(--dy, 0px) * 1.6)) scale(0.5) rotate(360deg); + } +} + +@keyframes tutorial-xp-rise { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.7); + } + 20% { + opacity: 1; + transform: translate(-50%, calc(-50% - 12px)) scale(1.08); + } + 85% { + opacity: 1; + transform: translate(-50%, calc(-50% - 40px)) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, calc(-50% - 56px)) scale(1); + } +} + +/* Flow-picker popover — bigger than the standard welcome popover, hosts + selectable flow cards. */ +.driver-popover.tutorial-picker-popover { + max-width: 520px; + + .tutorial-picker { + .picker-intro { + margin-bottom: 14px; + } + .picker-tagline { + margin: 0 0 10px; + color: #333; + font-size: 13px; + line-height: 1.55; + } + .picker-cta { + margin: 0 0 4px; + color: #1890ff; + font-size: 13px; + font-weight: 600; + } + .picker-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; + } + .picker-flow-card { + display: block; + width: 100%; + text-align: left; + padding: 10px 14px; + border-radius: 10px; + border: 1.5px solid rgba(24, 144, 255, 0.2); + background: #fff; + cursor: pointer; + transition: all 0.15s ease; + + &:hover:not(.disabled):not(:disabled) { + transform: translateY(-1px); + border-color: #1890ff; + box-shadow: 0 6px 16px rgba(24, 144, 255, 0.25); + } + &.disabled, + &:disabled { + cursor: not-allowed; + opacity: 0.55; + background: repeating-linear-gradient(45deg, #fff, #fff 6px, #fafafa 6px, #fafafa 12px); + } + } + .picker-flow-header { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + color: #1f1f1f; + margin-bottom: 4px; + } + .picker-flow-emoji { + font-size: 18px; + } + .picker-flow-name { + flex: 1; + } + .picker-flow-desc { + font-size: 12px; + color: #666; + line-height: 1.4; + } + .picker-flow-meta { + margin-top: 4px; + font-size: 11px; + color: #999; + } + .picker-ribbon { + font-size: 10px; + padding: 1px 6px; + border-radius: 6px; + letter-spacing: 0.5px; + text-transform: uppercase; + font-weight: 700; + + &.done { + background: #d9f7be; + color: #389e0d; + } + &.soon { + background: #fff7e6; + color: #d4a017; + } + } + .picker-skip { + display: block; + margin: 6px auto 0; + background: transparent; + border: none; + color: #888; + font-size: 12px; + cursor: pointer; + text-decoration: underline; + + &:hover { + color: #555; + } + } + } +} + +/* Resume popover — narrower, friendly "welcome back" prompt. */ +.driver-popover.tutorial-resume-popover { + max-width: 420px; + + .tutorial-resume p { + margin: 0 0 6px; + font-size: 14px; + } + .resume-actions { + display: flex; + flex-direction: column; + gap: 8px; + } + .resume-btn { + padding: 8px 14px; + border-radius: 8px; + border: 1.5px solid rgba(24, 144, 255, 0.25); + background: #fff; + color: #1f1f1f; + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + border-color: #1890ff; + transform: translateY(-1px); + } + &.primary { + background: #1890ff; + border-color: #1890ff; + color: #fff; + box-shadow: 0 4px 12px rgba(24, 144, 255, 0.45); + + &:hover { + background: #40a9ff; + } + } + &.ghost { + background: transparent; + border-color: transparent; + color: #888; + &:hover { + color: #555; + transform: none; + } + } + } +} + +/* Hide the progress text on popovers tagged with this class. driver.js's + per-step `showProgress: false` is resolved via an OR-chain + (`a = w || s("showProgress") || false`) which means a per-step `false` + value gets overridden by a global `true` — leaving us no real per-step + off switch. This CSS hook is the workaround: detail steps that should + not be counted in the main spine declare popoverClass: + "tutorial-no-progress" and the counter element disappears. */ +.driver-popover.tutorial-no-progress .driver-popover-progress-text { + display: none !important; +} + +/* Wide variant for popovers that hover above wider spotlight targets + (e.g. the middle toolbar group, which spans most of the viewport). + driver.js's default popover maxes at 300px which looks dwarfed next to a + wide stage; bump to 440px on this step so the popover reads naturally. */ +.driver-popover.tutorial-wide-popover { + max-width: 440px; +} + +/* "Tell me more" opt-in deep-dive button injected into overview-step + popovers (workspace overview tour). Visually identical shape to the + default Next button, just gray-on-gray instead of accented — keeps Next + as the obvious primary action while the deeper path stays available. */ +.driver-popover .driver-popover-navigation-btns .tutorial-show-details-btn { + all: unset; + box-sizing: border-box; + display: inline-block; + padding: 3px 7px; + line-height: 1.3; + font: 12px / normal sans-serif; + color: #555; + background: #f5f5f5; + border: 1px solid #d9d9d9; + border-radius: 3px; + cursor: pointer; + margin-right: 4px; + transition: + background 0.15s ease, + border-color 0.15s ease; +} +.driver-popover .driver-popover-navigation-btns .tutorial-show-details-btn:hover { + background: #ebebeb; + border-color: #b3b3b3; +} + +/* driver.js by default sets pointer-events:none on every element outside the + highlighted one. That breaks drag-and-drop tutorial steps (user can grab the + operator label but the canvas refuses the drop). Re-enable interaction + everywhere while the tour is active — the dim overlay still gives the visual + cue, but the page stays fully usable. */ +body.driver-active *:not(.driver-overlay) { + pointer-events: auto !important; +} +body.driver-active .driver-overlay { + pointer-events: none !important; +} + +/* Suppress ng-zorro tooltips/popovers while a tour step is active. Their + CDK-overlay positioning re-measures whenever driver.js mutates the active + element's inline styles, producing visible tooltip jitter on every step. + The driver.js popover already fills the "tooltip" role during the tour. */ +body.driver-active .ant-tooltip, +body.driver-active .ant-popover { + display: none !important; +} + +/* driver.js's default CSS forces `overflow: hidden !important` on the active + element's direct parent (so the user can't scroll past the spotlight). + That breaks two things in the property editor: + 1. The form's nz-select dropdowns get clipped / unclickable. + 2. The ancestor scroll container (#content) can't scroll because the + forced-hidden node in between intercepts wheel events. + We restore the parent's overflow to visible — its original default — so + the form behaves normally and scroll events bubble up to #content. */ +body.driver-active :not(body):has(> .driver-active-element) { + overflow: visible !important; +} + +/* ng-zorro renders nz-select / nz-autocomplete dropdowns via CDK Overlay + portaled to a -level container at default z-index 1000. driver.js's + dim overlay sits at z-index ~100000+, which means open dropdowns get + visually buried under the overlay — they "open" but the user can't see + them. Lift the CDK overlay container above driver.js's popover so + dropdowns inside the spotlighted form remain visible and clickable. */ +body.driver-active .cdk-overlay-container { + z-index: 1000000001 !important; +} + +/* Freeze hover / focus / wave reactions on small clickable spotlights + (icons, menu items, buttons) so cursor wobble at the stage cutout edge + doesn't flicker them. Scoped to li / button / nz-icon hosts only — the + form-area spotlights (.property-editor-form, .driver-active-element with + nested ng-zorro components) MUST keep their transitions, otherwise + nz-select / formly-form get stuck in mid-transition state and the form + becomes unresponsive after the first selection. */ +body.driver-active li.driver-active-element, +body.driver-active button.driver-active-element, +body.driver-active .driver-active-element[nz-icon], +body.driver-active li.driver-active-element *, +body.driver-active button.driver-active-element * { + transition: none !important; + animation: none !important; +} +body.driver-active li.driver-active-element .ant-wave, +body.driver-active button.driver-active-element .ant-wave, +body.driver-active li.driver-active-element [ant-click-animating-without-extra-node], +body.driver-active button.driver-active-element [ant-click-animating-without-extra-node] { + display: none !important; +} diff --git a/frontend/src/app/common/service/gui-config.service.mock.ts b/frontend/src/app/common/service/gui-config.service.mock.ts index 179259c5a97..608766dd879 100644 --- a/frontend/src/app/common/service/gui-config.service.mock.ts +++ b/frontend/src/app/common/service/gui-config.service.mock.ts @@ -51,6 +51,7 @@ export class MockGuiConfigService { expirationTimeInMinutes: 2880, activeTimeInMinutes: 15, copilotEnabled: false, + tutorialEnabled: false, limitColumns: 15, }; diff --git a/frontend/src/app/common/type/gui-config.ts b/frontend/src/app/common/type/gui-config.ts index 3b7554e8d4c..f81ea781f95 100644 --- a/frontend/src/app/common/type/gui-config.ts +++ b/frontend/src/app/common/type/gui-config.ts @@ -42,6 +42,7 @@ export interface GuiConfig { expirationTimeInMinutes: number; activeTimeInMinutes: number; copilotEnabled: boolean; + tutorialEnabled: boolean; limitColumns: number; } diff --git a/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html b/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html index 9fbc1ddffdc..0a8a2c57f7a 100644 --- a/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html +++ b/frontend/src/app/workspace/component/dataset-file-selector/dataset-file-selector.component.html @@ -26,6 +26,7 @@ *ngIf="isFileSelectionEnabled" nz-button nzSize="small" + data-tutorial="file-select-button" (click)="isFileSelectionEnabled && onClickOpenFileSelectionModal()"> Select File diff --git a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html index ff8bb97a296..60e1500ffec 100644 --- a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html +++ b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.html @@ -54,13 +54,15 @@ *ngFor="let groupname of groupNames" [nzHeader]="groupname.groupName" class="operator-group" - [attr.data-depth]="depth"> + [attr.data-depth]="depth" + [attr.data-group-name]="groupname.groupName">
+ class="operator-label" + [attr.data-operator-type]="operatorSchema.operatorType">
diff --git a/frontend/src/app/workspace/component/menu/menu.component.html b/frontend/src/app/workspace/component/menu/menu.component.html index a21e4d56429..008a6d2ec13 100644 --- a/frontend/src/app/workspace/component/menu/menu.component.html +++ b/frontend/src/app/workspace/component/menu/menu.component.html @@ -326,7 +326,8 @@ + + + + diff --git a/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.scss b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.scss new file mode 100644 index 00000000000..aa4a5c7bf7e --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.scss @@ -0,0 +1,135 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.badge-unlocked-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(2px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10002; + animation: badge-fade-in 0.25s ease; +} + +.badge-unlocked-card { + background: linear-gradient(160deg, #ffffff 0%, #fff7d6 100%); + border: 2px solid #faad14; + border-radius: 16px; + padding: 24px 32px 22px; + width: min(420px, 90vw); + text-align: center; + box-shadow: 0 24px 60px rgba(250, 173, 20, 0.4); + animation: badge-pop-in 0.55s cubic-bezier(0.18, 1.25, 0.4, 1); +} + +.badge-argus { + display: inline-block; +} + +.badge-unlocked-label { + margin-top: 6px; + font-size: 12px; + letter-spacing: 2px; + text-transform: uppercase; + color: #faad14; + font-weight: 700; +} + +.badge-icon { + margin: 14px auto 10px; + width: 96px; + height: 96px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: + inset 0 -6px 14px rgba(0, 0, 0, 0.12), + 0 8px 24px rgba(0, 0, 0, 0.12); + animation: badge-spin-in 0.8s cubic-bezier(0.18, 1.25, 0.4, 1); +} + +.badge-emoji { + font-size: 56px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); +} + +.badge-name { + font-size: 22px; + font-weight: 700; + color: #1f1f1f; + margin-bottom: 6px; +} + +.badge-desc { + font-size: 14px; + color: #555; + line-height: 1.5; +} + +.badge-flow-context { + margin-top: 10px; + font-size: 12px; + color: #888; +} + +.badge-actions { + margin-top: 18px; + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; +} + +@keyframes badge-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes badge-pop-in { + 0% { + opacity: 0; + transform: scale(0.7) translateY(20px); + } + 60% { + opacity: 1; + transform: scale(1.04) translateY(-4px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes badge-spin-in { + 0% { + transform: rotate(-180deg) scale(0.3); + opacity: 0; + } + 100% { + transform: rotate(0deg) scale(1); + opacity: 1; + } +} diff --git a/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.ts b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.ts new file mode 100644 index 00000000000..0582bab53ac --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/badge-unlocked/badge-unlocked.component.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { NgIf } from "@angular/common"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { ArgusComponent } from "../argus/argus.component"; +import type { BadgeDef } from "../../../service/tutorial/flows"; + +@Component({ + selector: "texera-badge-unlocked", + templateUrl: "badge-unlocked.component.html", + styleUrls: ["badge-unlocked.component.scss"], + imports: [NgIf, NzButtonComponent, NzWaveDirective, ɵNzTransitionPatchDirective, ArgusComponent], +}) +export class BadgeUnlockedComponent { + @Input() badge: BadgeDef | null = null; + @Input() flowName: string = ""; + @Output() dismiss = new EventEmitter(); + @Output() viewShelf = new EventEmitter(); + + close(): void { + this.dismiss.emit(); + } + + openShelf(): void { + this.viewShelf.emit(); + } +} diff --git a/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.html b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.html new file mode 100644 index 00000000000..990a2bac69b --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.html @@ -0,0 +1,94 @@ + + +
+ + diff --git a/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.scss b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.scss new file mode 100644 index 00000000000..d33a7b44e38 --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.scss @@ -0,0 +1,267 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.trophy-backdrop { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.4); + z-index: 10002; + animation: trophy-fade-in 0.2s ease; +} + +.trophy-drawer { + position: fixed; + top: 0; + right: 0; + height: 100vh; + width: 380px; + max-width: 92vw; + background: linear-gradient(180deg, #ffffff 0%, #fff9ec 100%); + border-left: 2px solid #faad14; + box-shadow: -16px 0 40px rgba(0, 0, 0, 0.18); + z-index: 10003; + transform: translateX(100%); + transition: transform 0.35s cubic-bezier(0.2, 0.8, 0.3, 1); + display: flex; + flex-direction: column; + + &.open { + transform: translateX(0); + } +} + +.trophy-header { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid rgba(250, 173, 20, 0.3); +} + +.trophy-title-block { + flex: 1; +} + +.trophy-title { + margin: 0; + font-size: 18px; + color: #1f1f1f; +} + +.trophy-subtitle { + font-size: 12px; + color: #888; +} + +.trophy-close { + font-size: 16px; +} + +.trophy-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; + padding: 16px 20px; + overflow-y: auto; +} + +.trophy-tile { + perspective: 800px; + cursor: pointer; + min-height: 150px; + + &.locked .tile-front { + filter: grayscale(60%); + opacity: 0.7; + } +} + +.tile-inner { + position: relative; + width: 100%; + height: 100%; + min-height: 150px; + transform-style: preserve-3d; + transition: transform 0.55s cubic-bezier(0.4, 0.2, 0.2, 1); + + .trophy-tile.is-flipped & { + transform: rotateY(180deg); + } +} + +.tile-face { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 14px 8px; + border-radius: 12px; + background: #fff; + border: 1.5px solid rgba(0, 0, 0, 0.06); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transition: box-shadow 0.2s ease; + + .trophy-tile.earned & { + border-color: #faad14; + box-shadow: 0 6px 16px rgba(250, 173, 20, 0.18); + } + + .trophy-tile.earned:hover & { + box-shadow: 0 10px 22px rgba(250, 173, 20, 0.3); + } +} + +.tile-back { + transform: rotateY(180deg); + background: linear-gradient(160deg, #fff9ec 0%, #fff 100%); + justify-content: flex-start; + gap: 6px; + padding: 16px 12px; +} + +.tile-icon { + margin: 0 auto 8px; + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.tile-emoji { + font-size: 30px; +} + +.tile-name { + font-size: 13px; + font-weight: 600; + color: #1f1f1f; + margin-bottom: 2px; +} + +.tile-back-name { + font-size: 13px; + font-weight: 700; + color: #1f1f1f; + margin-bottom: 4px; +} + +.tile-back-desc { + font-size: 11px; + color: #555; + line-height: 1.45; + flex: 1; + overflow-y: auto; +} + +.tile-meta { + margin-top: 6px; + font-size: 10px; + color: #aaa; +} + +.tile-flip-hint { + margin-top: auto; + font-size: 9px; + letter-spacing: 0.5px; + text-transform: uppercase; + color: #c0c0c0; + user-select: none; +} + +.trophy-flow-list { + border-top: 1px solid rgba(250, 173, 20, 0.3); + padding: 14px 20px 20px; + + h4 { + margin: 0 0 10px; + font-size: 14px; + color: #1f1f1f; + } +} + +.trophy-flow-btn { + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + margin-bottom: 8px; + padding: 10px 12px; + border-radius: 10px; + background: #fff; + border: 1.5px solid rgba(24, 144, 255, 0.18); + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + + &:hover:not(:disabled) { + border-color: #1890ff; + box-shadow: 0 4px 12px rgba(24, 144, 255, 0.22); + transform: translateY(-1px); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } +} + +.flow-name { + font-size: 14px; + font-weight: 600; + color: #1f1f1f; +} + +.flow-meta { + font-size: 11px; + color: #888; + margin-top: 2px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.flow-stub { + background: rgba(250, 173, 20, 0.15); + color: #d4a017; + font-weight: 600; + padding: 1px 6px; + border-radius: 6px; + font-size: 10px; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.coming-soon { + background: repeating-linear-gradient(45deg, #fff, #fff 6px, #fff9ec 6px, #fff9ec 12px); +} + +@keyframes trophy-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.ts b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.ts new file mode 100644 index 00000000000..d9842975eff --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/trophy-shelf/trophy-shelf.component.ts @@ -0,0 +1,111 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, EventEmitter, Input, Output, OnChanges } from "@angular/core"; +import { DatePipe, NgClass, NgFor, NgIf } from "@angular/common"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { ArgusComponent } from "../argus/argus.component"; +import { BONUS_BADGES, BadgeDef, FLOWS, TutorialFlow } from "../../../service/tutorial/flows"; +import type { TutorialProgress } from "../../../service/tutorial/tutorial-progress"; + +interface BadgeRow { + badge: BadgeDef; + earned: boolean; + earnedAt?: number; +} + +@Component({ + selector: "texera-trophy-shelf", + templateUrl: "trophy-shelf.component.html", + styleUrls: ["trophy-shelf.component.scss"], + imports: [ + NgIf, + NgFor, + NgClass, + DatePipe, + NzButtonComponent, + NzWaveDirective, + ɵNzTransitionPatchDirective, + ArgusComponent, + ], +}) +export class TrophyShelfComponent implements OnChanges { + @Input() open = false; + @Input() progress: TutorialProgress | null = null; + @Input() flows: TutorialFlow[] = FLOWS; + @Output() closed = new EventEmitter(); + @Output() startFlow = new EventEmitter(); + + rows: BadgeRow[] = []; + /** Badge ids whose tile is currently flipped to show the back. */ + flipped = new Set(); + + ngOnChanges(): void { + this.rows = this.buildRows(); + } + + toggleFlip(badgeId: string): void { + if (this.flipped.has(badgeId)) { + this.flipped.delete(badgeId); + } else { + this.flipped.add(badgeId); + } + } + + get earnedCount(): number { + return this.rows.filter(r => r.earned).length; + } + + get totalCount(): number { + return this.rows.length; + } + + get xp(): number { + return this.progress?.xp ?? 0; + } + + onClose(): void { + this.closed.emit(); + } + + onStartFlow(flowId: string): void { + this.startFlow.emit(flowId); + } + + private buildRows(): BadgeRow[] { + const earned = new Set(this.progress?.earnedBadges ?? []); + const completed = this.progress?.completed ?? {}; + const rows: BadgeRow[] = []; + + for (const flow of this.flows) { + const completion = completed[flow.id]; + rows.push({ + badge: flow.badge, + earned: earned.has(flow.badge.id), + earnedAt: completion?.completedAt, + }); + } + for (const bonus of BONUS_BADGES) { + rows.push({ badge: bonus, earned: earned.has(bonus.id) }); + } + return rows; + } +} diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html new file mode 100644 index 00000000000..7c6cf3df19a --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.html @@ -0,0 +1,113 @@ + + + + +
+ I'm your tutor — ask me anything! +
+ + + + +
+
+ + Argus — your Texera tutor + +
+ +
+
+
+ 🦚 +
{{ msg.content }}
+
+
+ +
+
+ 🦚 +
+
+
+
+ +
+ + +
+
+
diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss new file mode 100644 index 00000000000..ea675bf90a1 --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.scss @@ -0,0 +1,353 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Floating Argus = the clickable agent avatar. +// +// Position: bottom-right (per user preference). Compact 48px disc so it +// doesn't loom over Texera's minimap or the property editor. +// +// Z-index: driver.js's spotlight overlay sits at z-index ~1_000_000_000, so +// the button has to stack above it. 1_000_000_010 keeps Argus clickable on +// top of any tour state. +// One-time "ask me anything" bubble next to Argus. Same z-index as the +// button so it sits above driver.js's tour overlay. Arrow on the right +// points at the button; clicking the bubble itself also opens the chat. +.argus-nudge { + position: fixed; + bottom: 113px; + right: 84px; + max-width: 220px; + padding: 8px 12px; + background: linear-gradient(135deg, #ffffff 0%, #f0fdfa 100%); + color: #0d6f8c; + font-size: 12px; + font-weight: 600; + line-height: 1.35; + border: 1.5px solid #1aa3c4; + border-radius: 12px; + box-shadow: 0 4px 14px rgba(20, 130, 150, 0.25); + cursor: pointer; + z-index: 1000000010; + animation: argus-nudge-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) 0.6s both; + transform-origin: right center; + + &::after { + content: ""; + position: absolute; + right: -9px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 9px solid #1aa3c4; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + } + + &::before { + content: ""; + position: absolute; + right: -6px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 7px solid #f0fdfa; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + z-index: 1; + } + + &:hover { + background: linear-gradient(135deg, #ffffff 0%, #e0f9f5 100%); + } +} + +@keyframes argus-nudge-pop { + 0% { + opacity: 0; + transform: translateX(8px) scale(0.85); + } + 100% { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +.argus-toggle-btn { + position: fixed; + bottom: 100px; + right: 24px; + z-index: 1000000010; + display: inline-flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + padding: 0; + border-radius: 50%; + background: linear-gradient(135deg, #ffffff 0%, #f0fdfa 100%); + border: 2px solid #1aa3c4; + box-shadow: 0 6px 18px rgba(20, 130, 150, 0.35); + cursor: pointer; + pointer-events: auto; + transition: + transform 0.18s ease, + box-shadow 0.18s ease; + animation: argus-toggle-pulse 2.4s ease-in-out infinite; + + /* Argus inside the button is decorative. Let clicks fall through to the + button so the entire disc is one big click target. */ + texera-argus { + pointer-events: none; + } + + &:hover { + transform: translateY(-1px) scale(1.04); + box-shadow: 0 10px 24px rgba(20, 130, 150, 0.5); + } + + &:active { + transform: translateY(0) scale(0.97); + } +} + +// Label removed in the new compact disc layout. Keep the rule so it doesn't +// regress if added back inadvertently. +.argus-toggle-label { + display: none; +} + +@keyframes argus-toggle-pulse { + 0%, + 100% { + box-shadow: 0 8px 24px rgba(20, 130, 150, 0.35); + } + 50% { + box-shadow: 0 8px 32px rgba(26, 163, 196, 0.6); + } +} + +.argus-toggle-label { + font-size: 13px; + font-weight: 700; + color: #0d6f8c; + letter-spacing: 0.3px; + padding-right: 4px; + white-space: nowrap; +} + +.chat-header-argus { + flex-shrink: 0; + /* The header has a deep gradient background; Argus's own drop-shadow looks + muddy on it, so suppress the filter and let the SVG sit cleanly. */ + filter: none !important; +} + +// Slide-in chat panel — anchored above the bottom-right Argus button so it +// expands upward. +.chat-panel { + position: fixed; + bottom: 160px; + right: 24px; + z-index: 1000000009; // just below the Argus button so the button stays clickable on top + width: 340px; + height: 460px; + background: #fff; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid #e8e8e8; + + // Slide up from the Argus button at bottom-right + transform: translateY(12px) scale(0.95); + opacity: 0; + pointer-events: none; + transition: + transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), + opacity 0.2s ease; + transform-origin: bottom right; + + &.open { + transform: translateY(0) scale(1); + opacity: 1; + pointer-events: all; + } +} + +// Header — peacock palette so Argus visually belongs. +.chat-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + background: linear-gradient(135deg, #0d6f8c 0%, #1aa3c4 60%, #1ab37e 100%); + color: #fff; + flex-shrink: 0; +} + +.chat-header-title { + font-weight: 600; + font-size: 14px; + flex: 1; +} + +.chat-header-subtitle { + font-size: 11px; + opacity: 0.8; + display: none; +} + +.chat-close-btn { + color: rgba(255, 255, 255, 0.8) !important; + margin-left: auto; + + &:hover { + color: #fff !important; + } +} + +// Message list +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + background: #f9fafb; +} + +.chat-loading { + display: flex; + justify-content: center; + padding: 20px; +} + +.chat-message { + display: flex; + max-width: 90%; + + &.user-message { + align-self: flex-end; + + .message-bubble { + background: #1890ff; + color: #fff; + border-radius: 16px 16px 4px 16px; + } + } + + &.agent-message { + align-self: flex-start; + + .message-bubble { + background: #fff; + color: #262626; + border: 1px solid #e8e8e8; + border-radius: 16px 16px 16px 4px; + } + } +} + +.message-bubble { + padding: 8px 12px; + display: flex; + align-items: flex-start; + gap: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.message-icon { + font-size: 14px; + flex-shrink: 0; + margin-top: 1px; +} + +.message-content { + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +// Typing indicator animation +.typing-indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; + + span { + width: 6px; + height: 6px; + background: #bfbfbf; + border-radius: 50%; + animation: bounce 1.2s infinite; + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + } +} + +@keyframes bounce { + 0%, + 60%, + 100% { + transform: translateY(0); + } + 30% { + transform: translateY(-5px); + } +} + +// Input area +.chat-input-area { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid #f0f0f0; + background: #fff; + flex-shrink: 0; +} + +.chat-input { + flex: 1; + border-radius: 20px; + font-size: 13px; +} + +.chat-send-btn { + border-radius: 50%; + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts new file mode 100644 index 00000000000..da62dc003e9 --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/tutorial-chat/tutorial-chat.component.ts @@ -0,0 +1,354 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { AfterViewChecked, Component, ElementRef, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { NgClass, NgFor, NgIf } from "@angular/common"; +import { FormsModule } from "@angular/forms"; +import { firstValueFrom, Subject, Subscription } from "rxjs"; +import { takeUntil } from "rxjs/operators"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzInputDirective } from "ng-zorro-antd/input"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { TutorialService } from "../../../service/tutorial/tutorial.service"; +import { ArgusComponent, ArgusState } from "../argus/argus.component"; +import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service"; +import { WorkflowUtilService } from "../../../service/workflow-graph/util/workflow-util.service"; +import { Point } from "../../../types/workflow-common.interface"; +import { AgentService } from "../../../service/agent/agent.service"; + +interface ChatMessage { + role: "user" | "assistant" | "system"; + content: string; + timestamp?: Date; +} + +const SYSTEM_PROMPT = `You are Argus, a friendly AI tutor for Texera, a visual workflow editor for big-data analysis. +Workflows are built by dragging operators (CSVScan, Filter, KeywordSearch, ...) onto a canvas and connecting them via edges from output ports to input ports. + +Style rules (important): +- Keep answers SHORT: 2-3 sentences max. +- Plain, clear English. Skip emojis entirely unless the user uses one first. +- Do NOT use em dashes (—) or en dashes (–). Use a period or comma instead. +- No exclamation marks unless genuinely celebrating completion. +- If the user is stuck on a step, give a direct hint without being condescending. +- If asked about an operator, briefly explain what it does in one or two sentences. + +Tool calls (use sparingly): +You can perform an action on the user's canvas by appending ONE directive on the very last line of your reply, then stopping. The line MUST match this exact format: + [[ACTION:add_operator {"type":""}]] +- Only emit an action when the user explicitly asks you to do something on the canvas (e.g. "add a Filter for me", "drop a Bar Chart in"). +- For general questions, EXPLANATIONS, or "how do I..." prompts, DO NOT emit an action. Just answer with text. +- Examples of valid OperatorType: CSVFileScan, Filter, Limit, KeywordSearch, Sort, Unnest, Projection, BarChart, DotPlot, LineChart, PieChart, WordCloud, Histogram, ScatterPlot, SklearnLogisticRegression. +- Briefly mention what you did in the text BEFORE the directive ("Adding a Filter on the canvas now."). Do not write anything AFTER the directive.`; + +const ACTION_REGEX = /\[\[ACTION:(\w+)\s+(\{[\s\S]*?\})\]\]/; +/** + * Argus picks a canvas position for new operators by stacking them to the + * right of whatever it last placed. The seed point is chosen to land in the + * middle of the default-zoom viewport, with vertical wiggle so successive + * adds don't all overlap on the same row when the canvas is otherwise empty. + */ +const ARGUS_PLACEMENT_SEED: Point = { x: 600, y: 260 }; +const ARGUS_PLACEMENT_STEP_X = 180; + +@Component({ + selector: "texera-tutorial-chat", + templateUrl: "tutorial-chat.component.html", + styleUrls: ["tutorial-chat.component.scss"], + imports: [ + NgIf, + NgFor, + NgClass, + FormsModule, + NzButtonComponent, + NzWaveDirective, + ɵNzTransitionPatchDirective, + NzIconDirective, + NzInputDirective, + NzTooltipDirective, + ArgusComponent, + ], +}) +export class TutorialChatComponent implements OnInit, OnDestroy, AfterViewChecked { + @ViewChild("messageContainer") private messageContainer?: ElementRef; + + private readonly destroy$ = new Subject(); + + public isOpen = false; + public isTutorialActive = false; + /** Latches true the first time the user opens the chat — used to hide + * the one-time "ask me anything" nudge bubble. */ + public hasOpenedChatOnce = false; + public visibleMessages: ChatMessage[] = []; // shown to user (no system msg) + public inputText = ""; + public isSending = false; + public initialized = false; + /** Argus state mirrors what the agent is doing — `thinking` while a chat + * request is in flight, `wave` while idle (inviting the user to click). */ + public argusState: ArgusState = "wave"; + private shouldScrollToBottom = false; + + private agentId: string | null = null; + private agentInitPromise: Promise | null = null; + private agentStepsSub: Subscription | null = null; + private processedMessageIds = new Set(); + + constructor( + private tutorialService: TutorialService, + private workflowActionService: WorkflowActionService, + private workflowUtilService: WorkflowUtilService, + private agentService: AgentService + ) {} + + ngOnInit(): void { + this.tutorialService.isActive$.pipe(takeUntil(this.destroy$)).subscribe(active => { + this.isTutorialActive = active; + if (!active) this.isOpen = false; + }); + } + + ngAfterViewChecked(): void { + if (this.shouldScrollToBottom) { + this.scrollToBottom(); + this.shouldScrollToBottom = false; + } + } + + togglePanel(): void { + this.isOpen = !this.isOpen; + if (this.isOpen) this.hasOpenedChatOnce = true; + this.argusState = this.isOpen ? "idle" : "wave"; + if (this.isOpen && !this.initialized) { + this.visibleMessages.push({ + role: "assistant", + content: + "👋 Hey! I'm Argus — your Texera tutor. Stuck on a step? Curious what an operator does? Type a question below — I'll keep it short and useful.", + timestamp: new Date(), + }); + this.shouldScrollToBottom = true; + this.initialized = true; + } + } + + closePanel(): void { + this.isOpen = false; + this.argusState = "wave"; + } + + async sendMessage(): Promise { + const text = this.inputText.trim(); + if (!text || this.isSending) return; + + this.visibleMessages.push({ role: "user", content: text, timestamp: new Date() }); + this.shouldScrollToBottom = true; + this.inputText = ""; + this.isSending = true; + this.argusState = "thinking"; + + const id = await this.ensureAgent(); + if (!id) { + this.isSending = false; + this.argusState = "idle"; + return; + } + + const contextLines = this.buildWorkflowContext(); + const fullPrompt = [ + SYSTEM_PROMPT, + "", + ...contextLines, + "", + `User: ${text}`, + "", + "Reply now as Argus, following the style rules.", + ].join("\n"); + + this.agentService.sendMessage(id, fullPrompt, "chat"); + } + + /** + * Lazily create + activate a dedicated agent-service agent on first use. + * Prefers claude-haiku-4.5 (per bin/litellm-config.yaml), falls back to + * the first model the gateway exposes. + */ + private ensureAgent(): Promise { + if (this.agentId) return Promise.resolve(this.agentId); + if (this.agentInitPromise) return this.agentInitPromise; + + this.agentInitPromise = (async () => { + try { + const models = await firstValueFrom(this.agentService.fetchModelTypes()); + if (!models.length) { + this.pushSystem("⚠️ No models available. Is LiteLLM + agent-service running?"); + return null; + } + const preferred = models.find(m => /haiku/i.test(m.id)) ?? models[0]; + const workflowId = this.workflowActionService.getWorkflowMetadata()?.wid; + const info = await firstValueFrom(this.agentService.createAgent(preferred.id, "Argus (tutorial)", workflowId)); + this.agentId = info.id; + this.agentService.activateAgent(info.id); + this.agentStepsSub = this.agentService + .getReActStepsObservable(info.id) + .pipe(takeUntil(this.destroy$)) + .subscribe(steps => this.handleAgentSteps(steps)); + // Give the WebSocket time to finish CONNECTING → init handshake. + await new Promise(resolve => setTimeout(resolve, 800)); + return info.id; + } catch (err: any) { + this.pushSystem(`⚠️ Couldn't start Argus: ${err?.message ?? "agent-service unreachable"}`); + return null; + } + })(); + return this.agentInitPromise; + } + + private handleAgentSteps(steps: any[]): void { + if (!steps?.length) return; + for (const step of steps) { + if (step.role !== "agent" || !step.isEnd) continue; + if (this.processedMessageIds.has(step.messageId)) continue; + this.processedMessageIds.add(step.messageId); + + const parsed = this.parseReply(step.content ?? ""); + this.visibleMessages.push({ role: "assistant", content: parsed.text, timestamp: new Date() }); + this.shouldScrollToBottom = true; + if (parsed.action) { + const note = this.executeAction(parsed.action.name, parsed.action.params); + if (note) this.pushSystem(note); + } + this.isSending = false; + this.argusState = "cheer"; + setTimeout(() => (this.argusState = "idle"), 800); + } + } + + onKeyEnter(event: KeyboardEvent): void { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + this.sendMessage(); + } + } + + private pushSystem(content: string): void { + this.visibleMessages.push({ role: "system", content, timestamp: new Date() }); + this.shouldScrollToBottom = true; + } + + /** + * Gather the tutorial step + canvas state into a few context lines the LLM + * can read at the top of the prompt. Keeps the model grounded in what's + * actually on the canvas instead of guessing. + */ + private buildWorkflowContext(): string[] { + const step = this.tutorialService.currentStep; + const graph = this.workflowActionService.getTexeraGraph(); + const allOps = graph.getAllOperators(); + const highlighted = this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs(); + const selectedOp = highlighted.length === 1 ? graph.getOperator(highlighted[0]) : null; + + const stepLine = step + ? `[Tutorial step] ${step.title} — ${step.aiHint}` + : "[Tutorial step] (none — the user is exploring on their own)"; + const canvasLine = allOps.length + ? `[Canvas operators] ${allOps.map(o => `${o.operatorType}#${o.operatorID.slice(-6)}`).join(", ")}` + : "[Canvas operators] (empty)"; + const selectedLine = selectedOp + ? `[Selected operator] ${selectedOp.operatorType} | properties: ${JSON.stringify(selectedOp.operatorProperties)}` + : "[Selected operator] (none)"; + return [stepLine, canvasLine, selectedLine]; + } + + /** + * Pull an optional action directive off the tail of the LLM reply. The + * directive format is `[[ACTION:name {json}]]`; the function returns the + * stripped user-facing text plus the parsed action (if any). + */ + private parseReply(reply: string): { text: string; action?: { name: string; params: any } } { + const match = reply.match(ACTION_REGEX); + if (!match) return { text: reply }; + try { + const params = JSON.parse(match[2]); + return { + text: reply.replace(ACTION_REGEX, "").trim(), + action: { name: match[1], params }, + }; + } catch { + // Malformed JSON — drop the directive but keep the text so the user + // still sees Argus's explanation. + return { text: reply.replace(ACTION_REGEX, "").trim() }; + } + } + + /** + * Run an Argus-emitted action against the workflow. Returns a short + * confirmation / error string to surface in the chat as a system message. + */ + private executeAction(name: string, params: any): string | null { + if (name === "add_operator") { + return this.executeAddOperator(params?.type); + } + return `(Unknown action: ${name})`; + } + + private executeAddOperator(operatorType: string | undefined): string | null { + if (!operatorType || typeof operatorType !== "string") { + return "(Argus tried to add an operator but didn't say which type.)"; + } + try { + const predicate = this.workflowUtilService.getNewOperatorPredicate(operatorType); + const point = this.pickAddPosition(); + this.workflowActionService.addOperator(predicate, point); + return `Added ${operatorType} on the canvas.`; + } catch (err: any) { + return `Couldn't add ${operatorType}: ${err?.message ?? "operator type not found"}`; + } + } + + /** + * Pick a position for a newly-added operator: start at the seed point, + * shift right by one step for each operator already on the canvas so they + * don't stack on top of each other. + */ + private pickAddPosition(): Point { + const count = this.workflowActionService.getTexeraGraph().getAllOperators().length; + return { + x: ARGUS_PLACEMENT_SEED.x + count * ARGUS_PLACEMENT_STEP_X, + y: ARGUS_PLACEMENT_SEED.y, + }; + } + + private scrollToBottom(): void { + if (this.messageContainer) { + const el = this.messageContainer.nativeElement; + el.scrollTop = el.scrollHeight; + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.agentStepsSub?.unsubscribe(); + if (this.agentId) { + this.agentService.deactivateAgent(this.agentId); + } + } +} diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html new file mode 100644 index 00000000000..ee6183d2860 --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.html @@ -0,0 +1,73 @@ + + + +
+ 🎉 +
+ Awesome — you did it! + You just built your first Texera workflow. Click any operator to peek at its data. +
+
+ + +
+ + +
+ + + diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss new file mode 100644 index 00000000000..41a1d1ef751 --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.scss @@ -0,0 +1,149 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Completion toast — pops in after the user finishes a flow, then drifts away +.tutorial-completion { + position: fixed; + bottom: 110px; + left: 50%; + transform: translateX(-50%); + z-index: 10001; + background: linear-gradient(135deg, #fff 0%, #f0f5ff 100%); + border: 2px solid #1890ff; + border-radius: 14px; + padding: 14px 22px; + display: flex; + align-items: center; + gap: 14px; + box-shadow: 0 8px 28px rgba(24, 144, 255, 0.28); + font-size: 15px; + max-width: 460px; + animation: tutorial-pop-in 0.5s cubic-bezier(0.18, 1.25, 0.4, 1); +} + +.tutorial-completion-emoji { + font-size: 34px; + line-height: 1; + animation: tutorial-bounce 1.6s ease-in-out infinite; +} + +.tutorial-completion-text { + display: flex; + flex-direction: column; + gap: 2px; + color: #1f1f1f; + + strong { + font-size: 15px; + color: #1890ff; + } +} + +.tutorial-completion-sub { + font-size: 13px; + color: #555; +} + +@keyframes tutorial-pop-in { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(20px) scale(0.85); + } + 60% { + opacity: 1; + transform: translateX(-50%) translateY(-4px) scale(1.04); + } + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + } +} + +@keyframes tutorial-bounce { + 0%, + 100% { + transform: translateY(0) rotate(-4deg); + } + 50% { + transform: translateY(-6px) rotate(8deg); + } +} + +// Floating launcher cluster (top-right) — pairs the Start button with the +// trophy-shelf shortcut so users can see their badges without re-launching. +.tutorial-launcher-cluster { + position: fixed; + top: 12px; + right: 16px; + z-index: 10000; + display: flex; + gap: 8px; + align-items: center; +} + +.tutorial-trophy-btn { + height: 36px; + width: 36px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: #fff9ec; + border: 1.5px solid #faad14; + color: #faad14; + font-size: 16px; + box-shadow: 0 3px 10px rgba(250, 173, 20, 0.3); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 14px rgba(250, 173, 20, 0.45); + } +} + +.tutorial-start-btn { + height: 36px; + padding: 0 16px; + border-radius: 20px; + font-weight: 600; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; + box-shadow: 0 4px 14px rgba(24, 144, 255, 0.4); + animation: tutorial-btn-pulse 2s ease-in-out infinite; + transition: transform 0.15s ease; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(24, 144, 255, 0.55); + } +} + +@keyframes tutorial-btn-pulse { + 0%, + 100% { + box-shadow: 0 4px 14px rgba(24, 144, 255, 0.4); + } + 50% { + box-shadow: 0 4px 24px rgba(24, 144, 255, 0.7); + } +} diff --git a/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts new file mode 100644 index 00000000000..9073b6020da --- /dev/null +++ b/frontend/src/app/workspace/component/tutorial/tutorial-panel/tutorial-panel.component.ts @@ -0,0 +1,391 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { NgIf } from "@angular/common"; +import { Subject } from "rxjs"; +import { takeUntil } from "rxjs/operators"; +import confetti from "canvas-confetti"; +import { driver, Driver } from "driver.js"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { TutorialService } from "../../../service/tutorial/tutorial.service"; +import { BadgeDef, TutorialFlow, getFlowById } from "../../../service/tutorial/flows"; +import { TutorialProgress } from "../../../service/tutorial/tutorial-progress"; +import { BadgeUnlockedComponent } from "../badge-unlocked/badge-unlocked.component"; +import { TrophyShelfComponent } from "../trophy-shelf/trophy-shelf.component"; + +@Component({ + selector: "texera-tutorial-panel", + templateUrl: "tutorial-panel.component.html", + styleUrls: ["tutorial-panel.component.scss"], + imports: [ + NgIf, + NzButtonComponent, + NzWaveDirective, + ɵNzTransitionPatchDirective, + NzIconDirective, + NzTooltipDirective, + BadgeUnlockedComponent, + TrophyShelfComponent, + ], +}) +export class TutorialPanelComponent implements OnInit, OnDestroy { + private readonly destroy$ = new Subject(); + private welcomeDriver: Driver | null = null; + private resumeDriver: Driver | null = null; + private chainDriver: Driver | null = null; + + public isActive = false; + public showCompletion = false; + public progress: TutorialProgress | null = null; + public trophyShelfOpen = false; + public pendingBadge: BadgeDef | null = null; + public pendingBadgeFlow = ""; + + constructor(public tutorialService: TutorialService) {} + + ngOnInit(): void { + this.tutorialService.isActive$.pipe(takeUntil(this.destroy$)).subscribe(active => { + this.isActive = active; + }); + + this.tutorialService.progress$.pipe(takeUntil(this.destroy$)).subscribe(p => { + this.progress = p; + }); + + this.tutorialService.completed$.pipe(takeUntil(this.destroy$)).subscribe(({ flowId }) => { + this.fireConfetti(); + + const finished = getFlowById(flowId); + const nextFlow = finished?.chainTo ? getFlowById(finished.chainTo) : null; + + if (finished && nextFlow) { + // Chain-able flow: merge the "you did it" celebration into the + // chain-confirmation popover. No separate "Awesome!" toast. + setTimeout(() => this.showChainConfirmPopover(finished, nextFlow), 400); + } else { + // Terminal flow: show the standard celebration toast. + this.showCompletion = true; + setTimeout(() => (this.showCompletion = false), 6000); + } + }); + + this.tutorialService.badgeUnlocked$.pipe(takeUntil(this.destroy$)).subscribe(evt => { + // Briefly defer so the badge card lands on top of the celebration toast, + // not under it. + setTimeout(() => { + this.pendingBadge = evt.badge; + this.pendingBadgeFlow = evt.flowName; + }, 700); + }); + + setTimeout(() => this.openInitialPopover(), 800); + } + + /** + * On workspace mount, show one of: + * - flow picker (first-time user, no prior progress) + * - resume prompt (in-flight flow exists) + * - nothing (user already saw welcome and isn't mid-tour) + */ + private openInitialPopover(): void { + const p = this.progress ?? this.tutorialService.progress; + if (p.current && getFlowById(p.current.flowId)?.steps.length) { + this.showResumePopover(); + } else if (!p.seenWelcome) { + this.showFlowPickerPopover(); + } + } + + onLaunchPicker(): void { + this.showFlowPickerPopover(); + } + + onTrophyClick(): void { + this.trophyShelfOpen = true; + } + + onTrophyClosed(): void { + this.trophyShelfOpen = false; + } + + onTrophyStartFlow(flowId: string): void { + this.trophyShelfOpen = false; + this.tutorialService.markWelcomeSeen(); + setTimeout(() => this.tutorialService.start(flowId), 200); + } + + onBadgeDismissed(): void { + this.pendingBadge = null; + } + + onBadgeViewShelf(): void { + this.pendingBadge = null; + this.trophyShelfOpen = true; + } + + // ===== Driver-based popovers ===== + + private showFlowPickerPopover(): void { + this.welcomeDriver = driver({ + showProgress: false, + allowClose: true, + overlayColor: "#000", + overlayOpacity: 0.7, + stagePadding: 0, + popoverClass: "tutorial-welcome-popover tutorial-picker-popover", + showButtons: ["close"], + onCloseClick: () => { + this.tutorialService.markWelcomeSeen(); + this.welcomeDriver?.destroy(); + }, + onDestroyed: () => { + this.welcomeDriver = null; + }, + steps: [ + { + popover: { + title: "👋 Welcome to Texera!", + description: this.buildPickerHtml(), + }, + }, + ], + }); + this.welcomeDriver.drive(); + + setTimeout(() => this.bindPickerHandlers(), 50); + } + + private buildPickerHtml(): string { + const flows = this.tutorialService.flows; + const earnedBadges = new Set(this.progress?.earnedBadges ?? []); + const cards = flows + .map(flow => { + const done = earnedBadges.has(flow.badge.id); + const disabled = flow.comingSoon || flow.steps.length === 0; + const meta = `${flow.estimatedMinutes} min · ${flow.difficulty}`; + const ribbon = done + ? "✓ Done" + : flow.comingSoon + ? "Coming soon" + : ""; + return ` + + `; + }) + .join(""); + + return ` +
+
+

+ Texera lets you build data workflows visually — drag operators (sources, filters, charts, ML models) + onto a canvas, wire them together, and run the pipeline. No code required. +

+

Ready to try? Pick a quick tour to get started.

+
+
${cards}
+ +
+ `; + } + + private bindPickerHandlers(): void { + const popoverRoot = document.querySelector(".tutorial-picker-popover"); + if (!popoverRoot) return; + + popoverRoot.querySelectorAll("button[data-flow-id]").forEach(btn => { + btn.addEventListener("click", () => { + const flowId = btn.dataset["flowId"]; + if (!flowId || btn.hasAttribute("disabled")) return; + this.tutorialService.markWelcomeSeen(); + this.welcomeDriver?.destroy(); + setTimeout(() => this.tutorialService.start(flowId), 250); + }); + }); + + const skipBtn = popoverRoot.querySelector("button[data-action='skip']"); + skipBtn?.addEventListener("click", () => { + this.tutorialService.markWelcomeSeen(); + this.welcomeDriver?.destroy(); + }); + } + + private showResumePopover(): void { + const p = this.progress ?? this.tutorialService.progress; + if (!p.current) return; + const flow = getFlowById(p.current.flowId); + if (!flow) return; + + const stepNum = (p.current.stepIndex ?? 0) + 1; + this.resumeDriver = driver({ + showProgress: false, + allowClose: true, + overlayColor: "#000", + overlayOpacity: 0.65, + stagePadding: 0, + popoverClass: "tutorial-welcome-popover tutorial-resume-popover", + showButtons: ["close"], + onCloseClick: () => this.resumeDriver?.destroy(), + onDestroyed: () => { + this.resumeDriver = null; + }, + steps: [ + { + popover: { + title: "👋 Welcome back!", + description: ` +
+

You're partway through ${flow.name} — step ${stepNum} of ${flow.steps.length}.

+

Pick up where you left off, restart this flow, or browse other tours.

+
+ + + +
+
+ `, + }, + }, + ], + }); + this.resumeDriver.drive(); + + setTimeout(() => this.bindResumeHandlers(flow), 50); + } + + private bindResumeHandlers(flow: TutorialFlow): void { + const root = document.querySelector(".tutorial-resume-popover"); + if (!root) return; + root.querySelector("[data-action='resume']")?.addEventListener("click", () => { + this.resumeDriver?.destroy(); + setTimeout(() => this.tutorialService.resume(), 250); + }); + root.querySelector("[data-action='restart']")?.addEventListener("click", () => { + this.resumeDriver?.destroy(); + setTimeout(() => this.tutorialService.start(flow.id), 250); + }); + root.querySelector("[data-action='picker']")?.addEventListener("click", () => { + this.resumeDriver?.destroy(); + setTimeout(() => this.showFlowPickerPopover(), 250); + }); + } + + /** + * Asks the user whether to chain into the next flow after the current one + * finished. Driven by the completed flow's `chainTo` field — fires after a + * short delay so the celebration burst + badge notification land first. + */ + private showChainConfirmPopover(finished: TutorialFlow, next: TutorialFlow): void { + this.chainDriver = driver({ + showProgress: false, + allowClose: true, + overlayColor: "#000", + overlayOpacity: 0.65, + stagePadding: 0, + popoverClass: "tutorial-welcome-popover tutorial-resume-popover", + showButtons: ["close"], + onCloseClick: () => this.chainDriver?.destroy(), + onDestroyed: () => { + this.chainDriver = null; + }, + steps: [ + { + popover: { + title: `${finished.badge.emoji} ${finished.badge.name} unlocked!`, + description: ` +
+

Nice — you've got the lay of the land. Want to build your first workflow now?

+
+ + +
+
+ `, + }, + }, + ], + }); + this.chainDriver.drive(); + + setTimeout(() => this.bindChainHandlers(next), 50); + } + + private bindChainHandlers(next: TutorialFlow): void { + const root = document.querySelector(".tutorial-resume-popover"); + if (!root) return; + root.querySelector("[data-action='continue']")?.addEventListener("click", () => { + this.chainDriver?.destroy(); + setTimeout(() => this.tutorialService.start(next.id), 250); + }); + root.querySelector("[data-action='later']")?.addEventListener("click", () => { + this.chainDriver?.destroy(); + }); + } + + private fireConfetti(): void { + const duration = 1800; + const end = Date.now() + duration; + const colors = ["#1890ff", "#52c41a", "#faad14", "#eb2f96", "#722ed1"]; + + const frame = () => { + confetti({ + particleCount: 4, + angle: 60, + spread: 60, + startVelocity: 55, + origin: { x: 0, y: 0.85 }, + colors, + }); + confetti({ + particleCount: 4, + angle: 120, + spread: 60, + startVelocity: 55, + origin: { x: 1, y: 0.85 }, + colors, + }); + if (Date.now() < end) requestAnimationFrame(frame); + }; + frame(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.welcomeDriver?.destroy(); + this.resumeDriver?.destroy(); + this.chainDriver?.destroy(); + } +} diff --git a/frontend/src/app/workspace/component/workspace.component.html b/frontend/src/app/workspace/component/workspace.component.html index c54446fb318..143a08c4300 100644 --- a/frontend/src/app/workspace/component/workspace.component.html +++ b/frontend/src/app/workspace/component/workspace.component.html @@ -37,4 +37,8 @@ *ngIf="copilotEnabled" [agentIdToActivate]="agentIdToActivate"> + + + + diff --git a/frontend/src/app/workspace/component/workspace.component.spec.ts b/frontend/src/app/workspace/component/workspace.component.spec.ts index 0eaaf7f5fb4..3bb23292b80 100644 --- a/frontend/src/app/workspace/component/workspace.component.spec.ts +++ b/frontend/src/app/workspace/component/workspace.component.spec.ts @@ -359,4 +359,12 @@ describe("WorkspaceComponent", () => { expect(component.copilotEnabled).toBe(false); }); }); + + describe("tutorialEnabled", () => { + it("passes through to GuiConfigService.env.tutorialEnabled", async () => { + await createFixture(); + // MockGuiConfigService defaults `tutorialEnabled` to false. + expect(component.tutorialEnabled).toBe(false); + }); + }); }); diff --git a/frontend/src/app/workspace/component/workspace.component.ts b/frontend/src/app/workspace/component/workspace.component.ts index 9968c26f647..02952db45e1 100644 --- a/frontend/src/app/workspace/component/workspace.component.ts +++ b/frontend/src/app/workspace/component/workspace.component.ts @@ -61,6 +61,8 @@ import { LeftPanelComponent } from "./left-panel/left-panel.component"; import { AgentPanelComponent } from "./agent/agent-panel/agent-panel.component"; import { PropertyEditorComponent } from "./property-editor/property-editor.component"; import { FormlyRepeatDndComponent } from "../../common/formly/repeat-dnd/repeat-dnd.component"; +import { TutorialPanelComponent } from "./tutorial/tutorial-panel/tutorial-panel.component"; +import { TutorialChatComponent } from "./tutorial/tutorial-chat/tutorial-chat.component"; export const SAVE_DEBOUNCE_TIME_IN_MS = 5000; @@ -84,6 +86,8 @@ export const SAVE_DEBOUNCE_TIME_IN_MS = 5000; AgentPanelComponent, PropertyEditorComponent, FormlyRepeatDndComponent, + TutorialPanelComponent, + TutorialChatComponent, ], }) export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { @@ -328,4 +332,8 @@ export class WorkspaceComponent implements AfterViewInit, OnInit, OnDestroy { public get copilotEnabled(): boolean { return this.config.env.copilotEnabled; } + + public get tutorialEnabled(): boolean { + return this.config.env.tutorialEnabled; + } } diff --git a/frontend/src/app/workspace/service/tutorial/flows/build-complex.ts b/frontend/src/app/workspace/service/tutorial/flows/build-complex.ts new file mode 100644 index 00000000000..ddf5788d41a --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/flows/build-complex.ts @@ -0,0 +1,215 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TutorialFlow, TutorialStep } from "./index"; + +/** + * Premise: the tour loads a prebuilt CSVFileScan -> Filter starter workflow + * (movies-filter.json) into the canvas before the first step runs. This flow + * only teaches the visualization addition — wiring a Dot Plot onto the + * Filter's output. The ML side was split off into `build-ml`. + */ +const STEPS: TutorialStep[] = [ + { + popover: { + title: "Ready for more operators? 🚀", + description: + "Time for a bit more analysis. We prepared a CSV + Filter starter on the canvas — click Next and we'll walk through them, then add a chart on top to actually see the data.", + }, + title: "Step 1 — Add-chart intro", + aiHint: + "Intro for the chart-adding flow. The tour auto-loaded a CSV File Scan + Filter starter workflow on the canvas. The next two steps spotlight each prebuilt operator with a one-line explanation, then the flow guides the user to add a Dot Plot on top.", + }, + { + element: '[model-id^="CSVFileScan-operator-"]', + popover: { + title: "Source: CSV File Scan", + description: + "This one reads rows from a CSV file. We pointed it at a movies dataset, so it's emitting one row per movie.", + side: "bottom", + align: "center", + }, + title: "Step 2 — Meet the source", + aiHint: + "Spotlight on the prebuilt CSV File Scan operator on the canvas, with a one-line explanation that it's reading a movies CSV. No interaction needed — user clicks Next.", + }, + { + element: '[model-id^="Filter-operator-"]', + popover: { + title: "Transform: Filter", + description: + "Right after CSV, this Filter keeps only rows where year > 1960 — so the chart later only shows the modern era.", + side: "bottom", + align: "center", + }, + title: "Step 3 — Meet the filter", + aiHint: + "Spotlight on the prebuilt Filter operator with its single predicate (year > 1960) explained. No interaction needed — user clicks Next.", + }, + { + element: 'nz-collapse-panel[data-group-name="Visualization"]', + popover: { + title: "Open the Visualization category", + description: + 'On the left, click "Visualization". This category holds every chart Texera supports: bar, line, pie, scatter, and more exotic ones.', + side: "right", + align: "start", + }, + title: "Step 4 — Visualization category", + aiHint: "The user is expanding the 'Visualization' group in the operator menu so they can find chart operators.", + advanceOnClick: true, + }, + { + element: 'nz-collapse-panel[data-group-name="Basic"]', + popover: { + title: "Then the Basic sub-group", + description: + 'Visualization is split into sub-groups. Click "Basic" to find the everyday charts (bar, line, pie).', + side: "right", + align: "start", + }, + title: "Step 5 — Basic sub-group", + aiHint: + "The Visualization group has nested sub-groups. The user is opening 'Basic' to reveal Bar/Line/Pie chart operators.", + advanceOnClick: true, + }, + { + element: '.operator-label[data-operator-type="DotPlot"]', + popover: { + title: "Drag Dot Plot onto the canvas", + description: + "Grab Dot Plot and drop it on the canvas, somewhere to the right of your Filter box. We're going to feed Filter's output into it.", + side: "right", + align: "start", + }, + title: "Step 6 — Drag Dot Plot", + aiHint: + "The user drags the Dot Plot visualization operator onto the canvas. It will be connected to Filter's output in the next step.", + autoAdvanceOn: "operatorAdded", + }, + { + element: "texera-workflow-editor", + popover: { + title: "Wire Filter into the chart", + description: + "Drag from the output port on the right edge of Filter over to the input port on the left edge of Dot Plot. Now the chart will receive the filtered rows.", + side: "over", + align: "center", + }, + title: "Step 7 — Connect Filter to Dot Plot", + aiHint: + "The user is dragging an edge from Filter's output to Dot Plot's input so the chart visualizes the filtered rows.", + autoAdvanceOn: "linkAdded", + }, + { + element: '[model-id^="DotPlot-operator-"]', + popover: { + title: "Click the chart to configure it", + description: + "Click the Dot Plot box. The property panel on the right will switch to its settings so you can pick which column to count.", + side: "bottom", + align: "center", + }, + title: "Step 8 — Select Dot Plot", + aiHint: + "The user clicks the Dot Plot operator on the canvas so the property panel switches to its single-field config (Count Attribute).", + autoAdvanceOn: "operatorSelected", + }, + { + element: 'li[data-tutorial="open-property-panel"]', + popover: { + title: "Open the property panel", + description: + "If the panel collapsed, click the form icon at the top-right to reopen it. (Already open? Skipping.)", + side: "left", + align: "center", + }, + title: "Step 9 — Reopen property panel", + aiHint: + "The user reopens the property panel for the Dot Plot operator. Auto-skipped when the panel is already open.", + advanceOnClick: true, + }, + { + element: ".property-editor-form", + popover: { + title: "Pick the column", + description: + "In the highlighted form, set the only required field:
Count Attribute: runtime
This will show one dot per runtime value, sized by how many movies share it. Click Next once set.", + side: "left", + align: "center", + }, + title: "Step 10 — Configure Dot Plot", + aiHint: + "The user fills Dot Plot's single required field: Count Attribute = 'runtime'. The field renders as an nz-select dropdown of column names (AutofillAttributeName), and after selection the operator groups by runtime and counts rows. Spotlight is on .property-editor-form; styles.scss handles overflow / cdk-overlay z-index so the dropdown opens normally.", + autoAdvanceOn: "propertyChanged", + }, + { + element: "#run-button", + popover: { + title: "Run it", + description: "Hit Run. Texera will pipe the rows through Filter into Dot Plot and render it.", + side: "bottom", + align: "start", + }, + title: "Step 11 — Run the workflow", + aiHint: "The user clicks the Run button to execute the workflow with the new Dot Plot.", + advanceOnClick: true, + }, + { + element: 'li[data-tutorial="open-result-panel"]', + popover: { + title: "Open the result panel", + description: + "Click the square icon at the bottom-left to expand the result panel. Then click the Dot Plot on the canvas to see its rendered output.", + side: "top", + align: "start", + }, + title: "Step 12 — Open result panel", + aiHint: "The user clicks the 'Open Result Panel' icon at the bottom-left to expand the data panel.", + advanceOnClick: true, + }, + { + popover: { + title: "Chart unlocked", + description: + "Your data is now visual. Click any operator to inspect its output in the result panel. When you're ready for the next level, try the Add a simple ML model tour next.", + }, + title: "Step 13 — Chart celebration", + aiHint: + "Final step. The user has wired a Dot Plot off Filter and run the workflow. No element highlighted, centered celebratory popover that teases the ML follow-up tour.", + }, +]; + +export const BUILD_COMPLEX_FLOW: TutorialFlow = { + id: "build-complex", + name: "Add a chart", + shortDesc: "Plug a Dot Plot onto a pre-built CSV + Filter workflow and see the data.", + difficulty: "medium", + estimatedMinutes: 2, + prerequisites: ["build-simple"], + prebuiltWorkflow: "movies-filter", + badge: { + id: "chart-maker", + emoji: "📊", + name: "Chart Maker", + description: "Visualized a Texera workflow with your first chart.", + hue: "#13c2c2", + }, + steps: STEPS, +}; diff --git a/frontend/src/app/workspace/service/tutorial/flows/build-simple.ts b/frontend/src/app/workspace/service/tutorial/flows/build-simple.ts new file mode 100644 index 00000000000..dd8cd8cea2f --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/flows/build-simple.ts @@ -0,0 +1,271 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TutorialFlow, TutorialStep } from "./index"; + +const STEPS: TutorialStep[] = [ + { + popover: { + title: "Let's build your first workflow", + description: + "We'll wire a tiny pipeline together: CSV File ScanLimit → Run → see the rows. Should take about 2 minutes. Ready? Click Next.", + }, + title: "Step 1 — Intro", + aiHint: + "Brief intro for build-simple. Assumes the user has seen (or will skip) the workspace overview tour, so this step does NOT dwell on what the operator panel is — it just states the goal (CSV -> Limit -> Run -> view) and starts. No element highlighted, centered popover.", + }, + { + element: 'nz-collapse-panel[data-group-name="Data Input"]', + popover: { + title: "First, let's get some data", + description: + 'Click "Data Input" to open the category. Sources are operators that pull data into your workflow — CSV files, databases, APIs.', + side: "right", + align: "start", + }, + title: "Step 2 — Find a Source", + aiHint: "The user is locating the Data Input category to find a source operator.", + advanceOnClick: true, + }, + { + element: '.operator-label[data-operator-type="CSVFileScan"]', + popover: { + title: "Drag it onto the canvas", + description: + "Grab CSV File Scan and drop it onto the empty canvas on the right. This little box's job: read rows from a CSV file.", + side: "right", + align: "start", + }, + title: "Step 3 — Drag CSV File Scan", + aiHint: "The user is dragging the CSV File Scan source operator from the operator panel onto the canvas.", + autoAdvanceOn: "operatorAdded", + }, + { + element: '[model-id^="CSVFileScan-operator-"]', + popover: { + title: "Nice! Now give it a click", + description: + "There's your CSV File Scan on the canvas. Click it once to select it — that's how we tell Texera \"I want to configure this one\".", + side: "bottom", + align: "center", + }, + title: "Step 4 — Click the Operator", + aiHint: "The user needs to click the just-placed CSV File Scan operator on the canvas to open its property editor.", + autoAdvanceOn: "operatorSelected", + }, + { + element: 'li[data-tutorial="open-property-panel"]', + popover: { + title: "Open its settings", + description: + "See the little form icon at the top-right? Click it to slide open the settings panel on the right side. (Already open? We'll just skip ahead.)", + side: "left", + align: "center", + }, + title: "Step 5 — Open the Property Panel", + aiHint: + "The user needs to click the small form icon at the top-right of the canvas to expand the (currently collapsed) property panel. Auto-skipped when the panel is already open.", + advanceOnClick: true, + }, + { + element: "#formly-title", + popover: { + title: "This is the property panel", + description: + "Every operator's settings live here. For CSV File Scan, the only thing we need is to pick a file. Click Next.", + side: "left", + align: "center", + }, + title: "Step 6 — Meet the property panel", + aiHint: + "Always-shown explainer that introduces the property panel. Runs whether the previous open-property-panel step was clicked or auto-skipped, so users who already had the panel open still get the orientation.", + }, + { + element: 'button[data-tutorial="file-select-button"]', + popover: { + title: "Pick a file", + description: "Click Select File to open the dataset browser — that's where your CSVs live.", + side: "left", + align: "center", + }, + title: "Step 7 — Open the File Picker", + aiHint: "The user is about to click the Select File button to open the dataset selection modal.", + advanceOnClick: true, + }, + { + element: ".ant-modal-content", + popover: { + title: "Browse your data", + description: + "Pick a dataset on the left, expand a version, then double-click any .csv. The modal closes itself and the file name auto-fills — no typing required.", + side: "top", + align: "end", + }, + title: "Step 8 — Pick a CSV File", + aiHint: + "The user is choosing a CSV file from the dataset selection modal. After they pick one, the modal closes and the fileName property updates.", + autoAdvanceOn: "propertyChanged", + }, + { + element: 'nz-collapse-panel[data-group-name="Data Cleaning"]', + popover: { + title: "Time for a second operator", + description: + 'Back to the left panel. Click "Data Cleaning" to open that category — we\'re about to chain a second operator after our source.', + side: "right", + align: "start", + }, + title: "Step 9 — Find the Data Cleaning Category", + aiHint: "The user needs to expand the Data Cleaning category in the operator panel to find Limit.", + advanceOnClick: true, + }, + { + element: '.operator-label[data-operator-type="Limit"]', + popover: { + title: "Drag Limit onto the canvas", + description: + "Grab Limit and drop it on the canvas next to CSV File Scan. Limit caps your output to the first N rows — perfect for a quick peek.", + side: "right", + align: "start", + }, + title: "Step 10 — Drag Limit", + aiHint: "The user is dragging the Limit operator onto the canvas as a second operator.", + autoAdvanceOn: "operatorAdded", + }, + { + element: "texera-workflow-editor", + popover: { + title: "Wire them up", + description: + 'Drag from the ▶ port on the right edge of CSV File Scan over to the port on the left edge of Limit. That edge tells Texera "send rows this way".', + side: "over", + align: "center", + }, + title: "Step 11 — Connect Operators", + aiHint: "The user is dragging an edge from CSV File Scan's output port to Limit's input port.", + autoAdvanceOn: "linkAdded", + }, + { + element: '[model-id^="Limit-operator-"]', + popover: { + title: "Click Limit to configure it", + description: + "Click the Limit box on the canvas. The property panel on the right will swap over to its settings — each operator has its own.", + side: "bottom", + align: "center", + }, + title: "Step 12 — Click Limit", + aiHint: + "The user needs to click the just-connected Limit operator on the canvas to switch the property editor to its fields.", + autoAdvanceOn: "operatorSelected", + }, + { + element: 'li[data-tutorial="open-property-panel"]', + popover: { + title: "Open its settings", + description: + "If the panel collapsed again, click the form icon at the top-right to reopen it. (Already open? Skipping…)", + side: "left", + align: "center", + }, + title: "Step 13 — Open the Property Panel (Limit)", + aiHint: + "Same as step 5: the user clicks the form icon to reopen the property panel for the Limit operator. Auto-skipped when the panel is already open.", + advanceOnClick: true, + }, + { + element: ".property-editor-form", + popover: { + title: "How many rows?", + description: + "Type a number into the Limit field — that's the max rows you'll see. Try 10 for a snappy preview.", + side: "left", + align: "start", + }, + title: "Step 14 — Configure Limit", + aiHint: "The user is configuring the Limit operator by typing a number in the limit field (max output rows).", + autoAdvanceOn: "propertyChanged", + }, + { + element: "#texera-compute-unit-selection", + popover: { + title: "Almost there — pick a Computing Unit", + description: + "Before running, click the Computing Unit selector at the top and pick (or start) one. That's the little worker that'll execute your workflow.", + side: "bottom", + align: "start", + }, + title: "Step 15 — Connect a Computing Unit", + aiHint: "The user needs to select/connect a Computing Unit from the top menu before running the workflow.", + autoAdvanceOn: "computingUnitConnected", + }, + { + element: "#run-button", + popover: { + title: "Hit Run!", + description: + "Once the unit shows Connected, smash that ▶ Run button and watch Texera get to work.", + side: "bottom", + align: "start", + }, + title: "Step 16 — Run the Workflow", + aiHint: "The user is clicking the Run button at the top to execute the workflow.", + advanceOnClick: true, + }, + { + element: 'li[data-tutorial="open-result-panel"]', + popover: { + title: "Open the result panel", + description: + "Your workflow finished — the data is ready! Click this little square icon at the bottom-left to slide open the result panel and see your rows.", + side: "top", + align: "start", + }, + title: "Step 17 — Open the Result Panel", + aiHint: + "The user just ran the workflow. They need to click the 'Open Result Panel' icon (the square / border icon at the bottom-left of the screen) to expand the result panel and view their data.", + advanceOnClick: true, + }, + { + popover: { + title: "🎉 You did it!", + description: + "Your CSV rows are now waiting for you in the result panel. You just built and ran your very first Texera workflow — from empty canvas to real data in under two minutes. Welcome aboard!", + }, + title: "Step 18 — Celebration", + aiHint: + "Final celebratory step. The user has opened the result panel and can now see their workflow's output. No element is highlighted — the popover is centered like the welcome screen for a clean closing moment.", + }, +]; + +export const BUILD_SIMPLE_FLOW: TutorialFlow = { + id: "build-simple", + name: "Build your first workflow", + shortDesc: "CSV → Limit → Run → View. The friendly 18-step intro.", + difficulty: "intro", + estimatedMinutes: 2, + badge: { + id: "workflow-builder", + emoji: "🧱", + name: "Workflow Builder", + description: "Built and ran your first Texera workflow.", + hue: "#1890ff", + }, + steps: STEPS, +}; diff --git a/frontend/src/app/workspace/service/tutorial/flows/flows-integrity.spec.ts b/frontend/src/app/workspace/service/tutorial/flows/flows-integrity.spec.ts new file mode 100644 index 00000000000..6e0666988c8 --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/flows/flows-integrity.spec.ts @@ -0,0 +1,154 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BONUS_BADGES, FLOWS, getFlowById, GRADUATE_BADGE, SPEED_RUNNER_BADGE, TutorialFlow } from "./index"; + +describe("FLOWS registry integrity", () => { + describe("identifiers", () => { + it("every flow has a unique id", () => { + const ids = FLOWS.map(f => f.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("every flow's badge id is unique across all flows + bonus badges", () => { + const ids = [...FLOWS.map(f => f.badge.id), ...BONUS_BADGES.map(b => b.id)]; + const seen = new Set(); + const dupes: string[] = []; + for (const id of ids) { + if (seen.has(id)) dupes.push(id); + seen.add(id); + } + expect(dupes).toEqual([]); + }); + + it("getFlowById returns the matching flow", () => { + for (const flow of FLOWS) { + expect(getFlowById(flow.id)).toBe(flow); + } + }); + + it("getFlowById returns null for an unknown id", () => { + expect(getFlowById("does-not-exist")).toBeNull(); + }); + }); + + describe("step content", () => { + it("every step in every launchable flow has a non-empty title and aiHint", () => { + const offenders: string[] = []; + for (const flow of FLOWS.filter(f => !f.comingSoon && f.steps.length > 0)) { + flow.steps.forEach((step, i) => { + if (!step.title) offenders.push(`${flow.id}[${i}].title`); + if (!step.aiHint) offenders.push(`${flow.id}[${i}].aiHint`); + }); + } + expect(offenders).toEqual([]); + }); + + it("flows marked comingSoon have empty steps arrays (convention check)", () => { + const violations = FLOWS.filter(f => f.comingSoon && f.steps.length > 0).map(f => f.id); + expect(violations).toEqual([]); + }); + }); + + describe("skipToStep targets", () => { + it("every skipToStep value points to a strictly later step inside the same flow", () => { + const offenders: string[] = []; + for (const flow of FLOWS) { + flow.steps.forEach((step, i) => { + if (step.skipToStep === undefined) return; + if (step.skipToStep <= i || step.skipToStep >= flow.steps.length) { + offenders.push(`${flow.id}[${i}] → ${step.skipToStep}`); + } + }); + } + expect(offenders).toEqual([]); + }); + + it("skipToStep is only used together with showDetailsButton (paired branching)", () => { + const unpaired: string[] = []; + for (const flow of FLOWS) { + flow.steps.forEach((step, i) => { + if (step.skipToStep !== undefined && step.showDetailsButton !== true) { + unpaired.push(`${flow.id}[${i}]`); + } + }); + } + expect(unpaired).toEqual([]); + }); + }); + + describe("chainTo references", () => { + it("every chainTo points at a real flow id", () => { + const broken: string[] = []; + for (const flow of FLOWS) { + if (flow.chainTo && getFlowById(flow.chainTo) === null) { + broken.push(`${flow.id} → ${flow.chainTo}`); + } + } + expect(broken).toEqual([]); + }); + + it("no flow chains to itself", () => { + const selfLoops = FLOWS.filter(f => f.chainTo === f.id).map(f => f.id); + expect(selfLoops).toEqual([]); + }); + }); + + describe("prerequisites", () => { + it("every prerequisite id resolves to a real flow", () => { + const broken: string[] = []; + for (const flow of FLOWS) { + for (const prereq of flow.prerequisites ?? []) { + if (getFlowById(prereq) === null) broken.push(`${flow.id} ← ${prereq}`); + } + } + expect(broken).toEqual([]); + }); + }); + + describe("bonus badges", () => { + it("BONUS_BADGES contains SPEED_RUNNER and GRADUATE", () => { + expect(BONUS_BADGES).toContain(SPEED_RUNNER_BADGE); + expect(BONUS_BADGES).toContain(GRADUATE_BADGE); + }); + }); +}); + +describe("overview-workspace branching", () => { + let flow: TutorialFlow; + + beforeAll(() => { + const found = getFlowById("overview-workspace"); + expect(found).not.toBeNull(); + flow = found!; + }); + + it("every group-overview step jumps over at least one detail step", () => { + const offenders: string[] = []; + flow.steps.forEach((step, index) => { + if (step.skipToStep === undefined) return; + if (step.skipToStep <= index + 1) { + offenders.push(`${flow.id}[${index}] → ${step.skipToStep} (no skip)`); + } + const target = flow.steps[step.skipToStep]; + if (!target?.title) offenders.push(`${flow.id}[${step.skipToStep}] has no title`); + }); + expect(offenders).toEqual([]); + }); +}); diff --git a/frontend/src/app/workspace/service/tutorial/flows/index.ts b/frontend/src/app/workspace/service/tutorial/flows/index.ts new file mode 100644 index 00000000000..cf873325c66 --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/flows/index.ts @@ -0,0 +1,145 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DriveStep } from "driver.js"; +import { OVERVIEW_WORKSPACE_FLOW } from "./overview-workspace"; +import { BUILD_SIMPLE_FLOW } from "./build-simple"; +import { BUILD_COMPLEX_FLOW } from "./build-complex"; +import { PUBLIC_WORKFLOW_FLOW } from "./public-workflow"; + +export type AutoAdvanceTrigger = + | "operatorAdded" + | "operatorSelected" + | "linkAdded" + | "executionStarted" + | "propertyChanged" + | "computingUnitConnected"; + +export interface TutorialStep extends DriveStep { + /** Short label used by the AI chat for context. */ + title: string; + /** Prompt fragment given to the AI when the user asks a question on this step. */ + aiHint: string; + /** If set, advance automatically when this event fires on the workflow. */ + autoAdvanceOn?: AutoAdvanceTrigger; + /** If true, clicking the highlighted element advances to the next step. */ + advanceOnClick?: boolean; + /** Default Next jumps to this index instead of `+1`. Pair with `showDetailsButton` for opt-in deep dives. */ + skipToStep?: number; + /** Adds a secondary "Show details" button to the popover that does normal moveNext. Only meaningful with `skipToStep`. */ + showDetailsButton?: boolean; +} + +export interface BadgeDef { + id: string; + emoji: string; + name: string; + description: string; + /** Optional palette to tint the locked silhouette / unlocked frame. */ + hue?: string; +} + +export interface TutorialFlow { + id: string; + name: string; + shortDesc: string; + difficulty: "intro" | "easy" | "medium" | "advanced"; + estimatedMinutes: number; + /** Stub flows have empty `steps` arrays and aren't launchable yet. */ + comingSoon?: boolean; + /** Flow ids the user is encouraged to finish first. */ + prerequisites?: string[]; + badge: BadgeDef; + steps: TutorialStep[]; + /** Basename of a JSON fixture under `assets/tutorial-workflows/` to load into the canvas before step 0. */ + prebuiltWorkflow?: string; + /** + * Flow id to chain into automatically when this one completes. + */ + chainTo?: string; +} + +/** Coming-soon placeholders advertised in the picker but not launchable. */ +const BUILD_ML_FLOW: TutorialFlow = { + id: "build-ml", + name: "Add a simple ML model", + shortDesc: "Train a Logistic Regression classifier on a pre-built workflow.", + difficulty: "advanced", + estimatedMinutes: 2, + prerequisites: ["build-simple"], + comingSoon: true, + badge: { + id: "data-scientist", + emoji: "🧪", + name: "Data Scientist", + description: "Trained your first ML model inside Texera.", + hue: "#52c41a", + }, + steps: [], +}; + +const HUB_TOUR_FLOW: TutorialFlow = { + id: "hub-tour", + name: "Tour the Texera hub", + shortDesc: "Workflows, datasets, public projects — start here.", + difficulty: "intro", + estimatedMinutes: 1, + comingSoon: true, + badge: { + id: "first-steps", + emoji: "🚀", + name: "First Steps", + description: "Took the Texera hub orientation tour.", + hue: "#1890ff", + }, + steps: [], +}; + +export const FLOWS: TutorialFlow[] = [ + OVERVIEW_WORKSPACE_FLOW, + BUILD_SIMPLE_FLOW, + BUILD_COMPLEX_FLOW, + BUILD_ML_FLOW, + HUB_TOUR_FLOW, + PUBLIC_WORKFLOW_FLOW, +]; + +export function getFlowById(id: string): TutorialFlow | null { + return FLOWS.find(f => f.id === id) ?? null; +} + +/** Special meta-badge granted automatically when every other badge is earned. */ +export const GRADUATE_BADGE: BadgeDef = { + id: "graduate", + emoji: "🎓", + name: "Texera Graduate", + description: "Completed every tutorial flow — true Texera fluency.", + hue: "#722ed1", +}; + +/** Bonus badge for fast completions. */ +export const SPEED_RUNNER_BADGE: BadgeDef = { + id: "speed-runner", + emoji: "⚡", + name: "Speed Runner", + description: "Finished a flow in under 90 seconds.", + hue: "#fa8c16", +}; + +export const BONUS_BADGES: BadgeDef[] = [SPEED_RUNNER_BADGE, GRADUATE_BADGE]; diff --git a/frontend/src/app/workspace/service/tutorial/flows/overview-workspace.ts b/frontend/src/app/workspace/service/tutorial/flows/overview-workspace.ts new file mode 100644 index 00000000000..c7f11f48990 --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/flows/overview-workspace.ts @@ -0,0 +1,348 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TutorialFlow, TutorialStep } from "./index"; + +/** + * Workspace orientation tour with branching detail dives. + * + * The main spine is 8 steps (Operator panel → 3 toolbar group overviews → + * Result panel → Mini-map → AI Agent → Hand-off). Each group overview + * offers two ways forward via the popover footer: + * - Next → skips ahead to the next main step + * - Learn more → drills into the group's individual icons one by one + * + * The 14 detail steps hide their own progress counter (`showProgress: false`) + * so the visible "Step X / 8" only ever reflects spine progress — detail + * dives feel like an aside instead of inflating the perceived length. + */ +const STEPS: TutorialStep[] = [ + // 0 · MAIN 1/8 + { + element: "texera-operator-menu", + popover: { + title: "Operator panel", + description: "The building blocks of every workflow live here. Drag them onto the canvas to use them.", + side: "right", + align: "start", + progressText: "Step 1 / 8", + }, + title: "Step 1 — Operator Panel", + aiHint: "Orientation: operator menu on the left.", + }, + // 1 · MAIN 2/8 (branch: Next skips to idx 9, Learn more drills in) + { + element: "#user-buttons", + popover: { + title: "Toolbar · file actions", + description: "Save, import, export, and other workflow-file operations.", + side: "bottom", + align: "start", + progressText: "Step 2 / 8", + }, + title: "Step 2 — Left group overview", + aiHint: + "Left toolbar group overview. Default Next skips past the 7 file-action details (idx 2-8) to the middle overview; 'Learn more' drills in.", + skipToStep: 9, + showDetailsButton: true, + }, + // 2 · DETAIL + { + element: 'button[title="dashboard"]', + popover: { + title: "Dashboard", + description: "Jump back to your workflow list.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Dashboard", + aiHint: "Detail (left group): dashboard button.", + }, + // 3 · DETAIL + { + element: 'button[title="create new"]', + popover: { + title: "New workflow", + description: "Start a blank workflow in a new tab.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — New", + aiHint: "Detail (left group): create-new button.", + }, + // 4 · DETAIL + { + element: 'button[title="save"]', + popover: { + title: "Save", + description: "Snapshots the workflow now instead of waiting for the next auto-save.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Save", + aiHint: "Detail (left group): save button.", + }, + // 5 · DETAIL + { + element: 'button[title="delete all"]', + popover: { + title: "Delete all", + description: "Wipe every operator from the canvas — handy when you want a fresh start.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Delete all", + aiHint: "Detail (left group): delete-all-operators button.", + }, + // 6 · DETAIL + { + element: 'button[title="import workflow"]', + popover: { + title: "Import", + description: "Upload a workflow JSON file and load it onto this canvas.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Import", + aiHint: "Detail (left group): import-workflow button.", + }, + // 7 · DETAIL + { + element: 'button[title="export workflow"]', + popover: { + title: "Export", + description: "Download the current workflow as a JSON file.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Export", + aiHint: "Detail (left group): export-workflow button.", + }, + // 8 · DETAIL + { + element: 'button[title="change description"]', + popover: { + title: "Description", + description: "Edit the workflow's description text.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Description", + aiHint: "Detail (left group): change-description button.", + }, + // 9 · MAIN 3/8 (branch: Next skips to idx 14, Learn more drills in) + { + element: "#expanded-utilities nz-space-compact", + popover: { + title: "Toolbar · canvas utilities", + description: + "Tools for keeping the canvas tidy as your workflow grows — layers, layout, comments, undo / redo, and more.", + side: "bottom", + align: "center", + progressText: "Step 3 / 8", + popoverClass: "tutorial-wide-popover", + }, + title: "Step 3 — Middle group overview", + aiHint: + "Middle toolbar group overview. Default Next skips past auto-layout/comment/undo/redo (idx 10-13) to the right overview; 'Learn more' drills in.", + skipToStep: 14, + showDetailsButton: true, + }, + // 10 · DETAIL + { + element: 'button[title="auto layout"]', + popover: { + title: "Auto-layout", + description: "Rearranges your operators into a tidy left-to-right pipeline.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Auto-layout", + aiHint: "Detail (middle group): auto-layout button.", + }, + // 11 · DETAIL + { + element: 'button[title="add a comment"]', + popover: { + title: "Add Comment", + description: "Drops a sticky-note comment box on the canvas.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Add Comment", + aiHint: "Detail (middle group): add-comment button.", + }, + // 12 · DETAIL + { + element: 'button[title="undo"]', + popover: { + title: "↶ Undo", + description: "Steps back through your last edit.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Undo", + aiHint: "Detail (middle group): undo button.", + }, + // 13 · DETAIL + { + element: 'button[title="redo"]', + popover: { + title: "↷ Redo", + description: "Replays an undone edit.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Redo", + aiHint: "Detail (middle group): redo button.", + }, + // 14 · MAIN 4/8 (branch: Next skips to idx 18, Learn more drills in) + { + element: "#execution-buttons", + popover: { + title: "Toolbar · run controls", + description: "Where you launch and watch your workflow execute.", + side: "bottom", + align: "end", + progressText: "Step 4 / 8", + }, + title: "Step 4 — Right group overview", + aiHint: + "Right toolbar group overview. Default Next skips past Computing Unit / Share / Run (idx 15-17) to the result panel; 'Learn more' drills in.", + skipToStep: 18, + showDetailsButton: true, + }, + // 15 · DETAIL + { + element: "#texera-compute-unit-selection", + popover: { + title: "Computing Unit", + description: "The worker that runs your workflow. Pick one before hitting Run.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Computing Unit", + aiHint: "Detail (right group): computing-unit selector.", + }, + // 16 · DETAIL + { + element: "#share-button", + popover: { + title: "Share", + description: "Invite collaborators to view or co-edit live.", + side: "bottom", + align: "center", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Share", + aiHint: "Detail (right group): share button.", + }, + // 17 · DETAIL + { + element: "#run-button", + popover: { + title: "▶ Run", + description: "Launches your workflow on the selected Computing Unit.", + side: "bottom", + align: "end", + popoverClass: "tutorial-no-progress", + }, + title: "Step — Run", + aiHint: "Detail (right group): the Run button.", + }, + // 18 · MAIN 5/8 + { + element: 'li[data-tutorial="open-result-panel"]', + popover: { + title: "Result panel", + description: "After a run, click here to see the rows and charts.", + side: "top", + align: "start", + progressText: "Step 5 / 8", + }, + title: "Step 5 — Result Panel", + aiHint: "Orientation: result-panel toggle in the bottom-left.", + }, + // 19 · MAIN 6/8 + { + element: "texera-mini-map", + popover: { + title: "Mini-map", + description: "Bottom-right helper for navigating large workflows.", + side: "top", + align: "end", + progressText: "Step 6 / 8", + }, + title: "Step 6 — Mini-map", + aiHint: "Orientation: mini-map widget in the bottom-right.", + }, + // 20 · MAIN 7/8 + { + element: "#agent-docked-button", + popover: { + title: "🤖 AI Agent", + description: "Bottom-right chat button. Ask Texera's AI to help you build, debug, or explain your workflow.", + side: "left", + align: "center", + progressText: "Step 7 / 8", + }, + title: "Step 7 — AI Agent", + aiHint: "Orientation: docked AI Agent launcher in the bottom-right.", + }, + // 21 · MAIN 8/8 + { + popover: { + title: "Ready to build?", + description: "That's the layout. Click Done when you're ready.", + progressText: "Step 8 / 8", + }, + title: "Step 8 — Hand-off", + aiHint: + "Final narration. Done triggers the chain-confirmation popover that asks the user whether to launch build-simple.", + }, +]; + +export const OVERVIEW_WORKSPACE_FLOW: TutorialFlow = { + id: "overview-workspace", + name: "Workspace overview", + shortDesc: "8-step tour of the workspace. Each toolbar group offers an optional deep dive into individual icons.", + difficulty: "intro", + estimatedMinutes: 2, + badge: { + id: "explorer", + emoji: "🧭", + name: "Explorer", + description: "Took the workspace overview tour and learned where everything lives.", + hue: "#52c41a", + }, + steps: STEPS, + chainTo: "build-simple", +}; diff --git a/frontend/src/app/workspace/service/tutorial/flows/public-workflow.ts b/frontend/src/app/workspace/service/tutorial/flows/public-workflow.ts new file mode 100644 index 00000000000..3396298004f --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/flows/public-workflow.ts @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { TutorialFlow } from "./index"; + +/** + * Cross-page flow: open a public workflow from the hub, clone it, run it. + * Stub for now — needs hub-side selectors and the cross-route resume plumbing. + */ +export const PUBLIC_WORKFLOW_FLOW: TutorialFlow = { + id: "public-workflow", + name: "Run a public workflow", + shortDesc: "Browse, clone, and run a community workflow end-to-end.", + difficulty: "easy", + estimatedMinutes: 2, + comingSoon: true, + prerequisites: ["hub-tour"], + badge: { + id: "voyager", + emoji: "🔭", + name: "Community Voyager", + description: "Cloned and ran your first public workflow.", + hue: "#eb2f96", + }, + steps: [], +}; diff --git a/frontend/src/app/workspace/service/tutorial/tutorial-progress.spec.ts b/frontend/src/app/workspace/service/tutorial/tutorial-progress.spec.ts new file mode 100644 index 00000000000..93675b715bd --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/tutorial-progress.spec.ts @@ -0,0 +1,94 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DEFAULT_PROGRESS, loadProgress, saveProgress, TutorialProgress } from "./tutorial-progress"; + +const STORAGE_KEY = "texera-tutorial-progress"; +const LEGACY_SEEN_KEY = "texera-tutorial-seen"; + +describe("tutorial-progress", () => { + beforeEach(() => { + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(LEGACY_SEEN_KEY); + }); + + describe("loadProgress", () => { + it("returns DEFAULT_PROGRESS when localStorage is empty", () => { + expect(loadProgress()).toEqual(DEFAULT_PROGRESS); + }); + + it("migrates the legacy `texera-tutorial-seen=1` flag into seenWelcome=true", () => { + localStorage.setItem(LEGACY_SEEN_KEY, "1"); + + const progress = loadProgress(); + + expect(progress.seenWelcome).toBe(true); + expect(progress.earnedBadges).toEqual(DEFAULT_PROGRESS.earnedBadges); + expect(progress.xp).toBe(DEFAULT_PROGRESS.xp); + }); + + it("returns DEFAULT_PROGRESS when the legacy flag is anything other than '1'", () => { + localStorage.setItem(LEGACY_SEEN_KEY, "0"); + + expect(loadProgress().seenWelcome).toBe(false); + }); + + it("resets to defaults but preserves seenWelcome when schema version is stale", () => { + const stale = { version: 0, seenWelcome: true, earnedBadges: ["should-be-dropped"] }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(stale)); + + const progress = loadProgress(); + + expect(progress.seenWelcome).toBe(true); + expect(progress.earnedBadges).toEqual([]); + expect(progress.version).toBe(DEFAULT_PROGRESS.version); + }); + + it("returns DEFAULT_PROGRESS when localStorage holds malformed JSON", () => { + localStorage.setItem(STORAGE_KEY, "{not-json"); + + expect(loadProgress()).toEqual(DEFAULT_PROGRESS); + }); + }); + + describe("saveProgress + loadProgress round-trip", () => { + it("persists every field unchanged", () => { + const sample: TutorialProgress = { + current: { flowId: "overview-workspace", stepIndex: 3 }, + completed: { "build-simple": { completedAt: 123456, durationMs: 78000 } }, + earnedBadges: ["explorer", "speed-runner"], + xp: 240, + microsEarned: { "build-simple": [0, 1, 2] }, + seenWelcome: true, + version: DEFAULT_PROGRESS.version, + }; + + saveProgress(sample); + + expect(loadProgress()).toEqual(sample); + }); + + it("survives an unrelated localStorage write between save and load", () => { + saveProgress({ ...DEFAULT_PROGRESS, xp: 50 }); + localStorage.setItem("some-other-key", "noise"); + + expect(loadProgress().xp).toBe(50); + }); + }); +}); diff --git a/frontend/src/app/workspace/service/tutorial/tutorial-progress.ts b/frontend/src/app/workspace/service/tutorial/tutorial-progress.ts new file mode 100644 index 00000000000..91c5d9ba853 --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/tutorial-progress.ts @@ -0,0 +1,75 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface TutorialProgress { + /** In-flight flow + step. Cleared when the user finishes or fully resets. */ + current?: { flowId: string; stepIndex: number }; + /** flowId → completion record. */ + completed: Record; + /** Earned badge ids (includes flow badges + bonus badges like speed-runner / graduate). */ + earnedBadges: string[]; + /** Total XP across all flows + replays. */ + xp: number; + /** flowId → step indices that have already fired their micro-reward. Prevents + * replays from awarding XP again for already-completed steps. */ + microsEarned: Record; + /** Whether the user has dismissed (or completed) the welcome flow picker. */ + seenWelcome: boolean; + /** Schema version — bump when shape changes; older payloads are reset. */ + version: number; +} + +const STORAGE_KEY = "texera-tutorial-progress"; +const CURRENT_VERSION = 1; + +export const DEFAULT_PROGRESS: TutorialProgress = { + completed: {}, + earnedBadges: [], + xp: 0, + microsEarned: {}, + seenWelcome: false, + version: CURRENT_VERSION, +}; + +export function loadProgress(): TutorialProgress { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + // Migrate the v1 "seen" key so returning users aren't re-prompted. + const legacy = localStorage.getItem("texera-tutorial-seen") === "1"; + return { ...DEFAULT_PROGRESS, seenWelcome: legacy }; + } + const parsed = JSON.parse(raw) as Partial; + if (parsed.version !== CURRENT_VERSION) { + // Older schema — reset gracefully but keep the seen flag if present. + return { ...DEFAULT_PROGRESS, seenWelcome: !!parsed.seenWelcome }; + } + return { ...DEFAULT_PROGRESS, ...parsed }; + } catch { + return { ...DEFAULT_PROGRESS }; + } +} + +export function saveProgress(p: TutorialProgress): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); + } catch { + /* localStorage may be unavailable in some environments */ + } +} diff --git a/frontend/src/app/workspace/service/tutorial/tutorial.service.ts b/frontend/src/app/workspace/service/tutorial/tutorial.service.ts new file mode 100644 index 00000000000..0c6ad374ce9 --- /dev/null +++ b/frontend/src/app/workspace/service/tutorial/tutorial.service.ts @@ -0,0 +1,576 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Injectable, NgZone, OnDestroy } from "@angular/core"; +import { BehaviorSubject, Subject } from "rxjs"; +import { filter, takeUntil } from "rxjs/operators"; +import { driver, Driver, DriveStep } from "driver.js"; +import { WorkflowActionService } from "../workflow-graph/model/workflow-action.service"; +import { ExecuteWorkflowService } from "../execute-workflow/execute-workflow.service"; +import { ExecutionState } from "../../types/execute-workflow.interface"; +import { ComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/computing-unit-status.service"; +import { ComputingUnitState } from "../../../common/type/computing-unit-connection.interface"; +import { + AutoAdvanceTrigger, + BONUS_BADGES, + BadgeDef, + FLOWS, + GRADUATE_BADGE, + SPEED_RUNNER_BADGE, + TutorialFlow, + TutorialStep, + getFlowById, +} from "./flows"; +import { DEFAULT_PROGRESS, TutorialProgress, loadProgress, saveProgress } from "./tutorial-progress"; +const XP_PER_STEP = 10; +const SPEED_RUNNER_THRESHOLD_MS = 90_000; + +export interface BadgeUnlockEvent { + badge: BadgeDef; + flowName: string; +} + +export type { TutorialStep, TutorialFlow, BadgeDef, AutoAdvanceTrigger }; + +@Injectable({ + providedIn: "root", +}) +export class TutorialService implements OnDestroy { + private readonly destroy$ = new Subject(); + private driverObj: Driver | null = null; + private currentFlow: TutorialFlow | null = null; + private lastActiveIndex = 0; + private flowStartedAt = 0; + + // One-shot DOM click listener for steps with `advanceOnClick: true`. + private clickAdvanceEl: Element | null = null; + private clickAdvanceHandler: ((ev: Event) => void) | null = null; + + // ===== Reactive state ===== + private readonly isActiveSubject = new BehaviorSubject(false); + public readonly isActive$ = this.isActiveSubject.asObservable(); + + private readonly currentStepSubject = new BehaviorSubject(0); + public readonly currentStep$ = this.currentStepSubject.asObservable(); + + private readonly currentFlowSubject = new BehaviorSubject(null); + public readonly currentFlow$ = this.currentFlowSubject.asObservable(); + + private readonly completedSubject = new Subject<{ flowId: string }>(); + public readonly completed$ = this.completedSubject.asObservable(); + + private readonly badgeUnlockedSubject = new Subject(); + public readonly badgeUnlocked$ = this.badgeUnlockedSubject.asObservable(); + + private readonly progressSubject = new BehaviorSubject(DEFAULT_PROGRESS); + public readonly progress$ = this.progressSubject.asObservable(); + + public readonly flows: TutorialFlow[] = FLOWS; + + constructor( + private workflowActionService: WorkflowActionService, + private executeWorkflowService: ExecuteWorkflowService, + private computingUnitStatusService: ComputingUnitStatusService, + private ngZone: NgZone + ) { + this.progressSubject.next(loadProgress()); + this.wireEventListeners(); + } + + // ===== Getters ===== + + get isActive(): boolean { + return this.isActiveSubject.getValue(); + } + + get currentStepIndex(): number { + return this.currentStepSubject.getValue(); + } + + get currentStep(): TutorialStep | null { + if (!this.isActive || !this.currentFlow) return null; + return this.currentFlow.steps[this.currentStepIndex] ?? null; + } + + get currentFlowSnapshot(): TutorialFlow | null { + return this.currentFlow; + } + + get stepCount(): number { + return this.currentFlow?.steps.length ?? 0; + } + + get progress(): TutorialProgress { + return this.progressSubject.getValue(); + } + + // ===== Flow control ===== + + /** Start a flow. Re-runs of completed flows act as replays (no second badge, sparkles still fire). */ + start(flowId: string, startAt: number = 0): void { + const flow = getFlowById(flowId); + if (!flow) { + // eslint-disable-next-line no-console + console.warn(`[tutorial] unknown flow "${flowId}"`); + return; + } + if (flow.comingSoon || flow.steps.length === 0) { + return; + } + + if (this.driverObj) { + this.driverObj.destroy(); + this.driverObj = null; + } + this.resetOperatorMenuCategories(); + const safeStart = Math.max(0, Math.min(startAt, flow.steps.length - 1)); + this.currentFlow = flow; + this.currentFlowSubject.next(flow); + this.lastActiveIndex = safeStart; + this.flowStartedAt = Date.now(); + this.currentStepSubject.next(safeStart); + this.isActiveSubject.next(true); + + // Persist the start so a refresh mid-tour can offer to resume. + this.updateProgress(p => ({ ...p, current: { flowId, stepIndex: safeStart } })); + + const driverSteps: DriveStep[] = flow.steps.map(step => { + // skipToStep redirects the default Next button. driver.js exposes onNextClick on Popover, not DriveStep. + const popover = + typeof step.skipToStep === "number" + ? { ...step.popover, onNextClick: () => this.driverObj?.moveTo(step.skipToStep!) } + : step.popover; + return { + element: step.element, + popover, + }; + }); + + // Skip prebuilt-workflow load on resume so we don't wipe the user's mid-tour canvas. + if (flow.prebuiltWorkflow && safeStart === 0) { + this.loadPrebuiltWorkflow(flow.prebuiltWorkflow) + .catch(err => { + // eslint-disable-next-line no-console + console.warn(`[tutorial] failed to load prebuilt workflow "${flow.prebuiltWorkflow}":`, err); + }) + .finally(() => this.launchDriver(driverSteps, safeStart)); + return; + } + + this.launchDriver(driverSteps, safeStart); + } + + /** Load a fixture from `assets/tutorial-workflows/`. Resolves after JointJS has had a tick to paint. */ + private loadPrebuiltWorkflow(name: string): Promise { + return fetch(`assets/tutorial-workflows/${name}.json`) + .then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then(workflow => { + this.ngZone.run(() => this.workflowActionService.reloadWorkflow(workflow)); + // Wait one paint tick for JointJS so spotlight rects measure correctly. + return new Promise(resolve => setTimeout(resolve, 150)); + }); + } + + private launchDriver(driverSteps: DriveStep[], safeStart: number): void { + // Construct driver.js outside Angular's zone — its resize / scroll / mutation listeners would + // otherwise re-run change detection on every event and make ng-zorro tooltips/waves jitter. + this.ngZone.runOutsideAngular(() => { + this.driverObj = driver({ + showProgress: true, + allowClose: true, + overlayClickBehavior: () => { + /* no-op: clicking the dim overlay does nothing */ + }, + animate: false, + smoothScroll: true, + stagePadding: 16, + stageRadius: 8, + disableActiveInteraction: false, + overlayColor: "#000", + overlayOpacity: 0.65, + nextBtnText: "Next →", + prevBtnText: "← Back", + doneBtnText: "All done! 🎉", + progressText: "Step {{current}} / {{total}}", + steps: driverSteps, + onHighlightStarted: (el, _step, opts) => this.handleHighlight(el, opts), + onDestroyed: () => this.handleDestroyed(), + }); + this.driverObj.drive(safeStart); + }); + } + + /** Continue an in-progress flow (resume from saved stepIndex). */ + resume(): boolean { + const cur = this.progress.current; + if (!cur) return false; + const flow = getFlowById(cur.flowId); + if (!flow || flow.comingSoon || flow.steps.length === 0) return false; + this.start(cur.flowId, cur.stepIndex); + return true; + } + + next(): void { + this.driverObj?.moveNext(); + } + + previous(): void { + this.driverObj?.movePrevious(); + } + + skip(): void { + this.driverObj?.destroy(); + } + + restart(): void { + if (this.currentFlow) this.start(this.currentFlow.id); + } + + markWelcomeSeen(): void { + this.updateProgress(p => ({ ...p, seenWelcome: true })); + } + + /** Wipe all progress — useful for demos / workshops. */ + resetProgress(): void { + this.progressSubject.next({ ...DEFAULT_PROGRESS }); + saveProgress(this.progressSubject.getValue()); + } + + // ===== Private helpers ===== + + /** Collapse every expanded operator-menu category so "Click Data Input to open the category" makes sense regardless of prior state. */ + private resetOperatorMenuCategories(): void { + const menu = document.querySelector("texera-operator-menu"); + if (!menu) return; + menu.querySelectorAll(".ant-collapse-item-active > .ant-collapse-header").forEach(header => { + (header as HTMLElement).click(); + }); + } + + /** Inject a "Learn more" button next to Next on group-overview steps. Idempotent. */ + private injectShowDetailsButton(stepIdx: number): void { + const popover = document.querySelector(".driver-popover"); + if (!popover || popover.querySelector(".tutorial-show-details-btn")) return; + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "tutorial-show-details-btn"; + btn.textContent = "Learn more"; + btn.addEventListener("click", () => { + if (this.currentStepIndex !== stepIdx) return; + this.driverObj?.moveNext(); + }); + + // Prefer sitting in the footer alongside Next/Skip; fall back to the + // description block if driver.js's footer markup changes in the future. + const navBtns = popover.querySelector(".driver-popover-navigation-btns"); + if (navBtns) { + navBtns.insertBefore(btn, navBtns.firstChild); + return; + } + const description = popover.querySelector(".driver-popover-description"); + description?.appendChild(btn); + } + + /** True if the box is inside the viewport AND every ancestor scroll container's clip rect. */ + private isFullyVisible(el: HTMLElement): boolean { + const r = el.getBoundingClientRect(); + if (r.top < 0 || r.left < 0 || r.bottom > window.innerHeight || r.right > window.innerWidth) { + return false; + } + let p: HTMLElement | null = el.parentElement; + while (p && p !== document.body) { + const style = getComputedStyle(p); + const scrollsY = /auto|scroll|hidden|clip/.test(style.overflowY); + const scrollsX = /auto|scroll|hidden|clip/.test(style.overflowX); + if (scrollsY || scrollsX) { + const pr = p.getBoundingClientRect(); + if ( + (scrollsY && (r.top < pr.top || r.bottom > pr.bottom)) || + (scrollsX && (r.left < pr.left || r.right > pr.right)) + ) { + return false; + } + } + p = p.parentElement; + } + return true; + } + + private updateProgress(updater: (p: TutorialProgress) => TutorialProgress): void { + const next = updater(this.progressSubject.getValue()); + this.progressSubject.next(next); + saveProgress(next); + } + + private handleHighlight(el: Element | undefined, opts: { state?: { activeIndex?: number } } | undefined): void { + const idx = opts?.state?.activeIndex ?? 0; + this.lastActiveIndex = idx; + this.ngZone.run(() => this.currentStepSubject.next(idx)); + this.detachClickAdvance(); + const step = this.currentFlow?.steps[idx]; + if (!step || !this.currentFlow) return; + + // Collapse expanded operator-menu categories before highlighting a TOP-LEVEL category, so prior + // expansions don't push the target down. Skip for nested sub-groups (the parent must stay open, + // otherwise the sub-group rect goes 0×0 and auto-skip jumps past the step). Refresh after the + // ~300ms collapse animation since driver.js measures synchronously. + if (typeof step.element === "string" && /nz-collapse-panel\[data-group-name=/.test(step.element)) { + const target = document.querySelector(step.element) as HTMLElement | null; + const isTopLevel = target?.getAttribute("data-depth") === "0"; + if (isTopLevel) { + this.resetOperatorMenuCategories(); + setTimeout(() => this.driverObj?.refresh(), 350); + } + } + + // Persist the new position so refresh mid-tour resumes here. + this.updateProgress(p => ({ + ...p, + current: { flowId: this.currentFlow!.id, stepIndex: idx }, + })); + + if (step.showDetailsButton) { + setTimeout(() => this.injectShowDetailsButton(idx), 0); + } + + // Scroll only when not already fully visible — keeps fixed toolbar icons flicker-free. + if (el && typeof (el as HTMLElement).scrollIntoView === "function") { + if (!this.isFullyVisible(el as HTMLElement)) { + (el as HTMLElement).scrollIntoView({ block: "center", inline: "nearest" }); + setTimeout(() => this.driverObj?.refresh(), 60); + } + } + + // Auto-skip steps whose target is missing / zero-size. Skip the final step (moveNext ends the tour). + const isLastStep = idx === this.currentFlow.steps.length - 1; + if (typeof step.element === "string" && !isLastStep) { + const selector = step.element; + setTimeout(() => { + if (this.currentStepIndex !== idx || !this.driverObj) return; + const reEl = document.querySelector(selector) as HTMLElement | null; + const rect = reEl?.getBoundingClientRect(); + const missing = !reEl || !rect || (rect.width === 0 && rect.height === 0); + if (missing) { + this.ngZone.run(() => this.driverObj?.moveNext()); + } + }, 250); + } + + // Fire a micro-reward (sparkles + XP) — but only the first time the user + // hits this step. Replays don't keep stacking XP. + this.fireMicroRewardIfFirstTime(idx); + + if (!el && step.element) { + // eslint-disable-next-line no-console + console.warn(`[tutorial] step ${idx + 1} highlight: NO ELEMENT MATCHED for`, step.element); + } + if (step.advanceOnClick && el) this.attachClickAdvance(el); + } + + private handleDestroyed(): void { + this.detachClickAdvance(); + this.ngZone.run(() => { + const flow = this.currentFlow; + const wasOnLast = flow ? this.lastActiveIndex === flow.steps.length - 1 : false; + this.driverObj = null; + this.isActiveSubject.next(false); + this.currentFlowSubject.next(null); + if (wasOnLast && flow) this.finalizeFlowCompletion(flow); + else { + // User skipped mid-flow — keep `current` so the resume toast can fire later. + } + this.currentFlow = null; + }); + } + + private finalizeFlowCompletion(flow: TutorialFlow): void { + const durationMs = Date.now() - this.flowStartedAt; + const isFirstCompletion = !this.progress.completed[flow.id]; + + this.updateProgress(p => { + const completed = { ...p.completed, [flow.id]: { completedAt: Date.now(), durationMs } }; + const earned = new Set(p.earnedBadges); + const newlyEarned: BadgeDef[] = []; + + if (isFirstCompletion && !earned.has(flow.badge.id)) { + earned.add(flow.badge.id); + newlyEarned.push(flow.badge); + } + // Speed runner bonus + if (durationMs < SPEED_RUNNER_THRESHOLD_MS && !earned.has(SPEED_RUNNER_BADGE.id)) { + earned.add(SPEED_RUNNER_BADGE.id); + newlyEarned.push(SPEED_RUNNER_BADGE); + } + // Graduate bonus — only when every primary flow badge is earned. + const flowBadgeIds = FLOWS.map(f => f.badge.id); + const hasAllFlowBadges = flowBadgeIds.every(id => earned.has(id)); + if (hasAllFlowBadges && !earned.has(GRADUATE_BADGE.id)) { + earned.add(GRADUATE_BADGE.id); + newlyEarned.push(GRADUATE_BADGE); + } + + // Fire badge-unlocked notifications after the state has been written. + setTimeout(() => { + newlyEarned.forEach(badge => this.badgeUnlockedSubject.next({ badge, flowName: flow.name })); + }, 0); + + return { + ...p, + completed, + earnedBadges: Array.from(earned), + current: undefined, + }; + }); + + this.completedSubject.next({ flowId: flow.id }); + } + + private fireMicroRewardIfFirstTime(stepIdx: number): void { + if (!this.currentFlow) return; + const flowId = this.currentFlow.id; + const microsForFlow = this.progress.microsEarned[flowId] ?? []; + if (microsForFlow.includes(stepIdx)) return; // already rewarded + + this.updateProgress(p => ({ + ...p, + xp: p.xp + XP_PER_STEP, + microsEarned: { + ...p.microsEarned, + [flowId]: [...(p.microsEarned[flowId] ?? []), stepIdx], + }, + })); + this.spawnSparkleBurst(); + } + + /** Pure-DOM sparkle + XP burst, anchored top-right so it never overlaps the spotlight. */ + private spawnSparkleBurst(): void { + const burst = document.createElement("div"); + burst.className = "tutorial-sparkle-burst"; + + // Bias particle trajectories to fly DOWN-LEFT (into the workspace), + // not up-right (off-screen) since the burst sits in the top-right corner. + const angles = [110, 140, 170, 200, 230, 260]; + angles.forEach((angle, i) => { + const radius = 18 + Math.random() * 8; + const dx = Math.cos((angle * Math.PI) / 180) * radius; + const dy = Math.sin((angle * Math.PI) / 180) * radius; + const sp = document.createElement("span"); + sp.className = "sparkle-particle"; + sp.textContent = i % 2 === 0 ? "✦" : "✧"; + sp.style.setProperty("--dx", `${dx}px`); + sp.style.setProperty("--dy", `${dy}px`); + sp.style.animationDelay = `${i * 30}ms`; + burst.appendChild(sp); + }); + + const xp = document.createElement("div"); + xp.className = "xp-float"; + xp.textContent = `+${XP_PER_STEP} XP`; + burst.appendChild(xp); + + document.body.appendChild(burst); + setTimeout(() => burst.remove(), 1300); + } + + private attachClickAdvance(el: Element): void { + this.clickAdvanceEl = el; + this.clickAdvanceHandler = () => { + setTimeout(() => this.ngZone.run(() => this.driverObj?.moveNext()), 500); + }; + el.addEventListener("click", this.clickAdvanceHandler, { once: true }); + } + + private detachClickAdvance(): void { + if (this.clickAdvanceEl && this.clickAdvanceHandler) { + this.clickAdvanceEl.removeEventListener("click", this.clickAdvanceHandler); + } + this.clickAdvanceEl = null; + this.clickAdvanceHandler = null; + } + + private autoAdvanceIfMatches(trigger: AutoAdvanceTrigger): void { + if (!this.isActive || !this.driverObj || !this.currentFlow) return; + const step = this.currentFlow.steps[this.currentStepIndex]; + if (step?.autoAdvanceOn !== trigger) return; + // When a drag-to-canvas finishes, fold the operator menu category back + // immediately — waiting until the next category step is too late: the + // category visually stays open during the entire intermediate steps + // (click operator, open property panel, configure, ...). + if (trigger === "operatorAdded") { + this.resetOperatorMenuCategories(); + } + setTimeout(() => this.ngZone.run(() => this.driverObj?.moveNext()), 700); + } + + private wireEventListeners(): void { + this.workflowActionService + .getTexeraGraph() + .getOperatorAddStream() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.autoAdvanceIfMatches("operatorAdded")); + + this.workflowActionService + .getTexeraGraph() + .getLinkAddStream() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.autoAdvanceIfMatches("linkAdded")); + + this.workflowActionService + .getTexeraGraph() + .getOperatorPropertyChangeStream() + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.autoAdvanceIfMatches("propertyChanged")); + + this.workflowActionService + .getJointGraphWrapper() + .getJointOperatorHighlightStream() + .pipe( + filter(ids => ids.length > 0), + takeUntil(this.destroy$) + ) + .subscribe(() => this.autoAdvanceIfMatches("operatorSelected")); + + this.executeWorkflowService + .getExecutionStateStream() + .pipe( + filter(({ current }) => current.state === ExecutionState.Running || current.state === ExecutionState.Completed), + takeUntil(this.destroy$) + ) + .subscribe(() => this.autoAdvanceIfMatches("executionStarted")); + + this.computingUnitStatusService + .getStatus() + .pipe( + filter(state => state === ComputingUnitState.Running), + takeUntil(this.destroy$) + ) + .subscribe(() => this.autoAdvanceIfMatches("computingUnitConnected")); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.detachClickAdvance(); + this.driverObj?.destroy(); + } +} diff --git a/frontend/src/assets/tutorial-workflows/movies-filter.json b/frontend/src/assets/tutorial-workflows/movies-filter.json new file mode 100644 index 00000000000..5a00a1acab0 --- /dev/null +++ b/frontend/src/assets/tutorial-workflows/movies-filter.json @@ -0,0 +1,102 @@ +{ + "name": "Tutorial — Movies (filtered)", + "description": "Pre-built starter workflow used by the chart-building tour. CSV → Filter(year > 1960).", + "wid": null, + "creationTime": null, + "lastModifiedTime": null, + "isPublished": 0, + "readonly": false, + "content": { + "operators": [ + { + "operatorID": "CSVFileScan-operator-171d5ebd-a2cb-4a8c-997c-135eae5a4978", + "operatorType": "CSVFileScan", + "operatorVersion": "N/A", + "operatorProperties": { + "fileEncoding": "UTF_8", + "customDelimiter": ",", + "hasHeader": true, + "fileName": "/texera/test/v1/movies (1).csv" + }, + "inputPorts": [], + "outputPorts": [ + { + "portID": "output-0", + "displayName": "", + "disallowMultiInputs": false, + "isDynamicPort": false + } + ], + "showAdvanced": false, + "isDisabled": false, + "customDisplayName": "CSV File Scan", + "dynamicInputPorts": false, + "dynamicOutputPorts": false + }, + { + "operatorID": "Filter-operator-83c55041-a9f9-4a0c-8ceb-c9b63e5e27bc", + "operatorType": "Filter", + "operatorVersion": "N/A", + "operatorProperties": { + "predicates": [ + { + "attribute": "year", + "condition": ">", + "value": "1960" + } + ] + }, + "inputPorts": [ + { + "portID": "input-0", + "displayName": "", + "disallowMultiInputs": false, + "isDynamicPort": false, + "dependencies": [] + } + ], + "outputPorts": [ + { + "portID": "output-0", + "displayName": "", + "disallowMultiInputs": false, + "isDynamicPort": false + } + ], + "showAdvanced": false, + "isDisabled": false, + "customDisplayName": "Filter", + "dynamicInputPorts": false, + "dynamicOutputPorts": false + } + ], + "operatorPositions": { + "CSVFileScan-operator-171d5ebd-a2cb-4a8c-997c-135eae5a4978": { + "x": 350, + "y": 200 + }, + "Filter-operator-83c55041-a9f9-4a0c-8ceb-c9b63e5e27bc": { + "x": 500, + "y": 200 + } + }, + "links": [ + { + "linkID": "link-3d2aef7b-0cb5-49b4-9811-71f74e871841", + "source": { + "operatorID": "CSVFileScan-operator-171d5ebd-a2cb-4a8c-997c-135eae5a4978", + "portID": "output-0" + }, + "target": { + "operatorID": "Filter-operator-83c55041-a9f9-4a0c-8ceb-c9b63e5e27bc", + "portID": "input-0" + } + } + ], + "commentBoxes": [], + "settings": { + "dataTransferBatchSize": 400, + "executionMode": "PIPELINED" + } + } +} diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 5f4a00952a9..c060d3b8815 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -19,6 +19,13 @@ @import "@ali-hm/angular-tree-component/css/angular-tree-component.css"; +/* Tutorial mode — driver.js popover styling, body.driver-active overrides, + sparkle-burst animation. All rules are gated by `body.driver-active` or + the `.tutorial-*` / `.driver-popover.tutorial-*` namespace, so they have + no effect when no tour is running. See the partial for why these rules + must live at the global root rather than in component stylesheets. */ +@import "tutorial-driver-overrides"; + .ant-menu-item, .ant-menu-submenu-title, img { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6a4ae4330c4..699d39f1ad8 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -6002,6 +6002,13 @@ __metadata: languageName: node linkType: hard +"@types/canvas-confetti@npm:^1.9.0": + version: 1.9.0 + resolution: "@types/canvas-confetti@npm:1.9.0" + checksum: 10c0/ffe2c674d466b8e13472c81ab2a97056f3433fd40a3513dbc1bb76764e4e7c3ff0a2a58d37b16ea6a245c831077c553db321b069dda6572eab59f2be61137b2e + languageName: node + linkType: hard + "@types/chai@npm:^5.2.2": version: 5.2.3 resolution: "@types/chai@npm:5.2.3" @@ -8224,6 +8231,13 @@ __metadata: languageName: node linkType: hard +"canvas-confetti@npm:^1.9.4": + version: 1.9.4 + resolution: "canvas-confetti@npm:1.9.4" + checksum: 10c0/08f11b5fa20b365922108828d7488f9b3eac2afdf39bc478339268c1250b5215df82682d7074520a3f0f72f4728f9703bbc937dcfe793f196b44e6e8ac0b1f7b + languageName: node + linkType: hard + "case-anything@npm:^2.1.13": version: 2.1.13 resolution: "case-anything@npm:2.1.13" @@ -9401,6 +9415,13 @@ __metadata: languageName: node linkType: hard +"driver.js@npm:^1.4.0": + version: 1.4.0 + resolution: "driver.js@npm:1.4.0" + checksum: 10c0/f868e85037d4180d55a883ec5bdad7a59e3b16e84438af4285ad605cab651b21fe565839a1607992362b0c028309d4ae6d052926d1c4ab67b2c62142a579b529 + languageName: node + linkType: hard + "dunder-proto@npm:1.0.1, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" @@ -11035,6 +11056,7 @@ __metadata: "@nx/angular": "npm:22.7.0" "@schematics/angular": "npm:21.2.8" "@types/backbone": "npm:1.4.15" + "@types/canvas-confetti": "npm:^1.9.0" "@types/concaveman": "npm:1.1.6" "@types/d3-shape": "npm:2.1.2" "@types/dagre": "npm:0.7.47" @@ -11057,10 +11079,12 @@ __metadata: "@vitest/coverage-v8": "npm:4.1.5" ai: "npm:5.0.93" ajv: "npm:8.10.0" + canvas-confetti: "npm:^1.9.4" concaveman: "npm:2.0.0" concurrently: "npm:7.4.0" d3-shape: "npm:2.1.0" dagre: "npm:0.8.5" + driver.js: "npm:^1.4.0" eslint: "npm:8.57.0" eslint-plugin-rxjs: "npm:5.0.3" eslint-plugin-rxjs-angular: "npm:2.0.1"