potassium 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +41 -45
- data/lib/potassium/assets/.eslintrc.json +13 -4
- data/lib/potassium/assets/.github/pull_request_template.md +9 -0
- data/lib/potassium/assets/README.yml +6 -0
- data/lib/potassium/assets/app/graphql/graphql_controller.rb +55 -0
- data/lib/potassium/assets/app/graphql/mutations/login_mutation.rb +23 -0
- data/lib/potassium/assets/app/graphql/queries/base_query.rb +4 -0
- data/lib/potassium/assets/app/graphql/types/base/base_argument.rb +4 -0
- data/lib/potassium/assets/app/graphql/types/base/base_enum.rb +4 -0
- data/lib/potassium/assets/app/graphql/types/base/base_field.rb +5 -0
- data/lib/potassium/assets/app/graphql/types/base/base_input_object.rb +5 -0
- data/lib/potassium/assets/app/graphql/types/base/base_interface.rb +7 -0
- data/lib/potassium/assets/app/graphql/types/base/base_object.rb +5 -0
- data/lib/potassium/assets/app/graphql/types/base/base_scalar.rb +4 -0
- data/lib/potassium/assets/app/graphql/types/base/base_union.rb +4 -0
- data/lib/potassium/assets/app/graphql/types/mutation_type.rb +10 -0
- data/lib/potassium/assets/app/graphql/types/query_type.rb +13 -0
- data/lib/potassium/assets/app/uploaders/base_uploader.rb +1 -3
- data/lib/potassium/assets/config/graphql_playground.rb +20 -0
- data/lib/potassium/assets/config/puma.rb +1 -1
- data/lib/potassium/assets/config/shrine.rb +4 -1
- data/lib/potassium/assets/redis.yml +1 -2
- data/lib/potassium/assets/testing/rails_helper.rb +2 -0
- data/lib/potassium/cli/commands/create.rb +11 -19
- data/lib/potassium/cli_options.rb +59 -7
- data/lib/potassium/newest_version_ensurer.rb +19 -36
- data/lib/potassium/node_version_ensurer.rb +30 -0
- data/lib/potassium/recipes/api.rb +91 -27
- data/lib/potassium/recipes/background_processor.rb +34 -1
- data/lib/potassium/recipes/database.rb +4 -0
- data/lib/potassium/recipes/draper.rb +0 -9
- data/lib/potassium/recipes/file_storage.rb +1 -0
- data/lib/potassium/recipes/front_end.rb +62 -0
- data/lib/potassium/recipes/github.rb +93 -15
- data/lib/potassium/version.rb +1 -1
- data/potassium.gemspec +2 -1
- data/spec/features/api_spec.rb +25 -0
- data/spec/features/background_processor_spec.rb +12 -1
- data/spec/features/draper_spec.rb +1 -6
- data/spec/features/file_storage_spec.rb +5 -0
- data/spec/features/front_end_spec.rb +14 -0
- data/spec/features/github_spec.rb +53 -8
- data/spec/features/graphql_spec.rb +71 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/support/fake_octokit.rb +31 -0
- metadata +40 -8
- data/lib/potassium/assets/api/api_error_concern.rb +0 -32
- data/lib/potassium/assets/api/base_controller.rb +0 -7
- data/lib/potassium/assets/api/draper_responder.rb +0 -62
- data/lib/potassium/assets/api/responder.rb +0 -41
@@ -7,7 +7,6 @@ class Recipes::Draper < Rails::AppBuilder
|
|
7
7
|
def create
|
8
8
|
return unless selected?(:draper)
|
9
9
|
add_draper
|
10
|
-
add_api_responder if selected?(:api_support)
|
11
10
|
end
|
12
11
|
|
13
12
|
def installed?
|
@@ -16,8 +15,6 @@ class Recipes::Draper < Rails::AppBuilder
|
|
16
15
|
|
17
16
|
def install
|
18
17
|
add_draper
|
19
|
-
api_recipe = load_recipe(:api)
|
20
|
-
add_api_responder if api_recipe.installed?
|
21
18
|
end
|
22
19
|
|
23
20
|
def add_draper
|
@@ -25,10 +22,4 @@ class Recipes::Draper < Rails::AppBuilder
|
|
25
22
|
add_readme_section :internal_dependencies, :draper
|
26
23
|
create_file 'app/decorators/.keep'
|
27
24
|
end
|
28
|
-
|
29
|
-
def add_api_responder
|
30
|
-
after(:gem_install) do
|
31
|
-
copy_file '../assets/api/draper_responder.rb', 'app/responders/api_responder.rb', force: true
|
32
|
-
end
|
33
|
-
end
|
34
25
|
end
|
@@ -43,6 +43,7 @@ class Recipes::FileStorage < Rails::AppBuilder
|
|
43
43
|
copy_file('../assets/config/shrine.rb', 'config/initializers/shrine.rb', force: true)
|
44
44
|
copy_file('../assets/app/uploaders/image_uploader.rb', 'app/uploaders/image_uploader.rb')
|
45
45
|
copy_file('../assets/app/uploaders/base_uploader.rb', 'app/uploaders/base_uploader.rb')
|
46
|
+
append_to_file('.gitignore', "/public/uploads\n")
|
46
47
|
end
|
47
48
|
|
48
49
|
def common_setup
|
@@ -26,6 +26,9 @@ class Recipes::FrontEnd < Rails::AppBuilder
|
|
26
26
|
if value == :vue
|
27
27
|
recipe.setup_vue_with_compiler_build
|
28
28
|
recipe.setup_jest
|
29
|
+
if get(:api) == :graphql
|
30
|
+
recipe.setup_apollo
|
31
|
+
end
|
29
32
|
end
|
30
33
|
recipe.add_responsive_meta_tag
|
31
34
|
recipe.setup_tailwind
|
@@ -83,6 +86,27 @@ class Recipes::FrontEnd < Rails::AppBuilder
|
|
83
86
|
copy_file '../assets/app/javascript/app.spec.js', 'app/javascript/app.spec.js'
|
84
87
|
end
|
85
88
|
|
89
|
+
def setup_apollo
|
90
|
+
run 'bin/yarn add vue-apollo graphql apollo-client apollo-link apollo-link-http apollo-cache-inmemory graphql-tag'
|
91
|
+
|
92
|
+
inject_into_file(
|
93
|
+
'app/javascript/packs/application.js',
|
94
|
+
apollo_imports,
|
95
|
+
after: "import App from '../app.vue';"
|
96
|
+
)
|
97
|
+
|
98
|
+
inject_into_file(
|
99
|
+
'app/javascript/packs/application.js',
|
100
|
+
apollo_loading,
|
101
|
+
after: "import VueApollo from 'vue-apollo';"
|
102
|
+
)
|
103
|
+
inject_into_file(
|
104
|
+
'app/javascript/packs/application.js',
|
105
|
+
"\n apolloProvider,",
|
106
|
+
after: "components: { App },"
|
107
|
+
)
|
108
|
+
end
|
109
|
+
|
86
110
|
private
|
87
111
|
|
88
112
|
def frameworks(framework)
|
@@ -94,6 +118,35 @@ class Recipes::FrontEnd < Rails::AppBuilder
|
|
94
118
|
frameworks[framework]
|
95
119
|
end
|
96
120
|
|
121
|
+
def apollo_imports
|
122
|
+
<<~JS
|
123
|
+
\n
|
124
|
+
import { ApolloClient } from 'apollo-client';
|
125
|
+
import { createHttpLink } from 'apollo-link-http';
|
126
|
+
import { InMemoryCache } from 'apollo-cache-inmemory';
|
127
|
+
import VueApollo from 'vue-apollo';
|
128
|
+
JS
|
129
|
+
end
|
130
|
+
|
131
|
+
def apollo_loading
|
132
|
+
<<~JS
|
133
|
+
\n
|
134
|
+
const httpLink = createHttpLink({
|
135
|
+
uri: `${window.location.origin}/graphql`,
|
136
|
+
})
|
137
|
+
const cache = new InMemoryCache()
|
138
|
+
const apolloClient = new ApolloClient({
|
139
|
+
link: httpLink,
|
140
|
+
cache,
|
141
|
+
})
|
142
|
+
|
143
|
+
Vue.use(VueApollo)
|
144
|
+
const apolloProvider = new VueApollo({
|
145
|
+
defaultClient: apolloClient,
|
146
|
+
})
|
147
|
+
JS
|
148
|
+
end
|
149
|
+
|
97
150
|
def setup_client_css
|
98
151
|
application_css = 'app/javascript/css/application.css'
|
99
152
|
create_file application_css, "", force: true
|
@@ -163,6 +216,15 @@ class Recipes::FrontEnd < Rails::AppBuilder
|
|
163
216
|
},
|
164
217
|
variants: {},
|
165
218
|
plugins: [],
|
219
|
+
purge: {
|
220
|
+
enabled: process.env.NODE_ENV === 'production',
|
221
|
+
content: [
|
222
|
+
'./app/**/*.html',
|
223
|
+
'./app/**/*.vue',
|
224
|
+
'./app/**/*.js',
|
225
|
+
'./app/**/*.erb',
|
226
|
+
],
|
227
|
+
}
|
166
228
|
};
|
167
229
|
JS
|
168
230
|
end
|
@@ -1,29 +1,107 @@
|
|
1
|
+
require 'octokit'
|
2
|
+
|
1
3
|
class Recipes::Github < Rails::AppBuilder
|
2
4
|
def ask
|
3
|
-
repo_name = "platanus/#{get(:dasherized_app_name)}"
|
4
5
|
github_repo_create = answer(:github) do
|
5
|
-
|
6
|
-
"for this project?"
|
7
|
-
Ask.confirm(q)
|
8
|
-
end
|
9
|
-
if github_repo_create
|
10
|
-
github_repo_private = answer(:"github-private") do
|
11
|
-
Ask.confirm("Should the repository be private?")
|
12
|
-
end
|
6
|
+
Ask.confirm('Do you want to create a Github repository?')
|
13
7
|
end
|
14
|
-
set(:github_repo_name, repo_name)
|
15
8
|
set(:github_repo, github_repo_create)
|
16
|
-
|
9
|
+
setup_repo if github_repo_create
|
10
|
+
end
|
11
|
+
|
12
|
+
def setup_repo
|
13
|
+
setup_repo_private
|
14
|
+
setup_repo_org
|
15
|
+
setup_repo_name
|
16
|
+
set(:github_access_token, get_access_token)
|
17
17
|
end
|
18
18
|
|
19
19
|
def create
|
20
|
-
|
20
|
+
return unless selected?(:github_repo)
|
21
|
+
|
22
|
+
create_github_repo
|
23
|
+
copy_file '../assets/.github/pull_request_template.md', '.github/pull_request_template.md'
|
21
24
|
end
|
22
25
|
|
23
26
|
private
|
24
27
|
|
25
|
-
def
|
26
|
-
|
27
|
-
|
28
|
+
def setup_repo_private
|
29
|
+
repo_private = answer(:github_private) do
|
30
|
+
Ask.confirm('Should the repository be private?')
|
31
|
+
end
|
32
|
+
set(:github_repo_private, repo_private)
|
33
|
+
end
|
34
|
+
|
35
|
+
def setup_repo_org
|
36
|
+
has_organization = answer(:github_has_org) do
|
37
|
+
Ask.confirm('Is this repo for a Github organization?')
|
38
|
+
end
|
39
|
+
set(:github_has_org, has_organization)
|
40
|
+
if has_organization
|
41
|
+
repo_organization = answer(:github_org) do
|
42
|
+
Ask.input('What is the organization for this repository?', default: 'platanus')
|
43
|
+
end
|
44
|
+
set(:github_org, repo_organization)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def setup_repo_name
|
49
|
+
repo_name = answer(:github_name) do
|
50
|
+
Ask.input('What is the name for this repository?', default: get(:dasherized_app_name))
|
51
|
+
end
|
52
|
+
set(:github_repo_name, repo_name)
|
53
|
+
end
|
54
|
+
|
55
|
+
def create_github_repo
|
56
|
+
options = { private: get(:github_repo_private) }
|
57
|
+
options[:organization] = get(:github_org) if get(:github_has_org)
|
58
|
+
repo_name = get(:github_repo_name)
|
59
|
+
|
60
|
+
is_retry = false
|
61
|
+
begin
|
62
|
+
github_client(is_retry).create_repository(repo_name, options)
|
63
|
+
rescue Octokit::Unauthorized
|
64
|
+
is_retry = true
|
65
|
+
retry if retry_create_repo
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def retry_create_repo
|
70
|
+
puts "Bad credentials, information on Personal Access Tokens here:"
|
71
|
+
puts "https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token"
|
72
|
+
puts "Make sure to give repo access to the personal access token"
|
73
|
+
Ask.confirm("Do you want to retry?")
|
74
|
+
end
|
75
|
+
|
76
|
+
def github_client(is_retry = false)
|
77
|
+
access_token = is_retry ? set_access_token : get(:github_access_token)
|
78
|
+
octokit_client.new(access_token: access_token)
|
79
|
+
end
|
80
|
+
|
81
|
+
def octokit_client
|
82
|
+
if answer(:test)
|
83
|
+
require_relative '../../../spec/support/fake_octokit'
|
84
|
+
FakeOctokit
|
85
|
+
else
|
86
|
+
Octokit::Client
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def get_access_token
|
91
|
+
return File.open(config_filename, 'r').read if File.exists?(config_filename)
|
92
|
+
|
93
|
+
set_access_token
|
94
|
+
end
|
95
|
+
|
96
|
+
def set_access_token
|
97
|
+
access_token = answer(:github_access_token) do
|
98
|
+
Ask.input('Enter a GitHub personal access token', password: true)
|
99
|
+
end
|
100
|
+
File.open(config_filename, 'w') { |f| f.write(access_token) }
|
101
|
+
access_token
|
102
|
+
end
|
103
|
+
|
104
|
+
def config_filename
|
105
|
+
@config_filename ||= File.expand_path('~/.potassium')
|
28
106
|
end
|
29
107
|
end
|
data/lib/potassium/version.rb
CHANGED
data/potassium.gemspec
CHANGED
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
|
|
20
20
|
|
21
21
|
spec.add_development_dependency "bundler", "~> 2.0"
|
22
22
|
spec.add_development_dependency "pry", "~> 0.10.3"
|
23
|
-
spec.add_development_dependency "rake", "~>
|
23
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
24
24
|
spec.add_development_dependency "rspec", "~> 3.4.0"
|
25
25
|
spec.add_development_dependency "rspec_junit_formatter"
|
26
26
|
spec.add_development_dependency "rubocop", Potassium::RUBOCOP_VERSION
|
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
|
|
29
29
|
spec.add_runtime_dependency "gli", "~> 2.12.2"
|
30
30
|
spec.add_runtime_dependency "inquirer", "~> 0.2"
|
31
31
|
spec.add_runtime_dependency "levenshtein", "~> 0.2"
|
32
|
+
spec.add_runtime_dependency "octokit", "~> 4.18"
|
32
33
|
spec.add_runtime_dependency "rails", Potassium::RAILS_VERSION
|
33
34
|
spec.add_runtime_dependency "semantic", "~> 1.4"
|
34
35
|
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
RSpec.describe "Api" do
|
4
|
+
before :all do
|
5
|
+
drop_dummy_database
|
6
|
+
remove_project_directory
|
7
|
+
create_dummy_project("api" => :rest)
|
8
|
+
end
|
9
|
+
|
10
|
+
it "adds power_api related gems to Gemfile" do
|
11
|
+
gemfile_content = IO.read("#{project_path}/Gemfile")
|
12
|
+
expect(gemfile_content).to include("gem 'power_api'")
|
13
|
+
expect(gemfile_content).to include("gem 'rswag-specs'")
|
14
|
+
end
|
15
|
+
|
16
|
+
it "adds the power_api brief to README file" do
|
17
|
+
readme = IO.read("#{project_path}/README.md")
|
18
|
+
expect(readme).to include("Power API")
|
19
|
+
end
|
20
|
+
|
21
|
+
it "installs power_api" do
|
22
|
+
content = IO.read("#{project_path}/app/controllers/api/base_controller.rb")
|
23
|
+
expect(content).to include("Api::BaseController < PowerApi::BaseController")
|
24
|
+
end
|
25
|
+
end
|
@@ -1,4 +1,5 @@
|
|
1
1
|
require "spec_helper"
|
2
|
+
require 'yaml'
|
2
3
|
|
3
4
|
RSpec.describe "BackgroundProcessor" do
|
4
5
|
context "working with sidekiq" do
|
@@ -41,6 +42,9 @@ RSpec.describe "BackgroundProcessor" do
|
|
41
42
|
it "adds ENV vars" do
|
42
43
|
content = IO.read("#{project_path}/.env.development")
|
43
44
|
expect(content).to include("DB_POOL=25")
|
45
|
+
expect(content).to include('REDIS_HOST=127.0.0.1')
|
46
|
+
expect(content).to include('REDIS_PORT=$(make services-port SERVICE=redis PORT=6379)')
|
47
|
+
expect(content).to include('REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}/1')
|
44
48
|
end
|
45
49
|
|
46
50
|
it "adds sidekiq.rb file" do
|
@@ -55,12 +59,19 @@ RSpec.describe "BackgroundProcessor" do
|
|
55
59
|
|
56
60
|
it "adds redis.yml file" do
|
57
61
|
content = IO.read("#{project_path}/config/redis.yml")
|
58
|
-
expect(content).to include("
|
62
|
+
expect(content).to include("REDIS_URL")
|
59
63
|
end
|
60
64
|
|
61
65
|
it "mounts sidekiq app" do
|
62
66
|
content = IO.read("#{project_path}/config/routes.rb")
|
63
67
|
expect(content).to include("mount Sidekiq::Web => '/queue'")
|
64
68
|
end
|
69
|
+
|
70
|
+
it 'adds redis to docker-compose' do
|
71
|
+
compose_file = IO.read("#{project_path}/docker-compose.yml")
|
72
|
+
compose_content = YAML.safe_load(compose_file, symbolize_names: true)
|
73
|
+
|
74
|
+
expect(compose_content[:services]).to include(:redis)
|
75
|
+
end
|
65
76
|
end
|
66
77
|
end
|
@@ -4,7 +4,7 @@ RSpec.describe "Draper" do
|
|
4
4
|
before :all do
|
5
5
|
drop_dummy_database
|
6
6
|
remove_project_directory
|
7
|
-
create_dummy_project("draper" => true
|
7
|
+
create_dummy_project("draper" => true)
|
8
8
|
end
|
9
9
|
|
10
10
|
it "adds the Draper gem to Gemfile" do
|
@@ -17,11 +17,6 @@ RSpec.describe "Draper" do
|
|
17
17
|
expect(readme).to include("Draper")
|
18
18
|
end
|
19
19
|
|
20
|
-
it "adds api responder to work with draper" do
|
21
|
-
responder_content = IO.read("#{project_path}/app/responders/api_responder.rb")
|
22
|
-
expect(responder_content).to include("decorated_resource")
|
23
|
-
end
|
24
|
-
|
25
20
|
it "adds decorators directory" do
|
26
21
|
content = IO.read("#{project_path}/app/decorators/.keep")
|
27
22
|
expect(content).to be_empty
|
@@ -66,5 +66,10 @@ RSpec.describe "File Storage" do
|
|
66
66
|
content = IO.read("#{project_path}/.env.development")
|
67
67
|
expect(content).to include("S3_BUCKET=")
|
68
68
|
end
|
69
|
+
|
70
|
+
it "adds filestorage path to gitignore" do
|
71
|
+
content = IO.read("#{project_path}/.gitignore")
|
72
|
+
expect(content).to include("/public/uploads")
|
73
|
+
end
|
69
74
|
end
|
70
75
|
end
|
@@ -54,6 +54,20 @@ RSpec.describe "Front end" do
|
|
54
54
|
)
|
55
55
|
expect(tailwind_config_file).to include('module.exports')
|
56
56
|
end
|
57
|
+
|
58
|
+
context "with graphql" do
|
59
|
+
before(:all) do
|
60
|
+
remove_project_directory
|
61
|
+
create_dummy_project("front_end" => "vue", "api" => "graphql")
|
62
|
+
end
|
63
|
+
|
64
|
+
it "creates a vue project with apollo" do
|
65
|
+
expect(node_modules_file).to include("\"vue-apollo\"")
|
66
|
+
expect(application_js_file).to include("import { ApolloClient } from 'apollo-client';")
|
67
|
+
expect(application_js_file).to include("Vue.use(VueApollo)")
|
68
|
+
expect(application_js_file).to include("apolloProvider,")
|
69
|
+
end
|
70
|
+
end
|
57
71
|
end
|
58
72
|
|
59
73
|
context "with angular" do
|
@@ -1,22 +1,67 @@
|
|
1
1
|
require "spec_helper"
|
2
2
|
|
3
3
|
RSpec.describe "GitHub" do
|
4
|
+
let(:org_name) { "platanus" }
|
5
|
+
let(:repo_name) { PotassiumTestHelpers::APP_NAME.dasherize }
|
6
|
+
let(:access_token) { "1234" }
|
7
|
+
let(:pr_template_file) { IO.read("#{project_path}/.github/pull_request_template.md") }
|
8
|
+
|
4
9
|
before do
|
5
10
|
drop_dummy_database
|
6
11
|
remove_project_directory
|
7
12
|
end
|
8
13
|
|
9
|
-
it "
|
10
|
-
create_dummy_project(
|
11
|
-
|
14
|
+
it "creates the github repository" do
|
15
|
+
create_dummy_project(
|
16
|
+
"github" => true,
|
17
|
+
"github_private" => false,
|
18
|
+
"github_has_org" => false,
|
19
|
+
"github_name" => repo_name,
|
20
|
+
"github_access_token" => access_token
|
21
|
+
)
|
22
|
+
|
23
|
+
expect(FakeOctokit).to have_created_repo(repo_name)
|
24
|
+
expect(pr_template_file).to include('Contexto')
|
25
|
+
end
|
26
|
+
|
27
|
+
it "creates the private github repository" do
|
28
|
+
create_dummy_project(
|
29
|
+
"github" => true,
|
30
|
+
"github_private" => true,
|
31
|
+
"github_has_org" => false,
|
32
|
+
"github_name" => repo_name,
|
33
|
+
"github_access_token" => access_token
|
34
|
+
)
|
35
|
+
|
36
|
+
expect(FakeOctokit).to have_created_private_repo(repo_name)
|
37
|
+
expect(pr_template_file).to include('Contexto')
|
38
|
+
end
|
39
|
+
|
40
|
+
it "creates the github repository for the organization" do
|
41
|
+
create_dummy_project(
|
42
|
+
"github" => true,
|
43
|
+
"github_private" => false,
|
44
|
+
"github_has_org" => true,
|
45
|
+
"github_org" => org_name,
|
46
|
+
"github_name" => repo_name,
|
47
|
+
"github_access_token" => access_token
|
48
|
+
)
|
12
49
|
|
13
|
-
expect(
|
50
|
+
expect(FakeOctokit).to have_created_repo_for_org(repo_name, org_name)
|
51
|
+
expect(pr_template_file).to include('Contexto')
|
14
52
|
end
|
15
53
|
|
16
|
-
it "
|
17
|
-
create_dummy_project(
|
18
|
-
|
54
|
+
it "creates the private github repository for the organization" do
|
55
|
+
create_dummy_project(
|
56
|
+
"github" => true,
|
57
|
+
"github_private" => true,
|
58
|
+
"github_has_org" => true,
|
59
|
+
"github_org" => org_name,
|
60
|
+
"github_name" => repo_name,
|
61
|
+
"github_access_token" => access_token
|
62
|
+
)
|
19
63
|
|
20
|
-
expect(
|
64
|
+
expect(FakeOctokit).to have_created_private_repo_for_org(repo_name, org_name)
|
65
|
+
expect(pr_template_file).to include('Contexto')
|
21
66
|
end
|
22
67
|
end
|