diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..1e68ac5d6 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,9 @@ +{ + "servers": { + "figma": { + "url": "http://127.0.0.1:3845/mcp", + "type": "http" + } + }, + "inputs": [] +} \ No newline at end of file diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index dca6ae16e..8520f55ee 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -14,4 +14,4 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. -version: 10.30.13 +version: 10.32.0 diff --git a/helm-chart/README.md b/helm-chart/README.md index 939cd5c5d..c5e5277ec 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -22,7 +22,7 @@ ingress: - host: datalab.my-domain.net EOF -helm install onyxia onyxia/onyxia --version "10.30.13" -f onyxia-values.yaml +helm install onyxia onyxia/onyxia --version "10.32.0" -f onyxia-values.yaml ``` ### Using the Keycloak Theme (Optional) @@ -44,7 +44,7 @@ extraInitContainers: | args: - -c - | - curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v10.30.13/keycloak-theme.jar + curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v10.32.0/keycloak-theme.jar volumeMounts: - name: extensions mountPath: /extensions @@ -71,7 +71,7 @@ After that, you should be able to select *onyxia* as *Login Theme*. Documentation reference for the available configuration parameter of the Onyxia Helm Chart. - [The REST API (`api`)](https://github.com/InseeFrLab/onyxia-api/blob/v4.11.0/README.md#configuration) -- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v4.56.19/web/.env) +- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v4.58.0/web/.env) Below is a sample `onyxia-values.yaml` file that illustrates where to specify the `api` and `web` configuration parameters. @@ -107,4 +107,4 @@ Below is a sample `onyxia-values.yaml` file that illustrates where to specify th If you are building your own service catalog for Onyxia ([learn how](https://docs.onyxia.sh/catalog-of-services)). Here are defined the onyxia reserved parameter and the structure of the dynamic context: -[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v4.56.19/web/src/core/ports/OnyxiaApi/XOnyxia.ts) +[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v4.58.0/web/src/core/ports/OnyxiaApi/XOnyxia.ts) diff --git a/helm-chart/templates/web/deployment.yaml b/helm-chart/templates/web/deployment.yaml index 4b7c2beee..c266bfe43 100644 --- a/helm-chart/templates/web/deployment.yaml +++ b/helm-chart/templates/web/deployment.yaml @@ -41,6 +41,10 @@ spec: value: {{ $value | quote }} {{- end -}} {{- end }} + {{- if .Values.onboarding.enabled }} + - name: OIDC_DISABLE_DPOP + value: "true" + {{- end }} ports: - name: http containerPort: {{ .Values.web.containerPort }} diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 52d85383a..a8ce8131c 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -30,7 +30,7 @@ web: replicaCount: 1 image: repository: inseefrlab/onyxia-web - tag: 4.56.19 + tag: 4.58.0 pullPolicy: IfNotPresent ## Pod priority settings diff --git a/web/.env b/web/.env index 64ce846ce..7ba131255 100644 --- a/web/.env +++ b/web/.env @@ -887,3 +887,6 @@ ONYXIA_VERSION_URL= SCREEN_SCALER=true OIDC_DEBUG_LOGS=false + +# NOTE: Temporary workaround until the Go onboarding API supports it. +OIDC_DISABLE_DPOP=false diff --git a/web/package.json b/web/package.json index 3fb0a3feb..076a7cfa3 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "onyxia-web", "homepage": "https://onyxia.sh", "type": "module", - "version": "4.56.19", + "version": "4.58.0", "license": "MIT", "scripts": { "postinstall": "yarn install-git-hooks && yarn postinstall:code-gen", @@ -69,7 +69,7 @@ "memoizee": "^0.4.17", "minimal-polyfills": "^2.2.3", "mui-icons-material-lazy": "^1.0.4", - "oidc-spa": "^8.6.3", + "oidc-spa": "10.0.1-rc.6", "onyxia-ui": "^6.7.8", "pathe": "^2.0.3", "powerhooks": "^2.0.1", diff --git a/web/src/core/adapters/oidc/mock.ts b/web/src/core/adapters/oidc/mock.ts index e693ac466..bbcd61d4b 100644 --- a/web/src/core/adapters/oidc/mock.ts +++ b/web/src/core/adapters/oidc/mock.ts @@ -1,5 +1,5 @@ import type { Oidc } from "core/ports/Oidc"; -import { createMockOidc } from "oidc-spa/mock"; +import { createMockOidc } from "oidc-spa/core-mock"; export async function createOidc(params: { isUserInitiallyLoggedIn: boolean; @@ -8,7 +8,6 @@ export async function createOidc(params: { const oidc = await createMockOidc({ isUserInitiallyLoggedIn, - homeUrl: import.meta.env.BASE_URL, mockedTokens: { decodedIdToken: {} } diff --git a/web/src/core/adapters/oidc/oidc.ts b/web/src/core/adapters/oidc/oidc.ts index 098fe03c7..314b17c5d 100644 --- a/web/src/core/adapters/oidc/oidc.ts +++ b/web/src/core/adapters/oidc/oidc.ts @@ -1,5 +1,5 @@ import type { Oidc } from "core/ports/Oidc"; -import { createOidc as createOidcSpa } from "oidc-spa"; +import { createOidc as createOidcSpa } from "oidc-spa/core"; import { isKeycloak } from "oidc-spa/keycloak"; import type { OidcParams, OidcParams_Partial } from "core/ports/OnyxiaApi"; import { objectKeys } from "tsafe/objectKeys"; diff --git a/web/src/core/adapters/sqlOlap/sqlOlap.ts b/web/src/core/adapters/sqlOlap/sqlOlap.ts index 1ecbd6018..9893da3c6 100644 --- a/web/src/core/adapters/sqlOlap/sqlOlap.ts +++ b/web/src/core/adapters/sqlOlap/sqlOlap.ts @@ -173,8 +173,8 @@ export const createDuckDbSqlOlap = (params: { await conn.query( [ - "LOAD httpfs;", - `SET custom_extension_repository = '${window.location.origin}${import.meta.env.BASE_URL}duckdb-extensions';` + `SET custom_extension_repository = '${window.location.origin}${import.meta.env.BASE_URL}duckdb-extensions';`, + "LOAD httpfs;" ].join("\n") ); @@ -278,7 +278,7 @@ export const createDuckDbSqlOlap = (params: { sourceUrl = sourceUrl_noRedirect; } - const sqlQuery = `SELECT * FROM ${(() => { + let sqlQuery = `SELECT * FROM ${(() => { switch (fileType) { case "csv": return `read_csv('${sourceUrl}')`; @@ -287,7 +287,11 @@ export const createDuckDbSqlOlap = (params: { case "json": return `read_json('${sourceUrl}')`; } - })()} LIMIT ${rowsPerPage} OFFSET ${rowsPerPage * (page - 1)}`; + })()} LIMIT ${rowsPerPage}`; + + if (page !== 1) { + sqlQuery += ` OFFSET ${rowsPerPage * (page - 1)}`; + } const conn = await db.connect(); const stmt = await conn.prepare(sqlQuery); diff --git a/web/src/core/ports/Oidc.ts b/web/src/core/ports/Oidc.ts index 7398db522..e2ee77328 100644 --- a/web/src/core/ports/Oidc.ts +++ b/web/src/core/ports/Oidc.ts @@ -2,10 +2,9 @@ export declare type Oidc = Oidc.LoggedIn | Oidc.NotLoggedIn; export declare namespace Oidc { export type Common = { - params: { - issuerUri: string; - clientId: string; - }; + issuerUri: string; + clientId: string; + validRedirectUri: string; }; export type NotLoggedIn = Common & { diff --git a/web/src/core/tools/S3Uri.ts b/web/src/core/tools/S3Uri.ts new file mode 100644 index 000000000..04f709c76 --- /dev/null +++ b/web/src/core/tools/S3Uri.ts @@ -0,0 +1,232 @@ +import { assert, type Equals, id } from "tsafe"; +import { same } from "evt/tools/inDepth/same"; +import { z } from "zod"; + +export type S3Uri = S3Uri.Object | S3Uri.Prefix; + +export namespace S3Uri { + type Common = { + bucket: string; + delimiter: string; + keySegments: string[]; + }; + + export type Object = Common & { + type: "object"; + keyBasename: string; + }; + + export type Prefix = Prefix.TerminatedByDelimiter | Prefix.NonTerminatedByDelimiter; + + export namespace Prefix { + type Common_Prefix = Common & { + type: "prefix"; + }; + + export type TerminatedByDelimiter = Common_Prefix & { + isDelimiterTerminated: true; + }; + + export type NonTerminatedByDelimiter = Common_Prefix & { + isDelimiterTerminated: false; + nextKeySegmentPrefix: string; + }; + } +} + +export function stringifyS3Uri(s3Uri: S3Uri): string { + let s3UriStr = [ + "s3://", + `${s3Uri.bucket}/`, + s3Uri.keySegments.map(keySegment => `${keySegment}${s3Uri.delimiter}`).join("") + ].join(""); + + switch (s3Uri.type) { + case "object": + s3UriStr += s3Uri.keyBasename; + break; + case "prefix": + if (!s3Uri.isDelimiterTerminated) { + s3UriStr += s3Uri.nextKeySegmentPrefix; + } + break; + default: + assert>; + } + + return s3UriStr; +} + +export function getS3UriKeyOrKeyPrefix(s3Uri: S3Uri): string { + return stringifyS3Uri(s3Uri).slice(`s3://${s3Uri.bucket}/`.length); +} + +export function parseS3Uri(params: { + value: string; + delimiter: string; + isPrefix: false; +}): S3Uri.Object; +export function parseS3Uri(params: { + value: string; + delimiter: string; + isPrefix: true; +}): S3Uri.Prefix; +export function parseS3Uri(params: { + value: string; + delimiter: string; + isPrefix: boolean; +}): S3Uri { + const { value, delimiter, isPrefix } = params; + + const match = value.match(/^s3:\/\/([^/]+)(\/?.*)$/); + + if (match === null) { + throw new Error(`Malformed S3 URI: ${value}`); + } + + const bucket = match[1]; + + const group2 = match[2]; + + if (group2 === "" || group2 === "/") { + return id({ + type: "prefix", + bucket, + delimiter, + keySegments: [], + isDelimiterTerminated: true + }); + } + + const key = group2.slice(1); + + const [last, ...rest_reversed] = key.split(delimiter).reverse(); + + const keySegments = rest_reversed.reverse(); + + if (last === "") { + assert(isPrefix); + return id({ + type: "prefix", + bucket, + delimiter, + keySegments, + isDelimiterTerminated: true + }); + } + + if (isPrefix) { + return id({ + type: "prefix", + bucket, + delimiter, + keySegments, + isDelimiterTerminated: false, + nextKeySegmentPrefix: last + }); + } + + return id({ + type: "object", + bucket, + delimiter, + keySegments, + keyBasename: last + }); +} + +export function getIsInside(params: { + s3UriPrefix: S3Uri.Prefix; + s3Uri: S3Uri; +}): { isInside: false; isTopLevel?: never } | { isInside: true; isTopLevel: boolean } { + const { s3UriPrefix, s3Uri } = params; + + if (!stringifyS3Uri(s3Uri).startsWith(stringifyS3Uri(s3UriPrefix))) { + return { isInside: false }; + } + return { + isInside: true, + isTopLevel: same(s3UriPrefix.keySegments, s3Uri.keySegments) + }; +} + +export const zS3UriObject = (() => { + type TargetType = S3Uri.Object; + + const zTargetType = z.object({ + type: z.literal("object"), + bucket: z.string(), + delimiter: z.string(), + keySegments: z.array(z.string()), + keyBasename: z.string() + }); + + type InferredType = z.infer; + + assert>; + + return id>(zTargetType); +})(); + +export const zS3UriPrefixDelimiterTerminated = (() => { + type TargetType = S3Uri.Prefix.TerminatedByDelimiter; + + const zTargetType = z.object({ + type: z.literal("prefix"), + bucket: z.string(), + delimiter: z.string(), + keySegments: z.array(z.string()), + isDelimiterTerminated: z.literal(true) + }); + + type InferredType = z.infer; + + assert>; + + return id>(zTargetType); +})(); + +export const zS3UriPrefixNonDelimiterTerminated = (() => { + type TargetType = S3Uri.Prefix.NonTerminatedByDelimiter; + + const zTargetType = z.object({ + type: z.literal("prefix"), + bucket: z.string(), + delimiter: z.string(), + keySegments: z.array(z.string()), + isDelimiterTerminated: z.literal(false), + nextKeySegmentPrefix: z.string() + }); + + type InferredType = z.infer; + + assert>; + + return id>(zTargetType); +})(); + +export const zS3UriPrefix = (() => { + type TargetType = S3Uri.Prefix; + + const zTargetType = z.union([ + zS3UriPrefixDelimiterTerminated, + zS3UriPrefixNonDelimiterTerminated + ]); + + type InferredType = z.infer; + + assert>; + return id>(zTargetType); +})(); + +export const zS3Uri = (() => { + type TargetType = S3Uri; + + const zTargetType = z.union([zS3UriObject, zS3UriPrefix]); + + type InferredType = z.infer; + + assert>; + + return id>(zTargetType); +})(); diff --git a/web/src/core/usecases/k8sCodeSnippets.ts b/web/src/core/usecases/k8sCodeSnippets.ts index 4130701b1..0b57282ea 100644 --- a/web/src/core/usecases/k8sCodeSnippets.ts +++ b/web/src/core/usecases/k8sCodeSnippets.ts @@ -158,8 +158,8 @@ export const thunks = { dispatch( actions.refreshed({ - idpIssuerUrl: kubernetesOidcClient.params.issuerUri, - clientId: kubernetesOidcClient.params.clientId, + idpIssuerUrl: kubernetesOidcClient.issuerUri, + clientId: kubernetesOidcClient.clientId, refreshToken: oidcTokens.refreshToken ?? "", idToken: oidcTokens.idToken, user: `${region.kubernetes.usernamePrefix ?? ""}${user.username}`, diff --git a/web/src/core/usecases/userAuthentication/thunks.ts b/web/src/core/usecases/userAuthentication/thunks.ts index e8dd94b6a..ffd1d0112 100644 --- a/web/src/core/usecases/userAuthentication/thunks.ts +++ b/web/src/core/usecases/userAuthentication/thunks.ts @@ -1,7 +1,7 @@ import { assert } from "tsafe/assert"; import type { Thunks } from "core/bootstrap"; import { actions } from "./state"; -import { parseKeycloakIssuerUri } from "oidc-spa/tools/parseKeycloakIssuerUri"; +import { createKeycloakUtils, isKeycloak } from "oidc-spa/keycloak"; export const thunks = { login: @@ -33,14 +33,16 @@ export const thunks = { assert(oidc.isUserLoggedIn); - const keycloak = parseKeycloakIssuerUri(oidc.params.issuerUri); + assert(isKeycloak({ issuerUri: oidc.issuerUri })); - assert(keycloak !== undefined); + const keycloakUtils = createKeycloakUtils({ + issuerUri: oidc.issuerUri + }); - window.location.href = keycloak.getAccountUrl({ - backToAppFromAccountUrl: window.location.href, - clientId: oidc.params.clientId, - locale: paramsOfBootstrapCore.getCurrentLang() + window.location.href = keycloakUtils.getAccountUrl({ + clientId: oidc.clientId, + locale: paramsOfBootstrapCore.getCurrentLang(), + validRedirectUri: oidc.validRedirectUri }); return new Promise(() => {}); @@ -61,9 +63,7 @@ export const protectedThunks = { : { isUserLoggedIn: true, user: (await onyxiaApi.getUserAndProjects()).user, - isKeycloak: - parseKeycloakIssuerUri(oidc.params.issuerUri) !== - undefined + isKeycloak: isKeycloak({ issuerUri: oidc.issuerUri }) } ) ); diff --git a/web/src/main.tsx b/web/src/main.tsx index 50b6a5e62..79bf2fcbe 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -18,11 +18,20 @@ if (import.meta.env.DEV) { return; } - const { oidcEarlyInit } = await import("oidc-spa/entrypoint"); + const [{ oidcEarlyInit }, { browserRuntimeFreeze }, { DPoP }] = await Promise.all([ + import("oidc-spa/entrypoint"), + import("oidc-spa/browser-runtime-freeze"), + import.meta.env.OIDC_DISABLE_DPOP === "true" + ? { DPoP: undefined } + : import("oidc-spa/DPoP") + ]); const { shouldLoadApp } = oidcEarlyInit({ - enableTokenExfiltrationDefense: false, - BASE_URL: import.meta.env.BASE_URL + BASE_URL: import.meta.env.BASE_URL, + securityDefenses: { + ...browserRuntimeFreeze(), + ...DPoP?.({ mode: "auto" }) + } }); if (!shouldLoadApp) { diff --git a/web/src/ui/assets/svg/S3UriBucket.svg b/web/src/ui/assets/svg/S3UriBucket.svg new file mode 100644 index 000000000..f1572920c --- /dev/null +++ b/web/src/ui/assets/svg/S3UriBucket.svg @@ -0,0 +1,12 @@ + + + + diff --git a/web/src/ui/assets/svg/S3UriHome.svg b/web/src/ui/assets/svg/S3UriHome.svg new file mode 100644 index 000000000..c7eff89fb --- /dev/null +++ b/web/src/ui/assets/svg/S3UriHome.svg @@ -0,0 +1,6 @@ + + + diff --git a/web/src/ui/general.spec.md b/web/src/ui/general.spec.md new file mode 100644 index 000000000..f6b0ed333 --- /dev/null +++ b/web/src/ui/general.spec.md @@ -0,0 +1,12 @@ +# UI General Spec + +## Styling Guidelines (tss-react) + +- Each component owns one dedicated `useStyles` created with `tss.create()`. +- Prefer `tss.withParams<...>().create(...)` for conditional styles. +- Avoid style explosion from many atomic variant class combinations. + +Recommended pattern: + +- `const { classes, cx } = useStyles({ ...params })` +- `className={cx(classes.root, props.className)}` diff --git a/web/src/ui/shared/S3BookmarksBar.md b/web/src/ui/shared/S3BookmarksBar.md new file mode 100644 index 000000000..f6d05b89d --- /dev/null +++ b/web/src/ui/shared/S3BookmarksBar.md @@ -0,0 +1,140 @@ +# S3BookmarksBar Specification + +## Purpose + +`S3BookmarksBar` displays a horizontal list of shortcuts to frequently used S3 paths. + +Bookmarks can originate from: + +- The user +- The administrator + +The component is controlled: + +- It owns no business state. +- It does not manage persistence. +- It renders only what is provided via props. + +It is always displayed: +`S3UriBar +↓ +S3BookmarksBar +↓ +Object list` + +--- + +# 1. Bookmark Types + +## 1.1 User Bookmarks + +- Created by the current user. +- Can be removed (unpinned) by the user. +- Displayed with an interactive pin icon. +- Uses the interactive surfaces defined in the design system. + +## 1.2 Admin Bookmarks + +- Created by an administrator. +- Visible to users of the corresponding S3 profile. +- Cannot be removed by standard users. +- Does not expose interactive actions. +- Uses a secondary background surface similar to non-interactive cards. + +--- + +# 2. Bookmark Definition + +A bookmark represents: +`profileId + fullPath` + +Where: + +- `profileId` = active S3 profile +- `fullPath` = complete S3 path (bucket + prefix) + +## Uniqueness Rules + +- A path cannot be duplicated within the same bookmark type. +- If an admin bookmark exists for a path: + - The user cannot create a personal bookmark for that same path. + +Uniqueness enforcement belongs to the business layer. + +--- + +# 3. Ordering Rules (Priority) + +Bookmarks follow these ordering rules: + +1. Most recently added bookmarks appear first. +2. When recency is equivalent: + - User bookmarks take visual priority over admin bookmarks. + +This ordering applies to: + +- `S3BookmarksBar` +- Bookmark entry point lists (defined in a separate specification) + +Within the horizontal bar: + +- Higher priority items appear further to the left. + +--- + +# 4. Visual Differentiation + +User bookmarks and admin bookmarks must be visually distinct. + +## 4.1 User Bookmark (Interactive) + +Structure: +`[PinIcon] Label` + +### Behavior + +- Pin icon is visible. +- Clicking the pin icon triggers unpin. +- Hover produces slight elevation. +- Active state (current path) uses a subtle accent background. +- Uses the action surface variations defined in Storybook and UI tokens. + +### Surface + +- Interactive background surface. +- Hover and active states are visible. + +--- + +## 4.2 Admin Bookmark (Non-interactive) + +Structure: +`Label` + +(No interactive pin icon.) + +### Behavior + +- No elevation on hover. +- No unpin interaction. +- Click action only performs navigation. + +### Surface + +- Simple secondary background (similar to application cards). +- No action color variations. +- No hover surface changes. + +The goal is to clearly communicate that the bookmark is defined by the organisation and cannot be removed. + +--- + +# 5. Component Architecture + +## BookmarkChip + +Supports two visual modes: + +```ts +type BookmarkKind = "user" | "admin"; +``` diff --git a/web/src/ui/shared/S3UriBar.spec.md b/web/src/ui/shared/S3UriBar.spec.md new file mode 100644 index 000000000..904c3a5a0 --- /dev/null +++ b/web/src/ui/shared/S3UriBar.spec.md @@ -0,0 +1,119 @@ +# S3UriBar Spec + +## Purpose + +`S3UriBar` is a Chrome-like address bar for S3 prefixes. + +It is a controlled UI component: + +- State is provided by props. +- The component requests changes through callbacks. +- Parent code owns business logic and data fetching. + +## External Responsibilities + +The parent (or surrounding usecase layer) is responsible for: + +- Debounced listing/validation while the user types. +- Fetching and ordering hint content. +- Deciding whether bookmark toggling is available. + +## Modes + +The component has two modes: + +- Navigation mode (`isEditing = false`): breadcrumb-like path with clickable segments. +- Editing mode (`isEditing = true`): text input with optional keyboard-navigable hints. + +## Props Contract + +```ts +export type S3UriBarProps = { + /** + * Allows the parent to position/size the component. + * + * Must take precedence over internal styles: + * className={cx(classes.root, props.className)} + */ + className?: string; + + /** + * The currently selected S3 prefix. + * + * Example object (simplified): + * const s3UriPrefix: S3Uri.Prefix = { ... } + * + * Use helpers from `core/tools/S3Uri` to parse/stringify/manipulate. + */ + s3UriPrefix: S3Uri.Prefix; + + /** + * Current mode. + * - true => Editing mode (text input + hints) + * - false => Navigation mode (breadcrumb) + */ + isEditing: boolean; + + /** + * Request a change to the current prefix. + * + * Called when: + * - user selects a hint + * - user validates input externally (if you wire that) + * - user clicks a breadcrumb segment in navigation mode + */ + onS3UriPrefixChange: (params: { s3UriPrefix: S3Uri.Prefix }) => void; + + /** + * Request an editing mode change. + * + * The component decides *when* to request mode changes based on user interactions: + * - Request `isEditing: true`: + * - pointer down anywhere inside the bar, except on a path segment + * - long-press on a path segment (press duration >= 100ms) + * - Stay in navigation mode: + * - short click on a path segment (press duration < 100ms) triggers navigation instead + * - Request `isEditing: false`: + * - on blur (component loses focus) + * - on Escape (while editing) + */ + onIsEditingChange: (params: { isEditing: boolean }) => void; + + /** + * Hints to display while editing. + * + * - type: "object" => object entry + * - type: "key-segment" => common prefix / folder-like segment + * + * Note: ordering, debouncing, and content correctness are external responsibilities. + */ + hints: { + type: "object" | "key-segment"; + name: string; + }[]; + + /** + * Whether the current prefix is bookmarked. + * If true, show an “active” bookmark indicator. + */ + isBookmarked: boolean; + + /** + * Called when the user requests bookmarking/unbookmarking. + * Can be undefined when bookmarks are not editable in the current context. + */ + onToggleBookmark?: () => void; +}; +``` + +## Interaction Summary + +- Navigation mode: + - Segment short click => request navigation (`onS3UriPrefixChange`). + - Segment long press (`>= 100ms`) => request edit mode (`onIsEditingChange({ isEditing: true })`). +- Editing mode: + - Input updates are handled by parent via requested prefix changes. + - Hints are selectable (pointer and keyboard). + - `Escape` requests return to navigation mode. +- Focus handling: + - Blur requests return to navigation mode. diff --git a/web/src/ui/shared/S3UriBar.stories.tsx b/web/src/ui/shared/S3UriBar.stories.tsx new file mode 100644 index 000000000..a4b8e2cfd --- /dev/null +++ b/web/src/ui/shared/S3UriBar.stories.tsx @@ -0,0 +1,325 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import { useEffect, useMemo, useState } from "react"; +import { parseS3Uri, stringifyS3Uri, type S3Uri } from "core/tools/S3Uri"; +import { S3UriBar, type S3UriBarProps } from "./S3UriBar"; + +const meta = { + title: "Shared/S3UriBar", + component: S3UriBar +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const delimiter = "/"; + +function parsePrefixOrThrow(s3Uri: string): S3Uri.Prefix { + return parsePrefixOrThrowWithDelimiter({ + s3Uri, + delimiter + }); +} + +function parsePrefixOrThrowWithDelimiter(params: { + s3Uri: string; + delimiter: string; +}): S3Uri.Prefix { + const { s3Uri, delimiter } = params; + + return parseS3Uri({ + value: s3Uri, + delimiter, + isPrefix: true + }); +} + +function StatefulS3UriBar(args: S3UriBarProps) { + const [s3UriPrefix, setS3UriPrefix] = useState(args.s3UriPrefix); + const [isEditing, setIsEditing] = useState(args.isEditing); + + useEffect(() => { + setS3UriPrefix(args.s3UriPrefix); + }, [args.s3UriPrefix]); + + useEffect(() => { + setIsEditing(args.isEditing); + }, [args.isEditing]); + + return ( + { + args.onS3UriPrefixChange(params); + setS3UriPrefix(params.s3UriPrefix); + }} + onIsEditingChange={params => { + args.onIsEditingChange(params); + setIsEditing(params.isEditing); + }} + /> + ); +} + +const baseArgs: S3UriBarProps = { + s3UriPrefix: parsePrefixOrThrow( + "s3://analytics-data/exports/2024/quarter-1/report.csv" + ), + isEditing: false, + onS3UriPrefixChange: action("s3UriPrefixChange"), + onIsEditingChange: action("isEditingChange"), + hints: [ + { type: "key-segment", name: "quarter-2" }, + { type: "key-segment", name: "quarter-3" }, + { type: "object", name: "report.parquet" }, + { type: "shortcut", name: "exports/2024/" } + ], + isBookmarked: false, + onToggleBookmark: action("toggleBookmark") +}; + +export const NavigationMode: Story = { + args: { + ...baseArgs + }, + render: args => +}; + +export const EditingModeWithHints: Story = { + args: { + ...baseArgs, + isEditing: true, + s3UriPrefix: parsePrefixOrThrow("s3://analytics-data/exports/2024/") + }, + render: args => +}; + +export const LongPathCollapsed: Story = { + args: { + ...baseArgs, + s3UriPrefix: parsePrefixOrThrow( + "s3://very-long-bucket-name/one/two/three/four/five/six/seven/eight/nine/ten/report.csv" + ) + }, + render: args => ( +
+ +
+ ) +}; + +export const BookmarkedReadonlyIndicator: Story = { + args: { + ...baseArgs, + isBookmarked: true, + onToggleBookmark: undefined + }, + render: args => +}; + +export const RootPrefix: Story = { + args: { + ...baseArgs, + s3UriPrefix: parsePrefixOrThrow("s3://analytics-data/") + }, + render: args => +}; + +export const HashDelimiter: Story = { + args: { + ...baseArgs, + s3UriPrefix: parsePrefixOrThrowWithDelimiter({ + s3Uri: "s3://mybucket/foo#bar#file.txt", + delimiter: "#" + }), + hints: [ + { type: "key-segment", name: "baz" }, + { type: "object", name: "other.txt" }, + { type: "shortcut", name: "foo#bar#" } + ] + }, + render: args => +}; + +export const EditingModeWithShortcuts: Story = { + args: { + ...baseArgs, + isEditing: true, + s3UriPrefix: parsePrefixOrThrow("s3://analytics-data/exports/"), + hints: [ + { type: "shortcut", name: "2024/quarter-1/" }, + { type: "shortcut", name: "raw/events/" }, + { type: "key-segment", name: "dashboards" }, + { type: "object", name: "README.md" } + ] + }, + render: args => +}; + +export const EditingModeWithManyHints: Story = { + args: { + ...baseArgs, + isEditing: true, + s3UriPrefix: parsePrefixOrThrow("s3://analytics-data/exports/"), + hints: [ + { type: "shortcut", name: "2024/" }, + { type: "shortcut", name: "2024/quarter-1/" }, + { type: "shortcut", name: "raw/events/" }, + { type: "key-segment", name: "2021" }, + { type: "key-segment", name: "2022" }, + { type: "key-segment", name: "2023" }, + { type: "key-segment", name: "2024" }, + { type: "key-segment", name: "2025" }, + { type: "key-segment", name: "dashboards" }, + { type: "key-segment", name: "reports" }, + { type: "object", name: "README.md" }, + { type: "object", name: "manifest.json" }, + { type: "object", name: "report-quarterly.parquet" }, + { type: "object", name: "report-yearly.parquet" } + ] + }, + render: args => +}; + +export const EditingModeWithVeryLongHints: Story = { + args: { + ...baseArgs, + isEditing: true, + s3UriPrefix: parsePrefixOrThrow("s3://analytics-data/exports/"), + hints: [ + { + type: "shortcut", + name: "raw/events/2025/region=eu-west-1/source=streaming-ingestion/job=very-long-job-name-with-version-v12/" + }, + { + type: "shortcut", + name: "dashboards/internal/department=analytics/team=core-platform/topic=quarterly-business-review/" + }, + { + type: "key-segment", + name: "department=analytics-team-with-an-extremely-descriptive-name" + }, + { + type: "key-segment", + name: "source-system=customer-interaction-and-engagement-platform" + }, + { + type: "object", + name: "report-quarterly-performance-and-financial-projections-for-fiscal-year-2025.parquet" + }, + { + type: "object", + name: "dashboard_configuration_snapshot_with_extended_metadata_and_annotations.json" + }, + { + type: "object", + name: "this-is-a-very-very-very-very-very-very-long-object-name.csv" + } + ] + }, + render: args => +}; + +const mockS3Tree = { + "analytics-data": { + keySegments: ["exports", "dashboards", "raw", "sandbox"], + objects: ["README.md", "latest.parquet", "manifest.json"] + }, + "research-data": { + keySegments: ["experiments", "snapshots", "models"], + objects: ["index.csv", "summary.txt"] + }, + "shared-datasets": { + keySegments: ["open", "curated", "incoming"], + objects: ["dataset.json", "schema.yaml"] + } +} as const; + +function ControlledS3UriBarStory() { + const [s3UriPrefix, setS3UriPrefix] = useState( + parsePrefixOrThrow("s3://analytics-data/exports/") + ); + const [isEditing, setIsEditing] = useState(false); + const [bookmarkedS3Uris, setBookmarkedS3Uris] = useState([]); + + const currentS3Uri = useMemo(() => stringifyS3Uri(s3UriPrefix), [s3UriPrefix]); + + const hints = useMemo(() => { + const bucketData = mockS3Tree[s3UriPrefix.bucket as keyof typeof mockS3Tree]; + + if (!bucketData) { + return []; + } + + return [ + { + type: "shortcut" as const, + name: `${bucketData.keySegments[0]}${s3UriPrefix.delimiter}` + }, + ...bucketData.keySegments.map(name => ({ + type: "key-segment" as const, + name + })), + ...bucketData.objects.map(name => ({ + type: "object" as const, + name + })) + ]; + }, [s3UriPrefix.bucket]); + + const isBookmarked = bookmarkedS3Uris.includes(currentS3Uri); + + return ( +
+ { + setS3UriPrefix(s3UriPrefix); + action("s3UriPrefixChange")(stringifyS3Uri(s3UriPrefix)); + }} + onIsEditingChange={({ isEditing }) => { + setIsEditing(isEditing); + action("isEditingChange")(isEditing); + }} + onToggleBookmark={() => { + setBookmarkedS3Uris(current => + current.includes(currentS3Uri) + ? current.filter(s3Uri => s3Uri !== currentS3Uri) + : [...current, currentS3Uri] + ); + action("toggleBookmark")(currentS3Uri); + }} + /> + +
+
Current URI: {currentS3Uri}
+
Mode: {isEditing ? "editing" : "navigation"}
+
Hints count: {hints.length}
+
Bookmarked URIs: {bookmarkedS3Uris.length}
+
+
+ ); +} + +export const ControlledShell: Story = { + args: { + ...baseArgs + }, + render: () => +}; diff --git a/web/src/ui/shared/S3UriBar.tsx b/web/src/ui/shared/S3UriBar.tsx new file mode 100644 index 000000000..3c5eb6d21 --- /dev/null +++ b/web/src/ui/shared/S3UriBar.tsx @@ -0,0 +1,1571 @@ +import { + Fragment, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type FocusEvent, + type KeyboardEvent, + type PointerEvent +} from "react"; +import { tss } from "tss"; +import { Tooltip } from "onyxia-ui/Tooltip"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Icon } from "onyxia-ui/Icon"; +import { getIconUrlByName } from "lazy-icons"; +import { useDomRect } from "powerhooks/useDomRect"; +import { parseS3Uri, stringifyS3Uri, type S3Uri } from "core/tools/S3Uri"; +import s3UriBucketSvgUrl from "ui/assets/svg/S3UriBucket.svg"; +import s3UriHomeSvgUrl from "ui/assets/svg/S3UriHome.svg"; + +export type S3UriBarProps = { + className?: string; + s3UriPrefix: S3Uri.Prefix; + isEditing: boolean; + onS3UriPrefixChange: (params: { s3UriPrefix: S3Uri.Prefix }) => void; + onIsEditingChange: (params: { isEditing: boolean }) => void; + hints: { + type: "object" | "key-segment" | "shortcut"; + name: string; + }[]; + isBookmarked: boolean; + onToggleBookmark?: () => void; +}; + +type NavigationCrumb = { + label: string; + kind: "root" | "bucket" | "segment"; + s3UriPrefix: S3Uri.Prefix; + isCurrent: boolean; +}; + +type DisplayCrumb = + | NavigationCrumb + | { + label: "..."; + kind: "ellipsis"; + }; + +const longPressDelayMs = 200; +const hintsPanelHorizontalEdgePaddingPx = 8; +const hintsPanelVerticalOffsetPx = 6; +const hintsPanelFallbackWidthPx = 280; +type CrumbKind = DisplayCrumb["kind"]; +type HintType = S3UriBarProps["hints"][number]["type"]; +const hintMiddleEllipsisMaxLength = 58; +const hintMiddleEllipsisHeadLength = 34; +const hintMiddleEllipsisTailLength = 20; + +function collapseMiddle(text: string): string { + if (text.length <= hintMiddleEllipsisMaxLength) { + return text; + } + + return `${text.slice(0, hintMiddleEllipsisHeadLength)}...${text.slice( + -hintMiddleEllipsisTailLength + )}`; +} + +function getHintTypeLabel(type: HintType): string { + switch (type) { + case "key-segment": + return "Prefix"; + case "shortcut": + return "Shortcut"; + case "object": + return "Object"; + } +} + +function getHintTypeIcon(type: HintType): string { + switch (type) { + case "key-segment": + return getIconUrlByName("Folder"); + case "shortcut": + return getIconUrlByName("Link"); + case "object": + return getIconUrlByName("Description"); + } +} + +function getSeparatorTokenBetweenKinds(params: { + leftKind: CrumbKind; + rightKind: CrumbKind; + delimiter: string; +}): string | undefined { + const { leftKind, rightKind, delimiter } = params; + + if (leftKind === "root" && rightKind === "bucket") { + return undefined; + } + + if (leftKind === "bucket") { + return "/"; + } + + return delimiter; +} + +function shouldShowSeparatorAtIndex( + crumbs: Array<{ kind: CrumbKind }>, + index: number, + delimiter: string +): boolean { + if (index >= crumbs.length - 1) { + return false; + } + + return ( + getSeparatorTokenBetweenKinds({ + leftKind: crumbs[index].kind, + rightKind: crumbs[index + 1].kind, + delimiter + }) !== undefined + ); +} + +function getSeparatorCount( + crumbs: Array<{ kind: CrumbKind }>, + delimiter: string +): number { + let count = 0; + + for (let index = 0; index < crumbs.length - 1; index += 1) { + if (shouldShowSeparatorAtIndex(crumbs, index, delimiter)) { + count += 1; + } + } + + return count; +} + +function getTrailingSeparatorToken(s3UriPrefix: S3Uri.Prefix): string | undefined { + if (!s3UriPrefix.isDelimiterTerminated) { + return undefined; + } + + if (s3UriPrefix.keySegments.length === 0) { + return "/"; + } + + return s3UriPrefix.delimiter; +} + +function getSeparatorWidthForKinds(params: { + leftKind: CrumbKind; + rightKind: CrumbKind; + delimiter: string; + slashSeparatorWidth: number; + delimiterSeparatorWidth: number; +}): number { + const { + leftKind, + rightKind, + delimiter, + slashSeparatorWidth, + delimiterSeparatorWidth + } = params; + const token = getSeparatorTokenBetweenKinds({ + leftKind, + rightKind, + delimiter + }); + + if (token === undefined) { + return 0; + } + + return token === "/" ? slashSeparatorWidth : delimiterSeparatorWidth; +} + +function getBucketRootPrefix(params: { + bucket: string; + delimiter: string; +}): S3Uri.Prefix { + const { bucket, delimiter } = params; + + return { + type: "prefix", + bucket, + delimiter, + keySegments: [], + isDelimiterTerminated: true + }; +} + +function tryParsePrefix(params: { + s3Uri: string; + delimiter: string; +}): S3Uri.Prefix | undefined { + const { s3Uri, delimiter } = params; + + try { + return parseS3Uri({ + value: s3Uri, + delimiter, + isPrefix: true + }); + } catch { + return undefined; + } +} + +function getBreadcrumbs(params: { s3UriPrefix: S3Uri.Prefix }): NavigationCrumb[] { + const { s3UriPrefix } = params; + const { bucket, delimiter } = s3UriPrefix; + const bucketRootPrefix = getBucketRootPrefix({ bucket, delimiter }); + + const segmentLabels = s3UriPrefix.isDelimiterTerminated + ? s3UriPrefix.keySegments + : [...s3UriPrefix.keySegments, s3UriPrefix.nextKeySegmentPrefix]; + + const crumbs: NavigationCrumb[] = [ + { + label: "s3://", + kind: "root", + s3UriPrefix: bucketRootPrefix, + isCurrent: false + }, + { + label: bucket, + kind: "bucket", + s3UriPrefix: bucketRootPrefix, + isCurrent: segmentLabels.length === 0 + } + ]; + + segmentLabels.forEach((segmentLabel, index) => { + const isLast = index === segmentLabels.length - 1; + + const segmentPrefix: S3Uri.Prefix = + isLast && !s3UriPrefix.isDelimiterTerminated + ? { + type: "prefix", + bucket, + delimiter, + keySegments: [...s3UriPrefix.keySegments], + isDelimiterTerminated: false, + nextKeySegmentPrefix: s3UriPrefix.nextKeySegmentPrefix + } + : { + type: "prefix", + bucket, + delimiter, + keySegments: segmentLabels.slice(0, index + 1), + isDelimiterTerminated: true + }; + + crumbs.push({ + label: segmentLabel, + kind: "segment", + s3UriPrefix: segmentPrefix, + isCurrent: isLast + }); + }); + + return crumbs; +} + +export function S3UriBar(props: S3UriBarProps) { + const { + className, + s3UriPrefix, + isEditing, + onS3UriPrefixChange, + onIsEditingChange, + hints, + isBookmarked, + onToggleBookmark + } = props; + + const normalizedPrefix = s3UriPrefix; + const canonicalS3Uri = useMemo( + () => stringifyS3Uri(normalizedPrefix), + [normalizedPrefix] + ); + const crumbs = useMemo( + () => getBreadcrumbs({ s3UriPrefix: normalizedPrefix }), + [normalizedPrefix] + ); + const trailingSeparatorToken = getTrailingSeparatorToken(normalizedPrefix); + + const rootRef = useRef(null); + const inputRef = useRef(null); + const hintsPanelRef = useRef(null); + const textMeasureCanvasRef = useRef(null); + const longPressTimeoutRef = useRef(undefined); + const longPressTriggeredRef = useRef(false); + const wasEditingRef = useRef(isEditing); + const ignoreNextBlurRef = useRef(false); + const lastEnterEditRequestTimeRef = useRef(Number.NEGATIVE_INFINITY); + + const measureCrumbRefs = useRef>([]); + const measureSlashSeparatorRef = useRef(null); + const measureDelimiterSeparatorRef = useRef(null); + const measureEllipsisRef = useRef(null); + const { domRect: pathDisplayRect, ref: pathDisplayRef } = useDomRect(); + + const [draftS3Uri, setDraftS3Uri] = useState(canonicalS3Uri); + const [displayCrumbs, setDisplayCrumbs] = useState(crumbs); + const [activeHintIndex, setActiveHintIndex] = useState(-1); + const [hintsPanelPosition, setHintsPanelPosition] = useState({ + left: hintsPanelHorizontalEdgePaddingPx, + top: 0 + }); + + const inputId = useId(); + const hintsListId = useId(); + + const { classes, cx } = useStyles({ isEditing }); + + const updateHintsPanelPosition = useCallback(() => { + const input = inputRef.current; + const root = rootRef.current; + + if (!input || !root) { + return; + } + + const rootRect = root.getBoundingClientRect(); + const inputRect = input.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(input); + const selectionStart = input.selectionStart ?? input.value.length; + const textBeforeCursor = input.value.slice(0, selectionStart); + + if (textMeasureCanvasRef.current === null) { + textMeasureCanvasRef.current = document.createElement("canvas"); + } + + const context = textMeasureCanvasRef.current.getContext("2d"); + + if (!context) { + return; + } + + context.font = computedStyle.font; + + const measuredTextWidth = context.measureText(textBeforeCursor).width; + const letterSpacingValue = Number.parseFloat(computedStyle.letterSpacing); + const letterSpacingPx = Number.isFinite(letterSpacingValue) + ? letterSpacingValue + : 0; + const letterSpacingWidth = + textBeforeCursor.length > 0 + ? (textBeforeCursor.length - 1) * letterSpacingPx + : 0; + const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0; + const cursorLeft = + inputRect.left - + rootRect.left + + paddingLeft + + measuredTextWidth + + letterSpacingWidth - + input.scrollLeft; + + const panelWidth = + hintsPanelRef.current?.getBoundingClientRect().width ?? + hintsPanelFallbackWidthPx; + const minLeft = hintsPanelHorizontalEdgePaddingPx; + const maxLeft = Math.max( + minLeft, + rootRect.width - panelWidth - hintsPanelHorizontalEdgePaddingPx + ); + const nextLeft = Math.min(Math.max(cursorLeft, minLeft), maxLeft); + const nextTop = inputRect.bottom - rootRect.top + hintsPanelVerticalOffsetPx; + + setHintsPanelPosition(previous => + previous.left === nextLeft && previous.top === nextTop + ? previous + : { left: nextLeft, top: nextTop } + ); + }, []); + + useEffect(() => { + if (!isEditing) { + ignoreNextBlurRef.current = false; + lastEnterEditRequestTimeRef.current = Number.NEGATIVE_INFINITY; + } + + if (!isEditing || !wasEditingRef.current) { + setDraftS3Uri(canonicalS3Uri); + } + + wasEditingRef.current = isEditing; + }, [canonicalS3Uri, isEditing]); + + useEffect(() => { + if (!isEditing) { + setActiveHintIndex(-1); + return; + } + + if (hints.length === 0) { + setActiveHintIndex(-1); + return; + } + + setActiveHintIndex(index => + index >= hints.length ? hints.length - 1 : Math.max(index, 0) + ); + }, [hints, isEditing]); + + useEffect(() => { + if (!isEditing) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + const input = inputRef.current; + + if (!input) { + return; + } + + input.focus(); + const cursorPosition = input.value.length; + input.setSelectionRange(cursorPosition, cursorPosition); + updateHintsPanelPosition(); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [isEditing, updateHintsPanelPosition]); + + useEffect(() => { + if (!isEditing || hints.length === 0) { + return; + } + + const frameId = window.requestAnimationFrame(() => { + updateHintsPanelPosition(); + }); + + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [activeHintIndex, draftS3Uri, hints, isEditing, updateHintsPanelPosition]); + + useEffect(() => { + if (!isEditing || activeHintIndex < 0) { + return; + } + + const activeHintElement = document.getElementById( + `${hintsListId}-${activeHintIndex}` + ); + + if (!(activeHintElement instanceof HTMLElement)) { + return; + } + + activeHintElement.scrollIntoView({ + block: "nearest" + }); + }, [activeHintIndex, hintsListId, isEditing]); + + useEffect(() => { + if (isEditing) { + setDisplayCrumbs(crumbs); + return; + } + + const availableWidth = pathDisplayRect.width; + + if (!availableWidth || crumbs.length === 0) { + setDisplayCrumbs(crumbs); + return; + } + + const slashSeparatorWidth = + measureSlashSeparatorRef.current?.getBoundingClientRect().width ?? 0; + const delimiterSeparatorWidth = + normalizedPrefix.delimiter === "/" + ? slashSeparatorWidth + : (measureDelimiterSeparatorRef.current?.getBoundingClientRect().width ?? + slashSeparatorWidth); + const separatorWidth = Math.max(slashSeparatorWidth, delimiterSeparatorWidth); + const ellipsisWidth = + measureEllipsisRef.current?.getBoundingClientRect().width ?? 0; + const crumbWidths = crumbs.map( + (_, index) => + measureCrumbRefs.current[index]?.getBoundingClientRect().width ?? 0 + ); + const separatorCount = getSeparatorCount(crumbs, normalizedPrefix.delimiter); + const trailingSeparatorWidth = + trailingSeparatorToken === undefined + ? 0 + : trailingSeparatorToken === "/" + ? slashSeparatorWidth + : delimiterSeparatorWidth; + + const totalWidth = + crumbWidths.reduce((sum, width) => sum + width, 0) + + separatorWidth * separatorCount + + trailingSeparatorWidth; + + if (totalWidth <= availableWidth) { + setDisplayCrumbs(crumbs); + return; + } + + if (crumbs.length < 2) { + setDisplayCrumbs(crumbs); + return; + } + + const rootIndex = 0; + const bucketIndex = Math.min(1, crumbs.length - 1); + const lastIndex = crumbs.length - 1; + + const ellipsisCrumb: DisplayCrumb = { + label: "...", + kind: "ellipsis" + }; + + let widthSum = crumbWidths[rootIndex] ?? 0; + + if (bucketIndex !== rootIndex) { + widthSum += + getSeparatorWidthForKinds({ + leftKind: crumbs[rootIndex].kind, + rightKind: crumbs[bucketIndex].kind, + delimiter: normalizedPrefix.delimiter, + slashSeparatorWidth, + delimiterSeparatorWidth + }) + (crumbWidths[bucketIndex] ?? 0); + } + + const shouldUseEllipsis = lastIndex > bucketIndex; + + if (shouldUseEllipsis) { + widthSum += separatorWidth + ellipsisWidth; + } + + if (lastIndex !== bucketIndex) { + widthSum += separatorWidth + (crumbWidths[lastIndex] ?? 0); + } + + const tailIndices: number[] = []; + + if (lastIndex - 1 > bucketIndex) { + for (let index = lastIndex - 1; index > bucketIndex; index -= 1) { + const nextWidth = widthSum + separatorWidth + (crumbWidths[index] ?? 0); + + if (nextWidth > availableWidth) { + break; + } + + tailIndices.unshift(index); + widthSum = nextWidth; + } + } + + const collapsed: DisplayCrumb[] = []; + collapsed.push(crumbs[rootIndex]); + + if (bucketIndex !== rootIndex) { + collapsed.push(crumbs[bucketIndex]); + } + + if (shouldUseEllipsis) { + collapsed.push(ellipsisCrumb); + } + + tailIndices.forEach(index => collapsed.push(crumbs[index])); + + if (lastIndex !== bucketIndex && !tailIndices.includes(lastIndex)) { + collapsed.push(crumbs[lastIndex]); + } + + setDisplayCrumbs(collapsed); + }, [ + crumbs, + isEditing, + normalizedPrefix.delimiter, + pathDisplayRect.width, + trailingSeparatorToken + ]); + + useEffect(() => { + return () => { + if (longPressTimeoutRef.current !== undefined) { + window.clearTimeout(longPressTimeoutRef.current); + } + }; + }, []); + + const clearLongPressTimeout = () => { + if (longPressTimeoutRef.current === undefined) { + return; + } + + window.clearTimeout(longPressTimeoutRef.current); + longPressTimeoutRef.current = undefined; + }; + + const startLongPressTimer = () => { + clearLongPressTimeout(); + longPressTriggeredRef.current = false; + + longPressTimeoutRef.current = window.setTimeout(() => { + longPressTriggeredRef.current = true; + lastEnterEditRequestTimeRef.current = performance.now(); + onIsEditingChange({ isEditing: true }); + }, longPressDelayMs); + }; + + const onInputChange = (nextDraftS3Uri: string) => { + setDraftS3Uri(nextDraftS3Uri); + + const parsed = tryParsePrefix({ + s3Uri: nextDraftS3Uri.trim(), + delimiter: normalizedPrefix.delimiter + }); + + if (!parsed) { + return; + } + + onS3UriPrefixChange({ s3UriPrefix: parsed }); + }; + + const selectHint = (hint: S3UriBarProps["hints"][number]) => { + const source = draftS3Uri.startsWith("s3://") ? draftS3Uri : canonicalS3Uri; + const sourcePrefix = + tryParsePrefix({ + s3Uri: source, + delimiter: normalizedPrefix.delimiter + }) ?? normalizedPrefix; + + let nextPrefix: S3Uri.Prefix | undefined; + + if (hint.type === "key-segment") { + nextPrefix = { + type: "prefix", + bucket: sourcePrefix.bucket, + delimiter: sourcePrefix.delimiter, + keySegments: [...sourcePrefix.keySegments, hint.name], + isDelimiterTerminated: true + }; + } else if (hint.type === "object") { + nextPrefix = { + type: "prefix", + bucket: sourcePrefix.bucket, + delimiter: sourcePrefix.delimiter, + keySegments: [...sourcePrefix.keySegments], + isDelimiterTerminated: false, + nextKeySegmentPrefix: hint.name + }; + } else { + const shortcut = hint.name.trim(); + + nextPrefix = + tryParsePrefix({ + s3Uri: shortcut, + delimiter: normalizedPrefix.delimiter + }) ?? + tryParsePrefix({ + s3Uri: `s3://${sourcePrefix.bucket}${shortcut.startsWith(sourcePrefix.delimiter) ? "" : sourcePrefix.delimiter}${shortcut}`, + delimiter: normalizedPrefix.delimiter + }); + } + + if (!nextPrefix) { + return; + } + + const nextDraftS3Uri = stringifyS3Uri(nextPrefix); + + setDraftS3Uri(nextDraftS3Uri); + + const parsed = tryParsePrefix({ + s3Uri: nextDraftS3Uri, + delimiter: normalizedPrefix.delimiter + }); + + if (!parsed) { + return; + } + + onS3UriPrefixChange({ s3UriPrefix: parsed }); + }; + + const onInputKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + ignoreNextBlurRef.current = true; + onIsEditingChange({ isEditing: false }); + return; + } + + if (hints.length > 0 && event.key === "ArrowDown") { + event.preventDefault(); + setActiveHintIndex(index => { + if (index < 0) { + return 0; + } + + return (index + 1) % hints.length; + }); + return; + } + + if (hints.length > 0 && event.key === "ArrowUp") { + event.preventDefault(); + setActiveHintIndex(index => { + if (index < 0) { + return hints.length - 1; + } + + return (index - 1 + hints.length) % hints.length; + }); + return; + } + + if (event.key === "Enter" && activeHintIndex >= 0) { + event.preventDefault(); + + const activeHint = hints[activeHintIndex]; + + if (!activeHint) { + return; + } + + selectHint(activeHint); + } + }; + + const onBarPointerDown = (event: PointerEvent) => { + if (isEditing || event.button !== 0) { + return; + } + + const target = event.target; + + if (!(target instanceof Element)) { + return; + } + + if (target.closest("[data-s3-uri-segment='true']")) { + return; + } + + if (target.closest("[data-s3-uri-ignore-edit='true']")) { + return; + } + + lastEnterEditRequestTimeRef.current = performance.now(); + onIsEditingChange({ isEditing: true }); + }; + + const onRootBlur = (event: FocusEvent) => { + if (!isEditing) { + return; + } + + if (ignoreNextBlurRef.current) { + ignoreNextBlurRef.current = false; + return; + } + + const nextFocusedElement = event.relatedTarget; + + if ( + nextFocusedElement instanceof Node && + event.currentTarget.contains(nextFocusedElement) + ) { + return; + } + + if ( + nextFocusedElement === null && + performance.now() - lastEnterEditRequestTimeRef.current < 250 + ) { + return; + } + + onIsEditingChange({ isEditing: false }); + }; + + const displayLeadingCrumbs = displayCrumbs.slice( + 0, + Math.min(2, displayCrumbs.length) + ); + const displayKeyCrumbs = displayCrumbs.slice(2); + + return ( +
+
+ {isEditing ? ( +
+ + onInputChange(event.target.value)} + onKeyDown={onInputKeyDown} + onKeyUp={updateHintsPanelPosition} + onSelect={updateHintsPanelPosition} + onClick={updateHintsPanelPosition} + autoComplete="off" + spellCheck={false} + aria-autocomplete={hints.length > 0 ? "list" : undefined} + aria-controls={hints.length > 0 ? hintsListId : undefined} + aria-expanded={hints.length > 0} + aria-activedescendant={ + activeHintIndex >= 0 + ? `${hintsListId}-${activeHintIndex}` + : undefined + } + /> +
+ ) : ( + + )} + +
+ +
+ { + event.stopPropagation(); + + if ("clipboard" in navigator) { + void navigator.clipboard.writeText( + canonicalS3Uri + ); + } + }} + className={classes.bookmarkButton} + /> +
+
+ +
+ { + event.stopPropagation(); + + const nextIsEditing = !isEditing; + + if (nextIsEditing) { + lastEnterEditRequestTimeRef.current = + performance.now(); + } + + onIsEditingChange({ isEditing: nextIsEditing }); + }} + className={cx( + classes.bookmarkButton, + isEditing && classes.editButtonActive + )} + /> +
+
+ {(onToggleBookmark || isBookmarked) && ( + +
+ { + event.stopPropagation(); + onToggleBookmark?.(); + }} + disabled={!onToggleBookmark} + className={cx( + classes.bookmarkButton, + isBookmarked && classes.bookmarkButtonActive, + !onToggleBookmark && + classes.bookmarkButtonReadonly + )} + /> +
+
+ )} +
+
+ + {isEditing && hints.length > 0 && ( +
+ {hints.map((hint, index) => ( + + ))} +
+ )} +
+ ); +} + +const useStyles = tss + .withName({ S3UriBar }) + .withParams<{ isEditing: boolean }>() + .create(({ theme, isEditing }) => { + const barHeight = "56px"; + const accentColor = theme.colors.useCases.buttons.actionActive; + + return { + root: { + width: "100%", + position: "relative" + }, + bar: { + display: "flex", + alignItems: "center", + gap: theme.spacing(3), + cursor: "text", + width: "100%", + minWidth: 0, + height: barHeight, + paddingTop: "6px", + paddingBottom: "6px", + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + boxSizing: "border-box", + borderRadius: "16px", + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: theme.colors.useCases.surfaces.surface1, + transition: "box-shadow 0.2s ease, border-color 0.2s ease", + boxShadow: isEditing ? `inset 0 -2px 0 ${accentColor}` : undefined, + "&:hover": { + boxShadow: !isEditing ? theme.shadows[6] : undefined + } + }, + pathDisplay: { + position: "relative", + flex: 1, + minWidth: 0, + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + color: theme.colors.useCases.typography.textPrimary + }, + crumbs: { + display: "inline-flex", + alignItems: "center", + gap: theme.spacing(2) + }, + keyGroup: { + display: "inline-flex", + alignItems: "center", + gap: theme.spacing(1), + height: "36px", + paddingLeft: "10px", + paddingRight: "10px", + borderRadius: "12px", + boxSizing: "border-box", + backgroundColor: theme.colors.useCases.surfaces.background, + boxShadow: `inset 0 0 0 1px ${theme.colors.useCases.surfaces.surface2}` + }, + crumbItem: { + display: "inline-flex", + alignItems: "center", + gap: theme.spacing(1) + }, + segmentGroupTag: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: "24px", + height: "24px", + borderRadius: "999px", + whiteSpace: "nowrap" + }, + segmentGroupTagBucket: { + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: "transparent" + }, + segmentGroupTagKey: { + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: theme.colors.useCases.surfaces.background + }, + separator: { + margin: `0 ${theme.spacing(2)}`, + color: theme.colors.useCases.typography.textSecondary + }, + segmentButton: { + display: "inline-flex", + alignItems: "center", + gap: theme.spacing(1), + height: "36px", + paddingTop: "6px", + paddingBottom: "6px", + paddingLeft: "10px", + paddingRight: "10px", + margin: 0, + border: "1px solid transparent", + borderRadius: "12px", + boxSizing: "border-box", + backgroundColor: "transparent", + color: "inherit", + font: "inherit", + cursor: "pointer", + textDecoration: "none", + "&:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface2 + } + }, + segmentRoot: { + fontWeight: 600, + borderColor: theme.colors.useCases.surfaces.surface2, + backgroundColor: theme.colors.useCases.surfaces.background + }, + segmentBucket: { + fontWeight: 700, + borderColor: theme.colors.useCases.surfaces.surface2, + backgroundColor: theme.colors.useCases.surfaces.background + }, + segmentPrefix: { + borderColor: theme.colors.useCases.surfaces.surface2, + backgroundColor: theme.colors.useCases.surfaces.background + }, + segmentCurrent: { + fontWeight: 700 + }, + segmentKeyFirst: { + paddingLeft: "6px" + }, + segmentLabel: { + display: "inline-block" + }, + rootIcon: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center" + }, + rootIconGlyph: { + fontSize: "18px" + }, + inputWrapper: { + flex: 1, + minWidth: 0 + }, + input: { + width: "100%", + border: "none", + outline: "none", + background: "transparent", + paddingLeft: "10px", + font: "inherit", + fontSize: "1.05rem", + fontWeight: 600, + color: theme.colors.useCases.typography.textPrimary + }, + trailingActions: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + flexShrink: 0 + }, + bookmarkButton: { + borderRadius: "10px", + backgroundColor: theme.colors.useCases.surfaces.surface2, + width: "32px", + height: "32px", + minWidth: "32px", + padding: 0, + "&:hover": { + backgroundColor: theme.colors.useCases.surfaces.surface1 + } + }, + bookmarkButtonActive: { + "& .MuiSvgIcon-root, & img": { + color: accentColor + } + }, + bookmarkButtonReadonly: { + opacity: 0.65 + }, + editButtonActive: { + "& .MuiSvgIcon-root, & img": { + color: accentColor + } + }, + hintsPanel: { + position: "absolute", + zIndex: 2, + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), + padding: theme.spacing(2), + width: "fit-content", + minWidth: "260px", + maxWidth: "calc(100% - 16px)", + maxHeight: "260px", + overflowY: "auto", + borderRadius: "10px", + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[6] + }, + hintItem: { + display: "flex", + alignItems: "center", + gap: theme.spacing(3), + width: "100%", + border: "none", + borderRadius: "10px", + background: "transparent", + color: "inherit", + textAlign: "left", + padding: `${theme.spacing(2)} ${theme.spacing(3)}`, + cursor: "pointer" + }, + hintItemActive: { + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + hintType: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: "24px", + height: "24px", + borderRadius: "6px", + color: theme.colors.useCases.typography.textSecondary, + backgroundColor: theme.colors.useCases.surfaces.surface2, + flexShrink: 0 + }, + hintName: { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: theme.colors.useCases.typography.textPrimary + }, + measure: { + position: "absolute", + visibility: "hidden", + pointerEvents: "none", + whiteSpace: "nowrap", + left: 0, + top: 0 + }, + ellipsis: { + color: theme.colors.useCases.typography.textSecondary, + padding: `${theme.spacing(1)} ${theme.spacing(2)}` + }, + srOnly: { + position: "absolute", + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: "hidden", + clip: "rect(0, 0, 0, 0)", + whiteSpace: "nowrap", + border: 0 + } + }; + }); diff --git a/web/src/ui/shared/bookmarks/BookmarkChip.stories.tsx b/web/src/ui/shared/bookmarks/BookmarkChip.stories.tsx new file mode 100644 index 000000000..4fbe4e182 --- /dev/null +++ b/web/src/ui/shared/bookmarks/BookmarkChip.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BookmarkChip } from "./BookmarkChip"; + +const meta = { + title: "Bookmarks/BookmarkChip", + component: BookmarkChip +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + label: "Analytics exports", + path: "s3://analytics-data/exports/", + onNavigate: () => undefined, + onUnpin: () => undefined + } +}; + +export const Active: Story = { + args: { + label: "Pinned bucket", + path: "s3://analytics-data", + active: true, + onNavigate: () => undefined, + onUnpin: () => undefined + } +}; + +export const LongLabel: Story = { + args: { + label: "Very long bookmark label for nested datasets", + path: "s3://analytics-data/exports/2024/", + onNavigate: () => undefined, + onUnpin: () => undefined + } +}; + +export const WithOptionalUnpin: Story = { + args: { + label: "To unpin", + path: "s3://example-bucket", + onNavigate: () => undefined, + onUnpin: () => undefined + } +}; diff --git a/web/src/ui/shared/bookmarks/BookmarkChip.tsx b/web/src/ui/shared/bookmarks/BookmarkChip.tsx new file mode 100644 index 000000000..5b32a198c --- /dev/null +++ b/web/src/ui/shared/bookmarks/BookmarkChip.tsx @@ -0,0 +1,191 @@ +import { useEffect } from "react"; +import { alpha } from "@mui/material/styles"; +import { tss } from "tss"; +import { Tooltip } from "onyxia-ui/Tooltip"; + +export type BookmarkChipProps = { + label: string; + path: string; + active?: boolean; + onNavigate: (path: string) => void; + onUnpin?: (path: string) => void; +}; + +const materialSymbolsHref = + "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap"; + +function ensureMaterialSymbols() { + if (typeof document === "undefined") { + return; + } + + const linkId = "material-symbols-outlined-keep"; + + const existing = document.getElementById(linkId) as HTMLLinkElement | null; + + if (existing) { + if (existing.href !== materialSymbolsHref) { + existing.href = materialSymbolsHref; + } + return; + } + + const link = document.createElement("link"); + link.id = linkId; + link.rel = "stylesheet"; + link.href = materialSymbolsHref; + document.head.appendChild(link); +} + +export function BookmarkChip(props: BookmarkChipProps) { + const { label, path, active = false, onNavigate, onUnpin } = props; + + useEffect(() => { + ensureMaterialSymbols(); + }, []); + + const { classes } = useStyles({ active, isUnpinDisabled: !onUnpin }); + + return ( +
+ +
+ +
+
+ +
+ +
+
+
+ ); +} + +const useStyles = tss + .withName({ BookmarkChip }) + .withParams<{ active: boolean; isUnpinDisabled: boolean }>() + .create(({ theme, active, isUnpinDisabled }) => { + const accent = theme.colors.useCases.buttons.actionActive; + const baseBackground = alpha(accent, 0.1); + const hoverBackground = alpha(accent, 0.14); + const activeBackground = alpha(accent, 0.2); + const inactiveText = theme.colors.useCases.typography.textPrimary; + const activeText = inactiveText; + + return { + container: { + display: "inline-flex", + alignItems: "center", + gap: 8, + backgroundColor: active ? activeBackground : baseBackground, + color: active ? activeText : inactiveText, + borderRadius: 999, + padding: "8px 16px 8px 12px", + fontSize: theme.typography.variants["label 1"].style.fontSize, + lineHeight: theme.typography.variants["label 1"].style.lineHeight, + fontFamily: theme.typography.variants["label 1"].style.fontFamily, + fontWeight: active + ? 600 + : theme.typography.variants["label 1"].style.fontWeight, + transition: "background-color 120ms ease", + maxWidth: 300, + flexShrink: 0, + flexWrap: "nowrap", + overflow: "hidden", + minWidth: 0, + "&:hover": { + backgroundColor: active ? alpha(accent, 0.24) : hoverBackground + } + }, + pinWrapper: { + display: "inline-flex", + alignItems: "center" + }, + labelWrapper: { + flex: "1 1 auto", + minWidth: 0, + overflow: "hidden" + }, + chip: { + display: "inline-flex", + alignItems: "center", + border: "none", + background: "transparent", + color: "inherit", + padding: 0, + font: "inherit", + cursor: "pointer", + maxWidth: "100%", + minWidth: 0, + overflow: "hidden" + }, + labelText: { + display: "block", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis" + }, + pinButton: { + border: "none", + background: "transparent", + color: "inherit", + cursor: isUnpinDisabled ? "default" : "pointer", + width: 20, + height: 20, + borderRadius: 6, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + padding: 0, + opacity: isUnpinDisabled ? 0.4 : 1, + "&:hover": { + backgroundColor: isUnpinDisabled + ? "transparent" + : alpha(accent, theme.isDarkModeEnabled ? 0.3 : 0.2) + }, + "& .pinIconHover": { + display: "none" + }, + "&:hover .pinIconDefault": { + display: "none" + }, + "&:hover .pinIconHover": { + display: "inline-flex" + } + }, + pinIcon: { + fontSize: 16, + lineHeight: "16px", + fontFamily: '"Material Symbols Outlined"', + fontVariationSettings: '"FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24' + } + }; + }); + +// TODO: Tune alpha values once the final accent palette is confirmed. diff --git a/web/src/ui/shared/bookmarks/BookmarkNameModal.tsx b/web/src/ui/shared/bookmarks/BookmarkNameModal.tsx new file mode 100644 index 000000000..3b132d69e --- /dev/null +++ b/web/src/ui/shared/bookmarks/BookmarkNameModal.tsx @@ -0,0 +1,158 @@ +import { memo, useEffect, useState } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { TextField, type TextFieldProps } from "onyxia-ui/TextField"; +import { tss } from "tss"; +import { Evt } from "evt"; +import type { StatefulReadonlyEvt, UnpackEvt } from "evt"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { useConst } from "powerhooks/useConst"; +import { useRerenderOnStateChange } from "evt/hooks/useRerenderOnStateChange"; + +export type BookmarkNameModalProps = { + open: boolean; + initialValue?: string; + onSave: (label: string) => void; + onCancel: () => void; +}; + +export const BookmarkNameModal = memo((props: BookmarkNameModalProps) => { + const { open, initialValue = "", onSave, onCancel } = props; + + const evtResolve = useConst(() => + Evt.create>(null) + ); + + const onResolveFunctionChanged = useConstCallback< + BodyProps["onResolveFunctionChanged"] + >(({ resolve }) => (evtResolve.state = resolve)); + + return ( + + ) + } + buttons={} + /> + ); +}); + +type BodyProps = { + initialValue: string; + onSave: BookmarkNameModalProps["onSave"]; + onResolveFunctionChanged: (params: { resolve: (() => void) | null }) => void; +}; + +const Body = memo((props: BodyProps) => { + const { initialValue, onSave, onResolveFunctionChanged } = props; + + const { classes } = useStyles(); + + const getIsValidValue = useConstCallback(value => { + if (value.trim() === "") { + return { + isValidValue: false, + message: "Label can't be empty" + }; + } + + return { isValidValue: true }; + }); + + const [{ resolve }, setResolve] = useState<{ resolve: (() => void) | null }>({ + resolve: null + }); + + const onValueBeingTypedChange = useConstCallback< + TextFieldProps["onValueBeingTypedChange"] + >(({ value, isValidValue }) => + setResolve({ + resolve: isValidValue + ? () => { + onSave(value.trim()); + } + : null + }) + ); + + useEffect(() => { + onResolveFunctionChanged({ resolve }); + }, [resolve, onResolveFunctionChanged]); + + const evtAction = useConst(() => + Evt.create>>() + ); + + const onEnterKeyDown = useConstCallback( + ({ preventDefaultAndStopPropagation }) => { + preventDefaultAndStopPropagation(); + evtAction.post("TRIGGER SUBMIT"); + } + ); + + const onSubmit = useConstCallback(() => { + if (resolve === null) { + return; + } + + resolve(); + }); + + return ( + + ); +}); + +type ButtonsProps = { + onClose: BookmarkNameModalProps["onCancel"]; + evtResolve: StatefulReadonlyEvt<(() => void) | null>; +}; + +const Buttons = memo((props: ButtonsProps) => { + const { onClose, evtResolve } = props; + + useRerenderOnStateChange(evtResolve); + + return ( + <> + + + + ); +}); + +const useStyles = tss.withName({ BookmarkNameModal }).create(({ theme }) => ({ + textField: { + width: 250, + margin: theme.spacing(5) + } +})); + +// TODO: Add duplicate-name validation and hook this into real bookmark creation rules. diff --git a/web/src/ui/shared/bookmarks/BookmarkPinButton.tsx b/web/src/ui/shared/bookmarks/BookmarkPinButton.tsx new file mode 100644 index 000000000..cc0bb983a --- /dev/null +++ b/web/src/ui/shared/bookmarks/BookmarkPinButton.tsx @@ -0,0 +1,27 @@ +import type { CSSProperties } from "react"; + +export type BookmarkPinButtonProps = { + onClick?: () => void; + disabled?: boolean; +}; + +const buttonStyle: CSSProperties = { + border: "1px solid #cbd5e1", + background: "#ffffff", + borderRadius: 6, + padding: "6px 10px", + fontSize: 13, + cursor: "pointer" +}; + +export function BookmarkPinButton(props: BookmarkPinButtonProps) { + const { onClick, disabled } = props; + + return ( + + ); +} + +// TODO: Replace with icon button and design system styling. diff --git a/web/src/ui/shared/bookmarks/BookmarkRowItem.tsx b/web/src/ui/shared/bookmarks/BookmarkRowItem.tsx new file mode 100644 index 000000000..578befdee --- /dev/null +++ b/web/src/ui/shared/bookmarks/BookmarkRowItem.tsx @@ -0,0 +1,292 @@ +import { alpha } from "@mui/material/styles"; +import { tss } from "tss"; +import { useEffect } from "react"; +import { Tooltip } from "onyxia-ui/Tooltip"; +import { BucketTypeChip } from "./BucketTypeChip"; +import type { BucketType } from "./types"; + +const materialSymbolsHref = + "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=swap"; +const s3Scheme = "s3://"; + +function normalizeS3Path(value: string): string { + if (value.startsWith(s3Scheme)) { + return value; + } + + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value)) { + return value; + } + + return `${s3Scheme}${value.replace(/^\/+/, "")}`; +} + +function formatS3DisplayPath(value: string): string { + if (!value.startsWith(s3Scheme)) { + return value; + } + + if (value === s3Scheme) { + return value; + } + + const match = /^s3:\/\/([^/]+)(?:\/(.*))?$/.exec(value); + + if (!match) { + return value; + } + + const bucket = match[1]; + const rest = match[2] ?? ""; + const hasTrailingSlash = value.endsWith("/") && value !== s3Scheme; + + if (rest === "") { + return hasTrailingSlash ? `${s3Scheme}${bucket}/` : `${s3Scheme}${bucket}`; + } + + const trimmedRest = rest.replace(/\/$/, ""); + const parts = trimmedRest.split("/").filter(Boolean); + + if (parts.length <= 2) { + const base = `${s3Scheme}${bucket}/${parts.join("/")}`; + return hasTrailingSlash ? `${base}/` : base; + } + + const tail = parts.slice(-2).join("/"); + return `${s3Scheme}${bucket}/.../${tail}${hasTrailingSlash ? "/" : ""}`; +} + +function ensureMaterialSymbols() { + if (typeof document === "undefined") { + return; + } + + const linkId = "material-symbols-outlined-keep"; + + const existing = document.getElementById(linkId) as HTMLLinkElement | null; + + if (existing) { + if (existing.href !== materialSymbolsHref) { + existing.href = materialSymbolsHref; + } + return; + } + + const link = document.createElement("link"); + link.id = linkId; + link.rel = "stylesheet"; + link.href = materialSymbolsHref; + document.head.appendChild(link); +} + +export type BookmarkRowItemProps = { + label: string; + path: string; + subLabel?: string; + bucketType?: BucketType; + onSelect?: (path: string) => void; + onUnpin?: (path: string) => void; + active?: boolean; + variant?: "pinned" | "bucket"; +}; + +export function BookmarkRowItem(props: BookmarkRowItemProps) { + const { + label, + path, + subLabel, + bucketType, + onSelect, + onUnpin, + active = false, + variant = "bucket" + } = props; + const { classes } = useStyles({ active, variant }); + const rawPath = subLabel && subLabel.trim() !== "" ? subLabel : path; + const normalizedPath = normalizeS3Path(rawPath); + const displayPath = formatS3DisplayPath(normalizedPath); + + useEffect(() => { + ensureMaterialSymbols(); + }, []); + + return ( +
+ + {onUnpin && ( + +
+ +
+
+ )} +
+ ); +} + +// TODO: Add icons and keyboard focus styles. + +const useStyles = tss + .withName({ BookmarkRowItem }) + .withParams<{ active: boolean; variant: "pinned" | "bucket" }>() + .create(({ theme, active, variant }) => { + const accent = theme.colors.useCases.buttons.actionActive; + const baseBackground = + variant === "pinned" + ? alpha(accent, 0.1) + : theme.colors.useCases.surfaces.surface1; + const activeBackground = + variant === "pinned" + ? alpha(accent, 0.2) + : theme.colors.useCases.buttons.actionSelected; + return { + root: { + position: "relative", + borderRadius: 24, + backgroundColor: active ? activeBackground : baseBackground, + transition: "background-color 0.2s ease, border-color 0.2s ease", + width: "100%", + height: 162, + boxSizing: "border-box", + border: "2px solid transparent", + boxShadow: "none", + "&:hover": { + borderColor: variant === "pinned" ? "#FFAC80" : "#D0D4DA", + boxShadow: theme.shadows[1] + } + }, + mainButton: { + width: "100%", + height: "100%", + border: "none", + background: "transparent", + color: "inherit", + padding: 24, + cursor: "pointer", + textAlign: "left", + position: "relative", + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + justifyContent: "flex-end", + gap: 0, + "&:hover": { + borderRadius: 24 + }, + "&:focus-visible": { + outline: `2px solid ${theme.colors.useCases.typography.textFocus}`, + outlineOffset: 3, + borderRadius: 24 + } + }, + tagWrapper: { + position: "absolute", + top: 24, + right: 24 + }, + textBlock: { + display: "flex", + flexDirection: "column", + gap: 0, + width: "100%", + minWidth: 0 + }, + label: { + fontWeight: active + ? 600 + : theme.typography.variants["object heading"].style.fontWeight, + fontSize: theme.typography.variants["object heading"].style.fontSize, + lineHeight: theme.typography.variants["object heading"].style.lineHeight, + fontFamily: theme.typography.variants["object heading"].style.fontFamily, + color: theme.colors.useCases.typography.textPrimary, + display: "block", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis" + }, + path: { + fontWeight: theme.typography.variants["label 1"].style.fontWeight, + fontSize: theme.typography.variants["label 1"].style.fontSize, + lineHeight: theme.typography.variants["label 1"].style.lineHeight, + fontFamily: theme.typography.variants["label 1"].style.fontFamily, + color: theme.colors.useCases.typography.textSecondary, + display: "block", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis" + }, + unpinButton: { + position: "absolute", + top: 24, + right: 24, + border: "none", + background: "transparent", + cursor: "pointer", + padding: 0, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 24, + height: 24, + borderRadius: 6, + "&:hover": { + color: theme.colors.useCases.buttons.actionActive, + backgroundColor: alpha(accent, theme.isDarkModeEnabled ? 0.3 : 0.2) + }, + "& .pinIconHover": { + display: "none" + }, + "&:hover .pinIconDefault": { + display: "none" + }, + "&:hover .pinIconHover": { + display: "inline-flex" + } + }, + unpinIcon: { + fontSize: 24, + lineHeight: "24px", + color: theme.colors.useCases.typography.textPrimary, + fontFamily: '"Material Symbols Outlined"', + fontVariationSettings: '"FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24' + } + }; + }); diff --git a/web/src/ui/shared/bookmarks/BookmarksOverview.stories.tsx b/web/src/ui/shared/bookmarks/BookmarksOverview.stories.tsx new file mode 100644 index 000000000..dc4c12a36 --- /dev/null +++ b/web/src/ui/shared/bookmarks/BookmarksOverview.stories.tsx @@ -0,0 +1,117 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useMemo, useState } from "react"; +import { action } from "@storybook/addon-actions"; +import { S3PathControl, type ValidationResult } from "../S3PathControl"; +import { PinnedChipsBar } from "./PinnedChipsBar"; +import { BookmarkNameModal } from "./BookmarkNameModal"; +import { getDefaultBookmarkLabelFromPath } from "./getDefaultBookmarkLabelFromPath"; +import type { Bookmark } from "./types"; + +const meta = { + title: "Bookmarks/Demo/Overview", + component: S3PathControl +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const mockValidatePath = async (draftPath: string): Promise => ({ + status: "success", + resolvedPath: draftPath +}); + +export const Overview: Story = { + render: () => { + const [currentPath, setCurrentPath] = useState( + "s3://analytics-data/exports/2024/quarter-1/report.csv" + ); + const [pinnedBookmarks, setPinnedBookmarks] = useState([ + { + id: "bucket-analytics", + label: "analytics-data", + path: "s3://analytics-data", + createdAt: new Date().toISOString() + } + ]); + const [showNameModal, setShowNameModal] = useState(false); + const [pendingPath, setPendingPath] = useState(null); + + const activeBookmarkId = useMemo(() => { + return pinnedBookmarks.find(bookmark => bookmark.path === currentPath)?.id; + }, [pinnedBookmarks, currentPath]); + + return ( +
+ setCurrentPath(nextPath)} + validatePath={mockValidatePath} + onCopy={action("copy")} + onBookmark={() => { + setPendingPath(currentPath); + setShowNameModal(true); + }} + onCreatePrefix={action("create prefix")} + onImportData={action("import data")} + onError={action("error")} + /> + + setCurrentPath(bookmark.path)} + onUnpin={bookmark => + setPinnedBookmarks(prev => + prev.filter(item => item.id !== bookmark.id) + ) + } + /> + +
+ Placeholder file list area +
+ + { + setShowNameModal(false); + setPendingPath(null); + }} + onSave={label => { + if (!label || !pendingPath) { + setShowNameModal(false); + setPendingPath(null); + return; + } + + setPinnedBookmarks(prev => [ + { + id: `bookmark-${Date.now()}`, + label, + path: pendingPath, + createdAt: new Date().toISOString() + }, + ...prev + ]); + setShowNameModal(false); + setPendingPath(null); + }} + /> +
+ ); + } +}; + +// TODO: Replace placeholder file list with real file explorer integration. diff --git a/web/src/ui/shared/bookmarks/BucketTypeChip.stories.tsx b/web/src/ui/shared/bookmarks/BucketTypeChip.stories.tsx new file mode 100644 index 000000000..06fb55e34 --- /dev/null +++ b/web/src/ui/shared/bookmarks/BucketTypeChip.stories.tsx @@ -0,0 +1,42 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { BucketTypeChip } from "./BucketTypeChip"; + +const meta = { + title: "Bookmarks/BucketTypeChip", + component: BucketTypeChip +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const AllTypes: Story = { + render: () => ( +
+ + + + +
+ ) +}; + +export const Playground: Story = { + args: { + type: "personal", + showIcon: true, + colorVariant: "personal" + }, + argTypes: { + type: { + control: "select", + options: ["personal", "group", "read-write", "read-only"] + }, + colorVariant: { + control: "select", + options: ["personal", "group", "read-write", "read-only"] + }, + showIcon: { control: "boolean" } + }, + render: args => +}; diff --git a/web/src/ui/shared/bookmarks/BucketTypeChip.tsx b/web/src/ui/shared/bookmarks/BucketTypeChip.tsx new file mode 100644 index 000000000..47b177269 --- /dev/null +++ b/web/src/ui/shared/bookmarks/BucketTypeChip.tsx @@ -0,0 +1,112 @@ +import Chip from "@mui/material/Chip"; +import { tss } from "tss"; +import type { BucketType } from "./types"; + +export type BucketTypeChipProps = { + type: BucketType; + showIcon?: boolean; + colorVariant?: BucketType; +}; + +const tagLabels: Record = { + personal: "Personal", + group: "Group project", + "read-write": "Read and write", + "read-only": "Read-only" +}; + +const tagIcons: Record = { + personal: "person", + group: "groups", + "read-write": "edit", + "read-only": "visibility" +}; + +export function BucketTypeChip(props: BucketTypeChipProps) { + const { type, showIcon = true, colorVariant } = props; + const { classes } = useStyles({ type: colorVariant ?? type, showIcon }); + + return ( +