h2oai / wave

Realtime Web Apps and Dashboards for Python and R
https://wave.h2o.ai
Apache License 2.0
3.93k stars 329 forks source link

Highlight only the currently selected tab in a tab_card #283

Closed srini-x closed 3 years ago

srini-x commented 3 years ago

Right now, if there are multiple nav_groups in the tab_card, the selected nav_item in every nav_group is highlighted. When looking at the picture below, it is unclear which tab is the active tab.

The solution is to highlight only the last clicked tab/ nav_item.

Alternatives considered used nested ui.command but that doesn't highlight anything. and also works different than a nav_item

Additional context

Add any other context or screenshots about the feature request here. image

mtanco commented 3 years ago

The Tabs has a value option, an additional feature would be to add value to nav_group so the developer can manually choose a nav_item to highlight

mtanco commented 3 years ago

It's possible I am using nav & tabs wrong, but here is some example code that would benefit from this feature. Basically whenever someone clicks a tab I would want to "unclick" or clear the menu items rather than holding the menu item state.

from typing import List
from h2o_wave import Q, expando_to_dict
from transformers import pipeline, MarianTokenizer, MarianMTModel

from .utils import *
from .navigation_elements import *
from .config import *

def create_website_content(iframe_link: str):
    items = [
        ui.frame(iframe_link, height='80%'), 
        ui.link(label='View in a Separate Tab', path=iframe_link, button=True)
    ]
    return items

def create_image_content(image_path_wave):
    items = [
        ui.markup(f'<img style="max-width: 100%; max-height: 100%;" src="{image_path_wave}" />')
    ]
    return items

def create_dai_content(dai_url):
    items = []
    if dai_url is not None:
        items = items + create_website_content(dai_url)
    else:
        items.append(ui.text('Please configure an instance of Driverless AI!'))
        items.append(ui.button(name='go_to_configure', caption='Go to App Configuration', primary=True))

    return items

def create_wavecloud_content(app_name: str):
    # TODO: Handle id not existing because application was upgraded
    app_id = wave_app_homepage[app_name]
    return create_website_content('https://wave.h2o.ai/apps/' + app_id)

def create_markdown_content(file_path: str):
    with open(file_path) as f:
        contents = f.read()

    return [ui.text(contents)]

def get_dataprep_content():
    items = [
        ui.text_m('## Driverless AI Managed Data Processing Steps\n\n'
                  '* Checks for and handles missing values in your dataset\n\n'
                  '* Tests low cardinality features as numeric and categorical to find the best representation for '
                  'your dataset\n\n'
                  '* Runs special algorithms for imbalanced classification or skewed regression problems\n\n'
                  '* Identifies any columns that may cause your model to overfit and removes them or warns the user '
                  'based on severity\n\n'
                  '* Identifies any columns which have shifted between the train and test dataset, warning the user '
                  'that there may be time dependency in their data\n\n'
                  )
    ]
    return items

def get_snowflakequery_content(mojo_name, realtime_query, sf_username, sf_password, sf_account, sf_warehouse):
    if sf_username is None:
        return [
            ui.text('Please configure an instance of Snowflake!'),
            ui.button(name='go_to_configure', caption='Go to App Configuration', primary=True)
        ]

    if mojo_name is None:
        model = "riskmodel"
    else:
        model = mojo_name

    items = [
        ui.textbox(name='sql', label='Model Name', multiline=False, value=model),
        ui.button(name='snowflake_realtime_button', label='Real-Time Predict', primary=True),
        ui.link(label='Go To Snowflake',
                path='https://h20_ai_partner.snowflakecomputing.com/console#/internal/worksheet', button=False)
    ]

    if realtime_query:
        query = sf_query_part1 + model + sf_query_part2
        result, err = query_snowflake(query, sf_username, sf_password, sf_account, sf_warehouse)
        if err:
            items.append(ui.message_bar(type='error', text=err))
        else:
            fields, rows = result
            items.append(ui.text(make_markdown_table(fields, rows)))
    else:
        items.append(ui.text('Execute a query to view results here.'))

    return items

