From 0b1bdc1b9093cde5b6c47131df8ac49e5e5c40bf Mon Sep 17 00:00:00 2001 From: Christoph Jerolimov Date: Tue, 4 Jul 2023 18:21:50 +0200 Subject: [PATCH] Show ConsoleSamples resources in the samples catalog --- .../quick-starts/QuickStartConfiguration.tsx | 17 +- .../quick-starts/loader/QuickStartsLoader.tsx | 29 +-- .../utils/__tests__/useQuickStarts.data.ts | 59 +++++ .../utils/__tests__/useQuickStarts.spec.ts | 74 +++++++ .../quick-starts/utils/useQuickStarts.ts | 121 +++++++++++ .../src/extensions/catalog.ts | 1 + .../src/components/catalog/CatalogTile.tsx | 4 +- .../catalog/utils/catalog-utils.tsx | 3 + .../dev-console/console-extensions.json | 28 ++- .../dev-console/locales/en/devconsole.json | 10 +- .../__tests__/useConsoleSamples.test.xxx | 16 ++ .../__tests__/useDevfileSamples.data.ts | 8 +- .../src/components/catalog/providers/index.ts | 2 + .../catalog/providers/useConsoleSamples.tsx | 92 ++++++++ .../catalog/providers/useDevfile.tsx | 18 +- .../catalog/providers/useDevfileSamples.tsx | 9 +- .../src/components/import/GitImportForm.tsx | 10 +- .../import/ImportStrategySection.tsx | 6 +- .../components/import/devfile/devfileHooks.ts | 2 +- .../src/components/import/git/GitSection.tsx | 57 +++-- .../import/image-search/ImageSearch.tsx | 56 +++++ .../image-search/ImageSearchSection.tsx | 44 ++-- .../src/components/import/import-types.ts | 9 - .../AddServerlessFunctionForm.tsx | 85 +------- .../serverless-function/ExtensionCard.tsx | 44 ++++ .../serverless-function/ExtensionCards.tsx | 47 ++++ .../serverless-function/FuncSection.scss | 5 + .../FuncSection.tsx | 8 +- .../ServerlessFunctionSection.scss | 5 + .../ServerlessFunctionSection.tsx | 114 ++++++++++ .../ServerlessFunctionStrategySection.tsx | 2 +- .../import/serverlessfunc/FuncSection.scss | 5 - .../packages/dev-console/src/models/index.ts | 1 + .../dev-console/src/models/samples.ts | 14 ++ .../packages/dev-console/src/types/index.ts | 1 + .../packages/dev-console/src/types/samples.ts | 201 ++++++++++++++++++ .../src/utils/__tests__/samples.data.ts | 94 ++++++++ .../src/utils/__tests__/samples.spec.ts | 98 +++++++++ .../packages/dev-console/src/utils/samples.ts | 120 +++++++++++ .../serverless-functions.ts} | 12 +- .../src/utils/serverless-strategy-detector.ts | 8 + .../sections/RepositoryFormSection.tsx | 2 + 42 files changed, 1357 insertions(+), 184 deletions(-) create mode 100644 frontend/packages/console-app/src/components/quick-starts/utils/__tests__/useQuickStarts.data.ts create mode 100644 frontend/packages/console-app/src/components/quick-starts/utils/__tests__/useQuickStarts.spec.ts create mode 100644 frontend/packages/console-app/src/components/quick-starts/utils/useQuickStarts.ts create mode 100644 frontend/packages/dev-console/src/components/catalog/providers/__tests__/useConsoleSamples.test.xxx create mode 100644 frontend/packages/dev-console/src/components/catalog/providers/useConsoleSamples.tsx create mode 100644 frontend/packages/dev-console/src/components/import/serverless-function/ExtensionCard.tsx create mode 100644 frontend/packages/dev-console/src/components/import/serverless-function/ExtensionCards.tsx create mode 100644 frontend/packages/dev-console/src/components/import/serverless-function/FuncSection.scss rename frontend/packages/dev-console/src/components/import/{serverlessfunc => serverless-function}/FuncSection.tsx (95%) create mode 100644 frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionSection.scss create mode 100644 frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionSection.tsx delete mode 100644 frontend/packages/dev-console/src/components/import/serverlessfunc/FuncSection.scss create mode 100644 frontend/packages/dev-console/src/models/index.ts create mode 100644 frontend/packages/dev-console/src/models/samples.ts create mode 100644 frontend/packages/dev-console/src/types/index.ts create mode 100644 frontend/packages/dev-console/src/types/samples.ts create mode 100644 frontend/packages/dev-console/src/utils/__tests__/samples.data.ts create mode 100644 frontend/packages/dev-console/src/utils/__tests__/samples.spec.ts create mode 100644 frontend/packages/dev-console/src/utils/samples.ts rename frontend/packages/dev-console/src/{components/import/serverlessfunc/func-utils.ts => utils/serverless-functions.ts} (62%) diff --git a/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx b/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx index 1266532ff0d8..dfc5d27662cf 100644 --- a/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx +++ b/frontend/packages/console-app/src/components/quick-starts/QuickStartConfiguration.tsx @@ -7,7 +7,6 @@ import { getGroupVersionKindForModel, ResourceIcon, } from '@console/dynamic-plugin-sdk/src/lib-core'; -import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource'; import { K8sResourceKind } from '@console/internal/module/k8s'; import { useTelemetry } from '@console/shared/src'; import { @@ -19,6 +18,7 @@ import { SaveStatusProps, } from '@console/shared/src/components/cluster-configuration'; import { QuickStartModel } from '../../models'; +import { getQuickStartNameRef, useQuickStarts } from './utils/useQuickStarts'; type DisabledQuickStartsConsoleConfig = K8sResourceKind & { spec: { @@ -39,7 +39,7 @@ const Item: React.FC = ({ id, quickStart }) => (
{quickStart.spec.displayName || quickStart.metadata.name}
- {quickStart.spec.displayName ?
{quickStart.metadata.name}
: null} + {quickStart.spec.displayName ?
{id}
: null}
) : ( @@ -53,12 +53,7 @@ const QuickStartConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) const fireTelemetryEvent = useTelemetry(); // All available quick starts - const [allQuickStarts, allQuickStartsLoaded, allQuickStartsError] = useK8sWatchResource< - QuickStart[] - >({ - groupVersionKind: getGroupVersionKindForModel(QuickStartModel), - isList: true, - }); + const [allQuickStarts, allQuickStartsLoaded, allQuickStartsError] = useQuickStarts(false); // Current configuration const [consoleConfig, consoleConfigLoaded, consoleConfigError] = useConsoleOperatorConfig< @@ -77,7 +72,7 @@ const QuickStartConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) return []; } return allQuickStarts - .filter((quickStart) => !disabled || !disabled.includes(quickStart.metadata.name)) + .filter((quickStart) => !disabled || !disabled.includes(getQuickStartNameRef(quickStart))) .sort((quickStartA, quickStartB) => { const displayNameA = quickStartA.spec.displayName || quickStartA.metadata.name; const displayNameB = quickStartB.spec.displayName || quickStartB.metadata.name; @@ -86,7 +81,7 @@ const QuickStartConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) .map((quickStart) => ( )); @@ -97,7 +92,7 @@ const QuickStartConfiguration: React.FC<{ readonly: boolean }> = ({ readonly }) } const quickStartsByName = allQuickStarts.reduce>( (acc, quickStart) => { - acc[quickStart.metadata.name] = quickStart; + acc[getQuickStartNameRef(quickStart)] = quickStart; return acc; }, {}, diff --git a/frontend/packages/console-app/src/components/quick-starts/loader/QuickStartsLoader.tsx b/frontend/packages/console-app/src/components/quick-starts/loader/QuickStartsLoader.tsx index e3bb1cdf384c..1674315490e3 100644 --- a/frontend/packages/console-app/src/components/quick-starts/loader/QuickStartsLoader.tsx +++ b/frontend/packages/console-app/src/components/quick-starts/loader/QuickStartsLoader.tsx @@ -1,24 +1,11 @@ import * as React from 'react'; -import { QuickStart, isDisabledQuickStart, getDisabledQuickStarts } from '@patternfly/quickstarts'; +import { QuickStart } from '@patternfly/quickstarts'; import { QuickStartsLoaderProps } from '@console/dynamic-plugin-sdk/src/api/internal-types'; -import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; -import { referenceForModel } from '@console/internal/module/k8s/k8s'; -import { QuickStartModel } from '../../../models'; +import { useQuickStarts } from '../utils/useQuickStarts'; import QuickStartPermissionChecker from './QuickStartPermissionChecker'; const QuickStartsLoader: React.FC = ({ children }) => { - const [quickStarts, quickStartsLoaded] = useK8sWatchResource({ - kind: referenceForModel(QuickStartModel), - isList: true, - }); - - const enabledQuickstarts = React.useMemo(() => { - const disabledQuickStarts = getDisabledQuickStarts(); - if (quickStartsLoaded && disabledQuickStarts.length > 0) { - return quickStarts.filter((qs) => !isDisabledQuickStart(qs, disabledQuickStarts)); - } - return quickStarts; - }, [quickStarts, quickStartsLoaded]); + const [quickStarts, quickStartsLoaded] = useQuickStarts(); const [allowedQuickStarts, setAllowedQuickStarts] = React.useState([]); const [permissionsLoaded, setPermissionsLoaded] = React.useState(false); @@ -27,20 +14,20 @@ const QuickStartsLoader: React.FC = ({ children }) => { const handlePermissionCheck = React.useCallback( (quickStart, hasPermission) => { permissionChecks.current[quickStart.metadata.name] = hasPermission; - if (Object.keys(permissionChecks.current).length === enabledQuickstarts.length) { - const filteredQuickStarts = enabledQuickstarts.filter( + if (Object.keys(permissionChecks.current).length === quickStarts.length) { + const filteredQuickStarts = quickStarts.filter( (quickstart) => permissionChecks.current[quickstart.metadata.name], ); setAllowedQuickStarts(filteredQuickStarts); setPermissionsLoaded(true); } }, - [enabledQuickstarts], + [quickStarts], ); return ( <> - {enabledQuickstarts.map((quickstart) => { + {quickStarts.map((quickstart) => { return ( = ({ children }) => { })} {children( allowedQuickStarts, - quickStartsLoaded && (enabledQuickstarts.length === 0 || permissionsLoaded), + quickStartsLoaded && (quickStarts.length === 0 || permissionsLoaded), )} ); diff --git a/frontend/packages/console-app/src/components/quick-starts/utils/__tests__/useQuickStarts.data.ts b/frontend/packages/console-app/src/components/quick-starts/utils/__tests__/useQuickStarts.data.ts new file mode 100644 index 000000000000..cac3ad9037d6 --- /dev/null +++ b/frontend/packages/console-app/src/components/quick-starts/utils/__tests__/useQuickStarts.data.ts @@ -0,0 +1,59 @@ +import { QuickStart } from '@patternfly/quickstarts'; + +export const quickStartSample: QuickStart = { + apiVersion: 'console.openshift.io/v1', + kind: 'ConsoleQuickStart', + metadata: { name: 'quickstart-sample' }, + spec: { + displayName: 'QuickStart sample', + description: '', + icon: '', + }, +}; + +export const anotherQuickStartSample: QuickStart = { + apiVersion: 'console.openshift.io/v1', + kind: 'ConsoleQuickStart', + metadata: { name: 'another-quickstart-sample' }, + spec: { + displayName: 'QuickStart sample', + description: '', + icon: '', + }, +}; + +const getPseudoTranslatedQuickStart = ( + quickStart: QuickStart, + lang?: string, + country?: string, +): QuickStart => { + return { + ...quickStart, + metadata: { + ...quickStart.metadata, + name: `${quickStart.metadata.name}${lang ? `-${lang}` : ''}${country ? `-${country}` : ''}`, + labels: { + ...quickStart.metadata.labels, + 'console.openshift.io/name': quickStart.metadata.name, + 'console.openshift.io/lang': lang, + 'console.openshift.io/country': country, + }, + }, + spec: { + ...quickStart.spec, + displayName: `${quickStart.spec.displayName}${lang ? ` ${lang}` : ''}${ + country ? `-${country}` : '' + }`, + }, + }; +}; + +export const translatedQuickStarts: QuickStart[] = [ + getPseudoTranslatedQuickStart(quickStartSample), + getPseudoTranslatedQuickStart(quickStartSample, 'en', 'US'), + getPseudoTranslatedQuickStart(quickStartSample, 'EN', 'CA'), + getPseudoTranslatedQuickStart(quickStartSample, 'fr', 'CA'), + getPseudoTranslatedQuickStart(quickStartSample, 'fr'), + getPseudoTranslatedQuickStart(quickStartSample, 'de', 'DE'), + getPseudoTranslatedQuickStart(quickStartSample, 'de', 'AT'), +]; diff --git a/frontend/packages/console-app/src/components/quick-starts/utils/__tests__/useQuickStarts.spec.ts b/frontend/packages/console-app/src/components/quick-starts/utils/__tests__/useQuickStarts.spec.ts new file mode 100644 index 000000000000..626cb3f9bf53 --- /dev/null +++ b/frontend/packages/console-app/src/components/quick-starts/utils/__tests__/useQuickStarts.spec.ts @@ -0,0 +1,74 @@ +import { getBestMatch, groupQuickStartsByName } from '../useQuickStarts'; +import { + quickStartSample, + anotherQuickStartSample, + translatedQuickStarts, +} from './useQuickStarts.data'; + +describe('groupConsoleSamplesByName', () => { + it('should create a single group for one sample without localization labels', () => { + const actual = groupQuickStartsByName([quickStartSample]); + expect(actual).toEqual({ + 'quickstart-sample': [quickStartSample], + }); + }); + + it('should create a two groups for two different samples without localization labels', () => { + const actual = groupQuickStartsByName([quickStartSample, anotherQuickStartSample]); + expect(actual).toEqual({ + 'quickstart-sample': [quickStartSample], + 'another-quickstart-sample': [anotherQuickStartSample], + }); + }); + + it('should group the translated samples correct', () => { + const actual = groupQuickStartsByName(translatedQuickStarts); + + expect(actual).toEqual({ 'quickstart-sample': translatedQuickStarts }); + }); +}); + +describe('getBestMatch', () => { + it('should return null for null', () => { + expect(getBestMatch(null, '')).toBe(null); + }); + + it('should return null for an empty array', () => { + expect(getBestMatch([], 'en')).toBe(null); + }); + + it('should return the sample with an equal language and country', () => { + // neighter language or country is defined + expect(getBestMatch(translatedQuickStarts, '')).toEqual(translatedQuickStarts[0]); + // default language is preferred, no country + expect(getBestMatch(translatedQuickStarts, 'en')).toEqual(translatedQuickStarts[0]); + // non default language and country + expect(getBestMatch(translatedQuickStarts, 'fr-CA')).toEqual(translatedQuickStarts[3]); + // lowercase input + expect(getBestMatch(translatedQuickStarts, 'fr-ca')).toEqual(translatedQuickStarts[3]); + // uppercase input + expect(getBestMatch(translatedQuickStarts, 'FR-CA')).toEqual(translatedQuickStarts[3]); + // language defined, but not english, no country + expect(getBestMatch(translatedQuickStarts, 'fr')).toEqual(translatedQuickStarts[4]); + }); + + it('should return the sample with the same language, prefer no country', () => { + expect(getBestMatch(translatedQuickStarts, 'fr-MC')).toEqual(translatedQuickStarts[4]); + }); + + it('should return the sample with the same language, prefer any country', () => { + expect(getBestMatch(translatedQuickStarts, 'DE-CH')).toEqual(translatedQuickStarts[5]); + }); + + it('should return the fallback language with the same country', () => { + expect(getBestMatch(translatedQuickStarts, 'NA-CA')).toEqual(translatedQuickStarts[2]); + }); + + it('should return the fallback language with no country', () => { + expect(getBestMatch(translatedQuickStarts, 'NA-NA')).toEqual(translatedQuickStarts[0]); + }); + + it('should return the fallback language with any country', () => { + expect(getBestMatch(translatedQuickStarts.slice(1), 'NA-NA')).toEqual(translatedQuickStarts[1]); + }); +}); diff --git a/frontend/packages/console-app/src/components/quick-starts/utils/useQuickStarts.ts b/frontend/packages/console-app/src/components/quick-starts/utils/useQuickStarts.ts new file mode 100644 index 000000000000..eeb75d0a1bb7 --- /dev/null +++ b/frontend/packages/console-app/src/components/quick-starts/utils/useQuickStarts.ts @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { QuickStart, getDisabledQuickStarts } from '@patternfly/quickstarts'; +import { useTranslation } from 'react-i18next'; +import { + WatchK8sResult, + getGroupVersionKindForModel, +} from '@console/dynamic-plugin-sdk/src/lib-core'; +import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/utils/k8s/hooks/useK8sWatchResource'; +import { QuickStartModel } from '../../../models'; + +const LOCALIZATION_NAME_LABEL = 'console.openshift.io/name'; +const LOCALIZATION_LANGUAGE_LABEL = 'console.openshift.io/lang'; +const LOCALIZATION_COUNTRY_LABEL = 'console.openshift.io/country'; + +export const getQuickStartNameRef = (quickStart: QuickStart) => + quickStart.metadata.labels?.[LOCALIZATION_NAME_LABEL] || + quickStart.metadata.annotations?.[LOCALIZATION_NAME_LABEL] || + quickStart.metadata.name; + +export const groupQuickStartsByName = (quickStarts: QuickStart[]) => { + return quickStarts.reduce>((grouped, quickStart) => { + const name = getQuickStartNameRef(quickStart); + if (!grouped[name]) grouped[name] = []; + grouped[name].push(quickStart); + return grouped; + }, {}); +}; + +/** + * Returns the QuickStart with the best localization match, for the given + * preferred language and preferred country. It prefers a match in this order: + * + * 1. QuickStart language and country are equal to the preferred language and country. + * /// This includes that the no quick starts language and country is defined. + * + * 2. QuickStart language is equal to the preferred language. + * 1. And the quick starts has no country defined. (eg, select en quick starts is used for en-CA and en-GB) + * 2. Any country is defined. (eg, select en-CA quick starts is used for en-GB) + * + * 3. Fallback to an english quick starts + * (QuickStart language is en OR quick starts language is not defined): + * 1. Same country (use en-CA quick starts if preference is fr-CA) + * 2. No country () + * 3. Any country (use en-CA quick starts if preference is en-US) + */ +export const getBestMatch = (quickStarts: QuickStart[], language: string): QuickStart | null => { + if (!quickStarts || !quickStarts.length) { + return null; + } + const preferredLanguage = (language || 'en').split('-')[0].toLowerCase(); + const preferredCountry = ((language || '').split('-')[1] || '').toUpperCase(); + + let sameLanguageWithoutCountry: QuickStart = null; + let sameLanguageWithAnyCountry: QuickStart = null; + let fallbackLanguageSameCountry: QuickStart = null; + let fallbackLanguageNoCountry: QuickStart = null; + let fallbackLanguageAnyCountry: QuickStart = null; + + for (const quickStart of quickStarts) { + const quickStartLanguage = ( + quickStart.metadata?.labels?.[LOCALIZATION_LANGUAGE_LABEL] || 'en' + ).toLowerCase(); + const quickStartCountry = ( + quickStart.metadata?.labels?.[LOCALIZATION_COUNTRY_LABEL] || '' + ).toUpperCase(); + + if (quickStartLanguage === preferredLanguage && quickStartCountry === preferredCountry) { + return quickStart; + } + if (quickStartLanguage === preferredLanguage) { + if (!quickStartCountry && !sameLanguageWithoutCountry) { + sameLanguageWithoutCountry = quickStart; + } else if (quickStartCountry && !sameLanguageWithAnyCountry) { + sameLanguageWithAnyCountry = quickStart; + } + } + if (quickStartLanguage === 'en') { + if (quickStartCountry === preferredCountry && !fallbackLanguageSameCountry) { + fallbackLanguageSameCountry = quickStart; + } else if (!quickStartCountry && !fallbackLanguageNoCountry) { + fallbackLanguageNoCountry = quickStart; + } else if (!fallbackLanguageAnyCountry) { + fallbackLanguageAnyCountry = quickStart; + } + } + } + return ( + sameLanguageWithoutCountry || + sameLanguageWithAnyCountry || + fallbackLanguageSameCountry || + fallbackLanguageNoCountry || + fallbackLanguageAnyCountry + ); +}; + +export const useQuickStarts = (filterDisabledQuickStarts = true): WatchK8sResult => { + const preferredLanguage = useTranslation().i18n.language; + + const [quickStarts, quickStartsLoaded, quickStartsError] = useK8sWatchResource({ + groupVersionKind: getGroupVersionKindForModel(QuickStartModel), + isList: true, + }); + + const bestMatchQuickStarts = React.useMemo(() => { + if (!quickStartsLoaded) { + return []; + } + const groupedQuickStarts = groupQuickStartsByName(quickStarts); + + if (filterDisabledQuickStarts) { + const disabledQuickStarts = getDisabledQuickStarts(); + disabledQuickStarts.forEach((quickStartName) => delete groupedQuickStarts[quickStartName]); + } + + return Object.values(groupedQuickStarts).map((quickStartsByName) => + getBestMatch(quickStartsByName, preferredLanguage), + ); + }, [quickStarts, quickStartsLoaded, filterDisabledQuickStarts, preferredLanguage]); + + return [bestMatchQuickStarts, quickStartsLoaded, quickStartsError]; +}; diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/catalog.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/catalog.ts index 6ebc4d5be78f..6b91f3c64966 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/catalog.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/catalog.ts @@ -122,6 +122,7 @@ export type CatalogExtensionHookOptions = { export type CatalogItem = { uid: string; type: string; + typeLabel?: string | React.ReactNode; name: string; /** Optional title to render a custom title using ReactNode. * Rendered in catalog tile and side panel diff --git a/frontend/packages/console-shared/src/components/catalog/CatalogTile.tsx b/frontend/packages/console-shared/src/components/catalog/CatalogTile.tsx index 1f98b5905a3d..f882bc13837f 100644 --- a/frontend/packages/console-shared/src/components/catalog/CatalogTile.tsx +++ b/frontend/packages/console-shared/src/components/catalog/CatalogTile.tsx @@ -24,14 +24,14 @@ type CatalogTileProps = { const CatalogTile: React.FC = ({ item, catalogTypes, onClick, href }) => { const { t } = useTranslation(); - const { name, title, provider, description, type, badges } = item; + const { name, title, provider, description, type, typeLabel, badges } = item; const vendor = provider ? t('console-shared~Provided by {{provider}}', { provider }) : null; const catalogType = _.find(catalogTypes, ['value', type]); const typeBadges = [ - {catalogType?.label} + {typeLabel ?? catalogType?.label} , ]; diff --git a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx index 4e9e0cfd0cc7..d66803acf510 100644 --- a/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx +++ b/frontend/packages/console-shared/src/components/catalog/utils/catalog-utils.tsx @@ -39,6 +39,9 @@ export const keywordCompare = (filterString: string, items: CatalogItem[]): Cata export const getIconProps = (item: CatalogItem) => { const { icon } = item; + if (!icon) { + return {}; + } if (icon.url) { return { iconImg: icon.url, iconClass: null }; } diff --git a/frontend/packages/dev-console/console-extensions.json b/frontend/packages/dev-console/console-extensions.json index 3595462709a4..2df13179b09f 100644 --- a/frontend/packages/dev-console/console-extensions.json +++ b/frontend/packages/dev-console/console-extensions.json @@ -23,6 +23,18 @@ } }, + { + "type": "console.flag/model", + "properties": { + "model": { + "group": "console.openshift.io", + "version": "v1", + "kind": "ConsoleSample" + }, + "flag": "CONSOLESAMPLES" + } + }, + { "type": "console.cluster-configuration/item", "properties": { @@ -533,12 +545,24 @@ "required": ["OPENSHIFT"] } }, + { + "type": "console.catalog/item-provider", + "properties": { + "catalogId": "samples-catalog", + "type": "ConsoleSample", + "title": "%devconsole~Console sample%", + "provider": { "$codeRef": "catalog.useConsoleSamplesCatalogProvider" } + }, + "flags": { + "required": ["OPENSHIFT", "CONSOLESAMPLES"] + } + }, { "type": "console.catalog/item-provider", "properties": { "catalogId": "samples-catalog", "type": "BuilderImage", - "title": "%devconsole~Builder Images%", + "title": "%devconsole~Builder Image%", "provider": { "$codeRef": "catalog.builderImageSamplesProvider" } }, "flags": { @@ -550,7 +574,7 @@ "properties": { "catalogId": "samples-catalog", "type": "Devfile", - "title": "%devconsole~Devfiles%", + "title": "%devconsole~Devfile%", "provider": { "$codeRef": "catalog.devfileSamplesProvider" } }, "flags": { diff --git a/frontend/packages/dev-console/locales/en/devconsole.json b/frontend/packages/dev-console/locales/en/devconsole.json index a039ff409050..146c94f53372 100644 --- a/frontend/packages/dev-console/locales/en/devconsole.json +++ b/frontend/packages/dev-console/locales/en/devconsole.json @@ -45,6 +45,9 @@ "**Devfiles** are sets of objects for creating services, build configurations, and anything you have permission to create within a Project.": "**Devfiles** are sets of objects for creating services, build configurations, and anything you have permission to create within a Project.", "Service binding": "Service binding", "Bindable services can be easily consumed by applications because they expose their binding data (credentials, connection details, volume mounts, secrets, etc.) in a standard way.": "Bindable services can be easily consumed by applications because they expose their binding data (credentials, connection details, volume mounts, secrets, etc.) in a standard way.", + "Console sample": "Console sample", + "Builder Image": "Builder Image", + "Devfile": "Devfile", "+Add": "+Add", "Topology": "Topology", "Observe": "Observe", @@ -409,7 +412,6 @@ "this Builder Image": "this Builder Image", "Changing to this builder image will update your associated Pipeline and remove any customization you may have applied.": "Changing to this builder image will update your associated Pipeline and remove any customization you may have applied.", "There are no supported pipelines available for {{builderImage}}. Changing to this builder image will disconnect your associated Pipeline.": "There are no supported pipelines available for {{builderImage}}. Changing to this builder image will disconnect your associated Pipeline.", - "Builder Image": "Builder Image", "Detecting recommended Builder Images...": "Detecting recommended Builder Images...", "Unable to detect the Builder Image.": "Unable to detect the Builder Image.", "Select the most appropriate one from the list to continue.": "Select the most appropriate one from the list to continue.", @@ -543,13 +545,15 @@ "A {{deploymentLabel}} enables declarative updates for Pods and ReplicaSets.": "A {{deploymentLabel}} enables declarative updates for Pods and ReplicaSets.", "A {{deploymentConfigLabel}} defines the template for a Pod and manages deploying new Images or configuration changes.": "A {{deploymentConfigLabel}} defines the template for a Pod and manages deploying new Images or configuration changes.", "Resource type to generate. The default can be set in <2>User Preferences.": "Resource type to generate. The default can be set in <2>User Preferences.", - "Provided by Red Hat": "Provided by Red Hat", "func.yaml is not present and builder strategy is not s2i": "func.yaml is not present and builder strategy is not s2i", "func.yaml must be present and builder strategy should be s2i to create a Serverless function": "func.yaml must be present and builder strategy should be s2i to create a Serverless function", "VSCode": "VSCode", "This extension for Knative provides the app developer the tools and experience needed when working with Knative & Serverless Functions on a Kubernetes cluster. Using this extension, developers can develop and deploy functions in a serverless way through guided IDE workflow.": "This extension for Knative provides the app developer the tools and experience needed when working with Knative & Serverless Functions on a Kubernetes cluster. Using this extension, developers can develop and deploy functions in a serverless way through guided IDE workflow.", + "Provided by Red Hat": "Provided by Red Hat", "IntelliJ": "IntelliJ", "A plugin for working with Knative on a OpenShift or Kubernetes cluster. This plugin allows developers to view and deploy their applications in a serverless way.": "A plugin for working with Knative on a OpenShift or Kubernetes cluster. This plugin allows developers to view and deploy their applications in a serverless way.", + "Support for {{runtime}} is not yet available.": "Support for {{runtime}} is not yet available.", + "Unsupported Runtime detected. Please update the Repository URL or change the Build Strategy to continue.": "Unsupported Runtime detected. Please update the Repository URL or change the Build Strategy to continue.", "Builder Image {{image}} is not present.": "Builder Image {{image}} is not present.", "Builder image is not present on cluster": "Builder image is not present on cluster", "Support for Builder image {{image}} is not yet available.": "Support for Builder image {{image}} is not yet available.", @@ -559,8 +563,6 @@ "Domain mapping(s) will be updated": "Domain mapping(s) will be updated", "Warning: The following domain(s) will be removed from the associated service": "Warning: The following domain(s) will be removed from the associated service", "{{domainMapping}} from {{knativeService}}": "{{domainMapping}} from {{knativeService}}", - "Support for {{runtime}} is not yet available.": "Support for {{runtime}} is not yet available.", - "Unsupported Runtime detected. Please update the Repository URL or change the Build Strategy to continue.": "Unsupported Runtime detected. Please update the Repository URL or change the Build Strategy to continue.", "{{kind}} created successfully.": "{{kind}} created successfully.", "Must be a JAR file.": "Must be a JAR file.", "Replicas must be an integer.": "Replicas must be an integer.", diff --git a/frontend/packages/dev-console/src/components/catalog/providers/__tests__/useConsoleSamples.test.xxx b/frontend/packages/dev-console/src/components/catalog/providers/__tests__/useConsoleSamples.test.xxx new file mode 100644 index 000000000000..b640fa57505d --- /dev/null +++ b/frontend/packages/dev-console/src/components/catalog/providers/__tests__/useConsoleSamples.test.xxx @@ -0,0 +1,16 @@ +import { normalizeConsoleSamples } from '../useConsoleSamples'; +import { minimalConsoleSample } from './useConsoleSamples.data'; + +describe('normalizeConsoleSamplesToCatalogItems', () => { + test('ASD', () => { + expect( + normalizeConsoleSamplesToCatalogItems('my-namespace', () => '')(minimalConsoleSample), + ).toEqual({ + uid: '', + type: 'ConsoleSample', + name: '', + title: '', + description: '', + }); + }); +}); diff --git a/frontend/packages/dev-console/src/components/catalog/providers/__tests__/useDevfileSamples.data.ts b/frontend/packages/dev-console/src/components/catalog/providers/__tests__/useDevfileSamples.data.ts index 32a5f7cbd597..dd4c4f4b3b5b 100644 --- a/frontend/packages/dev-console/src/components/catalog/providers/__tests__/useDevfileSamples.data.ts +++ b/frontend/packages/dev-console/src/components/catalog/providers/__tests__/useDevfileSamples.data.ts @@ -71,7 +71,7 @@ export const expectedCatalogItems: CatalogItem[] = [ cta: { label: 'Create Devfile Sample', href: - '/import/ns/test?importType=devfile&formType=sample&devfileName=nodejs-basic&gitRepo=https://github.com/nodeshift-starters/devfile-sample.git', + '/import/ns/test?importType=devfile&formType=sample&devfileName=nodejs-basic&git.repository=https%3A%2F%2Fgithub.com%2Fnodeshift-starters%2Fdevfile-sample.git', }, icon: { url: 'trimmed' }, }, @@ -85,7 +85,7 @@ export const expectedCatalogItems: CatalogItem[] = [ cta: { label: 'Create Devfile Sample', href: - '/import/ns/test?importType=devfile&formType=sample&devfileName=code-with-quarkus&gitRepo=https://github.com/devfile-samples/devfile-sample-code-with-quarkus.git', + '/import/ns/test?importType=devfile&formType=sample&devfileName=code-with-quarkus&git.repository=https%3A%2F%2Fgithub.com%2Fdevfile-samples%2Fdevfile-sample-code-with-quarkus.git', }, icon: { url: 'trimmed' }, }, @@ -99,7 +99,7 @@ export const expectedCatalogItems: CatalogItem[] = [ cta: { label: 'Create Devfile Sample', href: - '/import/ns/test?importType=devfile&formType=sample&devfileName=java-springboot-basic&gitRepo=https://github.com/devfile-samples/devfile-sample-java-springboot-basic.git', + '/import/ns/test?importType=devfile&formType=sample&devfileName=java-springboot-basic&git.repository=https%3A%2F%2Fgithub.com%2Fdevfile-samples%2Fdevfile-sample-java-springboot-basic.git', }, icon: { url: 'trimmed' }, }, @@ -113,7 +113,7 @@ export const expectedCatalogItems: CatalogItem[] = [ cta: { label: 'Create Devfile Sample', href: - '/import/ns/test?importType=devfile&formType=sample&devfileName=python-basic&gitRepo=https://github.com/devfile-samples/devfile-sample-python-basic.git', + '/import/ns/test?importType=devfile&formType=sample&devfileName=python-basic&git.repository=https%3A%2F%2Fgithub.com%2Fdevfile-samples%2Fdevfile-sample-python-basic.git', }, icon: { url: 'trimmed' }, }, diff --git a/frontend/packages/dev-console/src/components/catalog/providers/index.ts b/frontend/packages/dev-console/src/components/catalog/providers/index.ts index 1d59ebbfb27c..e57e82885378 100644 --- a/frontend/packages/dev-console/src/components/catalog/providers/index.ts +++ b/frontend/packages/dev-console/src/components/catalog/providers/index.ts @@ -2,6 +2,8 @@ export { default as builderImageProvider } from './useBuilderImages'; export { default as templateProvider } from './useTemplates'; +export { default as useConsoleSamplesCatalogProvider } from './useConsoleSamples'; + export { default as builderImageSamplesProvider } from './useBuilderImageSamples'; export { default as devfileSamplesProvider } from './useDevfileSamples'; diff --git a/frontend/packages/dev-console/src/components/catalog/providers/useConsoleSamples.tsx b/frontend/packages/dev-console/src/components/catalog/providers/useConsoleSamples.tsx new file mode 100644 index 000000000000..65155a6efa78 --- /dev/null +++ b/frontend/packages/dev-console/src/components/catalog/providers/useConsoleSamples.tsx @@ -0,0 +1,92 @@ +/* eslint-disable no-console */ +import * as React from 'react'; +import { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; +import { CatalogItem } from '@console/dynamic-plugin-sdk'; +import { useActiveNamespace } from '@console/shared/src'; +import { ConsoleSample, isGitImportSource, isContainerImportSource } from '../../../types/samples'; +import { + createSampleQueryParameters, + getBestMatch, + groupConsoleSamplesByName, + useSamples, +} from '../../../utils/samples'; + +export const normalizeConsoleSamples = (activeNamespace: string, t: TFunction) => { + const createLabel = t('devconsole~Create'); + + return (sample: ConsoleSample): CatalogItem | null => { + let href: string; + + if (isGitImportSource(sample.spec.source)) { + const { gitImport } = sample.spec.source; + const searchParams = new URLSearchParams(); + searchParams.set('formType', 'sample'); + searchParams.set('git.repository', gitImport.repository.url); + if (gitImport.repository.revision) { + searchParams.set('git.revision', gitImport.repository.revision); + } + if (gitImport.repository.contextDir) { + searchParams.set('git.contextDir', gitImport.repository.contextDir); + } + href = `/import/ns/${activeNamespace}?${searchParams}`; + } else if (isContainerImportSource(sample.spec.source)) { + href = `/deploy-image/ns/${activeNamespace}?${createSampleQueryParameters(sample)}`; + } else { + // Unsupported source type, will be dropped. + return null; + } + + return { + uid: sample.metadata.uid, + type: 'ConsoleSample', + typeLabel: sample.spec.type || '', + name: sample.metadata.name, + title: sample.spec.title, + description: sample.spec.abstract, + provider: sample.spec.provider, + tags: sample.spec.tags, + icon: sample.spec.icon?.startsWith('data:') + ? { + url: sample.spec.icon, + } + : sample.spec.icon + ? { + url: `data:image;base64,${sample.spec.icon}`, + } + : null, + cta: { + label: createLabel, + href, + }, + data: sample, + }; + }; +}; + +export const useConsoleSamplesCatalogProvider = (): [CatalogItem[], boolean, any] => { + const { i18n, t } = useTranslation(); + const preferredLanguage = i18n.language; + const [activeNamespace] = useActiveNamespace(); + const [allSamples, loaded, loadedError] = useSamples(); + + const catalogItems = React.useMemo(() => { + const filteredSamples = allSamples.filter((sample) => !sample.spec.tags?.includes('hidden')); + + const groupedSamples = groupConsoleSamplesByName(filteredSamples); + + const bestMatchSamples = Object.values(groupedSamples).map((samples2) => + getBestMatch(samples2, preferredLanguage), + ); + + bestMatchSamples.sort((sampleA, sampleB) => + sampleA.spec.title.localeCompare(sampleB.spec.title), + ); + + return bestMatchSamples.map(normalizeConsoleSamples(activeNamespace, t)).filter(Boolean); + }, [allSamples, activeNamespace, preferredLanguage, t]); + + return [catalogItems, loaded, loadedError]; +}; + +export default useConsoleSamplesCatalogProvider; diff --git a/frontend/packages/dev-console/src/components/catalog/providers/useDevfile.tsx b/frontend/packages/dev-console/src/components/catalog/providers/useDevfile.tsx index b97861bcba59..280eb3f8b60c 100644 --- a/frontend/packages/dev-console/src/components/catalog/providers/useDevfile.tsx +++ b/frontend/packages/dev-console/src/components/catalog/providers/useDevfile.tsx @@ -15,17 +15,27 @@ import { DevfileSample } from '../../import/devfile/devfile-types'; const normalizeDevfile = (devfileSamples: DevfileSample[], t: TFunction): CatalogItem[] => { const normalizedDevfileSamples = devfileSamples?.map((sample) => { const { name: uid, displayName, description, tags, git, icon, provider } = sample; - const gitRepoUrl = Object.values(git.remotes)[0]; - const href = `/import?importType=devfile&devfileName=${uid}&gitRepo=${gitRepoUrl}`; + const gitRepositoryUrl = Object.values(git.remotes)[0]; + + const searchParams = new URLSearchParams(); + searchParams.set('importType', 'devfile'); + searchParams.set('devfileName', uid); + searchParams.set('git.repository', gitRepositoryUrl); + + const href = `/import?${searchParams}`; const createLabel = t('devconsole~Create'); const type = 'Devfile'; const detailsProperties: CatalogItemDetailsProperty[] = []; - if (gitRepoUrl) { + if (gitRepositoryUrl) { detailsProperties.push({ label: t('devconsole~Sample repository'), value: ( - + ), }); } diff --git a/frontend/packages/dev-console/src/components/catalog/providers/useDevfileSamples.tsx b/frontend/packages/dev-console/src/components/catalog/providers/useDevfileSamples.tsx index 2b3d1d164ca6..301a864ed382 100644 --- a/frontend/packages/dev-console/src/components/catalog/providers/useDevfileSamples.tsx +++ b/frontend/packages/dev-console/src/components/catalog/providers/useDevfileSamples.tsx @@ -15,7 +15,14 @@ const normalizeDevfileSamples = ( const { name: uid, displayName, description, tags, git, icon, provider } = sample; const gitRepoUrl = Object.values(git.remotes)[0]; const label = t('devconsole~Create Devfile Sample'); - const href = `/import/ns/${activeNamespace}?importType=devfile&formType=sample&devfileName=${uid}&gitRepo=${gitRepoUrl}`; + + const searchParams = new URLSearchParams(); + searchParams.set('importType', 'devfile'); + searchParams.set('formType', 'sample'); + searchParams.set('devfileName', uid); + searchParams.set('git.repository', gitRepoUrl); + + const href = `/import/ns/${activeNamespace}?${searchParams}`; const iconUrl = icon || ''; const item: CatalogItem = { diff --git a/frontend/packages/dev-console/src/components/import/GitImportForm.tsx b/frontend/packages/dev-console/src/components/import/GitImportForm.tsx index 2443bab053f0..40a6b5fbb9a6 100644 --- a/frontend/packages/dev-console/src/components/import/GitImportForm.tsx +++ b/frontend/packages/dev-console/src/components/import/GitImportForm.tsx @@ -27,7 +27,9 @@ const GitImportForm: React.FC & GitImportFormProps> = }) => { const { t } = useTranslation(); const searchParams = new URLSearchParams(window.location.search); - const gitRepoUrl = searchParams.get('gitRepo'); + const gitRepositoryUrl = searchParams.get('git.repository'); + const gitRevision = searchParams.get('git.revision'); + const gitContextDir = searchParams.get('git.contextDir'); const formType = searchParams.get('formType'); const importType = searchParams.get('importType'); const { @@ -43,8 +45,10 @@ const GitImportForm: React.FC & GitImportFormProps> = = ({ builderIm () => ({ [ImportStrategy.DEVFILE]: , [ImportStrategy.DOCKERFILE]: , - [ImportStrategy.SERVERLESS_FUNCTION]: , + [ImportStrategy.SERVERLESS_FUNCTION]: ( + + ), [ImportStrategy.S2I]: , }), [builderImages], diff --git a/frontend/packages/dev-console/src/components/import/devfile/devfileHooks.ts b/frontend/packages/dev-console/src/components/import/devfile/devfileHooks.ts index 98de3578195a..0a99780741cb 100644 --- a/frontend/packages/dev-console/src/components/import/devfile/devfileHooks.ts +++ b/frontend/packages/dev-console/src/components/import/devfile/devfileHooks.ts @@ -120,7 +120,7 @@ export const useDevfileServer = ( export const useDevfileSource = () => { const searchParams = new URLSearchParams(window.location.search); - const devfileSourceUrl = searchParams.get('gitRepo'); + const devfileSourceUrl = searchParams.get('git.repository'); const devfileName = searchParams.get('devfileName'); const formType = searchParams.get('formType'); const { values, setFieldValue, setFieldTouched } = useFormikContext(); diff --git a/frontend/packages/dev-console/src/components/import/git/GitSection.tsx b/frontend/packages/dev-console/src/components/import/git/GitSection.tsx index 3216b3db4cf3..95e4d09c70ff 100644 --- a/frontend/packages/dev-console/src/components/import/git/GitSection.tsx +++ b/frontend/packages/dev-console/src/components/import/git/GitSection.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import * as React from 'react'; import { Alert, TextInputTypes, ValidatedOptions } from '@patternfly/react-core'; import { useFormikContext, FormikErrors, FormikTouched } from 'formik'; @@ -106,14 +107,19 @@ const GitSection: React.FC = ({ setFieldTouched: formikSetFieldTouched, } = useFormikContext(); - const [knativeServiceAccess] = useAccessReview({ + const isKnativeServingAvailable = useFlag(FLAG_KNATIVE_SERVING_SERVICE); + const [canCreateKnativeService, canCreateKnativeServiceLoading] = useAccessReview({ group: ksvcModel.apiGroup, resource: ksvcModel.plural, namespace: getActiveNamespace(), verb: 'create', }); - - const canIncludeKnative = useFlag(FLAG_KNATIVE_SERVING_SERVICE) && knativeServiceAccess; + console.log('xxx isKnativeServingAvailable', isKnativeServingAvailable); + console.log( + 'xxx canCreateKnativeService', + canCreateKnativeService, + canCreateKnativeServiceLoading, + ); const fieldPrefix = formContextField ? `${formContextField}.` : ''; const setFieldValue = React.useCallback( @@ -234,6 +240,11 @@ const GitSection: React.FC = ({ const handleGitUrlChange = React.useCallback( async (url: string, ref: string, dir: string) => { + if (isKnativeServingAvailable && canCreateKnativeServiceLoading) { + console.log('xxx handleGitUrlChange SKIP', url); + return; + } + console.log('xxx handleGitUrlChange GO', url); if (isSubmitting || status?.submitError) return; setValidated(ValidatedOptions.default); setFieldValue('git.validated', ValidatedOptions.default); @@ -280,7 +291,17 @@ const GitSection: React.FC = ({ values.docker?.dockerfilePath, ); - const importStrategyData = await detectImportStrategies(url, gitService, canIncludeKnative); + console.log( + 'xxx handleGitUrlChange', + url, + isKnativeServingAvailable, + canCreateKnativeService, + ); + const importStrategyData = await detectImportStrategies( + url, + gitService, + isKnativeServingAvailable && canCreateKnativeService, + ); const { loaded, @@ -404,7 +425,9 @@ const GitSection: React.FC = ({ values.application.name, values.application.selectedKey, values.build.strategy, - canIncludeKnative, + isKnativeServingAvailable, + canCreateKnativeService, + canCreateKnativeServiceLoading, nameTouched, importType, imageStreamName, @@ -417,16 +440,26 @@ const GitSection: React.FC = ({ const debouncedHandleGitUrlChange = useDebounceCallback(handleGitUrlChange); - const fillSample: React.ReactEventHandler = React.useCallback(() => { + const fillSample = React.useCallback(() => { + if (isKnativeServingAvailable && canCreateKnativeServiceLoading) return; + console.log('xxx fillSample', sampleRepo); const url = sampleRepo; const ref = getSampleRef(tag); const dir = getSampleContextDir(tag); - setFieldValue('git.url', url); - setFieldValue('git.dir', dir); - setFieldValue('git.ref', ref); - setFieldTouched('git.url', true); + setFieldValue('git.url', url, false); + setFieldValue('git.ref', ref, false); + setFieldValue('git.dir', dir, false); + setFieldTouched('git.url', true, true); handleGitUrlChange(url, ref, dir); - }, [handleGitUrlChange, sampleRepo, setFieldTouched, setFieldValue, tag]); + }, [ + handleGitUrlChange, + sampleRepo, + setFieldTouched, + setFieldValue, + tag, + isKnativeServingAvailable, + canCreateKnativeServiceLoading, + ]); React.useEffect(() => { (!dirty || gitDirTouched || gitTypeTouched || formReloadCount || values.git.secretResource) && @@ -519,7 +552,7 @@ const GitSection: React.FC = ({ React.useEffect(() => { inputRef.current?.focus(); - sampleRepo && fillSample(null); + sampleRepo && fillSample(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/frontend/packages/dev-console/src/components/import/image-search/ImageSearch.tsx b/frontend/packages/dev-console/src/components/import/image-search/ImageSearch.tsx index 64688a253e9c..4d85ba525c71 100644 --- a/frontend/packages/dev-console/src/components/import/image-search/ImageSearch.tsx +++ b/frontend/packages/dev-console/src/components/import/image-search/ImageSearch.tsx @@ -14,10 +14,64 @@ import { ImageStreamImportsModel } from '@console/internal/models'; import { k8sCreate, ContainerPort } from '@console/internal/module/k8s'; import { InputField, useDebounceCallback, CheckboxField } from '@console/shared'; import { UNASSIGNED_KEY, CREATE_APPLICATION_KEY } from '@console/topology/src/const'; +import { isContainerImportSource } from '../../../types/samples'; import { getSuggestedName, getPorts, makePortName } from '../../../utils/imagestream-utils'; +import { getSampleQueryParameters, getSample } from '../../../utils/samples'; import { secretModalLauncher } from '../CreateSecretModal'; import './ImageSearch.scss'; +const useQueryParametersIfDefined = (handleSearch: (image: string) => void) => { + const { setFieldValue } = useFormikContext(); + + /** + * Automatically prefill the container image search field into the Formik values + * and trigger a `ImageStreamImport` via `handleSearch`. + * + * 1. Prefer an `image` query parameter. + * 2. Use `sample` query parameter to lookup a ConsoleSample. + */ + React.useEffect(() => { + const { sampleName, image } = getSampleQueryParameters(); + if (image) { + const componentName = getSuggestedName(image); + setFieldValue('searchTerm', image, false); + setFieldValue('name', componentName, false); + setFieldValue('application.name', `${componentName}-app`, false); + handleSearch(image); + } else if (sampleName) { + // eslint-disable-next-line no-console + console.log(`Load ConsoleSample "${sampleName}"...`); + getSample(sampleName) + .then((sample) => { + if (isContainerImportSource(sample.spec.source)) { + const { containerImport } = sample.spec.source; + // eslint-disable-next-line no-console + console.log(`Loaded ConsoleSample "${sampleName}":`, sample); + // handleSearch will set the same attributes, but after another API call + // so we fill these attributes here first + const componentName = getSuggestedName(containerImport.image); + setFieldValue('searchTerm', containerImport.image, false); + setFieldValue('name', componentName, false); + setFieldValue('application.name', `${componentName}-app`, false); + handleSearch(containerImport.image); + } else { + // eslint-disable-next-line no-console + console.error( + `Unsupported ConsoleSample "${sampleName}" source type ${sample.spec?.source?.type}`, + ); + } + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.error(`Error while loading ConsoleSample "${sampleName}":`, error); + }); + // / ... + } + // Disable deps to load the samples only once when the component is loaded. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; + const ImageSearch: React.FC = () => { const { t } = useTranslation(); const inputRef = React.useRef(); @@ -108,6 +162,8 @@ const ImageSearch: React.FC = () => { ], ); + useQueryParametersIfDefined(handleSearch); + const debouncedHandleSearch = useDebounceCallback(handleSearch); const handleSave = React.useCallback( diff --git a/frontend/packages/dev-console/src/components/import/image-search/ImageSearchSection.tsx b/frontend/packages/dev-console/src/components/import/image-search/ImageSearchSection.tsx index 3f27597139e3..2ddf13ef742c 100644 --- a/frontend/packages/dev-console/src/components/import/image-search/ImageSearchSection.tsx +++ b/frontend/packages/dev-console/src/components/import/image-search/ImageSearchSection.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { ResourceLink } from '@console/internal/components/utils'; import { RadioGroupField } from '@console/shared'; import { imageRegistryType } from '../../../utils/imagestream-utils'; +import { hasSampleQueryParameter } from '../../../utils/samples'; import FormSection from '../section/FormSection'; import ImageSearch from './ImageSearch'; import ImageStream from './ImageStream'; @@ -13,6 +14,9 @@ const ImageSearchSection: React.FC<{ disabled?: boolean }> = ({ disabled = false const { t } = useTranslation(); const { values, setFieldValue, initialValues } = useFormikContext(); const [registry, setRegistry] = React.useState(values.registry); + + const showSample = React.useRef(hasSampleQueryParameter()).current; + React.useEffect(() => { if (values.registry !== registry) { setRegistry(values.registry); @@ -40,23 +44,29 @@ const ImageSearchSection: React.FC<{ disabled?: boolean }> = ({ disabled = false )} - , - }, - { - label: imageRegistryType(t).Internal.label, - value: imageRegistryType(t).Internal.value, - isDisabled: (values.formType === 'edit' && values.registry === 'external') || disabled, - activeChildren: , - }, - ]} - /> + {showSample ? ( + + ) : ( + , + }, + { + label: imageRegistryType(t).Internal.label, + value: imageRegistryType(t).Internal.value, + isDisabled: + (values.formType === 'edit' && values.registry === 'external') || disabled, + activeChildren: , + }, + ]} + /> + )} ); }; diff --git a/frontend/packages/dev-console/src/components/import/import-types.ts b/frontend/packages/dev-console/src/components/import/import-types.ts index 60680d2870df..53b7dbce959b 100644 --- a/frontend/packages/dev-console/src/components/import/import-types.ts +++ b/frontend/packages/dev-console/src/components/import/import-types.ts @@ -300,15 +300,6 @@ export enum Resources { KnativeService = 'knative', } -export enum SupportedRuntime { - Node = 'node', - NodeJS = 'nodejs', - TypeScript = 'typescript', - Quarkus = 'quarkus', -} - -export const notSupportedRuntime = ['go', 'rust', 'springboot', 'python']; - export enum SampleRuntime { // eslint-disable-next-line @typescript-eslint/naming-convention 'Node.js' = 'nodejs', diff --git a/frontend/packages/dev-console/src/components/import/serverless-function/AddServerlessFunctionForm.tsx b/frontend/packages/dev-console/src/components/import/serverless-function/AddServerlessFunctionForm.tsx index 48dd2bf60873..611771fa0ee1 100644 --- a/frontend/packages/dev-console/src/components/import/serverless-function/AddServerlessFunctionForm.tsx +++ b/frontend/packages/dev-console/src/components/import/serverless-function/AddServerlessFunctionForm.tsx @@ -1,13 +1,5 @@ import * as React from 'react'; -import { - Alert, - Card, - CardBody, - CardTitle, - Flex, - FlexItem, - ValidatedOptions, -} from '@patternfly/react-core'; +import { Alert, Flex, FlexItem, ValidatedOptions } from '@patternfly/react-core'; import { FormikProps, FormikValues } from 'formik'; import * as _ from 'lodash'; import { useTranslation } from 'react-i18next'; @@ -15,9 +7,6 @@ import { WatchK8sResultsObject } from '@console/dynamic-plugin-sdk'; import { k8sListResourceItems } from '@console/dynamic-plugin-sdk/src/utils/k8s'; import { getGitService, GitProvider } from '@console/git-service/src'; import { evaluateFunc } from '@console/git-service/src/utils/serverless-strategy-detector'; -import { ExternalLink } from '@console/internal/components/utils'; -import * as intellijImg from '@console/internal/imgs/logos/intellij.png'; -import * as vscodeImg from '@console/internal/imgs/logos/vscode.png'; import { K8sResourceKind } from '@console/internal/module/k8s'; import { ServerlessBuildStrategyType } from '@console/knative-plugin/src/types'; import PipelineSection from '@console/pipelines-plugin/src/components/import/pipeline/PipelineSection'; @@ -31,10 +20,11 @@ import { PipelineKind } from '@console/pipelines-plugin/src/types'; import { useFlag } from '@console/shared/src'; import { FlexForm, FormBody, FormFooter } from '@console/shared/src/components/form-utils'; import { NormalizedBuilderImages } from '../../../utils/imagestream-utils'; +import { notSupportedRuntime } from '../../../utils/serverless-functions'; import AdvancedSection from '../advanced/AdvancedSection'; import AppSection from '../app/AppSection'; import GitSection from '../git/GitSection'; -import { notSupportedRuntime } from '../import-types'; +import ExtensionCards from './ExtensionCards'; import ServerlessFunctionStrategySection from './ServerlessFunctionStrategySection'; import './AddServerlessFunctionForm.scss'; @@ -43,13 +33,6 @@ type AddServerlessFunctionFormProps = { projects: WatchK8sResultsObject; }; -type ExtensionCardProps = { - icon: string; - link: string; - title: string; - description: string; -}; - enum SupportedRuntime { node = 'nodejs', nodejs = 'nodejs', @@ -57,34 +40,6 @@ enum SupportedRuntime { quarkus = 'java', } -const ExtensionCard: React.FC = ({ icon, link, title, description }) => { - const { t } = useTranslation(); - return ( - - -
-
- - - -
-
-

