Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding "Run on OpenShift" functionality #884

Merged
merged 1 commit into from
Dec 6, 2016
Merged

Adding "Run on OpenShift" functionality #884

merged 1 commit into from
Dec 6, 2016

Conversation

rhamilto
Copy link
Member

@rhamilto rhamilto commented Nov 16, 2016

Proposed image stream query string params:

  • imageStream (required)
  • imageTag (required)
  • namespace (optional, but if not specified, defaults to 'openshift')
  • name (optional)
  • sourceURI (optional)
  • sourceRef (optional)
  • contextDir (optional)

Proposed template query string params:

  • template (required)
  • templateParamsMap (optional)
  • namespace (optional, but if not specified, defaults to 'openshift')

Testing URLs:

create?name=nodejs-edited&imageStream=nodejs&imageTag=4&sourceURI=https:%2F%2Fytb.ewangdian.workers.dev%2Fopenshift%2Fnodejs-ex.git-edited&sourceRef=master-edited&contextDir=%2F-edited

create?template=nodejs-mongodb-example&templateParamsMap=%7B"SOURCE_REPOSITORY_URL":"https:%2F%2Fytb.ewangdian.workers.dev%2Fopenshift%2Fnodejs-ex.git-edited"%7D

Screenshots:

User has existing project(s) and can create new project(s)

screen shot 2016-11-17 at 8 36 27 am

screen shot 2016-11-17 at 8 36 39 am

User has no project(s), but can create new project(s)

screen shot 2016-11-17 at 8 55 55 am

User has existing project(s), but cannot create new project(s)

screen shot 2016-11-17 at 8 56 28 am

User has no existing project(s) nor can create new project(s)

screen shot 2016-11-17 at 8 56 56 am

Wondering if the messaging at the bottom is obvious enough? This shouldn't be a common case, but still...

@jwforres or @spadgett, PTAL.

@spadgett
Copy link
Member

For query parameter names, I think we should align with the API fields where we can. Proposing

  • contextDir instead of contextDirectory
  • sourceURI (API field is source.git.uri)
  • sourceRef (API field is source.git.ref)
  • app instead of appName (matching the label key)

Can't decide between sourceURI and gitURI.

We might talk about the best way to handle template parameters. JSON is reasonable, but it's a bit hard to build that URL by hand. (I know I suggested JSON before :)

@rhamilto
Copy link
Member Author

rhamilto commented Nov 17, 2016

Great suggestions, @spadgett. I've implemented your proposed changes. I favor sourceURI as it seems more future proof.

@benjaminapetersen and I debated the JSON template params, but decided to go with it since it not only keeps the code simpler, but it also seems simpler with regard to how to construct the query string param since you're overriding template-defined JSON objects with query string-ified JSON objects (URL-building complications aside, which really isn't that bad as the browser will do the bracket escaping for you).

Thoughts?

@benjaminapetersen
Copy link
Contributor

benjaminapetersen commented Nov 17, 2016

Agree on making the params match, that makes sense.
I'd be up for exploring alternates to the JSON syntax in the url, jQuery.param() works fine to create urls with params & I expect many people are familiar with it. example: http://codepen.io/benjaminapetersen/pen/NbbMjJ?editors=0010 (though it will turn a nested object into [object Object]). Hopefully our API can be simple/familiar.

@rhamilto rhamilto changed the title [WIP]: Adding "Run on OpenShift" functionality Adding "Run on OpenShift" functionality Nov 18, 2016
]
],
// 'openshift' should always be included
CREATE_FROM_URL_WHITELIST: ['openshift','run-on-openshift']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should leave this just ['openshift'] by default.

if (createDetails.imageStream && createDetails.template) {
showInvalidResource();
} else if (!(createDetails.imageStream) && !(createDetails.template)) {
alertResourceRequired();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We typically use Navigate.toErrorPage() for these types of errors (invalid or conflicting route params)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was an intentional decision to facilitate the creation of URLs. By throwing an alert on the current page, we can give feedback on specific things that are incorrect while maintaining the context of the query string in the address bar. Do you disagree?

screen shot 2016-11-17 at 10 31 35 am

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rhamilto So the idea is that I can tweak the URL in the location bar until I get it right? Makes sense.

Is the rest of the page blank? How does that look?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the rest of the page blank? How does that look?

Yep. It looks like a blank page. I wasn't sure what else to include since it's hard to know what the user is trying to do. Thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe some text with help building the URL? Or a link to the docs when they're written?

createWithProject: function(project) {
$location
.url('/project/' + (project || $scope.selected.project.metadata.name) + '/create/' + ($routeParams.imageStream ? 'fromimage' : 'fromtemplate'))
.search(createDetails);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$scope.noProjects = (_.isEmpty($scope.projects));
});