def get_textgeneration_content(starter_text, num_words_to_generate):
    if num_words_to_generate is None:
        text = "H2O is one of the best tools for any machine learning team to have when dealing with large volumes " \
               "of data. H2O helps improve"
    else:
        text = starter_text

    items = [
        ui.text_xl('Enter text to begin generation: '),
        ui.textbox(name='input_text', label='', value=text, multiline=True),
        ui.separator(),
        ui.slider(name='num_words_to_generate', label="Number of Words to Generation", min=5, max=50, step=1,
                  value=num_words_to_generate if num_words_to_generate else 20),
        ui.button(name='online_generate_button', label='Generate', primary=True)
    ]

    if num_words_to_generate is not None:

        model = pipeline("text-generation")
        result = model(text, max_length=num_words_to_generate + len(text.split()), do_sample=False)[0]

        items.append(ui.text(result["generated_text"]))
    else:
        items.append(ui.text('Enter text to see results.'))

    return items

def get_texttranslation_content(online_translate_button, source_language, input_text, target_language):
    if online_translate_button is None:
        input_text = 'This is some sample text, hello!'

    if source_language is None:
        source_language = 'en'
        target_language = 'fr'

    language_name_map_dict = {'ar': 'Arabic', 'fr': 'French', 'de': 'German', 'en': 'English', 'hi': 'Hindi',
                              'ja': 'Japanese', 'ru': 'Russian', 'es': 'Spanish', 'zh': 'Chinese'}
    language_translation_dict = {
        'ar': ['de', 'en', 'es', 'fr', 'ru'],
        'fr': ['ar', 'de', 'en', 'es', 'ru'],
        'de': ['ar', 'de', 'en', 'es', 'fr'],
        'en': ['ar', 'de', 'es', 'fr', 'hi', 'ru', 'zh'],
        'hi': ['en'],
        'ja': ['ar', 'de', 'en', 'es', 'fr', 'ru'],
        'ru': ['ar', 'en', 'es', 'fr'],
        'es': ['ar', 'de', 'en', 'es', 'fr', 'ru'],
        'zh': ['de', 'en']
    }

    source_language_choices = []
    for l_abrv, l_name in language_name_map_dict.items():
        source_language_choices.append(ui.choice(l_abrv, l_name))

    target_language_choices = []
    for l_abrv in language_translation_dict[source_language]:
        target_language_choices.append(ui.choice(l_abrv, language_name_map_dict[l_abrv]))

    items = [
        ui.text_xl('Enter text to translate: '),
        ui.textbox(name='input_text', label='', value=input_text, multiline=True),
        ui.separator(),
        ui.dropdown(name='source_language_trigger', label='Select the language of input text:', value=source_language,
                    required=True, trigger=True, choices=source_language_choices),
        ui.dropdown(name='target_language', label='Select the target language for translation:', value=target_language,
                    required=True, trigger=False, choices=target_language_choices),
        ui.button(name='online_translate_button', label='Generate', primary=True)
    ]

    if online_translate_button is not None:

        model_name = f'Helsinki-NLP/opus-mt-{source_language}-{target_language}'
        model = MarianMTModel.from_pretrained(model_name)
        tokenizer = MarianTokenizer.from_pretrained(model_name)

        batch = tokenizer.prepare_seq2seq_batch(src_texts=[input_text])
        gen = model.generate(**batch)
        words: List[str] = tokenizer.batch_decode(gen, skip_special_tokens=True)

        items.append(ui.text(words[0]))
    else:
        items.append(ui.text('Enter text to see translation here.'))

    return items

def get_configure_content(dai_configuration_updated, dai_address, dai_username, dai_password,
                          sf_configuration_updated, sf_username, sf_password, sf_account, sf_warehouse):
    # TODO: check if credentials are valid
    items = []
    if dai_configuration_updated:
        items.append(ui.message_bar('success', 'Driverless AI credentials have been saved!'))
    if sf_configuration_updated:
        items.append(ui.message_bar('success', 'Snowflake credentials have been saved!'))

    # TODO: can currently only save one at a time
    items = items + configure_dai(dai_address, dai_username, dai_password)
    items = items + configure_snowflake(sf_username, sf_password, sf_account, sf_warehouse)
    return items

