diff --git a/packages/components/src/components/Trigger/Trigger.js b/packages/components/src/components/Trigger/Trigger.js
index e65a262d07..a8a1673bc2 100644
--- a/packages/components/src/components/Trigger/Trigger.js
+++ b/packages/components/src/components/Trigger/Trigger.js
@@ -11,14 +11,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { Launch16 as LinkIcon } from '@carbon/icons-react';
import { injectIntl } from 'react-intl';
import React from 'react';
import { urls } from '@tektoncd/dashboard-utils';
import { Link } from 'react-router-dom';
-import './Trigger.scss';
+import { Accordion, AccordionItem } from 'carbon-components-react';
import { Table } from '@tektoncd/dashboard-components';
+import './Trigger.scss';
+
const Trigger = ({ intl, eventListenerNamespace, trigger }) => {
const tableHeaders = [
{
@@ -37,120 +38,217 @@ const Trigger = ({ intl, eventListenerNamespace, trigger }) => {
}
];
- let triggerParams = [];
-
- if (trigger.params) {
- triggerParams = trigger.params.map(param => ({
- id: param.name,
- name: param.name,
- value: param.value
- }));
- }
-
- let interceptorValues = [];
-
- if (trigger.interceptor) {
- if (trigger.interceptor.header) {
- interceptorValues = trigger.interceptor.header.map(header => ({
- id: header.name,
- name: header.name,
- value: header.value
- }));
- }
- }
-
return (
<>
-
-
Trigger: {trigger.name}
+
Trigger: {trigger.name}
+
-
TriggerBinding
-
-
-
-
-
- {trigger.binding.name}
-
-
+
TriggerBindings:
+
+ {trigger.bindings &&
+ trigger.bindings.map((binding, index) => (
+
+
+ {binding.name}
+
+ {index !== trigger.bindings.length - 1 && , }
+
+ ))}
+
- TriggerTemplate
+ TriggerTemplate:
-
-
-
-
- {trigger.template.name}
-
+ {trigger.template.name}
+
-
-
- {intl.formatMessage({
- id: 'dashboard.parameters.title',
- defaultMessage: 'Parameters'
- })}
-
-
-
- {trigger.interceptor && (
- <>
-
-
- {intl.formatMessage({
- id: 'dashboard.triggerDetails.interceptorName',
- defaultMessage: 'Interceptor: '
- })}
- {trigger.interceptor.objectRef.name}
-
-
- {intl.formatMessage({
- id: 'dashboard.triggerDetails.interceptorHeaders',
- defaultMessage: 'Headers'
- })}
-
-
-
- >
- )}
-
+
+ {trigger.interceptors && trigger.interceptors.length !== 0 && (
+ <>
+
+ {intl.formatMessage({
+ id: 'dashboard.triggerDetails.interceptors',
+ defaultMessage: 'Interceptors:'
+ })}
+
+
+ {trigger.interceptors.map((interceptor, index) => {
+ let interceptorName;
+ let interceptorType;
+ let content;
+ const namespaceText = intl.formatMessage({
+ id: 'dashboard.triggerDetails.interceptorNamespace',
+ defaultMessage: 'Namespace:'
+ });
+ const nameText = intl.formatMessage({
+ id: 'dashboard.triggerDetails.interceptorName',
+ defaultMessage: 'Name:'
+ });
+ if (interceptor.webhook) {
+ // Webhook Interceptor
+ if (!interceptor.webhook.objectRef) {
+ return null;
+ }
+ interceptorType = 'Webhook';
+ interceptorName = interceptor.webhook.objectRef.name;
+ let headerValues = [];
+ if (interceptor.webhook.header) {
+ headerValues = interceptor.webhook.header.map(header => {
+ const headerValue = {
+ id: header.name,
+ name: header.name,
+ value: header.value
+ };
+ // Concatenate values with a comma if value is an array
+ if (Array.isArray(header.value)) {
+ headerValue.value = header.value.join(', ');
+ }
+ return headerValue;
+ });
+ }
+ const serviceText = intl.formatMessage({
+ id: 'dashboard.triggerDetails.webhookInterceptorService',
+ defaultMessage: 'Service:'
+ });
+ content = (
+ <>
+ {serviceText}
+
+
+ {nameText} {interceptor.webhook.objectRef.name}
+
+ {interceptor.webhook.objectRef.namespace && (
+
+ {namespaceText}{' '}
+ {interceptor.webhook.objectRef.namespace}
+
+ )}
+
+ {headerValues.length !== 0 && (
+ <>
+
+ {intl.formatMessage({
+ id: 'dashboard.triggerDetails.interceptorHeader',
+ defaultMessage: 'Header:'
+ })}
+
+
+ >
+ )}
+ >
+ );
+ } else if (interceptor.github || interceptor.gitlab) {
+ let data;
+ if (interceptor.github) {
+ // GitHub Interceptor
+ interceptorType = 'GitHub';
+ data = interceptor.github;
+ } else {
+ // GitLab Interceptor
+ interceptorType = 'GitLab';
+ data = interceptor.gitlab;
+ }
+ const eventTypes = data.eventTypes.join(', ');
+ interceptorName = eventTypes;
+ const secretText = intl.formatMessage({
+ id: 'dashboard.triggerDetails.webhookInterceptorSecret',
+ defaultMessage: 'Secret:'
+ });
+ const secretKeyText = intl.formatMessage({
+ id: 'dashboard.triggerDetails.webhookInterceptorSecretKey',
+ defaultMessage: 'Key:'
+ });
+ content = (
+ <>
+ {secretText}
+
+
+ {nameText} {data.secretRef.secretName}
+
+
+ {secretKeyText} {data.secretRef.secretKey}
+
+ {data.secretRef.namespace && (
+
+ {namespaceText} {data.secretRef.namespace}
+
+ )}
+
+ Event Types: {eventTypes}
+ >
+ );
+ } else if (interceptor.cel) {
+ // CEL Interceptor
+ interceptorType = 'CEL';
+ interceptorName = interceptor.cel.filter;
+ const filter = intl.formatMessage({
+ id: 'dashboard.triggerDetails.celInterceptorFilter',
+ defaultMessage: 'Filter: '
+ });
+ content = (
+ <>
+ {filter}
+
+ {interceptor.cel.filter}
+
+ >
+ );
+ } else {
+ return null;
+ }
+ const title = intl.formatMessage(
+ {
+ id: 'dashboard.triggerDetails.interceptorTitle',
+ defaultMessage:
+ '{interceptorNumber}. ({interceptorType}) {interceptorName}'
+ },
+ {
+ interceptorNumber: index + 1,
+ interceptorType,
+ interceptorName
+ }
+ );
+ return (
+
+ {content}
+
+ );
+ })}
+
+ >
+ )}
>
);
diff --git a/packages/components/src/components/Trigger/Trigger.scss b/packages/components/src/components/Trigger/Trigger.scss
index 7845f9800d..98395d508b 100644
--- a/packages/components/src/components/Trigger/Trigger.scss
+++ b/packages/components/src/components/Trigger/Trigger.scss
@@ -13,48 +13,43 @@ limitations under the License.
@import '~carbon-components/scss/globals/scss/vars';
-.triggermain {
- padding: 25px;
- padding-left: 0px;
- margin-bottom: 20px;
- border-bottom: 0.5px dotted grey;
-}
+.trigger--interceptors {
+ margin-top: $spacing-05;
-.interceptor h3 {
- margin: 5px;
- padding: 10px;
+ .trigger--interceptors-accordian {
+ margin-top: $spacing-03;
+ }
}
-.triggerinfo h3 {
- margin: 5px;
- padding: 10px;
+.triggerdetails {
+ margin-top: $spacing-05;
}
.triggerresourcelinks {
- margin: 10px;
- padding: 10px;
display: grid;
- grid-template-columns: 100px 500px 100px;
+ margin-top: $spacing-03;
+ grid-template-columns: 100px 500px;
grid-gap: 30px;
-
- .linkicon {
- margin-right: 1rem;
- }
+ line-height: 1.5rem;
.resourcekind {
- font-weight: bold;
+ font-weight: auto;
}
-
- a {
- display: flex;
- overflow: hidden;
- white-space: nowrap;
+
+ .triggerresourcelink {
+ display: inline-block;
+ overflow: visible;
}
}
-.truncate-text-end {
+.interceptor--cel-filter {
display: inline-block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+}
+
+.interceptor--secret-details {
+ margin-left: $spacing-04;
+}
+
+.interceptor--service-details {
+ margin-left: $spacing-04;
}
diff --git a/packages/components/src/components/Trigger/Trigger.stories.js b/packages/components/src/components/Trigger/Trigger.stories.js
new file mode 100644
index 0000000000..4c1461ea8e
--- /dev/null
+++ b/packages/components/src/components/Trigger/Trigger.stories.js
@@ -0,0 +1,91 @@
+/*
+Copyright 2019 The Tekton Authors
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+ http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import { storiesOf } from '@storybook/react';
+
+import { Router } from 'react-router-dom';
+import { IntlProvider } from 'react-intl';
+import { createMemoryHistory } from 'history';
+import Trigger from './Trigger';
+
+const route = '/';
+const history = createMemoryHistory({ initialEntries: [route] });
+const trigger = {
+ eventListenerNamespace: 'default',
+ trigger: {
+ name: 'my-trigger',
+ bindings: [
+ { name: 'triggerbinding0' },
+ { name: 'triggerbinding1' },
+ { name: 'triggerbinding2' }
+ ],
+ template: {
+ name: 'triggertemplate'
+ },
+ interceptors: [
+ {
+ webhook: {
+ header: [
+ {
+ name: 'header0',
+ value: 'value0'
+ },
+ {
+ name: 'header1',
+ value: ['value1-0', 'value1-1', 'value1-2']
+ }
+ ],
+ objectRef: {
+ apiVersion: 'v1',
+ kind: 'Service',
+ name: 'interceptor-service0',
+ namespace: 'foo'
+ }
+ }
+ },
+ {
+ github: {
+ secretRef: {
+ secretName: 'github-secret',
+ secretKey: 'secret'
+ },
+ eventTypes: ['push', 'pull_request']
+ }
+ },
+ {
+ gitlab: {
+ secretRef: {
+ secretName: 'gitlab-secret',
+ secretKey: 'secret',
+ namespace: 'foo'
+ },
+ eventTypes: ['Push Hook']
+ }
+ },
+ {
+ cel: {
+ filter: "body.matches('foo', 'bar')"
+ }
+ }
+ ]
+ }
+};
+
+storiesOf('Trigger', module).add('default', () => (
+
+
+
+
+
+));
diff --git a/packages/components/src/components/Trigger/Trigger.test.js b/packages/components/src/components/Trigger/Trigger.test.js
index fc7776130e..2f5f46f702 100644
--- a/packages/components/src/components/Trigger/Trigger.test.js
+++ b/packages/components/src/components/Trigger/Trigger.test.js
@@ -16,131 +16,210 @@ import Trigger from './Trigger';
import { renderWithRouter } from '../../utils/test';
const fakeTrigger = {
- binding: {
- apiversion: 'v1alpha1',
- name: 'simple-pipeline-push-binding'
- },
- interceptor: {
- header: [
- {
- name: 'Wext-Trigger-Name',
- value: 'mytrigger-tekton-pipelines-push-event'
- },
- {
- name: 'Wext-Repository-Url',
- value: 'https://github.com/myorg/myrepo'
- },
- {
- name: 'Wext-Incoming-Event',
- value: 'push'
- },
- {
- name: 'Wext-Secret-Name',
- value: 'mytoken'
- }
- ],
- objectRef: {
- apiVersion: 'v1',
- kind: 'Service',
- name: 'tekton-webhooks-extension-validator',
- namespace: 'tekton-pipelines'
- }
- },
- name: 'mytrigger-tekton-pipelines-push-event',
- params: [
+ name: 'my-fake-trigger',
+ bindings: [
{
- name: 'webhooks-tekton-release-name',
- value: 'myreleasename'
+ apiversion: 'v1alpha1',
+ name: 'triggerbinding-0'
},
{
- name: 'webhooks-tekton-target-namespace',
- value: 'tekton-pipelines'
+ apiversion: 'v1alpha1',
+ name: 'triggerbinding-1'
},
{
- name: 'webhooks-tekton-service-account',
- value: 'tekton-dashboard'
- },
+ apiversion: 'v1alpha1',
+ name: 'triggerbinding-2'
+ }
+ ],
+ template: {
+ apiversion: 'v1alpha1',
+ name: 'simple-pipeline-template'
+ },
+ interceptors: [
{
- name: 'webhooks-tekton-git-server',
- value: 'github.com'
+ webhook: {
+ header: [
+ {
+ name: 'Wext-Repository-Url',
+ value: 'https://github.com/myorg/myrepo'
+ },
+ {
+ name: 'Wext-Incoming-Event',
+ value: 'wext-incoming-event-value'
+ },
+ {
+ name: 'Wext-Secret-Name',
+ value: 'wext-secret-name-value'
+ },
+ {
+ name: 'Array-Header-Name',
+ value: [
+ 'array-header-value-0',
+ 'array-header-value-1',
+ 'array-header-value-2'
+ ]
+ }
+ ],
+ objectRef: {
+ apiVersion: 'v1',
+ kind: 'Service',
+ name: 'webhook-service-name',
+ namespace: 'webhook-service-namespace'
+ }
+ }
},
{
- name: 'webhooks-tekton-git-org',
- value: 'myorg'
+ github: {
+ secretRef: {
+ secretName: 'my-github-secret',
+ secretKey: 'github-secret-key',
+ namespace: 'github-secret-namespace'
+ },
+ eventTypes: ['github-event-0', 'github-event-1', 'github-event-2']
+ }
},
{
- name: 'webhooks-tekton-git-repo',
- value: 'myrepo'
+ gitlab: {
+ secretRef: {
+ secretName: 'my-gitlab-secret',
+ secretKey: 'gitlab-secret-key',
+ namespace: 'gitlab-secret-namespace'
+ },
+ eventTypes: ['gitlab-event-0', 'gitlab-event-1', 'gitlab-event-2']
+ }
},
{
- name: 'webhooks-tekton-docker-registry',
- value: 'myregistry'
+ cel: {
+ filter: 'cel-filter'
+ }
}
- ],
- template: {
- apiversion: 'v1alpha1',
- name: 'simple-pipeline-template'
- }
+ ]
};
describe('Trigger', () => {
- it('should render all details', () => {
+ it('renders all details', () => {
const props = {
eventListenerNamespace: 'tekton-pipelines',
trigger: fakeTrigger
};
const { queryByText } = renderWithRouter(
);
expect(queryByText(/Name/i)).toBeTruthy();
+ expect(queryByText(/my-fake-trigger/i)).toBeTruthy();
expect(queryByText(/TriggerBinding/i)).toBeTruthy();
+ expect(queryByText(/triggerbinding-0/i)).toBeTruthy();
+ expect(queryByText(/triggerbinding-1/i)).toBeTruthy();
+ expect(queryByText(/triggerbinding-2/i)).toBeTruthy();
expect(queryByText(/TriggerTemplate/i)).toBeTruthy();
- expect(queryByText(/Interceptor/i)).toBeTruthy();
- expect(queryByText(/Headers/i)).toBeTruthy();
- expect(queryByText(/Parameters/i)).toBeTruthy();
- expect(queryByText(/webhooks-tekton-release-name/i)).toBeTruthy();
- expect(queryByText(/webhooks-tekton-target-namespace/i)).toBeTruthy();
- expect(queryByText(/webhooks-tekton-service-account/i)).toBeTruthy();
- expect(queryByText(/webhooks-tekton-git-server/i)).toBeTruthy();
- expect(queryByText(/webhooks-tekton-git-org/i)).toBeTruthy();
- expect(queryByText(/webhooks-tekton-git-repo/i)).toBeTruthy();
- expect(queryByText(/mytrigger-tekton-pipelines-push-event/i)).toBeTruthy();
- expect(queryByText(/myreleasename/i)).toBeTruthy();
- expect(queryByText(/tekton-pipelines/i)).toBeTruthy();
- expect(queryByText(/myregistry/i)).toBeTruthy();
- expect(queryByText(/myrepo/i)).toBeTruthy();
- expect(queryByText(/myorg/i)).toBeTruthy();
- expect(queryByText(/github.com/i)).toBeTruthy();
expect(queryByText(/simple-pipeline-template/i)).toBeTruthy();
- expect(queryByText(/Wext-Trigger-Name/i)).toBeTruthy();
+ expect(queryByText(/Interceptors/i)).toBeTruthy();
+ // Check Webhook Interceptor
+ expect(queryByText(/(webhook)/i)).toBeTruthy();
+ expect(queryByText(/webhook-service-name/i)).toBeTruthy();
+ expect(queryByText(/Header/i)).toBeTruthy();
+ expect(queryByText(/webhook-service-namespace/i)).toBeTruthy();
expect(queryByText(/Wext-Repository-Url/i)).toBeTruthy();
+ expect(queryByText(/https:\/\/github.com\/myorg\/myrepo/i)).toBeTruthy();
expect(queryByText(/Wext-Incoming-Event/i)).toBeTruthy();
+ expect(queryByText(/wext-incoming-event-value/i)).toBeTruthy();
expect(queryByText(/Wext-Secret-Name/i)).toBeTruthy();
+ expect(queryByText(/wext-secret-name-value/i)).toBeTruthy();
+ expect(queryByText(/Array-Header-Name/i)).toBeTruthy();
+ expect(queryByText(/array-header-value-0/i)).toBeTruthy();
+ expect(queryByText(/array-header-value-1/i)).toBeTruthy();
+ expect(queryByText(/array-header-value-2/i)).toBeTruthy();
+ // Check GitHub Interceptor
+ expect(queryByText(/(github)/i)).toBeTruthy();
+ expect(queryByText(/my-github-secret/i)).toBeTruthy();
+ expect(queryByText(/github-secret-key/i)).toBeTruthy();
+ expect(queryByText(/github-secret-namespace/i)).toBeTruthy();
+ expect(queryByText(/github-event-0/i)).toBeTruthy();
+ expect(queryByText(/github-event-1/i)).toBeTruthy();
+ expect(queryByText(/github-event-2/i)).toBeTruthy();
+ // Check GitLab Interceptor
+ expect(queryByText(/(gitlab)/i)).toBeTruthy();
+ expect(queryByText(/my-gitlab-secret/i)).toBeTruthy();
+ expect(queryByText(/gitlab-secret-key/i)).toBeTruthy();
+ expect(queryByText(/gitlab-secret-namespace/i)).toBeTruthy();
+ expect(queryByText(/gitlab-event-0/i)).toBeTruthy();
+ expect(queryByText(/gitlab-event-1/i)).toBeTruthy();
+ expect(queryByText(/gitlab-event-2/i)).toBeTruthy();
+ // Check CEL Interceptor
+ expect(queryByText(/(cel)/i)).toBeTruthy();
+ expect(queryByText(/cel-filter/i)).toBeTruthy();
});
- it('should handle missing params and interceptor', () => {
+ it('handles no objectRef in webhook Interceptor', () => {
const props = {
eventListenerNamespace: 'tekton-pipelines',
trigger: {
...fakeTrigger,
- params: undefined,
- interceptor: undefined
+ interceptors: [
+ {
+ webhook: {}
+ }
+ ]
}
};
const { queryByText } = renderWithRouter(
);
- expect(queryByText(/Name/i)).toBeTruthy();
+ expect(queryByText(/1./i)).toBeFalsy();
});
- it('should handle missing interceptor headers', () => {
+ it('handles empty Interceptor', () => {
const props = {
eventListenerNamespace: 'tekton-pipelines',
trigger: {
...fakeTrigger,
- interceptor: {
- ...fakeTrigger.interceptor,
- header: undefined
- }
+ interceptors: [{}]
+ }
+ };
+ const { queryByText } = renderWithRouter(
);
+ expect(queryByText(/Interceptors/i)).toBeTruthy();
+ });
+
+ it('handles no Interceptors', () => {
+ const props = {
+ eventListenerNamespace: 'tekton-pipelines',
+ trigger: {
+ ...fakeTrigger,
+ interceptors: []
+ }
+ };
+ const { queryByText } = renderWithRouter(
);
+ expect(queryByText(/Interceptors/i)).toBeFalsy();
+ });
+
+ it('handles missing Interceptors', () => {
+ const props = {
+ eventListenerNamespace: 'tekton-pipelines',
+ trigger: {
+ ...fakeTrigger,
+ interceptors: undefined
+ }
+ };
+ const { queryByText } = renderWithRouter(
);
+ expect(queryByText(/Interceptors/i)).toBeFalsy();
+ });
+
+ it('handles webhook Interceptor without Header', () => {
+ const props = {
+ eventListenerNamespace: 'tekton-pipelines',
+ trigger: {
+ ...fakeTrigger,
+ interceptors: [
+ {
+ webhook: {
+ objectRef: {
+ apiVersion: 'v1',
+ kind: 'Service',
+ name: 'webhook-service-name',
+ namespace: 'webhook-service-namespace'
+ }
+ }
+ }
+ ]
}
};
const { queryByText } = renderWithRouter(
);
- expect(queryByText(/Interceptor/i)).toBeTruthy();
+ expect(queryByText(/Header/i)).toBeFalsy();
});
});
diff --git a/src/containers/EventListener/EventListener.js b/src/containers/EventListener/EventListener.js
index 50aab07282..93e45154b3 100644
--- a/src/containers/EventListener/EventListener.js
+++ b/src/containers/EventListener/EventListener.js
@@ -14,10 +14,8 @@ limitations under the License.
import React, { Component } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
-import '../../scss/Triggers.scss';
import { injectIntl } from 'react-intl';
import { InlineNotification, Tag } from 'carbon-components-react';
-
import { formatLabels } from '@tektoncd/dashboard-utils';
import {
FormattedDate,
@@ -32,9 +30,11 @@ import {
getSelectedNamespace,
isWebSocketConnected
} from '../../reducers';
-
import { fetchEventListener } from '../../actions/eventListeners';
+import '../../scss/Triggers.scss';
+import './EventListener.scss';
+
export /* istanbul ignore next */ class EventListenerContainer extends Component {
static notification({ kind, message, intl }) {
const titles = {
@@ -129,54 +129,71 @@ export /* istanbul ignore next */ class EventListenerContainer extends Component
})}
>
-
-
- {intl.formatMessage({
- id: 'dashboard.metadata.dateCreated',
- defaultMessage: 'Date Created:'
- })}
-
-
-
-
-
- {intl.formatMessage({
- id: 'dashboard.metadata.labels',
- defaultMessage: 'Labels:'
- })}
-
- {formattedLabelsToRender.length === 0
- ? intl.formatMessage({
- id: 'dashboard.metadata.none',
- defaultMessage: 'None'
- })
- : formattedLabelsToRender.map(label => (
-
- {label}
-
- ))}
-
-
-
- {intl.formatMessage({
- id: 'dashboard.metadata.namespace',
- defaultMessage: 'Namespace:'
- })}
-
- {eventListener.metadata.namespace}
-
- {triggers.map(trigger => {
- return (
-
+
+
+ {intl.formatMessage({
+ id: 'dashboard.metadata.dateCreated',
+ defaultMessage: 'Date Created:'
+ })}{' '}
+
+
- );
- })}
+
+
+
+ {intl.formatMessage({
+ id: 'dashboard.metadata.labels',
+ defaultMessage: 'Labels:'
+ })}{' '}
+
+ {formattedLabelsToRender.length === 0
+ ? intl.formatMessage({
+ id: 'dashboard.metadata.none',
+ defaultMessage: 'None'
+ })
+ : formattedLabelsToRender.map(label => (
+
+ {label}
+
+ ))}
+
+
+
+ {intl.formatMessage({
+ id: 'dashboard.metadata.namespace',
+ defaultMessage: 'Namespace:'
+ })}{' '}
+
+ {eventListener.metadata.namespace}
+
+ {eventListener.spec.serviceAccountName && (
+
+
+ {intl.formatMessage({
+ id: 'dashboard.metadata.serviceAccount',
+ defaultMessage: 'Service Account:'
+ })}{' '}
+
+ {eventListener.spec.serviceAccountName}
+
+ )}
+
+
+ {triggers.map((trigger, idx) => (
+
+
+
+ ))}
+