practical 0.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.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +37 -0
  3. data/Rakefile +10 -0
  4. data/app/components/practical/views/base_component.rb +6 -0
  5. data/app/components/practical/views/button_component.rb +27 -0
  6. data/app/components/practical/views/datatable/filter_applied.rb +25 -0
  7. data/app/components/practical/views/datatable/filter_section_component.html.erb +9 -0
  8. data/app/components/practical/views/datatable/filter_section_component.rb +19 -0
  9. data/app/components/practical/views/datatable/sort_link_component.rb +48 -0
  10. data/app/components/practical/views/datatable.rb +36 -0
  11. data/app/components/practical/views/flash_messages_component.rb +65 -0
  12. data/app/components/practical/views/form/error_list_component.rb +15 -0
  13. data/app/components/practical/views/form/error_list_item_component.rb +20 -0
  14. data/app/components/practical/views/form/error_list_item_template_component.rb +9 -0
  15. data/app/components/practical/views/form/fallback_errors_section_component.html.erb +7 -0
  16. data/app/components/practical/views/form/fallback_errors_section_component.rb +21 -0
  17. data/app/components/practical/views/form/field_errors_component.rb +28 -0
  18. data/app/components/practical/views/form/field_title_component.rb +23 -0
  19. data/app/components/practical/views/form/fieldset_title_component.rb +20 -0
  20. data/app/components/practical/views/form/input_component.html.erb +7 -0
  21. data/app/components/practical/views/form/input_component.rb +22 -0
  22. data/app/components/practical/views/form/option_label_component.rb +21 -0
  23. data/app/components/practical/views/form/practical_editor_component.rb +26 -0
  24. data/app/components/practical/views/form/required_radio_collection_wrapper_component.rb +23 -0
  25. data/app/components/practical/views/form_wrapper.rb +21 -0
  26. data/app/components/practical/views/icon_component.rb +36 -0
  27. data/app/components/practical/views/icon_for_file_extension_component.rb +53 -0
  28. data/app/components/practical/views/modal_dialog_component.html.erb +10 -0
  29. data/app/components/practical/views/modal_dialog_component.rb +16 -0
  30. data/app/components/practical/views/navigation/breadcrumb_item_component.rb +20 -0
  31. data/app/components/practical/views/navigation/breadcrumbs_component.html.erb +31 -0
  32. data/app/components/practical/views/navigation/breadcrumbs_component.rb +41 -0
  33. data/app/components/practical/views/navigation/navigation_link_component.rb +39 -0
  34. data/app/components/practical/views/navigation/pagination/goto_form_component.html.erb +31 -0
  35. data/app/components/practical/views/navigation/pagination/goto_form_component.rb +34 -0
  36. data/app/components/practical/views/navigation/pagination_component.html.erb +11 -0
  37. data/app/components/practical/views/navigation/pagination_component.rb +98 -0
  38. data/app/components/practical/views/open_dialog_button_component.rb +16 -0
  39. data/app/components/practical/views/page_component.html.erb +53 -0
  40. data/app/components/practical/views/page_component.rb +12 -0
  41. data/app/components/practical/views/relative_time_component.rb +13 -0
  42. data/app/components/practical/views/tiptap_document_component.rb +311 -0
  43. data/app/components/practical/views/toast_component.html.erb +26 -0
  44. data/app/components/practical/views/toast_component.rb +19 -0
  45. data/app/controllers/concerns/practical/auth/passkeys/emergency_registrations.rb +57 -0
  46. data/app/controllers/concerns/practical/auth/passkeys/web_authn_debug_context.rb +13 -0
  47. data/app/controllers/concerns/practical/views/flash_helpers.rb +37 -0
  48. data/app/controllers/concerns/practical/views/json_redirection.rb +7 -0
  49. data/app/lib/practical/defaults/shrine.rb +48 -0
  50. data/app/lib/practical/test/helpers/administrator/test_helpers.rb +7 -0
  51. data/app/lib/practical/test/helpers/extra_assertions.rb +7 -0
  52. data/app/lib/practical/test/helpers/flash_assertions.rb +8 -0
  53. data/app/lib/practical/test/helpers/integration/assertions.rb +23 -0
  54. data/app/lib/practical/test/helpers/passkey/system/base.rb +52 -0
  55. data/app/lib/practical/test/helpers/passkey/system/rack_test.rb +45 -0
  56. data/app/lib/practical/test/helpers/passkey/system/selenium.rb +107 -0
  57. data/app/lib/practical/test/helpers/passkey/test_helper.rb +128 -0
  58. data/app/lib/practical/test/helpers/postmark.rb +11 -0
  59. data/app/lib/practical/test/helpers/relation_builder_assertions.rb +18 -0
  60. data/app/lib/practical/test/helpers/setup/debug.rb +8 -0
  61. data/app/lib/practical/test/helpers/setup/faker_seed_pinning.rb +8 -0
  62. data/app/lib/practical/test/helpers/setup/simplecov.rb +17 -0
  63. data/app/lib/practical/test/helpers/shrine/test_data.rb +101 -0
  64. data/app/lib/practical/test/helpers/spy_assertions.rb +7 -0
  65. data/app/lib/practical/test/helpers/system/assertions.rb +33 -0
  66. data/app/lib/practical/test/helpers/system/capybara_prep.rb +10 -0
  67. data/app/lib/practical/test/shared/auth/passkeys/controllers/emergency_registration/base.rb +372 -0
  68. data/app/lib/practical/test/shared/auth/passkeys/controllers/emergency_registration/self_service.rb +66 -0
  69. data/app/lib/practical/test/shared/auth/passkeys/controllers/reauthentication/base.rb +119 -0
  70. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/no_self_destroy.rb +13 -0
  71. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/no_self_signup.rb +22 -0
  72. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/self_destroy.rb +134 -0
  73. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/self_signup.rb +221 -0
  74. data/app/lib/practical/test/shared/auth/passkeys/controllers/registrations/update.rb +220 -0
  75. data/app/lib/practical/test/shared/auth/passkeys/controllers/sessions/base.rb +108 -0
  76. data/app/lib/practical/test/shared/auth/passkeys/forms/emergency_registration.rb +82 -0
  77. data/app/lib/practical/test/shared/auth/passkeys/models/emergency_registration/base.rb +89 -0
  78. data/app/lib/practical/test/shared/auth/passkeys/models/emergency_registration/use_for_and_notify.rb +48 -0
  79. data/app/lib/practical/test/shared/auth/passkeys/models/passkey.rb +101 -0
  80. data/app/lib/practical/test/shared/auth/passkeys/models/resource_with_passkeys.rb +57 -0
  81. data/app/lib/practical/test/shared/auth/passkeys/policies/passkey.rb +18 -0
  82. data/app/lib/practical/test/shared/auth/passkeys/services/send_emergency_registration.rb +41 -0
  83. data/app/lib/practical/test/shared/models/normalized_email.rb +23 -0
  84. data/app/lib/practical/test/shared/models/user.rb +27 -0
  85. data/app/lib/practical/test/shared/models/utility/ip_address.rb +42 -0
  86. data/app/lib/practical/test/shared/models/utility/user_agent.rb +43 -0
  87. data/app/lib/practical/views/button/styling.rb +23 -0
  88. data/app/lib/practical/views/error_handling.rb +33 -0
  89. data/app/lib/practical/views/form_builders/base.rb +152 -0
  90. data/app/lib/practical/views/icon_set.rb +156 -0
  91. data/app/lib/practical/views/web_awesome/style_utility/appearance_variant.rb +19 -0
  92. data/app/lib/practical/views/web_awesome/style_utility/base.rb +21 -0
  93. data/app/lib/practical/views/web_awesome/style_utility/color_variant.rb +17 -0
  94. data/app/lib/practical/views/web_awesome/style_utility/size.rb +31 -0
  95. data/config/locales/auth.en.yml +38 -0
  96. data/config/locales/devise.passkeys.en.yml +18 -0
  97. data/config/locales/practical_framework.en.yml +9 -0
  98. data/config/routes.rb +4 -0
  99. data/db/seeds/setup.rb +13 -0
  100. data/db/seeds/users/default.rb +34 -0
  101. data/lib/generators/practical/test/helper/USAGE +8 -0
  102. data/lib/generators/practical/test/helper/helper_generator.rb +9 -0
  103. data/lib/generators/practical/test/helper/templates/helper.rb.tt +4 -0
  104. data/lib/generators/practical/test/shared_test/USAGE +9 -0
  105. data/lib/generators/practical/test/shared_test/shared_test_generator.rb +7 -0
  106. data/lib/generators/practical/test/shared_test/templates/shared_test.rb.tt +9 -0
  107. data/lib/generators/practical/views/component/USAGE +9 -0
  108. data/lib/generators/practical/views/component/component_generator.rb +20 -0
  109. data/lib/practical/framework/engine.rb +35 -0
  110. data/lib/practical/helpers/form_with_helper.rb +10 -0
  111. data/lib/practical/helpers/icon_helper.rb +18 -0
  112. data/lib/practical/helpers/text_helper.rb +20 -0
  113. data/lib/practical/helpers/translation_helper.rb +25 -0
  114. data/lib/practical/version.rb +5 -0
  115. data/lib/practical/views/element_helper.rb +48 -0
  116. data/lib/practical.rb +21 -0
  117. data/lib/tasks/practical/coverage.rake +19 -0
  118. data/lib/tasks/practical/framework_tasks.rake +6 -0
  119. metadata +303 -0