def get_contributors_content():
    return []

def get_demooverview_content():
    items = [
        ui.text_m('## Things you should know\n\n'
                  '* This application was build for the Gartner Cloud AI Developer Services 2021 Magic Quad\n\n'
                  '* To show DAI in browser, see this thread: '
                  'https://h2oai.slack.com/archives/CFF49UG65/p1602377325257700\n\n'
                  '* For the best demo experience, log into the following ahead of time in a seperate browser tab: '
                  'puddle.h2o.ai, your DAI instance, https://ui.mm-demo.h2o.ai/, wave.h2o.ai\n\n'
                  '* Currently, you cannot run jupyter notebooks in the H2O AI Cloud, so the H20-3 sections have '
                  'been replaced with static scripts\n\n'
                  '* You must provide your own Driverless AI instance, you can configure this in the Home / '
                  'Configure this Application section\n\n'
                  '* You must provide your own Snowflake instance, you can configure this in the Home / '
                  'Configure this Application section\n\n'
                  '* Credentials are saved at the user level, so if you close your tab and come back later you will '
                  'still be able to show DAI & Snowflake\n\n'
                  '* The Snowflake demo is currently hardcoded to Lending Club and will only work with mojos on that '
                  'data\n\n'
                  '* See the following tabs in this section for the script that was used for the Garnter '
                  'presentation\n\n'
                  '* Run the data pumper script for best MLOps demo experience\n\n'
                  '* Currently, UCF101 Video Analysis, NLP Explainer, and Real time drift detection are private apps '
                  'owned by Michelle so you may or may not get an error page on these. I will update this application '
                  'when those apps have been updated by the main develope\n\nr'
                  '* I had issues installing pytorch in wave.h2o.ai so that section is currently removed\n\n'
                  "* Data prep & feature engineering don't render because they are not https"
                  )
    ]
    return items

async def initialize_app(q: Q):
    # log at the client level
    q.client.logger = logger_setup('gartner_caids')
    q.client.logger.info('Application has been initialized.')

    # q.user.driverlessai_configured = False

    # Upload images and gifs used in this application
    # these are at that app level as they are the same for all users and will never change
    q.app.image_resources = {}
    keys_png = ['home', 'h2o_ai_cloud', 'h2o_products', 'h2o_wave', 'dataprep_pipeline', 'image_workflow',
                'ocr_workflow', 'github_logo']
    for i in keys_png:
        image_path_local = './static/' + i + '.png'
        image_path_wave, = await q.site.upload([image_path_local])
        q.app.image_resources[i] = image_path_wave

    keys_gif = ['automl', 'language', 'vision']
    for i in keys_gif:
        image_path_local = './static/' + i + '.gif'
        image_path_wave, = await q.site.upload([image_path_local])
        q.app.image_resources[i] = image_path_wave

    # Setup UI elements on the page
    q.page['meta'] = ui.meta_card(box='', title='H2O AIaaS')
    q.page['title'] = ui.header_card(
        box='1 1 2 1',
        title='AI as a Service',
        subtitle='H2O AI Cloud Demonstration',
        icon='AppIconDefault',
        icon_color=application_color,
    )
    q.page['topbar'] = ui.form_card(
        box='3 1 -1 1',
        items=[ui.tabs(name='service_tab', value='home', items=get_services_tabs())]
    )
    q.page['sidebar'] = ui.nav_card(box='1 2 2 -1', items=[])
    q.page['content'] = ui.form_card(box='3 2 -1 -1', items=[])

