diff --git a/.circleci/config.yml b/.circleci/config.yml index ee91513d09a7..f100fb92f1fc 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -880,7 +880,7 @@ workflows: compose_file: docker-compose-production-benelux.yml stack_name: cl2-prd-bnlx-stack env_file: ".env-production-benelux" - cluster_name: "prd" + cluster_name: "eu" - back-deploy-to-swarm: name: Deploy to Canada requires: diff --git a/Makefile b/Makefile index 53811f386ba5..caf8fa85a2ed 100644 --- a/Makefile +++ b/Makefile @@ -109,6 +109,23 @@ e2e-ci-env-run-test: cd e2e && \ docker-compose run --rm --name cypress_run front npm run cypress:run -- --config baseUrl=http://e2e.front:3000 --spec ${spec} +e2e-ci-env-db-dump: + cd e2e && \ + docker compose exec postgres pg_dumpall -c -U postgres > dump.sql + +e2e-ci-env-db-restore: + cd e2e && \ + docker compose exec postgres psql -U postgres -d cl2_back_development -c "SELECT 1" 1> /dev/null && \ + docker compose exec postgres psql -U postgres -d cl2_back_development -c "DROP SCHEMA IF EXISTS e2e_front,public CASCADE" 1> /dev/null 2> /dev/null && \ + docker compose exec postgres psql -U postgres -d cl2_back_development -c "CREATE SCHEMA public" && \ + cat dump.sql | docker compose exec -T postgres psql --quiet -U postgres 1> /dev/null 2> /dev/null + +e2e-ci-env-reproduce-flaky-test: + for i in $(shell seq 1 10); do \ + make e2e-ci-env-db-restore && \ + make e2e-ci-env-run-test spec="${spec}"; \ + done + # ================= # CircleCI # ================= diff --git a/back/app/services/cl2_data_listing_service.rb b/back/app/services/cl2_data_listing_service.rb index da9873a7650e..78609ae9eddf 100644 --- a/back/app/services/cl2_data_listing_service.rb +++ b/back/app/services/cl2_data_listing_service.rb @@ -25,13 +25,14 @@ def cl2_schema_models views = ActiveRecord::Base.connection.execute( "SELECT table_name FROM information_schema.tables WHERE table_type = 'VIEW'" ).pluck('table_name') - ActiveRecord::Base.descendants.select do |claz| - [ - *ActiveRecord::Base.subclasses.map(&:name), - Tenant.name - ].exclude? claz.name - end.select do |claz| - views.exclude? claz.table_name + subclasses = [ + *ActiveRecord::Base.subclasses.map(&:name), + Tenant.name + ] + ActiveRecord::Base.descendants.reject do |claz| + subclasses.include?(claz.name) || + views.include?(claz.table_name) || + claz.abstract_class end end diff --git a/back/db/migrate/20231109101517_add_memberships_count_to_groups.rb b/back/db/migrate/20231109101517_add_memberships_count_to_groups.rb new file mode 100644 index 000000000000..996efdb4a6ff --- /dev/null +++ b/back/db/migrate/20231109101517_add_memberships_count_to_groups.rb @@ -0,0 +1,5 @@ +class AddMembershipsCountToGroups < ActiveRecord::Migration[7.0] + def change + add_column :groups, :memberships_count, :integer, null: false, default: 0, if_not_exists: true + end +end diff --git a/back/db/structure.sql b/back/db/structure.sql index 48df6ede34e8..0429d2d8cb38 100644 --- a/back/db/structure.sql +++ b/back/db/structure.sql @@ -7960,6 +7960,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20230915391649'), ('20230927135924'), ('20231003095622'), -('20231018083110'); +('20231018083110'), +('20231109101517'); diff --git a/back/engines/commercial/id_criipto/app/lib/id_criipto/criipto_verification.rb b/back/engines/commercial/id_criipto/app/lib/id_criipto/criipto_verification.rb index eb251069817d..205ad3d814fe 100644 --- a/back/engines/commercial/id_criipto/app/lib/id_criipto/criipto_verification.rb +++ b/back/engines/commercial/id_criipto/app/lib/id_criipto/criipto_verification.rb @@ -69,7 +69,7 @@ def exposed_config_parameters def profile_to_uid(auth) case config[:identity_source] when DK_MIT_ID - auth['uuid'] + auth['uid'] else raise "Unsupported identity source #{config[:identity_source]}" end diff --git a/back/engines/commercial/id_criipto/spec/requests/criipto_verification_spec.rb b/back/engines/commercial/id_criipto/spec/requests/criipto_verification_spec.rb index 121775414339..b5d01d4a1a3f 100644 --- a/back/engines/commercial/id_criipto/spec/requests/criipto_verification_spec.rb +++ b/back/engines/commercial/id_criipto/spec/requests/criipto_verification_spec.rb @@ -7,80 +7,84 @@ let(:user) { create(:user, first_name: 'Rudolphi', last_name: 'Raindeari') } let(:token) { AuthToken::AuthToken.new(payload: user.to_token_payload).token } + let(:auth_hash) do + { + 'provider' => 'criipto', + 'uid' => '{29d14ea0-6e16-4732-86ac-5de87a941784}', + 'info' => + { 'name' => 'Bulenga Poulsen', + 'email' => nil, + 'email_verified' => nil, + 'nickname' => nil, + 'first_name' => nil, + 'last_name' => nil, + 'gender' => nil, + 'image' => nil, + 'phone' => nil, + 'urls' => { 'website' => nil } }, + 'credentials' => + { 'id_token' => + # rubocop:disable Layout/LineLength + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjhCREY3OUEzRkY5OTdFQTg1QjYyRjk1OUQzRDdCMzdFRDAyMjhFOTAifQ.eyJpc3MiOiJodHRwczovL2tvYmVuaGF2bi10ZXN0LmNyaWlwdG8uaWQiLCJhdWQiOiJ1cm46bXk6YXBwbGljYXRpb246aWRlbnRpZmllcjo0MDc3OTMiLCJub25jZSI6ImJmNTgxMWRmMGNiZjM5Mjc1NGNhMjUyYTI5YzBjYzM2IiwiaWRlbnRpdHlzY2hlbWUiOiJka21pdGlkIiwiYXV0aGVudGljYXRpb250eXBlIjoidXJuOmdybjphdXRobjpkazptaXRpZDpzdWJzdGFudGlhbCIsImF1dGhlbnRpY2F0aW9ubWV0aG9kIjoiYXBwOjE2OTI2MjE4ODg5NTY6U1VCU1RBTlRJQUw6U1VCU1RBTlRJQUw6SElHSDpISUdIIiwiYXV0aGVudGljYXRpb25pbnN0YW50IjoiMjAyMy0wOC0yMVQxMjo0NTowMS43MzNaIiwibmFtZWlkZW50aWZpZXIiOiIyOWQxNGVhMDZlMTY0NzMyODZhYzVkZTg3YTk0MTc4NCIsInN1YiI6InsyOWQxNGVhMC02ZTE2LTQ3MzItODZhYy01ZGU4N2E5NDE3ODR9Iiwic2Vzc2lvbmluZGV4IjoiNTMxNjkwY2UtOTc5Mi00OTQ5LThhMTEtZjNmNWE0YzUwNGI1IiwibG9BIjoiU1VCU1RBTlRJQUwiLCJpYWwiOiJTVUJTVEFOVElBTCIsImFhbCI6IlNVQlNUQU5USUFMIiwiZmFsIjoiSElHSCIsInV1aWQiOiI0MTBhNzdlYy00Zjg1LTQ2ZTQtYWFlZi1iZGJiZDFhOTUxZjIiLCJjcHJOdW1iZXJJZGVudGlmaWVyIjoiMzExMjc3Mjg0NiIsImJpcnRoZGF0ZSI6IjE5NzctMTItMzEiLCJkYXRlb2ZiaXJ0aCI6IjE5NzctMTItMzEiLCJhZ2UiOiI0NSIsIm5hbWUiOiJCdWxlbmdhIFBvdWxzZW4iLCJyZWZUZXh0SGVhZGVyIjoiTG9nIG9uIGF0IENyaWlwdG8iLCJyZWZUZXh0Qm9keSI6ImxvY2FsIGRldmVsb3BtZW50IHRlc3QgKEtvZW4pIiwiY291bnRyeSI6IkRLIiwiaWF0IjoxNjkyNjIxOTAyLCJuYmYiOjE2OTI2MjE5MDIsImV4cCI6MTY5MjYzOTg4OH0.1dMJe80vvEFt4EFIF2kd_Tdy5UPEEw3qGjjVuNYHhw1Oonxpjtpjm1t-Q8YiMUZ_zwsjtnZF8hoJ8PlNV_Q5f4PS0rRk7XOeYbCvwHqAUVyFdlQudXsKi7FatqsDBfBcxqNkR4Wi1kWCpGQGtPc3X2yjtBkZP7xvvOAzdOlWjL9VuI7s2LXk-TH_7SorEqKnEAIOFVD6wYLGJ0vbU-EAG3b1lAmGsPQPRNqbgrIic1ll4DEurKs76X_-Jcq4dZiRx-X2gMJ4lefU4aaBKkIyUiYdNSRtgZSN_V6J68ZzcU2UO-_PlQX8vgE7z0vRdM1wmJQIdXpQDL4PRmjpvKl_tg', + # rubocop:enable Layout/LineLength + 'token' => 'bb7cb707-f405-43af-9f7e-b151846fd92b', + 'refresh_token' => nil, + 'expires_in' => '120', + 'scope' => nil }, + 'extra' => + { 'raw_info' => + { 'nonce' => 'bf5811df0cbf392754ca252a29c0cc36', + 'identityscheme' => 'dkmitid', + 'authenticationtype' => 'urn:grn:authn:dk:mitid:substantial', + 'authenticationmethod' => + 'app:1692621888956:SUBSTANTIAL:SUBSTANTIAL:HIGH:HIGH', + 'authenticationinstant' => '2023-08-21T12:45:01.733Z', + 'nameidentifier' => '29d14ea06e16473286ac5de87a941784', + 'sub' => '{29d14ea0-6e16-4732-86ac-5de87a941784}', + 'sessionindex' => '531690ce-9792-4949-8a11-f3f5a4c504b5', + 'loA' => 'SUBSTANTIAL', + 'ial' => 'SUBSTANTIAL', + 'aal' => 'SUBSTANTIAL', + 'fal' => 'HIGH', + 'uuid' => '410a77ec-4f85-46e4-aaef-bdbbd1a951f2', + 'cprNumberIdentifier' => '3112772846', + 'birthdate' => '1977-12-31', + 'dateofbirth' => '1977-12-31', + 'age' => '45', + 'name' => 'Bulenga Poulsen', + 'refTextHeader' => 'Log on at Criipto', + 'refTextBody' => 'local development test (Koen)', + 'country' => 'DK', + 'iss' => 'https://kobenhavn-test.criipto.id', + 'aud' => 'urn:my:application:identifier:407793', + 'iat' => 1_692_621_902, + 'nbf' => 1_692_621_902, + 'exp' => 1_692_639_888, + address: { + formatted: "Paiman Petersen\nGrusgraven 1,3 tv\n3400 Hillerød\n(Lokalitet ukendt)\nDanmark", + common_name: 'Paiman Petersen', + street_address: 'Grusgraven 1,3 tv', + postal_code: '3400', + city: 'Hillerød', + locality: '(Lokalitet ukendt)', + region: nil, + country: 'Danmark' + }, + address_details: { + road: 'Grusgraven', + road_code: '1732', + municipality: 'Lyngby-Taarbæk', + municipality_code: '0173', + house_number: '001', + floor: '03', + apartment_code: ' tv' + } } } + } + end + before do OmniAuth.config.test_mode = true - OmniAuth.config.mock_auth[:criipto] = OmniAuth::AuthHash.new( - { 'provider' => 'criipto', - 'uid' => '{29d14ea0-6e16-4732-86ac-5de87a941784}', - 'info' => - { 'name' => 'Bulenga Poulsen', - 'email' => nil, - 'email_verified' => nil, - 'nickname' => nil, - 'first_name' => nil, - 'last_name' => nil, - 'gender' => nil, - 'image' => nil, - 'phone' => nil, - 'urls' => { 'website' => nil } }, - 'credentials' => - { 'id_token' => - # rubocop:disable Layout/LineLength - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjhCREY3OUEzRkY5OTdFQTg1QjYyRjk1OUQzRDdCMzdFRDAyMjhFOTAifQ.eyJpc3MiOiJodHRwczovL2tvYmVuaGF2bi10ZXN0LmNyaWlwdG8uaWQiLCJhdWQiOiJ1cm46bXk6YXBwbGljYXRpb246aWRlbnRpZmllcjo0MDc3OTMiLCJub25jZSI6ImJmNTgxMWRmMGNiZjM5Mjc1NGNhMjUyYTI5YzBjYzM2IiwiaWRlbnRpdHlzY2hlbWUiOiJka21pdGlkIiwiYXV0aGVudGljYXRpb250eXBlIjoidXJuOmdybjphdXRobjpkazptaXRpZDpzdWJzdGFudGlhbCIsImF1dGhlbnRpY2F0aW9ubWV0aG9kIjoiYXBwOjE2OTI2MjE4ODg5NTY6U1VCU1RBTlRJQUw6U1VCU1RBTlRJQUw6SElHSDpISUdIIiwiYXV0aGVudGljYXRpb25pbnN0YW50IjoiMjAyMy0wOC0yMVQxMjo0NTowMS43MzNaIiwibmFtZWlkZW50aWZpZXIiOiIyOWQxNGVhMDZlMTY0NzMyODZhYzVkZTg3YTk0MTc4NCIsInN1YiI6InsyOWQxNGVhMC02ZTE2LTQ3MzItODZhYy01ZGU4N2E5NDE3ODR9Iiwic2Vzc2lvbmluZGV4IjoiNTMxNjkwY2UtOTc5Mi00OTQ5LThhMTEtZjNmNWE0YzUwNGI1IiwibG9BIjoiU1VCU1RBTlRJQUwiLCJpYWwiOiJTVUJTVEFOVElBTCIsImFhbCI6IlNVQlNUQU5USUFMIiwiZmFsIjoiSElHSCIsInV1aWQiOiI0MTBhNzdlYy00Zjg1LTQ2ZTQtYWFlZi1iZGJiZDFhOTUxZjIiLCJjcHJOdW1iZXJJZGVudGlmaWVyIjoiMzExMjc3Mjg0NiIsImJpcnRoZGF0ZSI6IjE5NzctMTItMzEiLCJkYXRlb2ZiaXJ0aCI6IjE5NzctMTItMzEiLCJhZ2UiOiI0NSIsIm5hbWUiOiJCdWxlbmdhIFBvdWxzZW4iLCJyZWZUZXh0SGVhZGVyIjoiTG9nIG9uIGF0IENyaWlwdG8iLCJyZWZUZXh0Qm9keSI6ImxvY2FsIGRldmVsb3BtZW50IHRlc3QgKEtvZW4pIiwiY291bnRyeSI6IkRLIiwiaWF0IjoxNjkyNjIxOTAyLCJuYmYiOjE2OTI2MjE5MDIsImV4cCI6MTY5MjYzOTg4OH0.1dMJe80vvEFt4EFIF2kd_Tdy5UPEEw3qGjjVuNYHhw1Oonxpjtpjm1t-Q8YiMUZ_zwsjtnZF8hoJ8PlNV_Q5f4PS0rRk7XOeYbCvwHqAUVyFdlQudXsKi7FatqsDBfBcxqNkR4Wi1kWCpGQGtPc3X2yjtBkZP7xvvOAzdOlWjL9VuI7s2LXk-TH_7SorEqKnEAIOFVD6wYLGJ0vbU-EAG3b1lAmGsPQPRNqbgrIic1ll4DEurKs76X_-Jcq4dZiRx-X2gMJ4lefU4aaBKkIyUiYdNSRtgZSN_V6J68ZzcU2UO-_PlQX8vgE7z0vRdM1wmJQIdXpQDL4PRmjpvKl_tg', - # rubocop:enable Layout/LineLength - 'token' => 'bb7cb707-f405-43af-9f7e-b151846fd92b', - 'refresh_token' => nil, - 'expires_in' => '120', - 'scope' => nil }, - 'extra' => - { 'raw_info' => - { 'nonce' => 'bf5811df0cbf392754ca252a29c0cc36', - 'identityscheme' => 'dkmitid', - 'authenticationtype' => 'urn:grn:authn:dk:mitid:substantial', - 'authenticationmethod' => - 'app:1692621888956:SUBSTANTIAL:SUBSTANTIAL:HIGH:HIGH', - 'authenticationinstant' => '2023-08-21T12:45:01.733Z', - 'nameidentifier' => '29d14ea06e16473286ac5de87a941784', - 'sub' => '{29d14ea0-6e16-4732-86ac-5de87a941784}', - 'sessionindex' => '531690ce-9792-4949-8a11-f3f5a4c504b5', - 'loA' => 'SUBSTANTIAL', - 'ial' => 'SUBSTANTIAL', - 'aal' => 'SUBSTANTIAL', - 'fal' => 'HIGH', - 'uuid' => '410a77ec-4f85-46e4-aaef-bdbbd1a951f2', - 'cprNumberIdentifier' => '3112772846', - 'birthdate' => '1977-12-31', - 'dateofbirth' => '1977-12-31', - 'age' => '45', - 'name' => 'Bulenga Poulsen', - 'refTextHeader' => 'Log on at Criipto', - 'refTextBody' => 'local development test (Koen)', - 'country' => 'DK', - 'iss' => 'https://kobenhavn-test.criipto.id', - 'aud' => 'urn:my:application:identifier:407793', - 'iat' => 1_692_621_902, - 'nbf' => 1_692_621_902, - 'exp' => 1_692_639_888, - address: { - formatted: "Paiman Petersen\nGrusgraven 1,3 tv\n3400 Hillerød\n(Lokalitet ukendt)\nDanmark", - common_name: 'Paiman Petersen', - street_address: 'Grusgraven 1,3 tv', - postal_code: '3400', - city: 'Hillerød', - locality: '(Lokalitet ukendt)', - region: nil, - country: 'Danmark' - }, - address_details: { - road: 'Grusgraven', - road_code: '1732', - municipality: 'Lyngby-Taarbæk', - municipality_code: '0173', - house_number: '001', - floor: '03', - apartment_code: ' tv' - } } } } - ) + OmniAuth.config.mock_auth[:criipto] = OmniAuth::AuthHash.new(auth_hash) configuration = AppConfiguration.instance settings = configuration.settings @@ -111,17 +115,48 @@ expect(response).to redirect_to('/en/yipie?random-passthrough-param=somevalue&verification_success=true') - expect(user.reload).to have_attributes({ - verified: true - }) + expect(user.reload).to have_attributes(verified: true) expect(user.custom_field_values['municipality_code']).to eq '0173' expect(user.custom_field_values['birthyear']).to eq 1977 expect(user.custom_field_values['birthdate']).to eq '1977-12-31' expect(user.verifications.first).to have_attributes({ method_name: 'criipto', user_id: user.id, - active: true, - hashed_uid: '203fb09eaa8e93ee8439b92c4ce8a4e47ab820c1b87bd7d9772376cbd1e63529' + active: true }) + hash_value = Verification::VerificationService.new.send(:hashed_uid, '{29d14ea0-6e16-4732-86ac-5de87a941784}', 'criipto') + expect(user.verifications.first.hashed_uid).to eq(hash_value) + expect(user.verifications.first.hashed_uid).to eq('d006d4bf453dcd6abf792b0a18f330796a715bdf19315c2c1db8714371bcb025') + end + + it 'successfully verifies another user with another MitID account' do + get "/auth/criipto?token=#{token}" + follow_redirect! + expect(user.reload).to have_attributes({ + verified: true + }) + + user2 = create(:user) + token2 = AuthToken::AuthToken.new(payload: user2.to_token_payload).token + auth_hash['uid'] = '12345' + OmniAuth.config.mock_auth[:criipto] = OmniAuth::AuthHash.new(auth_hash) + + get "/auth/criipto?token=#{token2}" + follow_redirect! + expect(user2.reload).to have_attributes(verified: true) + end + + it 'fails when uid has already been used' do + uid = '{29d14ea0-6e16-4732-86ac-5de87a941784}' + create( + :verification, + method_name: 'criipto', + hashed_uid: Verification::VerificationService.new.send(:hashed_uid, uid, 'criipto') + ) + + get "/auth/criipto?token=#{token}" + follow_redirect! + + expect(user.reload).to have_attributes(verified: false) end end diff --git a/back/engines/commercial/multi_tenancy/app/models/tenant.rb b/back/engines/commercial/multi_tenancy/app/models/tenant.rb index 63f9317ff4f6..173b7491dbc0 100644 --- a/back/engines/commercial/multi_tenancy/app/models/tenant.rb +++ b/back/engines/commercial/multi_tenancy/app/models/tenant.rb @@ -51,18 +51,6 @@ class Tenant < ApplicationRecord where(id: ids) } - # Order by most important tenants (active) first - scope :prioritized, lambda { - priority_order = %w[active trial demo expired_trial churned not_applicable] - tenants = AppConfiguration.from_tenants(self).map do |config| - { id: config[:id], lifecycle_stage: config[:settings]['core']['lifecycle_stage'] } - end - ordered_tenants = tenants.sort_by { |tenant| priority_order.index(tenant[:lifecycle_stage]) } - - ordered_ids = ordered_tenants.pluck(:id) - sort_by { |tenant| ordered_ids.index(tenant[:id]) } - } - delegate :active?, :churned?, to: :configuration class << self @@ -73,6 +61,17 @@ def schema_name_to_host(schema_name) def host_to_schema_name(host) host&.tr('.', '_') end + + # Reorder tenants by most important tenants (active) first + def prioritize(tenants) + priority_order = %w[active trial demo expired_trial churned not_applicable] + tenant_lifecycles = AppConfiguration.from_tenants(tenants).map do |config| + { id: config[:id], lifecycle_stage: config[:settings]['core']['lifecycle_stage'] } + end + ordered_tenants = tenant_lifecycles.sort_by { |tenant| priority_order.index(tenant[:lifecycle_stage]) } + ordered_ids = ordered_tenants.pluck(:id) + tenants.sort_by { |tenant| ordered_ids.index(tenant[:id]) } + end end def self.current diff --git a/back/engines/commercial/multi_tenancy/db/seeds/tenants.rb b/back/engines/commercial/multi_tenancy/db/seeds/tenants.rb index fd5f0768082d..acdf60d1e974 100644 --- a/back/engines/commercial/multi_tenancy/db/seeds/tenants.rb +++ b/back/engines/commercial/multi_tenancy/db/seeds/tenants.rb @@ -369,6 +369,14 @@ def create_localhost_tenant environment: 'pre_production_integration', issuer: ENV.fetch('DEFAULT_NEMLOG_IN_ISSUER', 'fake issuer'), private_key: ENV.fetch('DEFAULT_NEMLOG_IN_PRIVATE_KEY', 'fake key') + }, + { + name: 'criipto', + domain: 'cl-test.criipto.id', + client_id: ENV.fetch('DEFAULT_CRIIPTO_CLIENT_ID', 'fake id'), + client_secret: ENV.fetch('DEFAULT_CRIIPTO_CLIENT_SECRET', 'fake secret'), + identity_source: 'DK MitID', + method_name_multiloc: { en: 'MitID (Criipto)' } } ] }, @@ -427,7 +435,7 @@ def create_localhost_tenant allowed: true }, posthog_integration: { - enabled: true, + enabled: false, allowed: true }, user_blocking: { diff --git a/back/engines/commercial/multi_tenancy/lib/tasks/core/setup_and_support.rake b/back/engines/commercial/multi_tenancy/lib/tasks/core/setup_and_support.rake index 1b181bda85e4..e05f7998a70b 100644 --- a/back/engines/commercial/multi_tenancy/lib/tasks/core/setup_and_support.rake +++ b/back/engines/commercial/multi_tenancy/lib/tasks/core/setup_and_support.rake @@ -1,6 +1,6 @@ # frozen_string_literal: true -namespace :setup_and_support do +namespace :setup_and_support do # rubocop:disable Metrics/BlockLength desc 'Mass official feedback' task :mass_official_feedback, %i[url host locale] => [:environment] do |_t, args| # ID, Feedback, Feedback Author Name, Feedback Email, New Status @@ -139,13 +139,15 @@ namespace :setup_and_support do Apartment::Tenant.switch(args[:host].tr('.', '_')) do translator = MachineTranslations::MachineTranslationService.new data_listing = Cl2DataListingService.new - data_listing.cl2_schema_leaf_models.each do |claz| - claz.find_each do |object| + data_listing.cl2_schema_models.each do |claz| + puts "Processing class #{claz.name}" + claz.all.each do |object| changes = {} data_listing.multiloc_attributes(claz).each do |ml| value = object.send ml next unless value.present? && value[args[:locale_from]].present? && value[args[:locale_to]].blank? + puts "Translating #{object.class.name} #{object.id}" changes[ml] = value.clone changes[ml][args[:locale_to]] = translator.translate value[args[:locale_from]], args[:locale_from], args[:locale_to], @@ -154,6 +156,7 @@ namespace :setup_and_support do object.update_columns changes if changes.present? end end + puts 'Successfully processed everything' end end diff --git a/back/engines/commercial/multi_tenancy/spec/models/tenant_spec.rb b/back/engines/commercial/multi_tenancy/spec/models/tenant_spec.rb index 275db50753f1..a6ac92a3303e 100644 --- a/back/engines/commercial/multi_tenancy/spec/models/tenant_spec.rb +++ b/back/engines/commercial/multi_tenancy/spec/models/tenant_spec.rb @@ -68,7 +68,7 @@ specify { expect(described_class.churned.count).to eq(1) } end - describe 'prioritized scope' do + describe 'tenants prioritized by lifecycle importance' do let!(:churned_tenant) { create(:tenant, lifecycle: 'churned') } let!(:expired_trial_tenant) { create(:tenant, lifecycle: 'expired_trial') } let!(:demo_tenant) { create(:tenant, lifecycle: 'demo') } @@ -76,7 +76,7 @@ let!(:trial_tenant) { create(:tenant, lifecycle: 'trial') } it 'returns tenants prioritized by lifecycle' do - prioritized = described_class.prioritized + prioritized = described_class.prioritize(described_class.all) expect(prioritized.count).to eq(6) expect(prioritized.map { |t| t[:settings]['core']['lifecycle_stage'] }).to eq %w[active active trial demo expired_trial churned] end diff --git a/cl2-component-library/jest.config.js b/cl2-component-library/jest.config.js index cafbcce9d1c7..982cbd6591d5 100644 --- a/cl2-component-library/jest.config.js +++ b/cl2-component-library/jest.config.js @@ -26,5 +26,6 @@ module.exports = { '\\.(css)$': 'identity-obj-proxy', '\\.(jpg|jpeg|png|gif|svg)$': '/src/utils/testUtils/fileMock.js', '^react-scroll-to-component$': 'identity-obj-proxy', + '^lodash-es$': 'lodash', }, }; diff --git a/cl2-component-library/package-lock.json b/cl2-component-library/package-lock.json index f66077526527..2cb6fa5815ef 100644 --- a/cl2-component-library/package-lock.json +++ b/cl2-component-library/package-lock.json @@ -11,12 +11,11 @@ "dependencies": { "@tippyjs/react": "4.2.6", "focus-visible": "5.2.0", - "lodash": "4.17.21", "lodash-es": "4.17.21", - "polished": "^4.2.2", + "polished": "4.2.2", "react-color": "2.19.3", "react-transition-group": "4.4.5", - "styled-components": "^5.3.9", + "styled-components": "5.3.9", "tippy.js": "6.3.7" }, "devDependencies": { @@ -66,6 +65,7 @@ "jest-environment-jsdom": "^29.6.4", "jest-junit": "^16.0.0", "jest-styled-components": "^7.1.1", + "lodash": "^4.17.21", "moment": "2.29.4", "postcss": "^8.4.31", "prettier": "^3.0.3", diff --git a/cl2-component-library/package.json b/cl2-component-library/package.json index ac880ea139a6..207661f54bc6 100644 --- a/cl2-component-library/package.json +++ b/cl2-component-library/package.json @@ -82,6 +82,7 @@ "jest-environment-jsdom": "^29.6.4", "jest-junit": "^16.0.0", "jest-styled-components": "^7.1.1", + "lodash": "4.17.21", "moment": "2.29.4", "postcss": "^8.4.31", "prettier": "^3.0.3", @@ -104,12 +105,11 @@ "dependencies": { "@tippyjs/react": "4.2.6", "focus-visible": "5.2.0", - "lodash": "4.17.21", "lodash-es": "4.17.21", - "polished": "^4.2.2", + "polished": "4.2.2", "react-color": "2.19.3", "react-transition-group": "4.4.5", - "styled-components": "^5.3.9", + "styled-components": "5.3.9", "tippy.js": "6.3.7" } } diff --git a/cl2-component-library/src/components/Button/index.tsx b/cl2-component-library/src/components/Button/index.tsx index 60f335d86785..7f0254b8421a 100644 --- a/cl2-component-library/src/components/Button/index.tsx +++ b/cl2-component-library/src/components/Button/index.tsx @@ -1,5 +1,5 @@ import React, { PureComponent, MouseEvent, ButtonHTMLAttributes } from 'react'; -import { isNil, get } from 'lodash'; +import { isNil, get } from 'lodash-es'; import styled from 'styled-components'; import { darken, lighten, transparentize, opacify, rgba } from 'polished'; import { @@ -8,7 +8,7 @@ import { fontSizes, defaultStyles, isRtl, - MainThemeProps + MainThemeProps, } from '../../utils/styleUtils'; import Spinner from '../Spinner'; import Icon, { IconProps } from '../Icon'; @@ -87,7 +87,9 @@ function getPadding(props: ButtonContainerProps & { theme: MainThemeProps }) { } } -function getLineHeight(props: ButtonContainerProps & { theme: MainThemeProps }) { +function getLineHeight( + props: ButtonContainerProps & { theme: MainThemeProps } +) { if (props.lineHeight) { return props.lineHeight; } else { @@ -100,7 +102,9 @@ function getLineHeight(props: ButtonContainerProps & { theme: MainThemeProps }) } } -function getButtonStyle(props: ButtonContainerProps & { theme: MainThemeProps }) { +function getButtonStyle( + props: ButtonContainerProps & { theme: MainThemeProps } +) { const defaultStyleValues: DefaultStyleValues = { primary: { bgColor: get(props.theme.colors, 'tenantPrimary'), diff --git a/cl2-component-library/src/components/Input/index.tsx b/cl2-component-library/src/components/Input/index.tsx index 48ed3ab5afc7..2b1463ec9b9a 100644 --- a/cl2-component-library/src/components/Input/index.tsx +++ b/cl2-component-library/src/components/Input/index.tsx @@ -1,5 +1,5 @@ import React, { PureComponent, FormEvent } from 'react'; -import { isNil, isEmpty, size as lodashSize, isBoolean } from 'lodash'; +import { isNil, isEmpty, size as lodashSize, isBoolean } from 'lodash-es'; // components import Error from '../Error'; diff --git a/cl2-component-library/src/components/LocaleSwitcher/index.tsx b/cl2-component-library/src/components/LocaleSwitcher/index.tsx index 6f9260b1080d..32a746f4bfc1 100644 --- a/cl2-component-library/src/components/LocaleSwitcher/index.tsx +++ b/cl2-component-library/src/components/LocaleSwitcher/index.tsx @@ -1,5 +1,5 @@ import React, { PureComponent, MouseEvent } from 'react'; -import { isEmpty, get } from 'lodash'; +import { isEmpty, get } from 'lodash-es'; import styled from 'styled-components'; import { rgba } from 'polished'; import { colors, fontSizes, isRtl } from '../../utils/styleUtils'; @@ -21,8 +21,7 @@ const StyledButton = styled.button` white-space: nowrap; padding: 7px 8px; margin-right: 6px; - border-radius: ${(props) => - props.theme.borderRadius}; + border-radius: ${(props) => props.theme.borderRadius}; background: ${colors.grey200}; cursor: pointer; transition: all 80ms ease-out; @@ -64,13 +63,19 @@ const Dot = styled.div` } `; -const isSingleMultilocObjectFilled = (locale: Locale, values?: MultilocFormValues) => { +const isSingleMultilocObjectFilled = ( + locale: Locale, + values?: MultilocFormValues +) => { return Object.getOwnPropertyNames(values).every( (key) => !isEmpty(get(values, `[${key}][${locale}]`)) ); -} +}; -export const isValueForLocaleFilled = (locale: Locale, values?: MultilocFormValues | MultilocFormValues[]) => { +export const isValueForLocaleFilled = ( + locale: Locale, + values?: MultilocFormValues | MultilocFormValues[] +) => { if (Array.isArray(values)) { return values.every((value) => isSingleMultilocObjectFilled(locale, value)); } @@ -87,7 +92,6 @@ interface Props { } class LocaleSwitcher extends PureComponent { - removeFocus = (event: MouseEvent) => { event.preventDefault(); }; diff --git a/cl2-component-library/src/components/Radio/index.tsx b/cl2-component-library/src/components/Radio/index.tsx index 446f6417acf1..5eaa147470f2 100644 --- a/cl2-component-library/src/components/Radio/index.tsx +++ b/cl2-component-library/src/components/Radio/index.tsx @@ -1,6 +1,6 @@ import React, { FormEvent, useState } from 'react'; import styled from 'styled-components'; -import { get } from 'lodash'; +import { get } from 'lodash-es'; import { hideVisually } from 'polished'; import { fontSizes, diff --git a/cl2-component-library/src/components/SearchInput/index.tsx b/cl2-component-library/src/components/SearchInput/index.tsx index bbdbd7451897..d3d822f17a05 100644 --- a/cl2-component-library/src/components/SearchInput/index.tsx +++ b/cl2-component-library/src/components/SearchInput/index.tsx @@ -1,5 +1,11 @@ -import React, { useState, useCallback, MouseEvent, useMemo, KeyboardEvent } from 'react'; -import { isEmpty } from 'lodash'; +import React, { + useState, + useCallback, + MouseEvent, + useMemo, + KeyboardEvent, +} from 'react'; +import { isEmpty } from 'lodash-es'; import debounceFn from 'lodash/debounce'; // components @@ -72,18 +78,27 @@ const SearchInput = ({ size, setInputRef, }: Props) => { - const [internalSearchTerm, setInternalSearchTerm] = useState(defaultValue ?? null); + const [internalSearchTerm, setInternalSearchTerm] = useState( + defaultValue ?? null + ); - const debouncedOnChange = useMemo(() => debounceFn((value: string | null) => { - onChange(value); - }, debounce), [onChange, debounce]); + const debouncedOnChange = useMemo( + () => + debounceFn((value: string | null) => { + onChange(value); + }, debounce), + [onChange, debounce] + ); - const handleOnChange = useCallback((value: string) => { - const newValue = !isEmpty(value) ? value : null; + const handleOnChange = useCallback( + (value: string) => { + const newValue = !isEmpty(value) ? value : null; - setInternalSearchTerm(newValue); - debouncedOnChange(newValue); - }, [debouncedOnChange]) + setInternalSearchTerm(newValue); + debouncedOnChange(newValue); + }, + [debouncedOnChange] + ); const handleOnReset = (event?: MouseEvent | KeyboardEvent) => { event?.preventDefault(); diff --git a/cl2-component-library/src/components/Select/index.tsx b/cl2-component-library/src/components/Select/index.tsx index 14ed8815bec5..30406507db3e 100644 --- a/cl2-component-library/src/components/Select/index.tsx +++ b/cl2-component-library/src/components/Select/index.tsx @@ -1,5 +1,5 @@ import React, { PureComponent, FocusEvent, ChangeEvent } from 'react'; -import { isString, get, isNumber } from 'lodash'; +import { isString, get, isNumber } from 'lodash-es'; import Icon from '../Icon'; import Label from '../Label'; import IconTooltip from '../IconTooltip'; diff --git a/cl2-component-library/src/utils/styleUtils.ts b/cl2-component-library/src/utils/styleUtils.ts index d51391a7e27d..ac06aef974d4 100644 --- a/cl2-component-library/src/utils/styleUtils.ts +++ b/cl2-component-library/src/utils/styleUtils.ts @@ -1,7 +1,7 @@ import 'focus-visible'; import { css } from 'styled-components'; import { darken, transparentize } from 'polished'; -import { get, isNil } from 'lodash'; +import { get, isNil } from 'lodash-es'; import { isNilOrError } from './helperUtils'; import { InputSize } from './typings'; @@ -13,7 +13,7 @@ export const isRtl = (style: any, ...args: any[]) => css` export const viewportWidths = { phone: 769, tablet: 1200, - smallDesktop: 1366 + smallDesktop: 1366, }; export const media = { @@ -120,7 +120,6 @@ export const semanticColors = { facebookMessenger: '#0084ff', }; - export const colors = { ...semanticColors, ...themeColors, @@ -252,16 +251,16 @@ export const stylingConsts = { }; type StylingConstsType = { - menuHeight: number, - mobileMenuHeight: number, - mobileTopBarHeight: number, - footerHeight: number, - maxPageWidth: number, - bannerWidth: number, - pageWidth: number, - textWidth: number, - borderRadius: string, -} + menuHeight: number; + mobileMenuHeight: number; + mobileTopBarHeight: number; + footerHeight: number; + maxPageWidth: number; + bannerWidth: number; + pageWidth: number; + textWidth: number; + borderRadius: string; +}; // Reusable text styling export function quillEditedContent( @@ -429,7 +428,7 @@ export interface MainThemeProps extends StylingConstsType { fontFamily: string; fontSizes: { [key in FontSizesType]: string; - } + }; signedOutHeaderOverlayOpacity: number; signedInHeaderOverlayOpacity: number; isRtl: boolean; diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index f01c44fa2de5..1450db10dcc7 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -17,7 +17,7 @@ services: build: context: .. dockerfile: back/Dockerfile - # To easily test locally. For unknow reason it doesn't work on CI + # To easily test locally. Volumes are not supported on CircleCI # volumes: # - "../back:/cl2_back" env_file: @@ -38,7 +38,7 @@ services: build: context: .. dockerfile: front/Dockerfile - # To easily test locally. For unknow reason it doesn't work on CI + # To easily test locally. Volumes are not supported on CircleCI # volumes: # - "../front:/front" ports: diff --git a/front/.gitignore b/front/.gitignore index c3358831b11a..b4a554692419 100644 --- a/front/.gitignore +++ b/front/.gitignore @@ -36,3 +36,6 @@ storybook-static/* .vscode/* cypress.env + +# Component library src copy for unit tests +internals/jest/cl2-component-library/* \ No newline at end of file diff --git a/front/.storybook/contexts/index.tsx b/front/.storybook/contexts/index.tsx index c6668d477d60..ffdaa53a15c2 100644 --- a/front/.storybook/contexts/index.tsx +++ b/front/.storybook/contexts/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '../../app/utils/cl-react-query/queryClient'; import { OutletsContext } from '../../app/containers/OutletsProvider'; @@ -14,14 +14,25 @@ const Portals = () => ( ); +// Reset cache when changing to a new story. +// Otherwise mock server overrides don't work +const ResetCacheContext = ({ children }) => { + useEffect(() => { + queryClient.invalidateQueries(); + }, [window.location.search]); + return (<>{children}); +} + export default (Story) => { return ( - - - - + + + + + + ); diff --git a/front/app/api/app_configuration/__mocks__/_mockServer.ts b/front/app/api/app_configuration/__mocks__/_mockServer.ts index 93fd36ae56bc..1da6f93e4505 100644 --- a/front/app/api/app_configuration/__mocks__/_mockServer.ts +++ b/front/app/api/app_configuration/__mocks__/_mockServer.ts @@ -58,6 +58,10 @@ export const getAppConfigurationData = ( tenant_site_id: '13', product_site_id: '14', }, + follow: { + allowed: true, + enabled: true, + }, }, logo: { small: 'http://zah.cy/wof.jpg', diff --git a/front/app/api/comment_reactions/keys.ts b/front/app/api/comment_reactions/keys.ts index 1dd363fa3516..e54760ff709e 100644 --- a/front/app/api/comment_reactions/keys.ts +++ b/front/app/api/comment_reactions/keys.ts @@ -1,5 +1,8 @@ import { QueryKeys } from 'utils/cl-react-query/types'; +const itemKey = { + type: 'reaction', +}; const baseKey = { type: 'reaction', variant: 'comment', @@ -9,7 +12,7 @@ const commentReactionsKeys = { all: () => [baseKey], items: () => [{ ...baseKey, operation: 'item' }], item: ({ id }: { id?: string }) => [ - { ...baseKey, operation: 'item', parameters: { id } }, + { ...itemKey, operation: 'item', parameters: { id } }, ], } satisfies QueryKeys; diff --git a/front/app/api/event_files/keys.ts b/front/app/api/event_files/keys.ts index b806f29bb2e7..6fc332330361 100644 --- a/front/app/api/event_files/keys.ts +++ b/front/app/api/event_files/keys.ts @@ -1,6 +1,7 @@ import { QueryKeys } from 'utils/cl-react-query/types'; import { IDeleteEventFileProperties } from './types'; +const itemKey = { type: 'file' }; const baseKey = { type: 'file', variant: 'event' }; const eventFilesKeys = { @@ -12,7 +13,7 @@ const eventFilesKeys = { items: () => [{ ...baseKey, operation: 'item' }], item: (properties: IDeleteEventFileProperties) => [ { - ...baseKey, + ...itemKey, operation: 'item', parameters: { id: properties.eventId }, }, diff --git a/front/app/api/event_images/keys.ts b/front/app/api/event_images/keys.ts index 0db9410d16f2..325231a5d772 100644 --- a/front/app/api/event_images/keys.ts +++ b/front/app/api/event_images/keys.ts @@ -1,5 +1,6 @@ import { QueryKeys } from 'utils/cl-react-query/types'; +const itemKey = { type: 'image' }; const baseKey = { type: 'image', variant: 'event' }; const eventImagesKeys = { @@ -11,7 +12,7 @@ const eventImagesKeys = { items: () => [{ ...baseKey, operation: 'item' }], item: ({ id }: { id?: string }) => [ { - ...baseKey, + ...itemKey, operation: 'item', parameters: { id }, }, diff --git a/front/app/api/home_page/types.ts b/front/app/api/home_page/types.ts index f6ce52f93103..9ebb576b14d1 100644 --- a/front/app/api/home_page/types.ts +++ b/front/app/api/home_page/types.ts @@ -60,6 +60,10 @@ export interface IHomepageSettingsAttributes extends IHomepageEnabledSettings { banner_cta_signed_out_text_multiloc: Multiloc; banner_cta_signed_out_type: CTASignedOutType; banner_cta_signed_out_url: string | null; + + banner_signed_in_header_overlay_color?: string | null; + // Number between 0 and 100, inclusive + banner_signed_in_header_overlay_opacity?: number | null; // content builder craftjs_json?: Record; } diff --git a/front/app/api/idea_images/keys.ts b/front/app/api/idea_images/keys.ts index 97de554c41a2..847f0c0b9f02 100644 --- a/front/app/api/idea_images/keys.ts +++ b/front/app/api/idea_images/keys.ts @@ -1,5 +1,6 @@ import { QueryKeys } from 'utils/cl-react-query/types'; +const itemKey = { type: 'image' }; const baseKey = { type: 'image', variant: 'idea' }; const ideaImagesKeys = { @@ -9,11 +10,11 @@ const ideaImagesKeys = { { ...baseKey, operation: 'list', parameters: { ideaId } }, ], items: () => [{ ...baseKey, operation: 'item' }], - item: ({ imageId }: { imageId?: string }) => [ + item: ({ id }: { id?: string }) => [ { - ...baseKey, + ...itemKey, operation: 'item', - parameters: { id: imageId }, + parameters: { id }, }, ], } satisfies QueryKeys; diff --git a/front/app/api/idea_images/useIdeaImage.ts b/front/app/api/idea_images/useIdeaImage.ts index 993ee32d7326..29dde668b4f1 100644 --- a/front/app/api/idea_images/useIdeaImage.ts +++ b/front/app/api/idea_images/useIdeaImage.ts @@ -18,7 +18,7 @@ const fetchIdeaImage = ({ const useIdeaImage = (ideaId: string, imageId?: string) => { return useQuery({ - queryKey: ideaImagesKeys.item({ imageId }), + queryKey: ideaImagesKeys.item({ id: imageId }), queryFn: () => fetchIdeaImage({ ideaId, imageId }), enabled: !!imageId, }); diff --git a/front/app/api/idea_json_form_schema/keys.ts b/front/app/api/idea_json_form_schema/keys.ts index e91441e01302..0d56968a56e7 100644 --- a/front/app/api/idea_json_form_schema/keys.ts +++ b/front/app/api/idea_json_form_schema/keys.ts @@ -1,13 +1,14 @@ import { QueryKeys } from 'utils/cl-react-query/types'; import { IParameters } from './types'; +const itemKey = { type: 'json_forms_schema' }; const baseKey = { type: 'json_forms_schema', variant: 'idea' }; const ideaJsonFormsSchemaKeys = { all: () => [baseKey], items: () => [{ ...baseKey, operation: 'item' }], item: (parameters: IParameters) => [ - { ...baseKey, operation: 'item', parameters }, + { ...itemKey, operation: 'item', parameters }, ], } satisfies QueryKeys; diff --git a/front/app/api/idea_reactions/keys.ts b/front/app/api/idea_reactions/keys.ts index 780e414c6ae1..41a82e313e58 100644 --- a/front/app/api/idea_reactions/keys.ts +++ b/front/app/api/idea_reactions/keys.ts @@ -1,15 +1,13 @@ import { QueryKeys } from 'utils/cl-react-query/types'; -const baseKey = { - type: 'reaction', - variant: 'idea', -}; +const itemKey = { type: 'reaction' }; +const baseKey = { type: 'reaction', variant: 'idea' }; const ideaReactionsKeys = { all: () => [baseKey], items: () => [{ ...baseKey, operation: 'item' }], item: ({ id }: { id?: string }) => [ - { ...baseKey, operation: 'item', parameters: { id } }, + { ...itemKey, operation: 'item', parameters: { id } }, ], } satisfies QueryKeys; diff --git a/front/app/api/ideas/__mocks__/_mockServer.ts b/front/app/api/ideas/__mocks__/_mockServer.ts index 8df02e674cbf..7dd2414bad08 100644 --- a/front/app/api/ideas/__mocks__/_mockServer.ts +++ b/front/app/api/ideas/__mocks__/_mockServer.ts @@ -217,6 +217,123 @@ export const ideaData: IIdeaData[] = [ }, ]; +const votingIdea = { + data: { + id: 'aadd62ad-646c-4351-bafd-3e0f72e68499', + type: 'idea', + attributes: { + title_multiloc: { + en: 'Option #1 Voting', + }, + body_multiloc: { + en: '\u003cp\u003eOption #1 VotingOption #1 VotingOption #1 VotingOption #1 VotingOption #1 VotingOption #1 VotingOption #1 VotingOption #1 VotingOption #1 VotingOption #1 VotingOption #1 Voting\u003c/p\u003e', + }, + slug: 'option-1-voting', + publication_status: 'published', + likes_count: 0, + dislikes_count: 0, + comments_count: 0, + internal_comments_count: 0, + official_feedbacks_count: 0, + followers_count: 1, + location_point_geojson: null, + location_description: null, + created_at: '2023-08-11T15:03:47.581Z', + updated_at: '2023-08-11T15:03:47.581Z', + published_at: '2023-08-11T15:03:47.581Z', + budget: null, + proposed_budget: null, + baskets_count: 1, + votes_count: 1, + anonymous: false, + author_hash: '5d78eb7936e_d99f2f3b7518aeddde3d45d78eb7936ed99f2f3b75', + author_name: 'Citizenlab Hermansen', + action_descriptor: { + commenting_idea: { + enabled: true, + disabled_reason: null, + future_enabled: null, + }, + reacting_idea: { + enabled: false, + disabled_reason: 'not_ideation', + cancelling_enabled: false, + up: { + enabled: false, + disabled_reason: 'not_ideation', + future_enabled: null, + }, + down: { + enabled: false, + disabled_reason: 'not_ideation', + future_enabled: null, + }, + }, + comment_reacting_idea: { + enabled: true, + disabled_reason: null, + future_enabled: null, + }, + voting: { + enabled: true, + disabled_reason: null, + future_enabled: null, + }, + }, + }, + relationships: { + topics: { + data: [], + }, + idea_images: { + data: [], + }, + phases: { + data: [], + }, + ideas_phases: { + data: [], + }, + author: { + data: { + id: '3d78a40f-91a6-4e80-835e-bca778704c9f', + type: 'user', + }, + }, + project: { + data: { + id: '276fc6c4-5780-45ff-90a0-e6655b67bc23', + type: 'project', + }, + }, + idea_status: { + data: { + id: 'fcea429f-f61d-49b3-9985-83f6d3fcd769', + type: 'idea_status', + }, + }, + user_reaction: { + data: null, + }, + user_follower: { + data: { + id: 'd849bb47-b7d1-493e-959c-8e2fd287e9f4', + type: 'follower', + }, + }, + assignee: { + data: { + id: '1b359a67-8bad-42dc-b9f5-7d5e3eb537b6', + type: 'user', + }, + }, + idea_import: { + data: null, + }, + }, + }, +}; + export const apiPathById = '/web_api/v1/ideas/:ideaId'; export const apiPathBySlug = '/web_api/v1/ideas/by_slug/:slug'; @@ -229,4 +346,8 @@ const endpoints = { }), }; +export const votingIdeaHandler = rest.get(apiPathById, (_req, res, ctx) => { + return res(ctx.status(200), ctx.json(votingIdea)); +}); + export default endpoints; diff --git a/front/app/api/ideas/types.ts b/front/app/api/ideas/types.ts index 1c04d68c7823..c62e8fec88c7 100644 --- a/front/app/api/ideas/types.ts +++ b/front/app/api/ideas/types.ts @@ -18,7 +18,7 @@ export type IdeaReactingDisabledReason = | 'project_inactive' | 'not_ideation' | 'reacting_disabled' - | 'disliking_disabled' + | 'reacting_dislike_disabled' | 'reacting_like_limited_max_reached' | 'reacting_dislike_limited_max_reached' | 'idea_not_in_current_phase' diff --git a/front/app/api/ideas/useIdeas.test.ts b/front/app/api/ideas/useIdeas.test.ts index 0751a8f9d535..577f4050a6d0 100644 --- a/front/app/api/ideas/useIdeas.test.ts +++ b/front/app/api/ideas/useIdeas.test.ts @@ -60,7 +60,7 @@ export const data: IIdeaData[] = [ }, down: { enabled: false, - disabled_reason: 'disliking_disabled', + disabled_reason: 'reacting_dislike_disabled', future_enabled: null, }, }, diff --git a/front/app/api/ideas_filter_counts/keys.ts b/front/app/api/ideas_filter_counts/keys.ts index f773ab5fbaf1..23ebcd3bb8eb 100644 --- a/front/app/api/ideas_filter_counts/keys.ts +++ b/front/app/api/ideas_filter_counts/keys.ts @@ -1,6 +1,7 @@ import { QueryKeys } from 'utils/cl-react-query/types'; import { IIdeasFilterCountsQueryParameters } from './types'; +const itemKey = { type: 'filter_counts' }; const baseKey = { type: 'filter_counts', variant: 'idea', @@ -10,7 +11,7 @@ const ideaFilterCountsKeys = { all: () => [baseKey], items: () => [{ ...baseKey, operation: 'item' }], item: (parameters: IIdeasFilterCountsQueryParameters) => [ - { ...baseKey, operation: 'item', parameters }, + { ...itemKey, operation: 'item', parameters }, ], } satisfies QueryKeys; diff --git a/front/app/api/initiative_allowed_transitions/keys.ts b/front/app/api/initiative_allowed_transitions/keys.ts index ef969a276129..1b5a3fe99188 100644 --- a/front/app/api/initiative_allowed_transitions/keys.ts +++ b/front/app/api/initiative_allowed_transitions/keys.ts @@ -1,12 +1,13 @@ import { QueryKeys } from 'utils/cl-react-query/types'; +const itemKey = { type: 'allowed_transitions' }; const baseKey = { type: 'allowed_transitions', variant: 'initiative' }; const initiativeAllowedTransitionsKeys = { all: () => [baseKey], items: () => [{ ...baseKey, operation: 'item' }], item: ({ id }: { id: string }) => [ - { ...baseKey, operation: 'item', parameters: { id } }, + { ...itemKey, operation: 'item', parameters: { id } }, ], } satisfies QueryKeys; diff --git a/front/app/api/initiative_images/keys.ts b/front/app/api/initiative_images/keys.ts index 4799ef671033..95f430b06092 100644 --- a/front/app/api/initiative_images/keys.ts +++ b/front/app/api/initiative_images/keys.ts @@ -1,5 +1,6 @@ import { QueryKeys } from 'utils/cl-react-query/types'; +const itemKey = { type: 'image' }; const baseKey = { type: 'image', variant: 'initiative' }; const initiativeImagesKeys = { @@ -11,7 +12,7 @@ const initiativeImagesKeys = { items: () => [{ ...baseKey, operation: 'item' }], item: ({ imageId }: { imageId?: string }) => [ { - ...baseKey, + ...itemKey, operation: 'item', parameters: { id: imageId }, }, diff --git a/front/app/api/initiatives_filter_counts/keys.ts b/front/app/api/initiatives_filter_counts/keys.ts index 384a325681e8..e739d5c7f699 100644 --- a/front/app/api/initiatives_filter_counts/keys.ts +++ b/front/app/api/initiatives_filter_counts/keys.ts @@ -1,6 +1,7 @@ import { QueryKeys } from 'utils/cl-react-query/types'; import { IQueryParameters } from './types'; +const itemKey = { type: 'filter_counts' }; const baseKey = { type: 'filter_counts', variant: 'initiative', @@ -10,7 +11,7 @@ const initiativeFilterCountsKeys = { all: () => [baseKey], items: () => [{ ...baseKey, operation: 'item' }], item: (parameters: IQueryParameters) => [ - { ...baseKey, operation: 'item', parameters }, + { ...itemKey, operation: 'item', parameters }, ], } satisfies QueryKeys; diff --git a/front/app/api/phase_permissions/usePhasesPermissions.ts b/front/app/api/phase_permissions/usePhasesPermissions.ts deleted file mode 100644 index ee5c98cf9cc0..000000000000 --- a/front/app/api/phase_permissions/usePhasesPermissions.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { UseQueryOptions, useQueries } from '@tanstack/react-query'; -import fetcher from 'utils/cl-react-query/fetcher'; -import phasePermissionKeys from './keys'; -import { IPCPermissions } from './types'; - -type PhasesPermissionsReturnType = UseQueryOptions[]; - -export const fetchPhasePermissions = (phaseId: string) => { - return fetcher({ - path: `/phases/${phaseId}/permissions`, - action: 'get', - }); -}; - -const usePhasesPermissions = (phaseIds?: string[]) => { - const queries = phaseIds - ? phaseIds.map((phaseId) => ({ - queryKey: phasePermissionKeys.list({ phaseId }), - queryFn: () => fetchPhasePermissions(phaseId), - })) - : []; - return useQueries({ - queries, - }); -}; - -export default usePhasesPermissions; diff --git a/front/app/api/projects/__mocks__/_mockServer.ts b/front/app/api/projects/__mocks__/_mockServer.ts index a0ce28e48f0e..e96032732ea5 100644 --- a/front/app/api/projects/__mocks__/_mockServer.ts +++ b/front/app/api/projects/__mocks__/_mockServer.ts @@ -1,5 +1,5 @@ import { rest } from 'msw'; -import { IProjects, IProjectData } from '../types'; +import { IProjects, IProjectData, IProject } from '../types'; export const project1: IProjectData = { id: '1aa8a788-3aee-4ada-a581-6d934e49784b', @@ -74,7 +74,7 @@ export const project1: IProjectData = { }, down: { enabled: false, - disabled_reason: 'disliking_disabled', + disabled_reason: 'reacting_dislike_disabled', }, }, // MISMATCH: this attribute doesn't exist on our type @@ -303,6 +303,173 @@ export const projects: IProjects = { }, }; +const votingProject: IProject = { + data: { + id: '276fc6c4-5780-45ff-90a0-e6655b67bc23', + type: 'project', + attributes: { + document_annotation_embed_url: null, + poll_anonymous: false, + survey_embed_url: null, + survey_service: null, + participation_method: 'voting', + posting_enabled: true, + // MISMATCH + // posting_method: "unlimited", + // posting_limited_max: 1, + commenting_enabled: true, + reacting_enabled: true, + reacting_like_method: 'unlimited', + reacting_like_limited_max: 10, + reacting_dislike_enabled: true, + reacting_dislike_method: 'unlimited', + reacting_dislike_limited_max: 10, + allow_anonymous_participation: false, + presentation_mode: 'card', + ideas_order: 'random', + input_term: 'idea', + voting_method: 'multiple_voting', + voting_max_total: 10, + voting_min_total: 0, + voting_max_votes_per_idea: 1, + baskets_count: 2, + voting_term_singular_multiloc: { + en: 'pick', + }, + voting_term_plural_multiloc: { + en: 'picks', + }, + votes_count: 2, + description_preview_multiloc: {}, + title_multiloc: { + en: 'Luuc voting test', + }, + comments_count: 0, + ideas_count: 1, + followers_count: 2, + include_all_areas: false, + internal_role: null, + process_type: 'continuous', + slug: 'luuc-voting-test', + visible_to: 'groups', + created_at: '2023-08-09T09:57:44.129Z', + updated_at: '2023-10-19T10:25:13.230Z', + folder_id: null, + publication_status: 'published', + description_multiloc: {}, + header_bg: { + large: null, + }, + action_descriptor: { + posting_idea: { + enabled: false, + disabled_reason: 'not_ideation', + future_enabled: null, + }, + commenting_idea: { + enabled: true, + disabled_reason: null, + }, + reacting_idea: { + enabled: false, + disabled_reason: 'not_ideation', + up: { + enabled: false, + disabled_reason: 'not_ideation', + }, + down: { + enabled: false, + disabled_reason: 'not_ideation', + }, + }, + // MISMATCH + // comment_reacting_idea: { + // enabled: true, + // disabled_reason: null + // }, + annotating_document: { + enabled: false, + disabled_reason: 'not_document_annotation', + }, + taking_survey: { + enabled: false, + disabled_reason: 'not_survey', + }, + taking_poll: { + enabled: false, + disabled_reason: 'not_poll', + }, + // MISMATCH + // voting: { + // enabled: true, + // disabled_reason: null + // } + }, + avatars_count: 2, + participants_count: 2, + // MISMATCH + // allocated_budget: 0, + uses_content_builder: true, + }, + relationships: { + admin_publication: { + data: { + id: '8efd352a-e4c9-478f-a108-1fbdd7e5747a', + type: 'admin_publication', + }, + }, + project_images: { + data: [], + }, + areas: { + data: [], + }, + topics: { + data: [], + }, + avatars: { + data: [ + { + id: 'a5167d4e-9f3a-4430-b973-8717ccb94dad', + type: 'avatar', + }, + { + id: '3d78a40f-91a6-4e80-835e-bca778704c9f', + type: 'avatar', + }, + ], + }, + permissions: { + data: [ + { + id: 'd73b7647-ea39-4bab-8391-d9da0a4c11cb', + type: 'permission', + }, + { + id: '1d12f7ff-6a86-478a-9314-26ebfdf90cca', + type: 'permission', + }, + ], + }, + user_basket: { + data: null, + }, + user_follower: { + data: { + id: '678ee006-231f-4d1f-a666-9c65a7b588af', + type: 'follower', + }, + }, + default_assignee: { + data: { + id: '1b359a67-8bad-42dc-b9f5-7d5e3eb537b6', + type: 'assignee', + }, + }, + }, + }, +}; + export const apiPathById = '/web_api/v1/projects/:id'; export const apiPathBySlug = '/web_api/v1/projects/by_slug/:slug'; export const apiPathAll = '/web_api/v1/projects'; @@ -319,4 +486,8 @@ const endpoints = { }), }; +export const votingProjectHandler = rest.get(apiPathById, (_req, res, ctx) => { + return res(ctx.status(200), ctx.json(votingProject)); +}); + export default endpoints; diff --git a/front/app/api/projects/types.ts b/front/app/api/projects/types.ts index 55c84e0218b8..47cfe7b5eae6 100644 --- a/front/app/api/projects/types.ts +++ b/front/app/api/projects/types.ts @@ -119,6 +119,9 @@ export interface IProjectData { user_follower: { data: IRelationship | null; }; + permissions?: { + data: IRelationship[]; + }; }; } @@ -136,7 +139,7 @@ type ProjectReactingDisabledReason = | 'project_inactive' | 'not_ideation' | 'reacting_disabled' - | 'disliking_disabled' + | 'reacting_dislike_disabled' | 'reacting_like_limited_max_reached' | 'reacting_dislike_limited_max_reached' | PermissionsDisabledReason; diff --git a/front/app/api/texting_campaigns/keys.ts b/front/app/api/texting_campaigns/keys.ts index b859f476ad72..c517fef5d58f 100644 --- a/front/app/api/texting_campaigns/keys.ts +++ b/front/app/api/texting_campaigns/keys.ts @@ -1,5 +1,6 @@ import { QueryKeys } from 'utils/cl-react-query/types'; +const itemKey = { type: 'campaign' }; const baseKey = { type: 'campaign', variant: 'texting', @@ -11,7 +12,7 @@ const textingCampaignsKeys = { items: () => [{ ...baseKey, operation: 'item' }], item: ({ id }: { id: string }) => [ { - ...baseKey, + ...itemKey, operation: 'item', parameters: { id }, }, diff --git a/front/app/components/IdeaCard/ImagePlaceholder.tsx b/front/app/components/IdeaCard/CardImage/ImagePlaceholder.tsx similarity index 100% rename from front/app/components/IdeaCard/ImagePlaceholder.tsx rename to front/app/components/IdeaCard/CardImage/ImagePlaceholder.tsx diff --git a/front/app/components/IdeaCard/CardImage/index.tsx b/front/app/components/IdeaCard/CardImage/index.tsx new file mode 100644 index 000000000000..846ad7a35f58 --- /dev/null +++ b/front/app/components/IdeaCard/CardImage/index.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +// components +import Image from 'components/UI/Image'; +import ImagePlaceholder from './ImagePlaceholder'; + +// styling +import styled from 'styled-components'; +import { media } from 'utils/styleUtils'; + +// typings +import { IProjectData } from 'api/projects/types'; +import { IPhaseData } from 'api/phases/types'; + +const IdeaCardImageWrapper = styled.div<{ $cardInnerHeight: string }>` + flex: 0 0 ${(props) => props.$cardInnerHeight}; + width: ${(props) => props.$cardInnerHeight}; + height: ${(props) => props.$cardInnerHeight}; + display: flex; + align-items: center; + justify-content: center; + margin-right: 18px; + overflow: hidden; + border-radius: ${(props) => props.theme.borderRadius}; + + ${media.tablet` + width: 100%; + margin-bottom: 18px; + `} +`; + +const IdeaCardImage = styled(Image)` + width: 100%; + height: 100%; + flex: 1; +`; + +interface Props { + participationContext?: IProjectData | IPhaseData; + image: string | null; + hideImagePlaceholder: boolean; + innerHeight: string; +} + +const CardImage = ({ + participationContext, + image, + hideImagePlaceholder, + innerHeight, +}: Props) => { + const participationMethod = + participationContext?.attributes.participation_method; + + const votingMethod = participationContext?.attributes.voting_method; + + return ( + <> + {image && ( + + + + )} + + {!image && !hideImagePlaceholder && ( + + + + )} + + ); +}; + +export default CardImage; diff --git a/front/app/components/IdeaCard/Footer/IdeaCardFooter.tsx b/front/app/components/IdeaCard/Footer/IdeaCardFooter.tsx index dac9125ac474..6364fa195dcc 100644 --- a/front/app/components/IdeaCard/Footer/IdeaCardFooter.tsx +++ b/front/app/components/IdeaCard/Footer/IdeaCardFooter.tsx @@ -2,21 +2,13 @@ import React from 'react'; // components import CommentCount from './CommentCount'; +import { Box } from '@citizenlab/cl2-component-library'; // types import { IIdeaData } from 'api/ideas/types'; // styles import ReadMoreButton from './ReadMoreButton'; -import styled from 'styled-components'; - -const Footer = styled.footer` - display: flex; - justify-content: space-between; - margin-right: auto; - margin-left: auto; - margin-top: 16px; -`; interface Props { idea: IIdeaData; @@ -25,12 +17,12 @@ interface Props { const IdeaCardFooter = ({ idea, showCommentCount }: Props) => { return ( -
+ {showCommentCount && ( )} -
+ ); }; export default IdeaCardFooter; diff --git a/front/app/components/IdeaCard/Footer/ReadMoreButton.tsx b/front/app/components/IdeaCard/Footer/ReadMoreButton.tsx index 3c243f801f73..a79eb14c826a 100644 --- a/front/app/components/IdeaCard/Footer/ReadMoreButton.tsx +++ b/front/app/components/IdeaCard/Footer/ReadMoreButton.tsx @@ -18,7 +18,6 @@ const ReadMoreButton = ({ slug }: Props) => { size="s" textColor={colors.coolGrey700} mr="8px" - ml="auto" m="0px" p="0px" buttonStyle="text" diff --git a/front/app/components/IdeaCard/IdeaCard.stories.tsx b/front/app/components/IdeaCard/IdeaCard.stories.tsx index 3cdd6b6ee6c6..8f560ee4a290 100644 --- a/front/app/components/IdeaCard/IdeaCard.stories.tsx +++ b/front/app/components/IdeaCard/IdeaCard.stories.tsx @@ -1,5 +1,9 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; +import mockEndpoints from 'utils/storybook/mockEndpoints'; +import { votingProjectHandler } from 'api/projects/__mocks__/_mockServer'; +import { votingIdeaHandler } from 'api/ideas/__mocks__/_mockServer'; +import { VotingContext } from 'api/baskets_ideas/useVoting'; import IdeaCard from '.'; @@ -21,6 +25,30 @@ type Story = StoryObj; export const Standard: Story = { args: { ideaId: '1', + showFollowButton: false, + hideImage: false, + hideImagePlaceholder: false, }, parameters: {}, }; + +export const Voting: Story = { + render: (props) => ( + +
+ +
+
+ ), + args: { + ideaId: '1', + hideImage: false, + hideImagePlaceholder: false, + }, + parameters: { + msw: mockEndpoints({ + 'GET projects/:id': votingProjectHandler, + 'GET ideas/:id': votingIdeaHandler, + }), + }, +}; diff --git a/front/app/components/IdeaCard/Interactions.tsx b/front/app/components/IdeaCard/Interactions.tsx index cb9bad4d894b..2235c007cbe0 100644 --- a/front/app/components/IdeaCard/Interactions.tsx +++ b/front/app/components/IdeaCard/Interactions.tsx @@ -1,5 +1,8 @@ import React from 'react'; +// api +import useBasket from 'api/baskets/useBasket'; + // config import { getVotingMethodConfig } from 'utils/configs/votingMethodConfig'; @@ -11,25 +14,43 @@ import { IIdea } from 'api/ideas/types'; import { IProjectData } from 'api/projects/types'; import { IPhaseData } from 'api/phases/types'; -type InteractionsProps = { +type Props = { idea: IIdea; participationContext?: IPhaseData | IProjectData | null; }; -const Interactions = ({ participationContext, idea }: InteractionsProps) => { +const Interactions = ({ participationContext, idea }: Props) => { + const isGeneralIdeasPage = window.location.pathname.endsWith('/ideas'); const votingMethod = participationContext?.attributes.voting_method; const config = getVotingMethodConfig(votingMethod); + const { data: basket } = useBasket( + participationContext?.relationships?.user_basket?.data?.id + ); if (!config || !participationContext) return null; - const isCurrentPhase = + if ( participationContext.type === 'phase' && pastPresentOrFuture([ participationContext.attributes.start_at, participationContext.attributes.end_at, - ]) === 'present'; + ]) !== 'present' + ) { + return null; + } + + const participationContextEnded = + participationContext?.type === 'phase' && + participationContext.attributes.end_at && + pastPresentOrFuture(participationContext?.attributes?.end_at) === 'past'; + + const hideInteractions = + isGeneralIdeasPage || + (participationContextEnded && basket?.data.attributes.submitted_at === null) + ? true + : false; - if (!isCurrentPhase) { + if (hideInteractions) { return null; } diff --git a/front/app/components/IdeaCard/index.tsx b/front/app/components/IdeaCard/index.tsx index 96bd3fd059e8..dcf5267b79be 100644 --- a/front/app/components/IdeaCard/index.tsx +++ b/front/app/components/IdeaCard/index.tsx @@ -1,10 +1,9 @@ import React, { memo, useEffect } from 'react'; // components -import Card from 'components/UI/Card/Compact'; -import { useBreakpoint, Box } from '@citizenlab/cl2-component-library'; +import { useBreakpoint, Box, Title } from '@citizenlab/cl2-component-library'; +import CardImage from './CardImage'; import Body from './Body'; -import ImagePlaceholder from './ImagePlaceholder'; import Footer from './Footer'; import Interactions from './Interactions'; import FollowUnfollow from 'components/FollowUnfollow'; @@ -14,21 +13,28 @@ import { updateSearchParams } from 'utils/cl-router/updateSearchParams'; import { removeSearchParams } from 'utils/cl-router/removeSearchParams'; import clHistory from 'utils/cl-router/history'; import { useSearchParams } from 'react-router-dom'; +import Link from 'utils/cl-router/Link'; // types import { IIdea } from 'api/ideas/types'; +// styling +import styled from 'styled-components'; +import { + defaultCardStyle, + defaultCardHoverStyle, + media, +} from 'utils/styleUtils'; + // hooks import useIdeaById from 'api/ideas/useIdeaById'; -import useIdeaImage from 'api/idea_images/useIdeaImage'; import useProjectById from 'api/projects/useProjectById'; import useLocalize from 'hooks/useLocalize'; import usePhase from 'api/phases/usePhase'; -import useBasket from 'api/baskets/useBasket'; +import useIdeaImage from 'api/idea_images/useIdeaImage'; // utils import { scrollToElement } from 'utils/scroll'; -import { pastPresentOrFuture } from 'utils/dateUtils'; import { getMethodConfig } from 'utils/configs/participationMethodConfig'; // events @@ -46,6 +52,20 @@ export interface Props { showFollowButton?: boolean; } +const Container = styled(Link)` + display: block; + ${defaultCardStyle}; + cursor: pointer; + ${defaultCardHoverStyle}; + width: 100%; + display: flex; + padding: 17px; + + ${media.tablet` + flex-direction: column; + `} +`; + const IdeaLoading = (props: Props) => { const { data: idea } = useIdeaById(props.ideaId); @@ -71,17 +91,23 @@ const IdeaCard = memo( goBackMode = 'browserGoBackButton', showFollowButton, }) => { - const isGeneralIdeasPage = window.location.pathname.endsWith('/ideas'); + const { data: ideaImage } = useIdeaImage( + idea.data.id, + idea.data.relationships.idea_images.data?.[0]?.id + ); + + const image = + !hideImage && ideaImage + ? ideaImage.data.attributes.versions.medium + : null; + const smallerThanPhone = useBreakpoint('phone'); + const smallerThanTablet = useBreakpoint('tablet'); const localize = useLocalize(); const { data: project } = useProjectById( idea.data.relationships.project.data.id ); const { data: phase } = usePhase(phaseId); - const { data: ideaImage } = useIdeaImage( - idea.data.id, - idea.data.relationships.idea_images.data?.[0]?.id - ); const participationContext = phase?.data || project?.data; const participationMethod = @@ -89,18 +115,9 @@ const IdeaCard = memo( const config = participationMethod && getMethodConfig(participationMethod); const hideBody = config?.hideAuthorOnIdeas; - const participationContextEnded = - participationContext?.type === 'phase' && - participationContext.attributes.end_at && - pastPresentOrFuture(participationContext?.attributes?.end_at) === 'past'; - const { data: basket } = useBasket( - participationContext?.relationships?.user_basket?.data?.id - ); - const ideaTitle = localize(idea.data.attributes.title_multiloc); const [searchParams] = useSearchParams(); const scrollToCardParam = searchParams.get('scroll_to_card'); - const votingMethod = participationContext?.attributes.voting_method; useEffect(() => { if (scrollToCardParam && idea.data.id === scrollToCardParam) { @@ -129,41 +146,47 @@ const IdeaCard = memo( clHistory.push(`/ideas/${slug}${params}`); }; - const hideInteractions = - isGeneralIdeasPage || - (participationContextEnded && - basket?.data.attributes.submitted_at === null) - ? true - : false; + const innerHeight = showFollowButton ? '192px' : '162px'; return ( - - ) - } - innerHeight={showFollowButton ? '192px' : undefined} - body={hideBody ? undefined : } - interactions={ - hideInteractions ? null : ( + > + + + + + + {ideaTitle} + + {!hideBody && } + + - ) - } - footer={ - <>