- -

- - {t('devconsole~Provided by Red Hat')} - -
-
-
- -

{description}

-
-
- ); -}; - const AddServerlessFunctionForm: React.FC< FormikProps & AddServerlessFunctionFormProps > = ({ @@ -206,39 +161,7 @@ const AddServerlessFunctionForm: React.FC< flex={{ default: 'flex_1' }} className="pf-u-display-none pf-u-display-flex-on-lg" > - - - - - - - - + = ({ + icon, + link, + title, + description, + provider, +}) => { + return ( + + +
+
+ + + +
+
+

+ +

+ {provider} +
+
+
+ +

{description}

+
+
+ ); +}; + +export default ExtensionCard; diff --git a/frontend/packages/dev-console/src/components/import/serverless-function/ExtensionCards.tsx b/frontend/packages/dev-console/src/components/import/serverless-function/ExtensionCards.tsx new file mode 100644 index 000000000000..7d8578a3f4c8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/serverless-function/ExtensionCards.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import * as intellijImg from '@console/internal/imgs/logos/intellij.png'; +import * as vscodeImg from '@console/internal/imgs/logos/vscode.png'; +import ExtensionCard from './ExtensionCard'; + +const ExtensionCards: React.FC<{}> = () => { + const { t } = useTranslation(); + + return ( + + + + + + + + + ); +}; + +export default ExtensionCards; diff --git a/frontend/packages/dev-console/src/components/import/serverless-function/FuncSection.scss b/frontend/packages/dev-console/src/components/import/serverless-function/FuncSection.scss new file mode 100644 index 000000000000..247b8a3fe669 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/serverless-function/FuncSection.scss @@ -0,0 +1,5 @@ +.odc-func-strategy-section { + &__error-alert { + margin: var(--pf-global--spacer--2xl) 0 0; + } +} diff --git a/frontend/packages/dev-console/src/components/import/serverlessfunc/FuncSection.tsx b/frontend/packages/dev-console/src/components/import/serverless-function/FuncSection.tsx similarity index 95% rename from frontend/packages/dev-console/src/components/import/serverlessfunc/FuncSection.tsx rename to frontend/packages/dev-console/src/components/import/serverless-function/FuncSection.tsx index 06d47518b0ae..e1ab83fdeece 100644 --- a/frontend/packages/dev-console/src/components/import/serverlessfunc/FuncSection.tsx +++ b/frontend/packages/dev-console/src/components/import/serverless-function/FuncSection.tsx @@ -7,10 +7,14 @@ import { BuilderImage } from '@console/dev-console/src/utils/imagestream-utils'; import { getGitService } from '@console/git-service/src'; import { evaluateFunc } from '@console/git-service/src/utils/serverless-strategy-detector'; import { Loading } from '@console/internal/components/utils'; +import { + notSupportedRuntime, + SupportedRuntime, + getRuntimeImage, +} from '../../../utils/serverless-functions'; import BuilderImageTagSelector from '../builder/BuilderImageTagSelector'; -import { notSupportedRuntime, Resources, SupportedRuntime } from '../import-types'; +import { Resources } from '../import-types'; import { useResourceType } from '../section/useResourceType'; -import { getRuntimeImage } from './func-utils'; import './FuncSection.scss'; const FuncSection = ({ builderImages }) => { diff --git a/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionSection.scss b/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionSection.scss new file mode 100644 index 000000000000..55bd4728b510 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionSection.scss @@ -0,0 +1,5 @@ +.odc-serverless-function-strategy-section { + &__error-alert { + margin: var(--pf-global--spacer--2xl) 0 0; + } +} diff --git a/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionSection.tsx b/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionSection.tsx new file mode 100644 index 000000000000..e66181cab053 --- /dev/null +++ b/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionSection.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import { Alert } from '@patternfly/react-core'; +import { FormikValues, useFormikContext } from 'formik'; +import { useTranslation } from 'react-i18next'; +import FormSection from '@console/dev-console/src/components/import/section/FormSection'; +import { BuilderImage } from '@console/dev-console/src/utils/imagestream-utils'; +import { getGitService } from '@console/git-service/src'; +import { evaluateFunc } from '@console/git-service/src/utils/serverless-strategy-detector'; +import { Loading } from '@console/internal/components/utils'; +import { + getRuntimeImage, + notSupportedRuntime, + SupportedRuntime, +} from '../../../utils/serverless-functions'; +import BuilderImageTagSelector from '../builder/BuilderImageTagSelector'; +import { Resources } from '../import-types'; +import { useResourceType } from '../section/useResourceType'; +import './ServerlessFunctionSection.scss'; + +const ServerlessFunctionSection = ({ builderImages }) => { + const { t } = useTranslation(); + const { values, setFieldValue, setFieldError, errors } = useFormikContext(); + const { + git: { url, type, ref, dir, secretResource }, + image, + } = values; + const [runtimeImage, setRuntimeImage] = React.useState(); + const [loaded, setLoaded] = React.useState(false); + const [, setResourceType] = useResourceType(); + const [helpText, setHelpText] = React.useState(''); + + React.useEffect(() => { + const gitService = url && getGitService(url, type, ref, dir, secretResource); + gitService && + evaluateFunc(gitService) + .then((res) => { + setResourceType(Resources.KnativeService); + setRuntimeImage(getRuntimeImage(res.values.runtime as SupportedRuntime, builderImages)); + if (notSupportedRuntime.includes(res.values.runtime)) { + setHelpText( + t('devconsole~Support for {{runtime}} is not yet available.', { + runtime: res.values.runtime, + }), + ); + } else { + setHelpText( + t( + 'devconsole~Unsupported Runtime detected. Please update the Repository URL or change the Build Strategy to continue.', + ), + ); + } + setFieldValue('resources', Resources.KnativeService); + setFieldValue('build.env', res.values.builderEnvs); + setFieldValue('deployment.env', res.values.runtimeEnvs); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.warn('Error fetching Serverless Function YAML: ', err); + setFieldError('ServerlessFunction', err.message); + }) + .finally(() => setLoaded(true)); + }, [ + setFieldValue, + setFieldError, + url, + type, + ref, + dir, + secretResource, + setResourceType, + builderImages, + setHelpText, + t, + ]); + + React.useEffect(() => { + if (loaded && runtimeImage) { + setFieldValue('image.tag', runtimeImage?.recentTag?.name); + setFieldValue('image.selected', runtimeImage?.name); + setFieldValue('image.recommended', runtimeImage?.name); + } + }, [runtimeImage, setFieldValue, loaded]); + + React.useEffect(() => { + if (loaded && !runtimeImage) { + setFieldError('ServerlessFunction', 'Unsupported Runtime detected'); + } + }, [setFieldError, loaded, runtimeImage, errors]); + + if (loaded && !runtimeImage) { + return ( + + + {helpText} + + + ); + } + + return loaded ? ( + + + + ) : ( + + ); +}; + +export default ServerlessFunctionSection; diff --git a/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionStrategySection.tsx b/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionStrategySection.tsx index 248945e6dc87..03aa0809d3af 100644 --- a/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionStrategySection.tsx +++ b/frontend/packages/dev-console/src/components/import/serverless-function/ServerlessFunctionStrategySection.tsx @@ -3,8 +3,8 @@ import { Alert, ValidatedOptions } from '@patternfly/react-core'; import { FormikValues, useFormikContext } from 'formik'; import { useTranslation } from 'react-i18next'; import { ServerlessBuildStrategyType } from '@console/knative-plugin/src'; +import { notSupportedRuntime } from '../../../utils/serverless-functions'; import BuilderImageTagSelector from '../builder/BuilderImageTagSelector'; -import { notSupportedRuntime } from '../import-types'; import FormSection from '../section/FormSection'; const ServerlessFunctionStrategySection = ({ builderImages }) => { diff --git a/frontend/packages/dev-console/src/components/import/serverlessfunc/FuncSection.scss b/frontend/packages/dev-console/src/components/import/serverlessfunc/FuncSection.scss deleted file mode 100644 index 8cbd01a6b7e4..000000000000 --- a/frontend/packages/dev-console/src/components/import/serverlessfunc/FuncSection.scss +++ /dev/null @@ -1,5 +0,0 @@ -.odc-func-strategy-section { - &__error-alert { - margin: var(--pf-global--spacer--2xl) 0 0; - } - } diff --git a/frontend/packages/dev-console/src/models/index.ts b/frontend/packages/dev-console/src/models/index.ts new file mode 100644 index 000000000000..b42d5695a7af --- /dev/null +++ b/frontend/packages/dev-console/src/models/index.ts @@ -0,0 +1 @@ +export * from './samples'; diff --git a/frontend/packages/dev-console/src/models/samples.ts b/frontend/packages/dev-console/src/models/samples.ts new file mode 100644 index 000000000000..016e4b13065a --- /dev/null +++ b/frontend/packages/dev-console/src/models/samples.ts @@ -0,0 +1,14 @@ +import { K8sModel } from '@console/internal/module/k8s'; + +export const ConsoleSampleModel: K8sModel = { + kind: 'ConsoleSample', + label: 'ConsoleSample', + labelPlural: 'ConsoleSamples', + apiGroup: 'console.openshift.io', + apiVersion: 'v1', + abbr: 'CS', + namespaced: false, + crd: true, + plural: 'consolesamples', + propagationPolicy: 'Background', +}; diff --git a/frontend/packages/dev-console/src/types/index.ts b/frontend/packages/dev-console/src/types/index.ts new file mode 100644 index 000000000000..b42d5695a7af --- /dev/null +++ b/frontend/packages/dev-console/src/types/index.ts @@ -0,0 +1 @@ +export * from './samples'; diff --git a/frontend/packages/dev-console/src/types/samples.ts b/frontend/packages/dev-console/src/types/samples.ts new file mode 100644 index 000000000000..6df158db4b93 --- /dev/null +++ b/frontend/packages/dev-console/src/types/samples.ts @@ -0,0 +1,201 @@ +import { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src'; + +/** + * ConsoleSample is an extension to customizing OpenShift web console by adding samples. + */ +export type ConsoleSample = K8sResourceCommon & { + spec: ConsoleSampleSpec; +}; + +/** + * ConsoleSampleSpec is the desired sample for the web console. + * Samples will appear with their title, descriptions and a badge in a samples catalog. + */ +export type ConsoleSampleSpec = { + /** + * title is the display name of the sample. + * + * It is required and must be no more than 50 characters in length. + */ + title: string; + /** + * abstract is a short introduction to the sample. + * + * It is required and must be no more than 100 characters in length. + * + * The abstract is shown on the sample card tile below the title and provider + * and is limited to three lines of content. + */ + abstract: string; + /** + * description is a long form explanation of the sample. + * + * It is required and can have a maximum length of **4096** characters. + * + * It is a README.md-like content for additional information, links, pre-conditions, and other instructions. + * It will be rendered as Markdown so that it can contain line breaks, links, and other simple formatting. + */ + description: string; + /** + * icon is an optional base64 encoded image and shown beside the sample title. + * + * The format must follow the [data: URL format](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs) + * and can have a maximum size of **10 KB**. + * + * `data:[][;base64],` + * + * For example: + * + * `data:image;base64,` plus the base64 encoded image. + * + * Vector images can also be used. SVG icons must start with: + * + * `data:image/svg+xml;base64,` plus the base64 encoded SVG image. + * + * All sample catalog icons will be shown on a white background (also when the dark theme is used). + * The web console ensures that different aspect radios work correctly. + * Currently, the surface of the icon is at most 40x100px. + */ + icon?: string; + /** + * type is an optional label to group multiple samples. + * + * It is optional and must be no more than 20 characters in length. + * + * Recommendation is a singular term like "Builder Image", "Devfile" or "Serverless Function". + * + * Currently, the type is shown a badge on the sample card tile in the top right corner. + */ + type?: string; + /** + * provider is an optional label to honor who provides the sample. + * + * It is optional and must be no more than 50 characters in length. + * + * A provider can be a company like "Red Hat" or an organization like "CNCF" or "Knative". + * + * Currently, the provider is only shown on the sample card tile below the title with the prefix "Provided by " + */ + provider?: string; + /** + * tags like "Java", "Quarkus", etc. + * Tags are optional and can be used by the user to find samples in the samples catalog. + * They will be also displayed on the samples details page. + */ + tags?: string[]; + /** + * The actual sample source, like an external git repository or container image. + */ + source: ConsoleSampleSource; +}; + +/** + * Union of the sample source types. + * Unsupported samples types will be ignored in the web console. + */ +export type ConsoleSampleSource = ConsoleSampleGitImportSource | ConsoleSampleContainerImportSource; + +/** ConsoleSampleGitImportSource let the user import code from a public Git repository. */ +export type ConsoleSampleGitImportSource = { + type: 'GitImport'; + gitImport: { + /** repository contains the reference to the actual Git repository.. */ + repository: ConsoleSampleGitImportSourceRepository; + /** service contains configuration for the Service resource created for this sample. */ + service?: ConsoleSampleGitImportSourceService; + }; +}; + +/** ConsoleSampleGitImportSourceRepository let the user import code from a public git repository. */ +export type ConsoleSampleGitImportSourceRepository = { + /** + * url of the Git repository that contains a HTTP service. + * The HTTP service must be exposed on the default port (8080) unless + * otherwise configured with the port field. + * + * Only public repositories on GitHub, GitLab and Bitbucket are currently supported: + * + * - https://github.com// + * - https://gitlab.com// + * - https://bitbucket.org// + * + * The url must have a maximum length of 256 characters. + */ + url: string; + /** + * revision is the git revision at which to clone the git repository + * Can be used to clone a specific branch, tag or commit SHA. + * Must be at most 256 characters in length. + * When omitted the repository's default branch is used. + */ + revision?: string; + /** + * contextDir is used to specify a directory within the repository to build the + * component. + * Must start with `/` and have a maximum length of 256 characters. + * When omitted, the default value is to build from the root of the repository. + */ + contextDir?: string; +}; + +/** + * ConsoleSampleGitImportSourceService let the samples author define defaults + * for the Service created for this sample + */ +export type ConsoleSampleGitImportSourceService = { + /** + * targetPort is the port that the service listens on for HTTP requests. + * This port will be used for Service created for this sample. + * Port must be in the range 1 to 65535. + * Default port is 8080. + */ + targetPort?: number; +}; + +/** ConsoleSampleContainerImportSource let the user import a container image. */ +export type ConsoleSampleContainerImportSource = { + type: 'ContainerImport'; + containerImport: { + /** + * reference to a container image that provides a HTTP service. + * The service must be exposed on the default port (8080) unless + * otherwise configured with the port field. + * + * Supported formats: + * - / + * - docker.io// + * - quay.io// + * - quay.io//@sha256: + * - quay.io//: + */ + image: string; + /** service contains configuration for the Service resource created for this sample. */ + service?: ConsoleSampleContainerImportSourceService; + }; +}; + +/** + * ConsoleSampleContainerImportSourceService let the samples author define defaults + * for the Service created for this sample + */ +export type ConsoleSampleContainerImportSourceService = { + /** + * targetPort is the port that the service listens on for HTTP requests. + * This port will be used for Service created for this sample. + * Port must be in the range 1 to 65535. + * Default port is 8080. + */ + targetPort?: number; +}; + +export function isGitImportSource( + source: ConsoleSampleSource, +): source is ConsoleSampleGitImportSource { + return source?.type === 'GitImport' && !!source.gitImport?.repository?.url; +} + +export function isContainerImportSource( + source: ConsoleSampleSource, +): source is ConsoleSampleContainerImportSource { + return source?.type === 'ContainerImport' && !!source.containerImport?.image; +} diff --git a/frontend/packages/dev-console/src/utils/__tests__/samples.data.ts b/frontend/packages/dev-console/src/utils/__tests__/samples.data.ts new file mode 100644 index 000000000000..843809c8c5ad --- /dev/null +++ b/frontend/packages/dev-console/src/utils/__tests__/samples.data.ts @@ -0,0 +1,94 @@ +import { ConsoleSample } from '../../types/samples'; + +export const gitImportSample: ConsoleSample = { + apiVersion: 'console.openshift.io/v1', + kind: 'ConsoleSample', + metadata: { name: 'nodeinfo-git-sample' }, + spec: { + title: 'Nodeinfo Git Import example', + abstract: 'Project to test OpenShift git s2i & Dockerfile import flow', + description: '# About this project\nProject to test OpenShift git import flow\n', + icon: 'data:image/svg+xml;base64,...', + provider: 'Red Hat', + type: 'Source to image', + tags: ['JavaScript', 'Node.js', 's2i'], + source: { + type: 'GitImport', + gitImport: { + repository: { + url: 'https://github.com/jerolimov/nodeinfo', + }, + }, + }, + }, +}; + +export const containerImportSample: ConsoleSample = { + apiVersion: 'console.openshift.io/v1', + kind: 'ConsoleSample', + metadata: { name: 'nodeinfo-container-sample' }, + spec: { + title: 'Nodeinfo Container Import example', + abstract: 'Project to test OpenShift import container image flow', + description: '# About this project\nProject to test OpenShift import container image flow\n', + icon: 'data:image/svg+xml;base64,...', + provider: 'Red Hat', + type: 'Source to image', + tags: ['JavaScript', 'Node.js', 's2i'], + source: { + type: 'ContainerImport', + containerImport: { + image: 'https://github.com/jerolimov/nodeinfo', + }, + }, + }, +}; + +const getPseudoTranslatedConsoleSample = ( + sample: ConsoleSample, + lang?: string, + country?: string, +): ConsoleSample => { + return { + ...sample, + metadata: { + ...sample.metadata, + name: `${sample.metadata.name}${lang ? `-${lang}` : ''}${country ? `-${country}` : ''}`, + labels: { + ...sample.metadata.labels, + 'console.openshift.io/name': sample.metadata.name, + 'console.openshift.io/lang': lang, + 'console.openshift.io/country': country, + }, + }, + spec: { + ...sample.spec, + title: `${sample.spec.title}${lang ? ` ${lang}` : ''}${country ? `-${country}` : ''}`, + }, + }; +}; + +export const translatedGitImportSamples: ConsoleSample[] = [ + getPseudoTranslatedConsoleSample(gitImportSample), + getPseudoTranslatedConsoleSample(gitImportSample, 'en', 'US'), + getPseudoTranslatedConsoleSample(gitImportSample, 'EN', 'CA'), + getPseudoTranslatedConsoleSample(gitImportSample, 'fr', 'CA'), + getPseudoTranslatedConsoleSample(gitImportSample, 'fr'), + getPseudoTranslatedConsoleSample(gitImportSample, 'de', 'DE'), + getPseudoTranslatedConsoleSample(gitImportSample, 'de', 'AT'), +]; + +export const translatedContainerImportSamples: ConsoleSample[] = [ + getPseudoTranslatedConsoleSample(containerImportSample), + getPseudoTranslatedConsoleSample(containerImportSample, 'en', 'US'), + getPseudoTranslatedConsoleSample(containerImportSample, 'EN', 'CA'), + getPseudoTranslatedConsoleSample(containerImportSample, 'fr', 'CA'), + getPseudoTranslatedConsoleSample(containerImportSample, 'fr'), + getPseudoTranslatedConsoleSample(containerImportSample, 'de', 'DE'), + getPseudoTranslatedConsoleSample(containerImportSample, 'de', 'AT'), +]; + +export const translatedSamples: ConsoleSample[] = [ + ...translatedGitImportSamples, + ...translatedContainerImportSamples, +]; diff --git a/frontend/packages/dev-console/src/utils/__tests__/samples.spec.ts b/frontend/packages/dev-console/src/utils/__tests__/samples.spec.ts new file mode 100644 index 000000000000..e813ec7c8a2e --- /dev/null +++ b/frontend/packages/dev-console/src/utils/__tests__/samples.spec.ts @@ -0,0 +1,98 @@ +import { getBestMatch, groupConsoleSamplesByName } from '../samples'; +import { + gitImportSample, + containerImportSample, + translatedSamples, + translatedGitImportSamples, + translatedContainerImportSamples, +} from './samples.data'; + +describe('groupConsoleSamplesByName', () => { + it('should create a single group for one sample without localization labels', () => { + const actual = groupConsoleSamplesByName([gitImportSample]); + expect(actual).toEqual({ + 'nodeinfo-git-sample': [gitImportSample], + }); + }); + + it('should create a two groups for two different samples without localization labels', () => { + const actual = groupConsoleSamplesByName([gitImportSample, containerImportSample]); + expect(actual).toEqual({ + 'nodeinfo-git-sample': [gitImportSample], + 'nodeinfo-container-sample': [containerImportSample], + }); + }); + + it('should group the translated samples correct', () => { + const actual = groupConsoleSamplesByName(translatedSamples); + + expect(Object.keys(actual)).toEqual(['nodeinfo-git-sample', 'nodeinfo-container-sample']); + + const groupedGitSamples = actual['nodeinfo-git-sample']; + const groupedContainerSamples = actual['nodeinfo-container-sample']; + + expect(groupedGitSamples).toEqual(translatedGitImportSamples); + expect(groupedContainerSamples).toEqual(translatedContainerImportSamples); + }); +}); + +describe('getBestMatch', () => { + it('should return null for null', () => { + expect(getBestMatch(null, '')).toBe(null); + }); + + it('should return null for an empty array', () => { + expect(getBestMatch([], 'en')).toBe(null); + }); + + it('should return the sample with an equal language and country', () => { + // neighter language or country is defined + expect(getBestMatch(translatedGitImportSamples, '')).toEqual(translatedGitImportSamples[0]); + // default language is preferred, no country + expect(getBestMatch(translatedGitImportSamples, 'en')).toEqual(translatedGitImportSamples[0]); + // non default language and country + expect(getBestMatch(translatedGitImportSamples, 'fr-CA')).toEqual( + translatedGitImportSamples[3], + ); + // lowercase input + expect(getBestMatch(translatedGitImportSamples, 'fr-ca')).toEqual( + translatedGitImportSamples[3], + ); + // uppercase input + expect(getBestMatch(translatedGitImportSamples, 'FR-CA')).toEqual( + translatedGitImportSamples[3], + ); + // language defined, but not english, no country + expect(getBestMatch(translatedGitImportSamples, 'fr')).toEqual(translatedGitImportSamples[4]); + }); + + it('should return the sample with the same language, prefer no country', () => { + expect(getBestMatch(translatedGitImportSamples, 'fr-MC')).toEqual( + translatedGitImportSamples[4], + ); + }); + + it('should return the sample with the same language, prefer any country', () => { + expect(getBestMatch(translatedGitImportSamples, 'DE-CH')).toEqual( + translatedGitImportSamples[5], + ); + }); + + it('should return the fallback language with the same country', () => { + expect(getBestMatch(translatedGitImportSamples, 'NA-CA')).toEqual( + translatedGitImportSamples[2], + ); + }); + + it('should return the fallback language with no country', () => { + expect(getBestMatch(translatedGitImportSamples, 'NA-NA')).toEqual( + translatedGitImportSamples[0], + ); + }); + + it('should return the fallback language with any country', () => { + expect(getBestMatch(translatedGitImportSamples.slice(1), 'NA-NA')).toEqual( + translatedGitImportSamples[1], + ); + }); +}); diff --git a/frontend/packages/dev-console/src/utils/samples.ts b/frontend/packages/dev-console/src/utils/samples.ts new file mode 100644 index 000000000000..fce7806488a0 --- /dev/null +++ b/frontend/packages/dev-console/src/utils/samples.ts @@ -0,0 +1,120 @@ +import { getGroupVersionKindForModel } from '@console/dynamic-plugin-sdk/src/lib-core'; +import { k8sGetResource } from '@console/dynamic-plugin-sdk/src/utils/k8s'; +import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook'; +import { ConsoleSampleModel } from '../models/samples'; +import { ConsoleSample, isContainerImportSource } from '../types/samples'; + +const SAMPLE_QUERY_PARAMETER_NAME = 'sample'; +const IMAGE_QUERY_PARAMETER_NAME = 'image'; + +const LOCALIZATION_NAME_LABEL = 'console.openshift.io/name'; +const LOCALIZATION_LANGUAGE_LABEL = 'console.openshift.io/lang'; +const LOCALIZATION_COUNTRY_LABEL = 'console.openshift.io/country'; + +export const createSampleQueryParameters = (sample: ConsoleSample) => { + const searchParams = new URLSearchParams(); + searchParams.set(SAMPLE_QUERY_PARAMETER_NAME, sample.metadata.name); + if (isContainerImportSource(sample.spec.source)) { + searchParams.set(IMAGE_QUERY_PARAMETER_NAME, sample.spec.source.containerImport.image); + } + return searchParams; +}; + +export const getSampleQueryParameters = () => { + const searchParams = new URLSearchParams(window.location.search); + return { + sampleName: searchParams.get(SAMPLE_QUERY_PARAMETER_NAME), + image: searchParams.get(IMAGE_QUERY_PARAMETER_NAME), + }; +}; + +export const hasSampleQueryParameter = () => { + return !!new URLSearchParams(window.location.search).get(SAMPLE_QUERY_PARAMETER_NAME); +}; + +export const groupConsoleSamplesByName = (samples: ConsoleSample[]) => { + return samples.reduce>((grouped, consoleSample) => { + const name = + consoleSample.metadata.labels?.[LOCALIZATION_NAME_LABEL] || consoleSample.metadata.name; + if (!grouped[name]) grouped[name] = []; + grouped[name].push(consoleSample); + return grouped; + }, {}); +}; + +/** + * Returns the samples with the best localization match, for the given + * preferred language and preferred country. It prefers a match in this order: + * + * 1. Sample language and country are equal to the preferred language and country. + * /// This includes that the no sample language and country is defined. + * + * 2. Sample language is equal to the preferred language. + * 1. And the sample has no country defined. (eg, select en sample is used for en-CA and en-GB) + * 2. Any country is defined. (eg, select en-CA sample is used for en-GB) + * + * 3. Fallback to an english sample + * (Sample language is en OR sample language is not defined): + * 1. Same country (use en-CA sample if preference is fr-CA) + * 2. No country () + * 3. Any country (use en-CA sample if preference is en-US) + */ +export const getBestMatch = (samples: ConsoleSample[], language: string): ConsoleSample | null => { + if (!samples || !samples.length) { + return null; + } + const preferredLanguage = (language || 'en').split('-')[0].toLowerCase(); + const preferredCountry = ((language || '').split('-')[1] || '').toUpperCase(); + + let sameLanguageWithoutCountry: ConsoleSample = null; + let sameLanguageWithAnyCountry: ConsoleSample = null; + let fallbackLanguageSameCountry: ConsoleSample = null; + let fallbackLanguageNoCountry: ConsoleSample = null; + let fallbackLanguageAnyCountry: ConsoleSample = null; + + for (const sample of samples) { + const sampleLanguage = ( + sample.metadata?.labels?.[LOCALIZATION_LANGUAGE_LABEL] || 'en' + ).toLowerCase(); + const sampleCountry = ( + sample.metadata?.labels?.[LOCALIZATION_COUNTRY_LABEL] || '' + ).toUpperCase(); + + if (sampleLanguage === preferredLanguage && sampleCountry === preferredCountry) { + return sample; + } + if (sampleLanguage === preferredLanguage) { + if (!sampleCountry && !sameLanguageWithoutCountry) { + sameLanguageWithoutCountry = sample; + } else if (sampleCountry && !sameLanguageWithAnyCountry) { + sameLanguageWithAnyCountry = sample; + } + } + if (sampleLanguage === 'en') { + if (sampleCountry === preferredCountry && !fallbackLanguageSameCountry) { + fallbackLanguageSameCountry = sample; + } else if (!sampleCountry && !fallbackLanguageNoCountry) { + fallbackLanguageNoCountry = sample; + } else if (!fallbackLanguageAnyCountry) { + fallbackLanguageAnyCountry = sample; + } + } + } + return ( + sameLanguageWithoutCountry || + sameLanguageWithAnyCountry || + fallbackLanguageSameCountry || + fallbackLanguageNoCountry || + fallbackLanguageAnyCountry + ); +}; + +export const useSamples = () => { + return useK8sWatchResource({ + isList: true, + groupVersionKind: getGroupVersionKindForModel(ConsoleSampleModel), + }); +}; + +export const getSample = (name: string): Promise => + k8sGetResource({ model: ConsoleSampleModel, name }); diff --git a/frontend/packages/dev-console/src/components/import/serverlessfunc/func-utils.ts b/frontend/packages/dev-console/src/utils/serverless-functions.ts similarity index 62% rename from frontend/packages/dev-console/src/components/import/serverlessfunc/func-utils.ts rename to frontend/packages/dev-console/src/utils/serverless-functions.ts index 457eff1d6180..c7e98a2c5b29 100644 --- a/frontend/packages/dev-console/src/components/import/serverlessfunc/func-utils.ts +++ b/frontend/packages/dev-console/src/utils/serverless-functions.ts @@ -1,5 +1,13 @@ -import { BuilderImage, NormalizedBuilderImages } from '../../../utils/imagestream-utils'; -import { SupportedRuntime } from '../import-types'; +import { BuilderImage, NormalizedBuilderImages } from './imagestream-utils'; + +export enum SupportedRuntime { + Node = 'node', + NodeJS = 'nodejs', + TypeScript = 'typescript', + Quarkus = 'quarkus', +} + +export const notSupportedRuntime = ['go', 'rust', 'springboot', 'python']; export const getRuntimeImage = ( runtime: SupportedRuntime, diff --git a/frontend/packages/git-service/src/utils/serverless-strategy-detector.ts b/frontend/packages/git-service/src/utils/serverless-strategy-detector.ts index 5bf01ce079d1..93757ed562e1 100644 --- a/frontend/packages/git-service/src/utils/serverless-strategy-detector.ts +++ b/frontend/packages/git-service/src/utils/serverless-strategy-detector.ts @@ -13,6 +13,8 @@ type FuncData = { export const evaluateFunc = async (gitService: BaseService): Promise => { const isFuncYamlPresent = await gitService.isFuncYamlPresent(); + // eslint-disable-next-line no-console + console.log('xxx evaluateFunc isFuncYamlPresent', isFuncYamlPresent); if (!isFuncYamlPresent) { return { @@ -47,6 +49,12 @@ export const isServerlessFxRepository = async ( ): Promise => { const isFuncYamlPresent = await gitService.isFuncYamlPresent(); + // eslint-disable-next-line no-console + console.log( + 'xxx isServerlessFxRepository isFuncYamlPresent', + isServerlessEnabled, + isFuncYamlPresent, + ); if (isFuncYamlPresent && isServerlessEnabled) { const content = await gitService.getFuncYamlContent(); const funcJSON = safeYAMLToJS(content); diff --git a/frontend/packages/pipelines-plugin/src/components/repository/sections/RepositoryFormSection.tsx b/frontend/packages/pipelines-plugin/src/components/repository/sections/RepositoryFormSection.tsx index 9ddc7c88cfb0..65adccd36738 100644 --- a/frontend/packages/pipelines-plugin/src/components/repository/sections/RepositoryFormSection.tsx +++ b/frontend/packages/pipelines-plugin/src/components/repository/sections/RepositoryFormSection.tsx @@ -34,6 +34,8 @@ const RepositoryFormSection = () => { detectedGitType && setFieldValue('gitProvider', detectedGitType); const gitService = getGitService(url, detectedGitType); + // eslint-disable-next-line no-console + console.log('xxx detectImportStrategies ALWAYS FALSE'); const importStrategyData = await detectImportStrategies(url, gitService); if (importStrategyData.strategies.length > 0) { const detectedBuildTypes = importStrategyData.strategies?.find(