async def main(q: Q):

    topbar = q.page['topbar']
    sidebar = q.page['sidebar']
    content = q.page['content']

    topbar_selection = q.args.service_tab
    sidebar_selection = q.args['#']
    content_selection = q.args.center_content_tabs

    content_tabs = q.client.content_tabs  # retrieve center tab ui element from previous screen
    content_items = [ui.text('404')]  # TODO: create an oopsies page

    if not q.client.app_initialized:
        await initialize_app(q)
        topbar_selection = 'home'
        q.client.app_initialized = True

    q.client.logger.info('Request handled.')
    if len(expando_to_dict(q.args)):
        q.client.logger.info('Main Function Arguments:\n%s\n', q.args)
    if len(expando_to_dict(q.client)):
        q.client.logger.info('Client Variables:\n%s\n', q.client)
    if len(expando_to_dict(q.user)):
        q.client.logger.info('User Variables:\n%s\n', q.user)
    if len(expando_to_dict(q.app)):
        q.client.logger.info('Application Variables:\n%s\n', q.app)

    if q.args.snowflake_realtime_button:
        content_selection = 'automl/modelops/snowflake'
        content.items = [ui.progress('Scoring data in Snowflake')]
        await q.page.save()

    if q.args.online_generate_button:
        content_selection = 'language/generation/generation'
        content.items = [ui.progress('Generating data using GPT-2')]
        await q.page.save()

    if q.args.online_translate_button:
        content_selection = 'language/translation/translation'
        content.items = [ui.progress('Translating data using MarianMTModel')]
        await q.page.save()

    if q.args.dai_configure_button:
        q.user.driverlessai_configured = True
        q.user.dai_address = q.args.dai_address
        q.user.dai_username = q.args.dai_username
        q.user.dai_password = q.args.dai_password
        sidebar_selection = 'about/configure'

    if q.args.sf_configure_button:
        q.user.snowflake_configured = True
        q.user.sf_username = q.args.sf_username
        q.user.sf_password = q.args.sf_password
        q.user.sf_account = q.args.sf_account
        q.user.sf_warehouse = q.args.sf_warehouse
        sidebar_selection = 'about/configure'

    if q.args.go_to_configure:
        topbar_selection = 'home'
        sidebar_selection = 'about/configure'

    if topbar_selection is not None:
        topbar.items[0].tabs.value = topbar_selection  # Make appropriate top tab selected
        content_tabs = []

        del q.page['about_bar']  # This is only needed for the home page, not services pages

        if topbar_selection == 'home':
            q.page['about_bar'] = ui.nav_card(box='1 7 2 -1', items=get_about_navigation())
            sidebar.items = get_home_navigation()
            content_items = create_image_content(q.app.image_resources['home'])
        elif topbar_selection == 'automl':
            sidebar.items = get_automl_navigation()
            content_items = create_image_content(q.app.image_resources['automl'])
        elif topbar_selection == 'language':
            sidebar.items = get_language_navigation()
            content_items = create_image_content(q.app.image_resources['language'])
        elif topbar_selection == 'vision':
            sidebar.items = get_vision_navigation()
            content_items = create_image_content(q.app.image_resources['vision'])

    if sidebar_selection is not None:
        content_tabs = []

        # Home sidebar options
        if sidebar_selection == 'home/platform':
            content_items = create_image_content(q.app.image_resources['h2o_ai_cloud'])

            q.page['about_bar'].items[0].nav_group.value = None

        elif sidebar_selection == 'home/products':
            content_items = create_image_content(q.app.image_resources['h2o_products'])
        elif sidebar_selection == 'home/wave':
            content_items = create_image_content(q.app.image_resources['h2o_wave'])

        # About sidebar options
        elif sidebar_selection == 'about/configure':
            content_items = get_configure_content(q.args.dai_configure_button, q.user.dai_address,
                                                  q.user.dai_username, q.user.dai_password,
                                                  q.args.sf_configure_button, q.user.sf_username,
                                                  q.user.sf_password, q.user.sf_account, q.user.sf_warehouse)
        elif sidebar_selection == 'about/script':
            content_tabs = get_demo_tabs()
        elif sidebar_selection == 'about/sourcecode':
            content_items = [ui.link(path='https://github.com/h2oai/q-product-demo/tree/caids',
                                     label='View me on GitHub!')] + python_code_content('run.py')
        elif sidebar_selection == 'about/contributors':
            content_items = get_contributors_content()

        # AutoML sidebar options
        elif sidebar_selection == 'automl/provision':
            content_tabs = get_automl_provision_tabs()

        elif sidebar_selection == 'automl/automl':
            content_tabs = get_automl_automl_tabs()
        elif sidebar_selection == 'automl/mli':
            content_tabs = get_automl_mli_tabs()

        elif sidebar_selection == 'automl/dataprep':
            content_tabs = get_automl_dataprep_tabs()
        elif sidebar_selection == 'automl/featureengineering':
            content_tabs = get_automl_featureengineering_tabs()
        elif sidebar_selection == 'automl/documentation':
            content_tabs = get_automl_documentation_tabs()
        elif sidebar_selection == 'automl/modelops':
            content_tabs = get_automl_modelops_tabs()
        elif sidebar_selection == 'automl/modelmanagement':
            content_tabs = get_automl_modelmanagement_tabs()

        # Language sidebar options
        elif sidebar_selection == 'language/analytics':
            content_tabs = get_language_analytics_tabs()
        elif sidebar_selection == 'language/generation':
            content_tabs = get_language_generation_tabs()
        elif sidebar_selection == 'language/translation':
            content_tabs = get_language_translation_tabs()

        # Vision sidebar options
        elif sidebar_selection == 'vision/image':
            content_tabs = get_vision_image_tabs()
        elif sidebar_selection == 'vision/video':
            content_tabs = get_vision_video_tabs()
        elif sidebar_selection == 'vision/ocr':
            content_tabs = get_vision_ocr_tabs()

        # Landing page for each section is the first tab
        if len(content_tabs) > 0:
            content_selection = content_tabs[0].name

    if content_selection is not None:

        if content_selection == 'about/script/home':
            content_items = get_demooverview_content()
        elif content_selection == 'about/script/intro':
            content_items = create_markdown_content('./scripts/caids_2021_intro_script.md')
        elif content_selection == 'about/script/automl':
            content_items = create_markdown_content('./scripts/caids_2021_automl_script.md')
        elif content_selection == 'about/script/language':
            content_items = create_markdown_content('./scripts/caids_2021_language_script.md')
        elif content_selection == 'about/script/vision':
            content_items = create_markdown_content('./scripts/caids_2021_vision_script.md')

        elif content_selection == 'automl/provision/puddle':
            content_items = create_website_content('https://puddle.h2o.ai')

        elif content_selection == 'automl/automl/dai':
            content_items = create_dai_content(q.user.dai_address)
        elif content_selection == 'automl/automl/oss':
            content_items = python_code_content('automl.py')
        elif content_selection == 'automl/automl/citizends':
            content_items = create_wavecloud_content('citizends')
        elif content_selection == 'automl/automl/umap':
            content_items = create_wavecloud_content('umap')

        elif content_selection == 'automl/mli/dai':
            content_items = create_dai_content(q.user.dai_address)
        elif content_selection == 'automl/mli/oss':
            content_items = python_code_content('autoexplain.py')
        elif content_selection == 'automl/mli/rml':
            content_items = create_wavecloud_content('rml')

        elif content_selection == 'automl/dataprep/dai':
            content_items = get_dataprep_content()
        elif content_selection == 'automl/dataprep/pipeline':
            content_items = create_image_content(q.app.image_resources['dataprep_pipeline'])
        elif content_selection == 'automl/dataprep/recipes':
            content_items = create_website_content('http://catalog.h2o.ai/')

        elif content_selection == 'automl/featureengineering/recipes':
            content_items = create_website_content('http://catalog.h2o.ai/')

        elif content_selection == 'automl/documentation/dai':
            content_items = create_dai_content(q.user.dai_address)
        elif content_selection == 'automl/documentation/oss':
            content_items = python_code_content('autodoc.py')

        elif content_selection == 'automl/modelops/dai':
            content_items = create_dai_content(q.user.dai_address)
        elif content_selection == 'automl/modelops/snowflake':
            content_items = get_snowflakequery_content(q.args.sql, q.args.snowflake_realtime_button,
                                                       q.user.sf_username, q.user.sf_password, q.user.sf_account,
                                                       q.user.sf_warehouse)
        elif content_selection == 'automl/modelops/modelops':
            content_items = create_website_content('https://ui.mm-demo.h2o.ai')

        elif content_selection == 'automl/modelmanagement/drift':
            content_items = create_wavecloud_content('drift')
        elif content_selection == 'automl/modelmanagement/backtesting':
            content_items = create_wavecloud_content('backtesting')
        elif content_selection == 'automl/modelmanagement/robustness':
            content_items = create_wavecloud_content('adversarial')

        elif content_selection == 'language/analytics/textpreprocessing':
            content_items = create_wavecloud_content('textpreprocessing')
        elif content_selection == 'language/analytics/datalabeling':
            content_items = create_wavecloud_content('datalabeling')
        elif content_selection == 'language/analytics/dai':
            content_items = create_dai_content(q.user.dai_address)
        elif content_selection == 'language/analytics/languageexplainer':
            content_items = create_wavecloud_content('languageexplainer')

        elif content_selection == 'language/generation/generation':
            content_items = get_textgeneration_content(q.args.input_text, q.args.num_words_to_generate)

        elif content_selection == 'language/translation/translation':
            content_items = get_texttranslation_content(q.args.online_translate_button, q.args.source_language,
                                                        q.args.input_text, q.args.target_language)

        elif content_selection == 'vision/image/howto':
            content_items = create_image_content(q.app.image_resources['image_workflow'])
        elif content_selection == 'vision/image/dai':
            content_items = create_dai_content(q.user.dai_address)

        elif content_selection == 'vision/video/video':
            content_items = create_wavecloud_content('deepfake')

        elif content_selection == 'vision/ocr/howto':
            content_items = create_image_content(q.app.image_resources['ocr_workflow'])
        elif content_selection == 'vision/ocr/ocr':
            content_items = create_wavecloud_content('ocr')

    q.client.content_tabs = content_tabs  # Save section that we are in for next function call

    if len(content_tabs) > 0:
        content.items = [ui.tabs(name='center_content_tabs', value=content_selection, items=content_tabs),
                         ui.frame(content=' ', height="20px")] + content_items
    else:
        content.items = content_items
    await q.page.save()

