diff --git a/cypress/e2e/po/lists/fleet/fleet.cattle.io.cluster.po.ts b/cypress/e2e/po/lists/fleet/fleet.cattle.io.cluster.po.ts index 9ee0cab104..758a5fdbb5 100644 --- a/cypress/e2e/po/lists/fleet/fleet.cattle.io.cluster.po.ts +++ b/cypress/e2e/po/lists/fleet/fleet.cattle.io.cluster.po.ts @@ -7,4 +7,8 @@ export default class FleetClusterList extends BaseResourceList { details(name: string, index: number) { return this.resourceTable().sortableTable().rowWithName(name).column(index); } + + subRows() { + return this.resourceTable().sortableTable().subRows(); + } } diff --git a/cypress/e2e/tests/pages/fleet/fleet-clusters.spec.ts b/cypress/e2e/tests/pages/fleet/fleet-clusters.spec.ts index 12eca5f170..b9e4001144 100644 --- a/cypress/e2e/tests/pages/fleet/fleet-clusters.spec.ts +++ b/cypress/e2e/tests/pages/fleet/fleet-clusters.spec.ts @@ -70,7 +70,8 @@ describe('Fleet Clusters', { tags: ['@fleet', '@adminUser'] }, () => { rke2ClusterAmazon: { clusterName: name, namespace, - } + }, + metadata: { labels: { foo: 'bar' } } }).then(() => { removeCluster = true; }); @@ -123,6 +124,8 @@ describe('Fleet Clusters', { tags: ['@fleet', '@adminUser'] }, () => { fleetClusterListPage.clusterList().details(clusterName, 4).should('have.text', '1'); // check resources: testing https://github.com/rancher/dashboard/issues/11154 fleetClusterListPage.clusterList().details(clusterName, 5).contains( ' 1 ', MEDIUM_TIMEOUT_OPT); + // check cluster labels + fleetClusterListPage.clusterList().subRows().should('contain.text', 'foo=bar'); const fleetClusterDetailsPage = new FleetClusterDetailsPo(namespace, clusterName); diff --git a/cypress/globals.d.ts b/cypress/globals.d.ts index 060c3b6e1e..81b932c271 100644 --- a/cypress/globals.d.ts +++ b/cypress/globals.d.ts @@ -29,7 +29,7 @@ export type CreateAmazonRke2ClusterParams = { type: string, clusterName: string, namespace: string -}, + }, cloudCredentialsAmazon: { workspace: string, name: string, @@ -40,7 +40,11 @@ export type CreateAmazonRke2ClusterParams = { rke2ClusterAmazon: { clusterName: string, namespace: string, - } + }, + metadata?: { + labels?: { [key: string]: string }, + annotations?: { [key: string]: string }, + }, } export type CreateAmazonRke2ClusterWithoutMachineConfigParams = { cloudCredentialsAmazon: { diff --git a/cypress/support/commands/rancher-api-commands.ts b/cypress/support/commands/rancher-api-commands.ts index 601db0080c..6e5fbf3564 100644 --- a/cypress/support/commands/rancher-api-commands.ts +++ b/cypress/support/commands/rancher-api-commands.ts @@ -602,7 +602,9 @@ Cypress.Commands.add('deleteNodeTemplate', (nodeTemplateId, timeout = 30000, fai * Create RKE2 cluster with Amazon EC2 cloud provider */ Cypress.Commands.add('createAmazonRke2Cluster', (params: CreateAmazonRke2ClusterParams) => { - const { machineConfig, rke2ClusterAmazon, cloudCredentialsAmazon } = params; + const { + machineConfig, rke2ClusterAmazon, cloudCredentialsAmazon, metadata + } = params; return cy.createAwsCloudCredentials(cloudCredentialsAmazon.workspace, cloudCredentialsAmazon.name, cloudCredentialsAmazon.region, cloudCredentialsAmazon.accessKey, cloudCredentialsAmazon.secretKey) .then((resp: Cypress.Response) => { @@ -625,8 +627,12 @@ Cypress.Commands.add('createAmazonRke2Cluster', (params: CreateAmazonRke2Cluster type: 'provisioning.cattle.io.cluster', metadata: { namespace: rke2ClusterAmazon.namespace, - annotations: { 'field.cattle.io/description': `${ rke2ClusterAmazon.clusterName }-description` }, - name: rke2ClusterAmazon.clusterName + annotations: { + 'field.cattle.io/description': `${ rke2ClusterAmazon.clusterName }-description`, + ...(metadata?.annotations || {}), + }, + labels: metadata?.labels || {}, + name: rke2ClusterAmazon.clusterName }, spec: { rkeConfig: { diff --git a/package.json b/package.json index 24f45bdc8a..77853c2c48 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,7 @@ "cookie": "0.7.0", "cookie-universal": "2.2.2", "cron-validator": "1.2.0", - "cronstrue": "1.95.0", + "cronstrue": "2.53.0", "cross-env": "7.0.3", "custom-event-polyfill": "1.0.7", "d3": "7.3.0", diff --git a/pkg/rancher-components/package.json b/pkg/rancher-components/package.json index d02cf332f3..f5ad2078c5 100644 --- a/pkg/rancher-components/package.json +++ b/pkg/rancher-components/package.json @@ -36,7 +36,7 @@ "babel-eslint": "10.1.0", "core-js": "3.40.0", "cron-validator": "1.3.1", - "cronstrue": "2.50.0", + "cronstrue": "2.53.0", "eslint-plugin-import": "2.31.0", "eslint-plugin-node": "11.1.0", "eslint-plugin-promise": "5.2.0", diff --git a/pkg/rancher-components/src/components/Form/LabeledInput/LabeledInput.test.ts b/pkg/rancher-components/src/components/Form/LabeledInput/LabeledInput.test.ts index 4846fdaa7e..e3e48943cb 100644 --- a/pkg/rancher-components/src/components/Form/LabeledInput/LabeledInput.test.ts +++ b/pkg/rancher-components/src/components/Form/LabeledInput/LabeledInput.test.ts @@ -20,7 +20,7 @@ describe('component: LabeledInput', () => { expect(wrapper.emitted('update:value')![0][0]).toBe(value); }); - it('using mode "multiline" should emit input value correctly', () => { + it('using type "multiline" should emit input value correctly', () => { const value = 'any-string'; const delay = 1; const wrapper = mount(LabeledInput, { @@ -37,4 +37,21 @@ describe('component: LabeledInput', () => { expect(wrapper.emitted('update:value')).toHaveLength(1); expect(wrapper.emitted('update:value')![0][0]).toBe(value); }); + + describe('using type "chron"', () => { + it.each([ + ['0 * * * *', 'Every hour, every day'], + ['@daily', 'At 12:00 AM, every day'], + ['You must fail! Go!', '%generic.invalidCron%'], + ])('passing value %p should display hint %p', (value, hint) => { + const wrapper = mount(LabeledInput, { + propsData: { value, type: 'cron' }, + mocks: { $store: { getters: { 'i18n/t': jest.fn() } } } + }); + + const subLabel = wrapper.find('[data-testid="sub-label"]'); + + expect(subLabel.text()).toBe(hint); + }); + }); }); diff --git a/pkg/rancher-components/src/components/Form/LabeledInput/LabeledInput.vue b/pkg/rancher-components/src/components/Form/LabeledInput/LabeledInput.vue index 8c03ee1340..a05a648dcf 100644 --- a/pkg/rancher-components/src/components/Form/LabeledInput/LabeledInput.vue +++ b/pkg/rancher-components/src/components/Form/LabeledInput/LabeledInput.vue @@ -179,14 +179,28 @@ export default defineComponent({ if (this.type !== 'cron' || !this.value) { return; } + + // TODO - #13202: This is required due use of 2 libraries and 3 different libraries through the code. + const predefined = [ + '@yearly', + '@annually', + '@monthly', + '@weekly', + '@daily', + '@midnight', + '@hourly' + ]; + const isPredefined = predefined.includes(this.value as string); + // refer https://github.com/GuillaumeRochat/cron-validator#readme - if (!isValidCron(this.value as string, { + if (!isPredefined && !isValidCron(this.value as string, { alias: true, allowBlankDay: true, allowSevenAsSunday: true, })) { return this.t('generic.invalidCron'); } + try { const hint = cronstrue.toString(this.value as string || '', { verbose: true }); @@ -382,6 +396,7 @@ export default defineComponent({
import ResourceTable from '@shell/components/ResourceTable'; +import Tag from '@shell/components/Tag.vue'; import { STATE, NAME, AGE, FLEET_SUMMARY } from '@shell/config/table-headers'; import { FLEET, MANAGEMENT } from '@shell/config/types'; export default { - components: { ResourceTable }, + components: { ResourceTable, Tag }, props: { rows: { @@ -75,6 +76,12 @@ export default { pluralLabel: this.$store.getters['type-map/labelFor'](schema, 99), }; }, + }, + + methods: { + toggleCustomLabels(row) { + row['displayCustomLabels'] = !row.displayCustomLabels; + } } }; @@ -85,6 +92,7 @@ export default { :schema="schema" :headers="headers" :rows="rows" + :sub-rows="true" :loading="loading" :use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering" key-field="_key" @@ -123,5 +131,78 @@ export default { :class="{'text-error': !row.bundleInfo.total}" >{{ row.bundleInfo.total }} + + + + diff --git a/shell/models/fleet.cattle.io.cluster.js b/shell/models/fleet.cattle.io.cluster.js index ace76744c0..92d9870d15 100644 --- a/shell/models/fleet.cattle.io.cluster.js +++ b/shell/models/fleet.cattle.io.cluster.js @@ -1,5 +1,5 @@ import { LOCAL_CLUSTER, MANAGEMENT, NORMAN } from '@shell/config/types'; -import { CAPI, FLEET as FLEET_LABELS } from '@shell/config/labels-annotations'; +import { CAPI, FLEET as FLEET_LABELS, SYSTEM_LABELS } from '@shell/config/labels-annotations'; import { _RKE2 } from '@shell/store/prefs'; import SteveModel from '@shell/plugins/steve/steve-class'; import { escapeHtml } from '@shell/utils/string'; @@ -190,6 +190,22 @@ export default class FleetCluster extends SteveModel { } } + get customLabels() { + const parsedLabels = []; + + if (this.labels) { + for (const k in this.labels) { + const [prefix] = k.split('/'); + + if (!SYSTEM_LABELS.includes(prefix) && k !== CAPI.PROVIDER) { + parsedLabels.push(`${ k }=${ this.labels[k] }`); + } + } + } + + return parsedLabels; + } + async saveYaml(yaml) { await this._saveYaml(yaml); diff --git a/shell/package.json b/shell/package.json index c35ec0df96..f89948c2c1 100644 --- a/shell/package.json +++ b/shell/package.json @@ -64,7 +64,7 @@ "cookie": "0.7.0", "core-js": "3.40.0", "cron-validator": "1.3.1", - "cronstrue": "2.50.0", + "cronstrue": "2.53.0", "cross-env": "7.0.3", "css-loader": "6.7.3", "csv-loader": "3.0.3", diff --git a/shell/plugins/steve/steve-pagination-utils.ts b/shell/plugins/steve/steve-pagination-utils.ts index d75d942ec9..ef94c2c5fc 100644 --- a/shell/plugins/steve/steve-pagination-utils.ts +++ b/shell/plugins/steve/steve-pagination-utils.ts @@ -432,7 +432,8 @@ class StevePaginationUtils extends NamespaceProjectFilters { // Check if the API supports filtering by this field this.validateField(validateFields, schema, field.field); - const exactPartial = field.exact ? `'${ field.value }'` : field.value; + const value = encodeURIComponent(field.value); + const exactPartial = field.exact ? `'${ value }'` : value; return `${ this.convertArrayPath(field.field) }${ field.equals ? '=' : '!=' }${ exactPartial }`; } diff --git a/shell/utils/validators/cron-schedule.js b/shell/utils/validators/cron-schedule.js index adfabbfe00..bc59c0c454 100644 --- a/shell/utils/validators/cron-schedule.js +++ b/shell/utils/validators/cron-schedule.js @@ -2,8 +2,13 @@ import cronstrue from 'cronstrue'; export function cronSchedule(schedule = '', getters, errors) { try { - cronstrue.toString(schedule, { verbose: true }); + cronScheduleRule.validation(schedule); } catch (e) { - errors.push(getters['i18n/t']('validation.invalidCron')); + errors.push(getters['i18n/t'](cronScheduleRule.message)); } } + +export const cronScheduleRule = { + validation: (text) => cronstrue.toString(text, { verbose: true }), + message: 'validation.invalidCron' +}; diff --git a/shell/utils/validators/formRules/__tests__/index.test.ts b/shell/utils/validators/formRules/__tests__/index.test.ts index d021a36091..679b58ffe3 100644 --- a/shell/utils/validators/formRules/__tests__/index.test.ts +++ b/shell/utils/validators/formRules/__tests__/index.test.ts @@ -27,22 +27,6 @@ describe('formRules', () => { expect(formRuleResult).toStrictEqual(expectedResult); }); - it('"cronSchedule" : returns undefined when valid cron string value supplied', () => { - const testValue = '0 * * * *'; - const formRuleResult = formRules.cronSchedule(testValue); - - expect(formRuleResult).toBeUndefined(); - }); - - it('"cronSchedule" : returns the correct message when invalid cron string value supplied', () => { - // specific logic of what constitutes a cron string is in the "cronstrue" function in an external library and not tested here - const testValue = '0 * * **'; - const formRuleResult = formRules.cronSchedule(testValue); - const expectedResult = JSON.stringify({ message: 'validation.invalidCron' }); - - expect(formRuleResult).toStrictEqual(expectedResult); - }); - it('"https" : returns undefined when valid https url value is supplied', () => { const testValue = 'https://url.com'; const formRuleResult = formRules.https(testValue); @@ -1112,6 +1096,13 @@ describe('formRules', () => { expect(formRuleResult).toStrictEqual(expectedResult); }); + /** + * Test all factory validators + * @param rule - the name of the factory validator + * @param argument - the value to validate + * @param correctValues - an array of values that should pass the validation + * @param wrongValues - an array of values that should fail the validation + */ describe.each([ ['minValue', 2, [3], [1]], ['maxValue', 256, [1], [300]], @@ -1133,12 +1124,18 @@ describe('formRules', () => { }); }); + /** + * Test all standard validators + * @param rule - the name of the standard validator + * @param correctValues - an array of values that should pass the validation + * @param wrongValues - an array of values that should fail the validation + */ describe.each([ ['requiredInt', [2, 2.2], ['e']], ['isInteger', ['2', 2, 0], [2.2, 'e', '1.0']], ['isPositive', ['0', 1], [-1]], ['isOctal', ['0', 0, 10], ['01']], - + ['cronSchedule', ['0 * * * *', '@daily'], ['0 * * **']], ])('given validator %p', (rule, correctValues, wrongValues) => { it.each(wrongValues as [])('should return error for value %p', (wrong) => { const formRuleResult = (formRules as any)[rule](wrong); diff --git a/shell/utils/validators/formRules/index.ts b/shell/utils/validators/formRules/index.ts index 3df2d01963..ab7de7c117 100644 --- a/shell/utils/validators/formRules/index.ts +++ b/shell/utils/validators/formRules/index.ts @@ -4,14 +4,26 @@ import isEmpty from 'lodash/isEmpty'; import has from 'lodash/has'; import isUrl from 'is-url'; // import uniq from 'lodash/uniq'; -import cronstrue from 'cronstrue'; import { Translation } from '@shell/types/t'; import { isHttps, isLocalhost, hasTrailingForwardSlash } from '@shell/utils/validators/setting'; +import { cronScheduleRule } from '@shell/utils/validators/cron-schedule'; // import uniq from 'lodash/uniq'; -export type Validator = (val: any, arg?: any) => T; -export type ValidatorFactory = (arg1: any, arg2?: any) => Validator +/** + * Fixed validation rule which require only the value to be evaluated + * @param value + * @returns { string | undefined } + */ +export type Validator = (value: any, arg?: any) => T; + +/** + * Factory function which returns a validation rule + * @param arg Argument used as part of the validation rule process, not necessarily as parameter of the validation rule + * @param value Value to be evaluated + * @returns { Validator } + */ +export type ValidatorFactory = (arg: any, value?: any) => Validator type ServicePort = { name?: string, @@ -131,9 +143,9 @@ export default function(t: Translation, { key = 'Value' }: ValidationOptions): { const cronSchedule: Validator = (val: string) => { try { - cronstrue.toString(val, { verbose: true }); + cronScheduleRule.validation(val); } catch (e) { - return t('validation.invalidCron'); + return t(cronScheduleRule.message); } }; diff --git a/storybook/src/stories/Form/Input.mdx b/storybook/src/stories/Form/Input.mdx index 11b980008e..a27fa6b95a 100644 --- a/storybook/src/stories/Form/Input.mdx +++ b/storybook/src/stories/Form/Input.mdx @@ -34,3 +34,23 @@ Input elements with type ‘text’ allow users to enter any combination of lett #### Error + +#### SubLabel + +Addition information can be added to the input field using the `subLabel` prop. + + + +#### Cron Type + +The `cron` prop can be used to add [Cron language](https://en.wikipedia.org/wiki/Cron) hints. + + + + + + + +#### Multiline Type + + diff --git a/storybook/src/stories/Form/Input.stories.ts b/storybook/src/stories/Form/Input.stories.ts index f7adf84778..9502cb1613 100644 --- a/storybook/src/stories/Form/Input.stories.ts +++ b/storybook/src/stories/Form/Input.stories.ts @@ -64,3 +64,53 @@ export const Error: Story = { tooltipKey: 'Error message' }, }; + +export const SubLabel: Story = { + ...Default, + args: { + label: 'Name', + subLabel: 'Additional information', + type: 'text', + value: 'Simon', + }, +}; + +export const CronType: Story = { + ...Default, + args: { + label: 'Period', + type: 'cron', + value: '0 * * * *', + }, +}; + +export const CronTypeDaily: Story = { + ...Default, + args: { + label: 'Period', + type: 'cron', + value: '@daily', + }, +}; + +export const CronTypeError: Story = { + ...Default, + args: { + label: 'Period', + type: 'cron', + value: 'not a cron expression', + }, +}; + +export const MultilineType: Story = { + ...Default, + args: { + label: 'Period', + type: 'multiline', + value: `this +is +a +multiline +text`, + }, +}; diff --git a/yarn.lock b/yarn.lock index 77e90d2dc7..ccdf232fe2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6386,15 +6386,10 @@ cron-validator@1.3.1: resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.3.1.tgz#8f2fe430f92140df77f91178ae31fc1e3a48a20e" integrity sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A== -cronstrue@1.95.0: - version "1.95.0" - resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-1.95.0.tgz#171df1fad8b0f0cb636354dd1d7842161c15478f" - integrity sha512-CdbQ17Z8Na2IdrK1SiD3zmXfE66KerQZ8/iApkGsxjmUVGJPS9M9oK4FZC3LM6ohUjjq3UeaSk+90Cf3QbXDfw== - -cronstrue@2.50.0: - version "2.50.0" - resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.50.0.tgz#eabba0f915f186765258b707b7a3950c663b5573" - integrity sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg== +cronstrue@2.53.0: + version "2.53.0" + resolved "https://registry.npmjs.org/cronstrue/-/cronstrue-2.53.0.tgz#5bbcd7483636b99379480f624faef5056f3efbd8" + integrity sha512-CkAcaI94xL8h6N7cGxgXfR5D7oV2yVtDzB9vMZP8tIgPyEv/oc/7nq9rlk7LMxvc3N+q6LKZmNLCVxJRpyEg8A== cross-env@7.0.3: version "7.0.3"