// copied from app/scripts/controllers/projects.js
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move into ProjectsService so we have the logic in one place?

invalidResource:'Image streams and templates cannot be combined.',
invalidTemplate: _.template('The requested template "<%= template %>" is not valid.'),
resourceRequired: 'A valid image stream or template is required.'
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these are templates, and some are just strings. I'm not sure I like mixing them in the same object because you have no way to know which is which where they're called. Is this really better than just inlining the messages? Then there's no confusion.

@@ -355,6 +354,13 @@ angular.module('openshiftConsole')
$scope.parameterDisplayNames[parameter.name] = parameter.displayName || parameter.name;
});

_.each($scope.template.parameters, function(parameter) {
var found = _.find(JSON.parse($routeParams.templateParamsMap), function(param){
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to catch the exception on JSON parse failures. This is not JSON we're generating, so it might have syntax errors.

_.each($scope.template.parameters, function(parameter) {
var found = _.find(JSON.parse($routeParams.templateParamsMap), function(param){
return param.name === parameter.name;
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use the shorthand (where params is the parsed JSON)

_.find(params, { name: parameter.name });

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to not make the JSON a map? For example,

{
  "SOURCE_URL_REPOSITORY": "http://github.com/openshift/origin-ruby-example"
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/openshift/openshift-ansible/blob/master/roles/openshift_examples/files/examples/v1.4/quickstart-templates/nodejs-mongodb.json#L364 If we do that, then users can only override name and value key values. If we leave it as is, they can override any key values they choose. Do we want to restrict to only name and value values?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to restrict to only name and value values?

Yeah, I don't think they should be able to change anything other than the value for an existing parameter. We should probably show an error if they give the name that does not match a param in the template.

if(!($scope.submitButtonLabel)) {
$scope.submitButtonLabel = 'Create';
}
$scope.createProject = function() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We typically make the directive just the editor that takes in the object and updates it, then let the controller call create and handle errors.

<ui-select-match placeholder="Project name">
{{$select.selected.metadata.name}}
</ui-select-match>
<ui-select-choices repeat="project in projects | toArray | filter : { metadata: { name: $select.search } } | orderBy : 'metadata.name'">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to convert to an array / sort once in the controller.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we match keywords on display name as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we match keywords on display name as well?

Or should we display displayName in the dropdown if it exists?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prob should show it instead of name, but search both fields for match?

<ui-select-match placeholder="Project name">
{{$select.selected.metadata.name}}
</ui-select-match>
<ui-select-choices repeat="project in projects | toArray | filter : { metadata: { name: $select.search } } | orderBy : 'metadata.name'">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about filter and sorting as line 43.

@openshift-bot openshift-bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Nov 21, 2016
@jwforres
Copy link
Member

Yes we should use display name when picking a project here. You'll need
the same logic we use for the main project dropdown, since you have to
handle duplicate display names.

On Tue, Nov 22, 2016 at 9:29 AM, Robb Hamilton [email protected]
wrote:

@rhamilto commented on this pull request.

In app/views/create-from-url.html
#884:

  •                      {{$select.selected.metadata.name}}
    
  •                    </ui-select-match>
    
  •                    <ui-select-choices repeat="project in projects | toArray | filter : { metadata: { name: $select.search } } | orderBy : 'metadata.name'">
    
  •                      <div ng-bind-html="project.metadata.name | highlight : $select.search"></div>
    
  •                    </ui-select-choices>
    
  •                  </ui-select>
    
  •                </div>
    
  •                <div ng-if="!noProjects && canCreateProject">
    
  •                  <uib-tabset>
    
  •                    <uib-tab>
    
  •                      <uib-tab-heading>Choose Existing Project</uib-tab-heading>
    
  •                      <ui-select ng-model="selected.project">
    
  •                        <ui-select-match placeholder="Project name">
    
  •                          {{$select.selected.metadata.name}}
    
  •                        </ui-select-match>
    
  •                        <ui-select-choices repeat="project in projects | toArray | filter : { metadata: { name: $select.search } } | orderBy : 'metadata.name'">
    

Should we match keywords on display name as well?

Or should we display displayName in the dropdown if it exists?


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
#884, or mute the
thread
https://github.com/notifications/unsubscribe-auth/ABZk7YwhzNn3JKpw4JUwocnCbjiAZAYqks5rAvxbgaJpZM4K0eNa
.

@rhamilto
Copy link
Member Author

rhamilto commented Nov 22, 2016

Code review to dos:

  • set whitelist to only 'openshift'
  • make error messages inline strings
  • consolidate create URL in Navigate service
  • move projectrequests in to ProjectsService to eliminate duplication of code
  • update Navigate service with renamed $routeParams.template
  • catch the exception on JSON parse failures
  • make the JSON a map
  • fix display and sort of project names in the dropdown
  • utilize display-name in project dropdown if set
  • BUG: validate appName query string params
  • add protractor tests
  • resolve issue where query string params are stripped on oauth redirect
  • add link to documentation on /create

@rhamilto
Copy link
Member Author

@spadgett, I believe @benjaminapetersen and I addressed your comments, but a few additional TODOs remain. So we're not ready for merge yet, but you're welcome to review the requested changes.

@openshift-bot openshift-bot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Nov 22, 2016
});
}, function(e) {
if(e.status === 403) {
alertInaccessibleNamespace(createDetails.namespace, e);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might have access to the namespace, but not the image stream. I'd change the message to say specifically that you don't have access to the image stream or template.

type: "error",
message: namespace ?
"Resources from the namespace \"" + namespace + "\" are not permitted." :
"A valid namespace is required."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's open a follow-on issue to add a doc link here for whitelisting namespaces.

var alertInvalidImageStream = function(imageStream, e) {
$scope.alerts.invalidImageStream = {
type: "error",
message: "The requested image stream \"" + imageStream + "\" is not valid.",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest a more general message saying "Could not load image stream ..." instead of saying "not valid." It might be valid, but we couldn't load it for another reason. Same for templates.

$scope.alerts.inaccessibleNamespace = {
type: "error",
message: "You do not have access to the namespace \"" + namespace + "\".",
details: "Reason: " + $filter('getErrorDetails')(e)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getErrorDetails can in some cases return an empty string, so you could have "Reason: " with nothing following. We should either fix the filter or leaving off "Reason:".

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I realize we have this problem in many places in existing code.)

var whiteListedCreateDetailsKeys = ['namespace', 'app', 'imageStream', 'imageTag', 'sourceURI', 'sourceRef', 'contextDir', 'template', 'templateParamsMap'];
var createDetails = _.pick($routeParams, function(value, key) {
// routeParams without a value (e.g., ?app&) return true, which results in "true" displaying in the UI
return _.contains(whiteListedCreateDetailsKeys, key) && (value && value !== true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe

return _.contains(whiteListedCreateDetailsKeys, key) && _.isString(value);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That doesn't work. Presumably because the boolean gets converted to a string?

catch (e) {
$scope.alerts.invalidTemplateParams = {
type: "error",
message: "The template parameters are not valid."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should say specifically that it's not valid JSON and ideally show the syntax error details from e if present.

catch (e) {
$scope.alertsTop.invalidTemplateParams = {
type: "error",
message: "The template parameters are not valid."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above about JSON parsing.

var templateParams = getValidTemplateParamsMap();
_.each($scope.template.parameters, function(parameter) {
if (templateParams[parameter.name]) {
parameter.value = templateParams[parameter.name];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we switch back to array for this controller? I think it would be better to use a map everywhere to be consistent if possible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I (we) understand the question. $scope.template.parameters is the existing array that was already in use on the page.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I misread the code. Disregard.

@@ -16,6 +16,7 @@
{{ emptyMessage }}
</div>
<div class="osc-form" ng-show="template">
<alerts alerts="alertsTop" hide-close-button="true"></alerts>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why you added this, but not sure we should have multiple alerts directives on this page. @jwforres Opinion? Were the alerts moved down for the quota changes?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah they were moved for the quota change because you would submit the form and not see the errors if they were scrolled off the top of the page

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a general problem with our alerts we might try and solve. Assuming it can happen with other forms, too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We talked about moving to toasts at one point, which I think would work as they are fixed position & lay on top of the screen. Maybe we consider again?

@rhamilto
Copy link
Member Author

Blocked by openshift/origin#12044

@benjaminapetersen
Copy link
Contributor

So it still seems like we could do a better job with a visual cue implying that the user will be installing a significant piece of software. I mentioned a tile before, though I know there were qualms. A rough side by side:

run-on-tile-side-by-side

I can roll with it if we generally think the plain text approach is sufficient, but wanted to bring it up one more time.

page.visit(qs);
expect(element(by.css('h1')).getText()).toEqual('Node.js 4');
});
it('should create a project to add to', function(){
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A quick thoughts here. an it() should have an expect() inside as an assertion of something, otherwise its not quite a test. After the tab.click() we can probably browser.wait() then check that the url contains the project name?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. The expects are going to be confirmation of the query string params in the corresponding fields. I just haven't gotten there yet. :)

expect(element(by.css('h1')).getText()).toEqual('Node.js + MongoDB (Ephemeral)');
});
it('should use an existing project', function(){
// if the tabs are not on the page, create a new project instead
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, we should expect() in here so that the test will pass or fail.

@@ -92,3 +96,31 @@ exports.waitFor = function(item, timeout, msg) {
msg = msg || '';
return browser.wait(item, timeout, msg);
};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that we can reuse this. Maybe we should do a helpers/ directory & put this in helpers/projectHelpers.js

@rhamilto
Copy link
Member Author

rhamilto commented Dec 5, 2016

@spadgett, I think @benjaminapetersen have successfully added sufficient e2e tests, so I believe this is ready for final review.

@rhamilto
Copy link
Member Author

rhamilto commented Dec 5, 2016

So it still seems like we could do a better job with a visual cue implying that the user will be installing a significant piece of software. I mentioned a tile before, though I know there were qualms. A rough side by side:

For me, the tile disassociates the image stream or template description from the project selection. They no longer feel like they're part of the same thing.

]
],
// 'openshift' should always be included
CREATE_FROM_URL_WHITELIST: ['openshift']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd move this above PROJET_NAVIGATION because it's a bit easy to miss below the two long constants.

Suggest adding more comments describing what it is. I'd like the file to be as self-documenting as possible since we expect admins to customize these values.

createDetails.namespace = createDetails.namespace || 'openshift';

var validateApp = function (app) {
return /^[a-z]([-a-z0-9]*[a-z0-9])?$/.test(app);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should also check it's not longer than 63 characters.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I will add a check for length. The max characters for app name is 24.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, yeah. Create from source limit is 24, you're right.

How do we handle app for templates? Do we set it as the app label? That limit would be 63.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given template params are completely open-ended (defined in the template), we don't try to validate any of them (including app aka name).

Copy link
Member

@spadgett spadgett Dec 6, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking we should change this to name so it's not confused with the app label. It's also consistent with oc new-app and the label we uses in the web console.

--name='': Set name to use for generated application artifacts

@@ -64,6 +65,9 @@ angular.module('openshiftConsole')
update: function(projectName, data) {
return DataService
.update('projects', projectName, cleanEditableAnnotations(data), {projectName: projectName}, {errorNotification: false});
},
canCreate: function() {
return DataService.get("projectrequests", null, {}, { errorNotification: false});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: indentation

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks at @benjaminapetersen. ;-)

<ui-select-match placeholder="Project name">
{{$select.selected | uniqueDisplayName : projects}}
</ui-select-match>
<ui-select-choices repeat="project in projects | filter : { metadata: { name: $select.search }}">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to filter on display name as well if that's what we're showing.

<ui-select-match placeholder="Project name">
{{$select.selected | uniqueDisplayName : projects}}
</ui-select-match>
<ui-select-choices repeat="project in projects | filter : { metadata: { name: $select.search }}">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment: Need to filter on display name as well.

@rhamilto
Copy link
Member Author

rhamilto commented Dec 5, 2016

@spadgett, comments addressed.

@benjaminapetersen
Copy link
Contributor

Btw, tests are in line with the PageObjects PR I opened a good while ago, hoping to more easily iterate after this merges.

@spadgett
Copy link
Member

spadgett commented Dec 6, 2016

@benjaminapetersen @rhamilto thank you for writing a lot of tests on this

@benjaminapetersen
Copy link
Contributor

The "public api" of this PR is currently the following:

['namespace', 'app', 'imageStream', 'imageTag', 'sourceURI', 'sourceRef', 'contextDir', 'template', 'templateParamsMap'];

Just confirming we are good with that. The only one I was not 100% on was templateParamsMap, if it should be shortened to templateParams or something similar.

@spadgett
Copy link
Member

spadgett commented Dec 6, 2016

We might change app to name for consistency with the CLI and field label, @jwforres ?

@jwforres
Copy link
Member

jwforres commented Dec 6, 2016 via email

@rhamilto
Copy link
Member Author

rhamilto commented Dec 6, 2016

I've renamed the query string param "app" to "name".

@spadgett
Copy link
Member

spadgett commented Dec 6, 2016

[merge]

@spadgett
Copy link
Member

spadgett commented Dec 6, 2016

@benjaminapetersen @rhamilto some test failures in jenkins

@rhamilto
Copy link
Member Author

rhamilto commented Dec 6, 2016

@spadgett and @benjaminapetersen, I removed the tests commit from the PR and moved it to a different branch so the feature commit can merge.

@spadgett
Copy link
Member

spadgett commented Dec 6, 2016

[merge]

@openshift-bot
Copy link

Evaluated for origin web console merge up to 094a415

@openshift-bot
Copy link

openshift-bot commented Dec 6, 2016

Origin Web Console Merge Results: SUCCESS (https://ci.openshift.redhat.com/jenkins/job/test_pull_requests_origin_web_console/818/) (Base Commit: d264ce4)

@openshift-bot openshift-bot merged commit a9f4523 into openshift:master Dec 6, 2016
@rhamilto rhamilto deleted the run-on branch December 6, 2016 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants