From b009a89ab6dddac701ce8b438ffb2a23f020c802 Mon Sep 17 00:00:00 2001 From: Jorinde Reijnierse Date: Wed, 27 Nov 2024 17:54:32 +0100 Subject: [PATCH] playground: Add tab menu and format button - Change tab dropdown to menu so we can have more than 1 type of action - Remove tab-dropdown specific properties since we don't need them anymore. - Add option to the menu component so we can disable the overlay - Add menu-tab specific styling - Render tab menu with optional title and optional format button: when both languages & actions are present both menus show a title to clarify the difference. If only one of them is configured the title is not shown and only the items are shown. - Handle events for language option selection: close menu & call change option function - Add format option for input tabs & enable when selected language is cue (for now) - Handle format button click: close menu & call format function - Fix issue onLoad which always loaded the default-example, even though workspace policy was selected in the url. - TODO: How can we use the WASM API to format the code? To test: - Click on the arrow next to the input tab name - Enable the policy-mode to test language selection in combination with the format option: src/config/workspaces/policy.ts > set enabled to true and click on the input dropdown. Fixes: cue-lang/cue#2996 Signed-off-by: Jorinde Reijnierse Change-Id: I5a2fe31cc69b7f3cb8313ad94f27f172c2b7ffd4 Dispatch-Trailer: {"type":"trybot","CL":1205272,"patchset":2,"ref":"refs/changes/72/1205272/2","targetBranch":"master"} --- playground/src/components/menu.tsx | 4 +- playground/src/components/tab.tsx | 18 ++- playground/src/components/tabs.tsx | 148 +++++++++++++++---- playground/src/config/workspaces/policy.ts | 2 +- playground/src/containers/app.tsx | 106 ++++++++----- playground/src/scss/components/menu.scss | 51 +++++-- playground/src/scss/components/tab-menu.scss | 88 +++++++++++ playground/src/scss/components/tabs-nav.scss | 7 +- playground/src/scss/main.scss | 1 + 9 files changed, 345 insertions(+), 80 deletions(-) create mode 100644 playground/src/scss/components/tab-menu.scss diff --git a/playground/src/components/menu.tsx b/playground/src/components/menu.tsx index db4de94d2..c997c9559 100644 --- a/playground/src/components/menu.tsx +++ b/playground/src/components/menu.tsx @@ -8,6 +8,7 @@ interface MenuProps { id: string; icon?: string; open?: boolean; + showOverlay?: boolean; title: string; type?: 'default' | 'icon' | 'icon-mobile'; onOpen?: () => void; @@ -106,7 +107,7 @@ export class Menu extends React.Component, MenuStat } - { this.state.open && + { (this.state.open && this.props.showOverlay !== false) &&
} @@ -132,6 +133,7 @@ export class Menu extends React.Component, MenuStat private onToggleClick(event: MouseEvent) { event.preventDefault(); + if (this.state.open) { this.hide(); } else { diff --git a/playground/src/components/tab.tsx b/playground/src/components/tab.tsx index b162f7bac..6c685583e 100644 --- a/playground/src/components/tab.tsx +++ b/playground/src/components/tab.tsx @@ -6,14 +6,24 @@ export interface TabProps { activeItem: DropdownItem; children: ReactNode; disabled?: boolean; - disabledText?: string; groupId: string; - items: DropdownItem[]; name: string; - onDropdownSelect?: {(change: DropdownChange): void}; open?: boolean; - readonly ?: boolean; + readonly?: boolean; type?: string; + menu?: TabMenu; + onOptionSelect?: {(change: DropdownChange): void}; + onFormatClick?: { (): void } +} + +export interface TabMenu { + options: DropdownItem[]; + actions: { + format: { + show: boolean; + disabled: boolean; + }; + } | false; } const defaultProps = { diff --git a/playground/src/components/tabs.tsx b/playground/src/components/tabs.tsx index 3cf6d1394..d758fe784 100644 --- a/playground/src/components/tabs.tsx +++ b/playground/src/components/tabs.tsx @@ -1,8 +1,10 @@ +import cx from 'classnames'; import * as React from 'react'; import { JSXElementConstructor, MouseEvent, PropsWithChildren } from 'react'; -import cx from 'classnames'; -import { Dropdown } from '@components/dropdown'; + +import { Menu } from '@components/menu'; import { Tab, TabProps } from '@components/tab'; +import { DropdownItem } from '@models/dropdown'; interface TabsProps { activeIndex?: number; @@ -10,6 +12,7 @@ interface TabsProps { interface TabsState { activeIndex: number; + activeMenu: string | null; } export class Tabs extends React.Component, TabsState> { @@ -18,6 +21,7 @@ export class Tabs extends React.Component, TabsStat this.state = { activeIndex: props.activeIndex ?? 0, + activeMenu: null, }; } @@ -26,7 +30,7 @@ export class Tabs extends React.Component, TabsStat this.setState({ activeIndex: index }); } - public render () { + public render() { const tabs: TabProps[] = []; React.Children.forEach(this.props.children, (child) => { const type = ((child as React.ReactElement).type as JSXElementConstructor); @@ -39,42 +43,106 @@ export class Tabs extends React.Component, TabsStat
    - { tabs.map((tab, index) => { + {tabs.map((tab, index) => { + const showMenu = tab.menu && (tab.menu.options.length > 0 || tab.menu.actions); const isActive = index === this.state.activeIndex; - const tabName = `${ tab.name ? (tab.name + ': ') : ''}${ tab.activeItem.name ?? ''}`; + const tabName = `${tab.name ? (tab.name + ': ') : ''}${tab.activeItem.name ?? ''}`; const tabClassNames = cx({ 'is-active': isActive, 'cue-tabs-nav__tab--output': tab.type === 'output', }, 'cue-tabs-nav__tab'); + const menuItems: React.JSX.Element[] = []; + if (tab.menu?.options?.length > 1) { + for (const menuItem of tab.menu.options) { + const linkClassNames = cx({ + 'is-active': menuItem.value === tab.activeItem.value + }, 'cue-tab-menu__link cue-tab-menu__link--group'); + + menuItems.push( +
  • + {menuItem.link + ? {menuItem.name} + : + } +
  • + ); + } + } + + const formatItem = tab.menu.actions ?
  • + +
  • : ''; + return ( -
  • - { (isActive && tab.items && tab.items.length > 1) && -
    - +
  • + {(isActive && !tab.readonly && showMenu) && +
    + { + this.setState({ activeMenu: tab.name }) + }} + onClose={() => { + this.setState({ activeMenu: null }) + }} + > + <> +
    +
      + {(menuItems.length > 0 && tab.menu.actions) && +
    • +

      Language

      +
        + {menuItems} +
      +
    • + } + + {(menuItems.length > 0 && !tab.menu.actions) && + menuItems + } + + {(tab.menu.actions && menuItems.length > 0) && +
    • +

      Actions

      +
        + {(tab.menu.actions.format.show) && + formatItem + } +
      +
    • + } + + + {(tab.menu.actions && tab.menu.actions.format.show && menuItems.length === 0) && + formatItem + } +
    +
    + +
    } - { (!isActive || !tab.items || tab.items.length <= 1) && + {(!isActive || tab.readonly || !showMenu) && + })} + onClick={(e) => this.selectTab(e, index)} + >{tabName} }
  • ) @@ -83,14 +151,34 @@ export class Tabs extends React.Component, TabsStat
- { tabs.map((tab, index) => -
+
{ tab.children } + })}>{tab.children}
)}
) } + + private onOptionClick(event: MouseEvent, tab: TabProps, option: DropdownItem) { + event.preventDefault(); + + tab.onOptionSelect({ + groupId: tab.groupId, + selected: option, + }); + + this.setState({ activeMenu: null }); + } + + private onFormatClick(event: MouseEvent, tab: TabProps) { + console.log('format click'); + event.preventDefault(); + + tab.onFormatClick(); + + this.setState({ activeMenu: null }); + } } diff --git a/playground/src/config/workspaces/policy.ts b/playground/src/config/workspaces/policy.ts index 69b198499..dc42ffa6e 100644 --- a/playground/src/config/workspaces/policy.ts +++ b/playground/src/config/workspaces/policy.ts @@ -3,7 +3,7 @@ import { OPTION_TYPE, optionCUE, optionJSON, optionTerminal, optionYAML } from ' import { deepFreeze } from '@helpers/deep-freeze'; export const policyWorkspace: Workspace = deepFreeze({ - enabled: false, + enabled: true, type: WORKSPACE.POLICY, title: 'Policy', description: 'In the Policy Workspace you can play around with multiple inputs: Policy, Input & Data.', diff --git a/playground/src/containers/app.tsx b/playground/src/containers/app.tsx index 647d731f3..9b5e2604b 100644 --- a/playground/src/containers/app.tsx +++ b/playground/src/containers/app.tsx @@ -30,8 +30,8 @@ import { setupWorkspaceConfig } from '@helpers/workspace'; import { DropdownChange } from '@models/dropdown'; import { Example } from '@models/example'; import { HASH_KEY, hashParams } from '@models/hashParams'; -import { OPTION_TYPE } from '@models/options'; -import { WORKSPACE, Workspace, Workspaces } from '@models/workspace'; +import { OPTION_TYPE, optionCUE } from '@models/options'; +import { WORKSPACE, Workspace, Workspaces, WorkspaceTab } from '@models/workspace'; interface AppProps { @@ -108,28 +108,20 @@ export class App extends React.Component } // If we don't have saved code: check for example param in the url - if (!saved) { - const exampleSlug = urlSearchParams.get('example'); - if (exampleSlug) { - const example = examples.find(item => item.slug === exampleSlug); - if (example) { - activeWorkspace = availableWorkspaces[example.workspace]; - activeWorkspace.config = example.workspaceConfig; - activeExample = example; - } else { - // Remove example from searchparams when it's not a valid example - urlSearchParams.delete('example'); - } - } else { // If we don't have an example, default to displaying a specific example's content - const example = examples.find(item => item.slug === defaultCUEInput); - if (example) { - activeWorkspace = availableWorkspaces[example.workspace]; - activeWorkspace.config = example.workspaceConfig; - } + const exampleSlug = urlSearchParams.get('example'); + if (!saved && exampleSlug) { + const example = examples.find(item => item.slug === exampleSlug); + if (example) { + activeWorkspace = availableWorkspaces[example.workspace]; + activeWorkspace.config = example.workspaceConfig; + activeExample = example; + } else { + // Remove example from searchparams when it's not a valid example + urlSearchParams.delete('example'); } } - // If no workspace is set from saved code: get from params + // If no workspace is set from saved code or example: get from params if (!activeWorkspace && params[HASH_KEY.WORKSPACE]) { const workspaceKey = params[HASH_KEY.WORKSPACE] as WORKSPACE; activeWorkspace = availableWorkspaces[workspaceKey]; @@ -140,6 +132,15 @@ export class App extends React.Component activeWorkspace = defaultWorkspace; } + // If we don't have an example or saved code, default to displaying a specific example's content if it fits workspace in url + if (!saved && !activeExample) { + const example = examples.find(item => item.slug === defaultCUEInput); + if (example && example.workspace === activeWorkspace.type) { + activeWorkspace = availableWorkspaces[example.workspace]; + activeWorkspace.config = example.workspaceConfig; + } + } + // Setup workspace config from url params activeWorkspace = setupWorkspaceConfig(activeWorkspace, params); @@ -243,7 +244,7 @@ export class App extends React.Component showSaveURL={ this.state.showSaveURL } share={ this.share.bind(this) } handleOptionsChange={ this.handleOptionsChange.bind(this) } - onDropdownSelect={ this.handleDropdownChange.bind(this) } + onDropdownSelect={ this.handleOptionChange.bind(this) } switchWorkspace={ this.switchWorkspace.bind(this) } >
@@ -257,16 +258,28 @@ export class App extends React.Component
{ activeWorkspace.config.inputTabs.map((tab) => { const id = `${ activeWorkspace.type }-${ tab.type }`; + return (
{ + this.formatCode(tab); + } } + readonly={ tab.optionsReadonly } > @@ -361,7 +377,7 @@ export class App extends React.Component this.updateHash(activeWorkspace); } - private handleDropdownChange(change: DropdownChange): void { + private handleOptionChange(change: DropdownChange): void { this.handleOptionsChange([ change ]); } @@ -417,15 +433,38 @@ export class App extends React.Component // } // } // - // const transaction = this.outputEditor.state.update({ - // changes: { from: 0, to: this.outputEditor.state.doc.length, insert: result.output }, - // }); - // this.outputEditor.dispatch(transaction); - // this.outputEditor.dispatch({ selection: { anchor: 0 } }); + // this.setState({ outputEditorValue: result.output }); /* END OF NEW CODE */ } + private async formatCode(tab: WorkspaceTab): Promise { + if (!this.props.WasmAPI.CUECompile || + Object.keys(this.inputEditors).length === 0) { + return; + } + + const activeWorkspace = this.state.workspaces[this.state.activeWorkspaceName]; + const inputEditor = this.inputEditors[`${ activeWorkspace.type }-${ tab.type }`]; + const code = inputEditor ? inputEditor.state.doc.toString() : ''; + const language = tab.selected.value; + + // Get result from wasm + // TODO CUE: What endpoint to call to format? + const result = this.props.WasmAPI.CUECompile(language, 'export', language, code); + + let val = result.error; + if (val === '') { + val = result.value; + } + + // Update editor + const transaction = inputEditor.state.update({ + changes: { from: 0, to: inputEditor.state.doc.length, insert: val }, + }); + inputEditor.dispatch(transaction); + } + private async share(): Promise { const activeWorkspace = this.state.workspaces[this.state.activeWorkspaceName]; const contents = workspaceToShareContent(activeWorkspace, this.inputEditors); @@ -436,6 +475,7 @@ export class App extends React.Component } } + private getExtensions(languageValue: string): Extension[] { const extensions = basicSetup({ highlightActiveLine: false, diff --git a/playground/src/scss/components/menu.scss b/playground/src/scss/components/menu.scss index 23bd1a28f..f3760e636 100644 --- a/playground/src/scss/components/menu.scss +++ b/playground/src/scss/components/menu.scss @@ -30,6 +30,7 @@ --button-radius: #{ $b-radius }; font-weight: $weight-bold; + gap: 1rem; line-height: 1; padding: 0 0.65rem 0 0.75rem; @@ -45,16 +46,6 @@ width: 18px; } - &__icon { - & + #{ $self }__text { - margin-left: 0.5rem; - } - } - - &__text { - margin-right: 0.5rem; - } - &__chevron { margin: 1px 0 0 auto; transition: rotate 0.2s; @@ -150,6 +141,34 @@ } } + &--tab { + min-width: $w-tab; + + #{ $self }__button { + --button-background: transparent; + --button-border: transparent; + --button-color: #{ $c-blue--darker }; + --button-background-hover: #{ $c-grey-blue--lighter }; + --button-border-hover: #{ $c-grey-blue }; + --button-color-hover: #{ $c-blue--darker }; + + font-size: 0.875rem; + font-weight: $weight-medium; + gap: 0.5rem; + height: 24px; + line-height: 22px; + padding: 0 .5rem; + } + + #{ $self }__chevron { + margin-right: -6px; + } + + #{ $self }__dropdown { + margin-top: 0; + } + } + @include screen($screen-medium) { position: relative; @@ -207,6 +226,18 @@ width: 450px; } } + + &--tab { + #{ $self }__button { + min-width: $w-tab; + } + + #{ $self }__dropdown { + margin-top: 0; + min-width: $w-tab; + width: fit-content; + } + } } } diff --git a/playground/src/scss/components/tab-menu.scss b/playground/src/scss/components/tab-menu.scss new file mode 100644 index 000000000..8a0b48dc9 --- /dev/null +++ b/playground/src/scss/components/tab-menu.scss @@ -0,0 +1,88 @@ +@import '../config/colors'; +@import '../config/prefix'; +@import '../config/sizes'; +@import '../config/typography'; +@import '../mixins/list-reset'; +@import '../mixins/svg'; + +.#{ $prefix }-tab-menu { + $self: &; + + line-height: 1; + padding: 0.5rem 0; + position: relative; + + &__list { + @include list-reset; + } + + &__item { + &:not(:last-child) { + > #{ $self }__list { + margin-bottom: 0.5rem; + } + } + } + + &__title { + color: var(--tab-menu-color, $c-blue--darker); + font-weight: $weight-bold; + margin-bottom: 0.25rem; + padding: 0.25rem 1rem; + position: relative; + + &::after { + background: var(--tab-menu-border, $c-grey-blue--medium); + content: ''; + display: block; + height: 1px; + left: 0.5rem; + position: absolute; + right: 0.5rem; + top: 100%; + } + } + + &__link { + color: var(--tab-menu-color, $c-blue--darker); + display: block; + font-size: 0.875rem; + font-weight: $weight-medium; + padding: 0.5rem 1rem; + position: relative; + text-decoration: none; + transition: background-color 0.2s; + width: 100%; + + &:focus-visible, + &:hover { + background-color: $c-grey--lighter; + } + + &:disabled { + --tab-menu-color: #{ $c-grey }; + + pointer-events: none; + } + + &--group { + padding-left: 1.75rem; + + &.is-active { + &::before { + @include svg('check', $c-black); + + display: block; + height: 12px; + left: 0.75rem; + line-height: 12px; + position: absolute; + top: 0.6rem; + vertical-align: -3px; + width: 12px; + } + } + + } + } +} diff --git a/playground/src/scss/components/tabs-nav.scss b/playground/src/scss/components/tabs-nav.scss index e8fff27cc..b6bf0df3b 100644 --- a/playground/src/scss/components/tabs-nav.scss +++ b/playground/src/scss/components/tabs-nav.scss @@ -51,7 +51,6 @@ &.is-active { background-color: var(--tab-background-active, $c-white); - pointer-events: none; } &--output { @@ -59,6 +58,12 @@ --tab-background-hover: #{ darken($c-output-bcg, 0.2) }; --tab-background-active: #{ $c-output-bcg }; } + + &--button { + &.is-active { + pointer-events: none; + } + } } &__pagination { diff --git a/playground/src/scss/main.scss b/playground/src/scss/main.scss index 9fffa8c38..b79d74e7c 100644 --- a/playground/src/scss/main.scss +++ b/playground/src/scss/main.scss @@ -11,5 +11,6 @@ @import 'components/share'; @import 'components/spinner'; @import 'components/tabs'; +@import 'components/tab-menu'; @import 'components/tabs-nav'; @import 'components/ws-menu';