tabs_and_menus

mturoci commented 3 years ago

I don't think this is a good idea. From UX and code point of view, you should have only a single navigation / nav card in your app (the code above has 2). Having multiple causes syncing problems with active / highlighted state as mentioned in the issue. The usecase for navigation in general is fairly simple: Allow users to explore different parts of your app and show them where they are currently. In my opinion adding an option to manually remove the state is not a good idea because:

My suggestion is that each app should have a single navigation that would take you to different pages. This also means that the tab navigation in Tour may not be a good example and should be probably rethought. I think of tabs as a "Javascript only" or "memory only" navigation that shows and hides groups of content in a certain context (part of the detail page, various aspects of some product etc.), but not for using it as a replacement for a proper navigation. @lo5 your thoughts on this?

mtanco commented 3 years ago

@mturoci I think there's a big picture problem being touch on here which is: the data science app developers keep, as a group, trying to do stuff that is bad

Specifically on this point though. Let's say an app has one navigation bar, since the developer has no way to "click" and highlight a tab manually when someone starts the app the "home" nav item isn't selected (and can't be) which feels weird to me as a user (but definitely isn't a big deal)

mturoci commented 3 years ago

@mtanco agree on initial selection. My previous comment refers to "unclicking". My proposal is: Let app developer set initial selection and don't care about the following state (it's going to be handled client side only when clicking). Encourage only single nav_card per app.

mtanco commented 3 years ago

I am fine with this proposal 👍

As for the encouragement, app templates will definitely help so hopefully there will be less of this type of issue in the future

mtanco commented 3 years ago

Adding here as it's related to this whole navigation conversation - Tour.py looks a little weird to me when you 1. click a menu item and then 2. use the next button to go forward. Then the old, "wrong" menu item is selected.

I get why, and I understand why we don't want to let people change it, but it also seems like if you click next it tour it should automatically "select" the appropriate menu item, purely from a UX perspective. Should a different widget than menu be used? @mturoci

image
mturoci commented 3 years ago

Good question @mtanco. The whole UX is not the best. I tried to search for some examples, but could not find any that would have both Previous / Next buttons together with nav. Tour of Go has no sidebar and let's you go one step at time only (which I believe is a true tour-like experience). Our tour is more like docs site / playground I would say. I would personally remove the Previous / Next buttons. However, if we still want to keep the original solution, we have no other option than just allow programatic control over nav selection.