@@ -0,0 +1,10 @@
1
+ <%= tag.dialog(**finalized_options) do %>
2
+ <header>
3
+ <h2><%= header %></h2>
4
+ <form method="dialog">
5
+ <button class="wa-plain"><%= render icon_set.dialog_close_icon %></button>
6
+ </form>
7
+ </header>
8
+
9
+ <%= content %>
10
+ <% end %>
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::ModalDialogComponent < Practical::Views::BaseComponent
4
+ renders_one :header
5
+ attr_accessor :id, :open, :options
6
+
7
+ def initialize(id:, open: false, options: {})
8
+ self.open = open
9
+ self.id = id
10
+ self.options = options
11
+ end
12
+
13
+ def finalized_options
14
+ mix({id: id, open: open, data: {ensure_modal: true}, class: 'wa-dialog-stack-patch'}, options)
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::Navigation::BreadcrumbItemComponent < Practical::Views::BaseComponent
4
+ renders_one :prefix
5
+ attr_accessor :options
6
+
7
+ def initialize(options: {})
8
+ self.options = options
9
+ end
10
+
11
+
12
+ def call
13
+ tag.wa_breadcrumb_item(**mix({}, options)) {
14
+ safe_join([
15
+ (prefix if prefix?),
16
+ content
17
+ ])
18
+ }
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ <%= tag.wa_breadcrumb(**finalized_options) do %>
2
+
3
+ <% if truncate_middle? %>
4
+ <% truncate_start.each do |crumb| %>
5
+ <%= build_crumb(crumb: crumb) %>
6
+ <% end %>
7
+
8
+ <wa-breadcrumb-item>
9
+ <wa-dropdown>
10
+ <wa-button slot="trigger" size="small" appearance="filled" pill>
11
+ <wa-icon label="More options" name="ellipsis" variant="solid"></wa-icon>
12
+ </wa-button>
13
+ <nav class="wa-stack wa-gap-xs dropdown-navigation">
14
+ <ul class="navigation-list">
15
+ <% truncated_items.each do |crumb| %>
16
+ <li><%= tag.a(crumb.name, href: (crumb.current? ? nil : crumb.url)) %></li>
17
+ <% end %>
18
+ </ul>
19
+ </nav>
20
+ </wa-dropdown>
21
+ </wa-breadcrumb-item>
22
+
23
+ <% truncate_end.each do |crumb| %>
24
+ <%= build_crumb(crumb: crumb) %>
25
+ <% end %>
26
+ <% else %>
27
+ <% breadcrumb_trail.each do |crumb| %>
28
+ <%= build_crumb(crumb: crumb) %>
29
+ <% end %>
30
+ <% end %>
31
+ <% end %>
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::Navigation::BreadcrumbsComponent < Practical::Views::BaseComponent
4
+ attr_accessor :breadcrumb_trail
5
+ attr_accessor :options
6
+
7
+ def initialize(breadcrumb_trail:, options: {})
8
+ self.breadcrumb_trail = breadcrumb_trail
9
+ self.options = options
10
+ end
11
+
12
+ def finalized_options
13
+ mix({}, options)
14
+ end
15
+
16
+ def crumbs_for_truncation
17
+ @crumbs_for_truncation ||= breadcrumb_trail.to_a
18
+ end
19
+
20
+ def truncate_middle?
21
+ crumbs_for_truncation.size > 5
22
+ end
23
+
24
+ def truncate_start
25
+ crumbs_for_truncation.first(1)
26
+ end
27
+
28
+ def truncate_end
29
+ crumbs_for_truncation.last(2)
30
+ end
31
+
32
+ def truncated_items
33
+ crumbs_for_truncation - truncate_start - truncate_end
34
+ end
35
+
36
+ def build_crumb(crumb:)
37
+ render(Practical::Views::Navigation::BreadcrumbItemComponent.new(options: { href: crumb.current? ? nil : crumb.url })) {
38
+ crumb.name
39
+ }
40
+ end
41
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::Navigation::NavigationLinkComponent < ApplicationComponent
4
+ attr_accessor :href, :options, :selected
5
+
6
+ renders_one :icon
7
+
8
+ def initialize(href:, selected:, options: {})
9
+ self.href = href
10
+ self.options = options
11
+ self.selected = selected
12
+ end
13
+
14
+ def flank_class
15
+ return "wa-flank wa-gap-2xs" if icon?
16
+ end
17
+
18
+ def selected?
19
+ return true if selected
20
+ return nil
21
+ end
22
+
23
+ def finalized_options
24
+ mix({
25
+ href: href,
26
+ class: helpers.class_names(flank_class, "navigation-link", "wa-accent"),
27
+ data: {selected: selected?}
28
+ }, options)
29
+ end
30
+
31
+ def call
32
+ tag.a(**finalized_options) {
33
+ safe_join([
34
+ (icon if icon?),
35
+ tag.span{ content }
36
+ ])
37
+ }
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ <%= render Practical::Views::OpenDialogButtonComponent.new(dialog_id: dialog_id, appearance: "filled outlined", size: :small) do %>
2
+ <%= pagy_t('pagy.nav.gap').html_safe %>
3
+ <% end %>
4
+
5
+ <%= render Practical::Views::ModalDialogComponent.new(id: dialog_id) do |component| %>
6
+
7
+ <% component.with_header do %>
8
+ <%= pagy_t("pagy.nav.goto_page_form.legend") %>
9
+ <% end %>
10
+ <section class="wa-stack">
11
+ <p><%= page_detail_text %></p>
12
+ <%= helpers.webawesome_form_with(
13
+ url: uri_parts.uri.to_s,
14
+ method: :get,
15
+ local: true,
16
+ builder: NewApplicationFormBuilder,
17
+ class: 'pagination-goto-form wa-size-s'
18
+ ) do |f| %>
19
+ <% uri_parts.params.each do |key, value| %>
20
+ <%= hidden_field_for_goto_form(key: key, value: value) %>
21
+ <% end %>
22
+
23
+ <section class="wa-cluster">
24
+ <%= f.number_field :page, value: pagy.page, placeholder: pagy.page.to_s, required: true, min: 1, max: pagy.last %>
25
+ <%= f.button_component(type: :submit, color_variant: :neutral, appearance: :filled) do
26
+ pagy_t("pagy.nav.goto_page_form.button")
27
+ end %>
28
+ </section>
29
+ <% end %>
30
+ </section>
31
+ <% end %>
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::Navigation::Pagination::GotoFormComponent < Practical::Views::BaseComponent
4
+ include Pagy::Frontend
5
+ attr_accessor :pagy, :dialog_id, :page_detail_text
6
+
7
+ URIParts = Data.define(:uri, :params)
8
+
9
+ def initialize(pagy:, dialog_id:, page_detail_text:)
10
+ self.pagy = pagy
11
+ self.dialog_id = dialog_id
12
+ self.page_detail_text = page_detail_text
13
+ end
14
+
15
+ def uri_parts
16
+ uri = URI.parse(pagy_url_for(pagy, nil))
17
+ params = Rack::Utils.parse_query(uri.query)
18
+ params.delete("page")
19
+ uri.query = ""
20
+
21
+ URIParts.new(uri: uri, params: params)
22
+ end
23
+
24
+ def hidden_field_for_goto_form(key:, value:)
25
+ case value
26
+ when Array
27
+ value.each do |x|
28
+ helpers.hidden_field_tag key, x
29
+ end
30
+ else
31
+ helpers.hidden_field_tag key, value
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,11 @@
1
+ <nav class="wa-stack pagination wa-size-s wa-gap-0">
2
+ <p><%= page_detail_text %></p>
3
+
4
+ <section class="wa-cluster">
5
+ <%= previous_item %>
6
+ <% pagy.series.each do |item| %>
7
+ <%= page_item(item) %>
8
+ <% end %>
9
+ <%= next_item %>
10
+ </section>
11
+ </nav>
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::Navigation::PaginationComponent < ApplicationComponent
4
+ include Pagy::Frontend
5
+ attr_reader :request
6
+ attr_accessor :pagy, :item_name, :i18n_key
7
+
8
+ def initialize(pagy:, request:, item_name: nil, i18n_key: nil)
9
+ @pagy = pagy
10
+ @request = request
11
+ @item_name = item_name
12
+ @i18n_key = i18n_key
13
+ end
14
+
15
+ def page_detail_text
16
+ pagy_count = pagy.count
17
+ if pagy_count == 0
18
+ key = "pagy.info.no_items"
19
+ elsif pagy.pages == 1
20
+ key = "pagy.info.single_page"
21
+ else
22
+ key = "pagy.info.multiple_pages"
23
+ end
24
+
25
+ item_name = item_name.presence || pagy_t(i18n_key || pagy.vars[:i18n_key], count: pagy_count)
26
+
27
+ item_text = pagy_t(key,
28
+ item_name: item_name,
29
+ count: pagy_count, from: pagy.from, to: pagy.to
30
+ )
31
+
32
+ page_count_text = pagy_t("pagy.info.page_count", page: pagy.page, count: pagy.pages)
33
+
34
+ return pagy_t("pagy.info.page_detail_text", item_text: item_text, page_count_text: page_count_text)
35
+ end
36
+
37
+ def previous_item
38
+ classes = helpers.class_names(:page, :previous, disabled: !pagy.prev)
39
+
40
+ text = icon_text(
41
+ icon: icon_set.previous_arrow,
42
+ text: pagy_t('pagy.nav.v2_prev')
43
+ )
44
+
45
+ tag.div(class: classes, role: :listitem){
46
+ if pagy.prev
47
+ tag.a(href: pagy_url_for(pagy, pagy.prev), title: pagy_t("pagy.nav.prev_page_title")) {
48
+ text
49
+ }
50
+ else
51
+ text
52
+ end
53
+ }
54
+ end
55
+
56
+ def next_item
57
+ classes = helpers.class_names(:page, :next, disabled: !pagy.next)
58
+
59
+ text = icon_text(
60
+ icon: icon_set.next_arrow,
61
+ text: pagy_t('pagy.nav.v2_next')
62
+ )
63
+
64
+ tag.div(class: classes, role: :listitem){
65
+ if pagy.next
66
+ tag.a(href: pagy_url_for(pagy, pagy.next), title: pagy_t("pagy.nav.next_page_title")) {
67
+ text
68
+ }
69
+ else
70
+ text
71
+ end
72
+ }
73
+ end
74
+
75
+ def goto_page_dialog_id
76
+ return [item_name, "pagy-goto-form"].compact.join("-")
77
+ end
78
+
79
+ def page_item(item)
80
+ case item
81
+ when Integer
82
+ tag.div(class: :page, role: :listitem) {
83
+ tag.a(item, href: pagy_url_for(pagy, item), title: pagy_t("pagy.nav.page_title", page_number: item))
84
+ }
85
+ when String
86
+ tag.div(
87
+ item,
88
+ class: "page current", role: :listitem, title: pagy_t("pagy.nav.current_page_title", page_number: item)
89
+ )
90
+ when :gap
91
+ render Practical::Views::Navigation::Pagination::GotoFormComponent.new(
92
+ pagy: pagy,
93
+ dialog_id: goto_page_dialog_id,
94
+ page_detail_text: page_detail_text
95
+ )
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::OpenDialogButtonComponent < Practical::Views::ButtonComponent
4
+ attr_accessor :dialog_id
5
+
6
+ def initialize(dialog_id:, appearance: nil, color_variant: nil, size: nil, options: {})
7
+ options = options.with_defaults(
8
+ onclick: self.class.inline_js_to_open_dialog(dialog_id: dialog_id)
9
+ )
10
+ super(type: :button, appearance: appearance, color_variant: color_variant, size: size, options: options)
11
+ end
12
+
13
+ def self.inline_js_to_open_dialog(dialog_id:)
14
+ return "document.getElementById(`#{dialog_id}`).showModal()"
15
+ end
16
+ end
@@ -0,0 +1,53 @@
1
+ <wa-page>
2
+ <% if banner? %>
3
+ <section slot="banner">
4
+ <%= banner %>
5
+ </section>
6
+ <% end %>
7
+
8
+ <% if header? %>
9
+ <header slot="header">
10
+ <%= header %>
11
+ </header>
12
+ <% end %>
13
+
14
+ <% if subheader? %>
15
+ <section slot="subheader">
16
+ <%= subheader %>
17
+ </section>
18
+ <% end %>
19
+
20
+ <% if navigation_header? %>
21
+ <nav slot="navigation-header">
22
+ <%= navigation_header %>
23
+ </nav>
24
+ <% end %>
25
+
26
+ <% if navigation? %>
27
+ <nav slot="navigation">
28
+ <%= navigation %>
29
+ </nav>
30
+ <% end %>
31
+
32
+ <% if navigation_footer? %>
33
+ <nav slot="navigation-footer">
34
+ <%= navigation_footer %>
35
+ </nav>
36
+ <% end %>
37
+
38
+ <% if main_header? %>
39
+ <aside slot="main-header">
40
+ <%= main_header %>
41
+ </aside>
42
+ <% end %>
43
+
44
+ <main class="wa-body-l">
45
+ <%= content %>
46
+ </main>
47
+
48
+ <% if footer? %>
49
+ <footer slot="footer">
50
+ <%= footer %>
51
+ </footer>
52
+ <% end %>
53
+ </wa-page>
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::PageComponent < Practical::Views::BaseComponent
4
+ renders_one :banner
5
+ renders_one :header
6
+ renders_one :subheader
7
+ renders_one :footer
8
+ renders_one :main_header
9
+ renders_one :navigation
10
+ renders_one :navigation_header
11
+ renders_one :navigation_footer
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Practical::Views::RelativeTimeComponent < ApplicationComponent
4
+ attr_accessor :time
5
+
6
+ def initialize(time:)
7
+ @time = time
8
+ end
9
+
10
+ def call
11
+ tag.wa_relative_time(helpers.time_ago_in_words(time), date: time.iso8601)
12
+ end
13
+ end