From 3ed0cba051a4bdaea26952a88d62bd3ff64957ed Mon Sep 17 00:00:00 2001 From: adi-herwana-nus Date: Fri, 6 Feb 2026 15:37:56 +0800 Subject: [PATCH 1/4] fix(user_invitations): mark permanently non-retryable invitations - add retryable flag to user_invitations (course and instance) - mark invitation as non-retryable if out of retries - if invitation fails with permanent error (e.g. bad addr), immediately mark as non-retryable --- .../course/user_invitations_controller.rb | 4 +- .../instance/user_invitations_controller.rb | 4 +- app/models/course/user_invitation.rb | 7 ++ app/models/instance/user_invitation.rb | 7 ++ config/initializers/mail_delivery_job.rb | 48 +++++++++++++ ..._add_retryable_flag_to_user_invitations.rb | 6 ++ db/schema.rb | 4 +- .../user_invitations_controller_spec.rb | 22 ++++++ .../user_invitations_controller_spec.rb | 22 ++++++ spec/jobs/mail_delivery_job_spec.rb | 72 +++++++++++++++++++ 10 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 config/initializers/mail_delivery_job.rb create mode 100644 db/migrate/20260206070824_add_retryable_flag_to_user_invitations.rb create mode 100644 spec/jobs/mail_delivery_job_spec.rb diff --git a/app/controllers/course/user_invitations_controller.rb b/app/controllers/course/user_invitations_controller.rb index 5cc79d1a66a..440addfb908 100644 --- a/app/controllers/course/user_invitations_controller.rb +++ b/app/controllers/course/user_invitations_controller.rb @@ -93,11 +93,11 @@ def resend_invitation_params def load_invitations @invitations ||= begin ids = resend_invitation_params - ids ||= current_course.invitations.unconfirmed.select(:id) + ids ||= current_course.invitations.retryable.unconfirmed.select(:id) if ids.blank? [] else - current_course.invitations.unconfirmed.where('course_user_invitations.id IN (?)', ids) + current_course.invitations.retryable.unconfirmed.where('course_user_invitations.id IN (?)', ids) end end end diff --git a/app/controllers/system/admin/instance/user_invitations_controller.rb b/app/controllers/system/admin/instance/user_invitations_controller.rb index b80e85e6f65..6cff186a351 100644 --- a/app/controllers/system/admin/instance/user_invitations_controller.rb +++ b/app/controllers/system/admin/instance/user_invitations_controller.rb @@ -87,11 +87,11 @@ def invitation_service def invitations @invitations ||= begin ids = resend_invitation_params - ids ||= @instance.invitations.unconfirmed.select(:id) + ids ||= @instance.invitations.retryable.unconfirmed.select(:id) if ids.blank? [] else - @instance.invitations.unconfirmed.where('instance_user_invitations.id IN (?)', ids) + @instance.invitations.retryable.unconfirmed.where('instance_user_invitations.id IN (?)', ids) end end end diff --git a/app/models/course/user_invitation.rb b/app/models/course/user_invitation.rb index 75dc2eb6fc5..bfbede2d9d8 100644 --- a/app/models/course/user_invitation.rb +++ b/app/models/course/user_invitation.rb @@ -18,6 +18,7 @@ class Course::UserInvitation < ApplicationRecord # Invitations that haven't been confirmed, i.e. pending the user's acceptance. scope :unconfirmed, -> { where(confirmed_at: nil) } + scope :retryable, -> { where(is_retryable: true) } INVITATION_KEY_IDENTIFIER = 'I' @@ -38,6 +39,12 @@ def confirmed? confirmed_at.present? end + # Called by MailDeliveryJob when a permanent SMTP error occurs (e.g. invalid address). + # Marks the invitation as not retryable to prevent further delivery attempts. + def mark_email_as_invalid(_error) + update_column(:is_retryable, false) + end + # Determines roles that current user can invite to current course # # @param [String] own_role Current user's role in current course diff --git a/app/models/instance/user_invitation.rb b/app/models/instance/user_invitation.rb index ff0382512b3..c227cac9951 100644 --- a/app/models/instance/user_invitation.rb +++ b/app/models/instance/user_invitation.rb @@ -17,6 +17,7 @@ class Instance::UserInvitation < ApplicationRecord # Invitations that haven't been confirmed, i.e. pending the user's acceptance. scope :unconfirmed, -> { where(confirmed_at: nil) } + scope :retryable, -> { where(is_retryable: true) } INVITATION_KEY_IDENTIFIER = 'J' @@ -37,6 +38,12 @@ def confirmed? confirmed_at.present? end + # Called by MailDeliveryJob when a permanent SMTP error occurs (e.g. invalid address). + # Marks the invitation as not retryable to prevent further delivery attempts. + def mark_email_as_invalid(_error) + update_column(:is_retryable, false) + end + private # Generates the invitation key. instance invitation keys generated start with J. diff --git a/config/initializers/mail_delivery_job.rb b/config/initializers/mail_delivery_job.rb new file mode 100644 index 00000000000..5648bd4ea41 --- /dev/null +++ b/config/initializers/mail_delivery_job.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true +# Discard mail delivery jobs that fail with permanent SMTP errors. +# These errors (e.g. invalid recipient address, authentication failure) cannot be +# resolved by retrying, so retrying only wastes queue resources. +# +# When discarding, we notify the originating record (e.g. a UserInvitation) by calling +# `mark_email_as_invalid` if it responds to that method. This lets models react to +# permanent delivery failures without coupling this initializer to specific mailers. +# +# For transient errors that allow retries, we also mark the record as invalid when +# all retry attempts are exhausted (via sidekiq_retries_exhausted hook). +Rails.application.config.after_initialize do + ActionMailer::MailDeliveryJob.discard_on(Net::SMTPSyntaxError, + Net::SMTPFatalError, + Net::SMTPAuthenticationError) do |job, error| + # MailDeliveryJob#perform signature: (mailer, mail_method, delivery_method, args:, kwargs: nil, params: nil) + # job.arguments: ["Course::Mailer", "user_invitation_email", "deliver_now", { args: [record], ... }] + args_hash = job.arguments[3] + record = args_hash[:args]&.first + record.mark_email_as_invalid(error) if record.respond_to?(:mark_email_as_invalid) + end + + if Rails.env.production? + # When retries are exhausted for transient errors (e.g. Net::SMTPServerBusy), + # mark the record as invalid so it won't be retried again. + ActionMailer::MailDeliveryJob.sidekiq_retries_exhausted do |msg, _exception| + # Sidekiq job payload structure: + # msg['args'] is an array with a single element (the ActiveJob serialized hash) + # The hash contains 'arguments' key with the job's arguments + job_data = msg['args'].first + next unless job_data.is_a?(Hash) + + arguments = job_data['arguments'] + next unless arguments.is_a?(Array) && arguments.length >= 4 + + args_hash = arguments[3] + next unless args_hash.is_a?(Hash) + + # Deserialize the GlobalID to get the actual record + serialized_record = args_hash['args']&.first + next unless serialized_record.is_a?(Hash) && serialized_record['_aj_globalid'] + + record = GlobalID::Locator.locate(serialized_record['_aj_globalid']) + error = StandardError.new("Retries exhausted: #{msg['error_class']} - #{msg['error_message']}") + record.mark_email_as_invalid(error) if record.respond_to?(:mark_email_as_invalid) + end + end +end diff --git a/db/migrate/20260206070824_add_retryable_flag_to_user_invitations.rb b/db/migrate/20260206070824_add_retryable_flag_to_user_invitations.rb new file mode 100644 index 00000000000..56a92c979b7 --- /dev/null +++ b/db/migrate/20260206070824_add_retryable_flag_to_user_invitations.rb @@ -0,0 +1,6 @@ +class AddRetryableFlagToUserInvitations < ActiveRecord::Migration[7.2] + def change + add_column :course_user_invitations, :is_retryable, :boolean, default: true, null: false + add_column :instance_user_invitations, :is_retryable, :boolean, default: true, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 42c5fbbdcba..86a0027e19f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_11_26_073121) do +ActiveRecord::Schema[7.2].define(version: 2026_02_06_070824) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -1330,6 +1330,7 @@ t.integer "role", default: 0, null: false t.boolean "phantom", default: false, null: false t.integer "timeline_algorithm" + t.boolean "is_retryable", default: true, null: false t.index "lower((email)::text)", name: "index_course_user_invitations_on_email" t.index ["confirmer_id"], name: "fk__course_user_invitations_confirmer_id" t.index ["course_id", "email"], name: "index_course_user_invitations_on_course_id_and_email", unique: true @@ -1534,6 +1535,7 @@ t.integer "updater_id", null: false t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.boolean "is_retryable", default: true, null: false t.index "lower((email)::text)", name: "index_instance_user_invitations_on_lower_email_text" t.index ["instance_id", "email"], name: "index_instance_user_invitations_on_instance_id_and_email", unique: true t.index ["instance_id"], name: "index_instance_user_invitations_on_instance_id" diff --git a/spec/controllers/course/user_invitations_controller_spec.rb b/spec/controllers/course/user_invitations_controller_spec.rb index ead92124509..5b640aee0e7 100644 --- a/spec/controllers/course/user_invitations_controller_spec.rb +++ b/spec/controllers/course/user_invitations_controller_spec.rb @@ -140,6 +140,14 @@ def replace_with_erroneous_course expect(controller.instance_variable_get(:@invitations)).to be_empty end end + + context 'if the provided invitation is not retryable' do + before { invitation.update_column(:is_retryable, false) } + it 'will not load the invitation' do + subject + expect(controller.instance_variable_get(:@invitations)).to be_empty + end + end end describe '#resend_invitations' do @@ -155,6 +163,20 @@ def replace_with_erroneous_course expect(controller.instance_variable_get(:@invitations)). to contain_exactly(*pending_invitations) end + + context 'with non-retryable invitations' do + let!(:non_retryable_invitations) do + create_list(:course_user_invitation, 2, course: course, is_retryable: false) + end + + it 'does not load non-retryable invitations' do + subject + expect(controller.instance_variable_get(:@invitations)). + to contain_exactly(*pending_invitations) + expect(controller.instance_variable_get(:@invitations)). + not_to include(*non_retryable_invitations) + end + end end describe '#toggle_registration' do diff --git a/spec/controllers/system/admin/instance/user_invitations_controller_spec.rb b/spec/controllers/system/admin/instance/user_invitations_controller_spec.rb index dff30e3f1f5..6e77c8fcf5c 100644 --- a/spec/controllers/system/admin/instance/user_invitations_controller_spec.rb +++ b/spec/controllers/system/admin/instance/user_invitations_controller_spec.rb @@ -75,6 +75,14 @@ expect(controller.instance_variable_get(:@invitations)).to be_empty end end + + context 'if the provided invitation is not retryable' do + before { invitation.update_column(:is_retryable, false) } + it 'will not load the invitation' do + subject + expect(controller.instance_variable_get(:@invitations)).to be_empty + end + end end describe '#resend_invitations' do @@ -89,6 +97,20 @@ expect(controller.instance_variable_get(:@invitations)). to contain_exactly(*pending_invitations) end + + context 'with non-retryable invitations' do + let!(:non_retryable_invitations) do + create_list(:instance_user_invitation, 2, instance: instance, is_retryable: false) + end + + it 'does not load non-retryable invitations' do + subject + expect(controller.instance_variable_get(:@invitations)). + to contain_exactly(*pending_invitations) + expect(controller.instance_variable_get(:@invitations)). + not_to include(*non_retryable_invitations) + end + end end end end diff --git a/spec/jobs/mail_delivery_job_spec.rb b/spec/jobs/mail_delivery_job_spec.rb new file mode 100644 index 00000000000..82be2cc1bd8 --- /dev/null +++ b/spec/jobs/mail_delivery_job_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe ActionMailer::MailDeliveryJob do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:invitation) { create(:course_user_invitation, course: course) } + let(:error) { Net::SMTPSyntaxError.new('501 Invalid RCPT TO address provided') } + + describe 'discard_on permanent SMTP errors' do + subject do + described_class.perform_now('Course::Mailer', 'user_invitation_email', 'deliver_now', + args: [invitation]) + end + + before do + # Stub mail delivery to raise SMTP error + allow_any_instance_of(Mail::Message).to receive(:deliver).and_raise(error) + end + + it 'calls mark_email_as_invalid on the invitation record' do + expect(invitation).to receive(:mark_email_as_invalid).with(error).and_call_original + subject + end + + it 'marks the invitation as not retryable' do + expect { subject }.to change { invitation.reload.is_retryable }.from(true).to(false) + end + + it 'does not raise the error' do + expect { subject }.not_to raise_error + end + + context 'with Net::SMTPFatalError' do + let(:error) { Net::SMTPFatalError.new('550 User not found') } + + it 'calls mark_email_as_invalid on the invitation record' do + expect(invitation).to receive(:mark_email_as_invalid).with(error).and_call_original + subject + end + + it 'marks the invitation as not retryable' do + expect { subject }.to change { invitation.reload.is_retryable }.from(true).to(false) + end + end + + context 'with Net::SMTPAuthenticationError' do + let(:error) { Net::SMTPAuthenticationError.new('535 Authentication failed') } + + it 'calls mark_email_as_invalid on the invitation record' do + expect(invitation).to receive(:mark_email_as_invalid).with(error).and_call_original + subject + end + + it 'marks the invitation as not retryable' do + expect { subject }.to change { invitation.reload.is_retryable }.from(true).to(false) + end + end + + context 'with Net::SMTPServerBusy (transient error)' do + let(:error) { Net::SMTPServerBusy.new('421 Try again later') } + + it 'does not discard the job' do + expect(invitation).not_to receive(:mark_email_as_invalid) + expect { subject }.to raise_error(Net::SMTPServerBusy) + expect(invitation.reload.is_retryable).to be true + end + end + end + end +end From c036ab3438faca83191e6554a5563f924b21e3c4 Mon Sep 17 00:00:00 2001 From: adi-herwana-nus Date: Fri, 6 Feb 2026 19:25:00 +0800 Subject: [PATCH 2/4] fix(Table): off-by-one header className assignment --- .../table/TanStackTableBuilder/useTanStackTableBuilder.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index a814819827f..be4f082e0d3 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -126,7 +126,7 @@ const useTanStackTableBuilder = ( forEach: (header, index) => ({ id: header.id, render: customHeaderRender(header), - className: getRealColumn(index)?.className, + className: getRealColumn(index - initialColumnsLength)?.className, sorting: header.column.getCanSort() ? { sorted: Boolean(header.column.getIsSorted()), @@ -140,7 +140,8 @@ const useTanStackTableBuilder = ( uniqueFilterValues: Array.from( header.column.getFacetedUniqueValues().keys(), ).sort(), - getFilterLabel: getRealColumn(index)?.filterProps?.getLabel, + getFilterLabel: getRealColumn(index - initialColumnsLength) + ?.filterProps?.getLabel, onAddFilter: (value): void => { resetPagination(); header.column.setFilterValue((currentFilters?: unknown[]) => From 28ad64149bcd1e0f75ef5c668ccf1ba4ada52d7e Mon Sep 17 00:00:00 2001 From: adi-herwana-nus Date: Fri, 6 Feb 2026 19:29:36 +0800 Subject: [PATCH 3/4] feat(course_user_Invitations): revamp invitations tab --- ...se_user_invitation_list_data.json.jbuilder | 1 + .../course/users/_tabs_data.json.jbuilder | 2 +- ...uttons.tsx => InvitationActionButtons.tsx} | 73 ++--- .../components/misc/InvitationsBarChart.tsx | 28 +- .../tables/UserInvitationsTable.tsx | 306 +++++------------- .../pages/InvitationsIndex/index.tsx | 149 +++++---- .../course/user-invitations/translations.ts | 39 +++ client/app/theme/palette.js | 1 + client/app/types/course/userInvitations.ts | 16 +- client/locales/en.json | 49 ++- client/locales/ko.json | 42 +-- client/locales/zh.json | 42 +-- 12 files changed, 325 insertions(+), 423 deletions(-) rename client/app/bundles/course/user-invitations/components/buttons/{PendingInvitationsButtons.tsx => InvitationActionButtons.tsx} (58%) create mode 100644 client/app/bundles/course/user-invitations/translations.ts diff --git a/app/views/course/user_invitations/_course_user_invitation_list_data.json.jbuilder b/app/views/course/user_invitations/_course_user_invitation_list_data.json.jbuilder index ed35f6b703f..fa5593e906f 100644 --- a/app/views/course/user_invitations/_course_user_invitation_list_data.json.jbuilder +++ b/app/views/course/user_invitations/_course_user_invitation_list_data.json.jbuilder @@ -7,6 +7,7 @@ json.role invitation.role json.phantom invitation.phantom json.timelineAlgorithm invitation.timeline_algorithm json.invitationKey invitation.invitation_key +json.isRetryable invitation.is_retryable json.confirmed invitation.confirmed? json.sentAt invitation.sent_at json.confirmedAt invitation.confirmed_at diff --git a/app/views/course/users/_tabs_data.json.jbuilder b/app/views/course/users/_tabs_data.json.jbuilder index 3e45bc780e5..d609bb62e68 100644 --- a/app/views/course/users/_tabs_data.json.jbuilder +++ b/app/views/course/users/_tabs_data.json.jbuilder @@ -1,3 +1,3 @@ # frozen_string_literal: true json.requestsCount current_course.enrol_requests.pending.count -json.invitationsCount current_course.invitations.unconfirmed.count +json.invitationsCount current_course.invitations.retryable.unconfirmed.count diff --git a/client/app/bundles/course/user-invitations/components/buttons/PendingInvitationsButtons.tsx b/client/app/bundles/course/user-invitations/components/buttons/InvitationActionButtons.tsx similarity index 58% rename from client/app/bundles/course/user-invitations/components/buttons/PendingInvitationsButtons.tsx rename to client/app/bundles/course/user-invitations/components/buttons/InvitationActionButtons.tsx index b5d04b33b0a..c78d23e7b13 100644 --- a/client/app/bundles/course/user-invitations/components/buttons/PendingInvitationsButtons.tsx +++ b/client/app/bundles/course/user-invitations/components/buttons/InvitationActionButtons.tsx @@ -1,59 +1,57 @@ import { FC, memo, useState } from 'react'; -import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { defineMessages } from 'react-intl'; import equal from 'fast-deep-equal'; -import { InvitationRowData } from 'types/course/userInvitations'; +import { InvitationMiniEntity } from 'types/course/userInvitations'; import DeleteButton from 'lib/components/core/buttons/DeleteButton'; import EmailButton from 'lib/components/core/buttons/EmailButton'; import { useAppDispatch } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; import { deleteInvitation, resendInvitationEmail } from '../../operations'; -interface Props extends WrappedComponentProps { - invitation: InvitationRowData; +interface Props { + invitation: InvitationMiniEntity; + isRetryable?: boolean; } -const styles = { - buttonStyle: { - padding: '0px 8px', - }, -}; const translations = defineMessages({ resendTooltip: { - id: 'course.userInvitations.PendingInvitationsButton.resendTooltip', + id: 'course.userInvitations.InvitationActionButtons.resendTooltip', defaultMessage: 'Resend Invitation', }, resendSuccess: { - id: 'course.userInvitations.PendingInvitationsButton.resendSuccess', + id: 'course.userInvitations.InvitationActionButtons.resendSuccess', defaultMessage: 'Resent email invitation to {email}!', }, resendFailure: { - id: 'course.userInvitations.PendingInvitationsButton.resendFailure', + id: 'course.userInvitations.InvitationActionButtons.resendFailure', defaultMessage: 'Failed to resend invitation - {error}', }, deletionTooltip: { - id: 'course.userInvitations.PendingInvitationsButton.deletionTooltip', + id: 'course.userInvitations.InvitationActionButtons.deletionTooltip', defaultMessage: 'Delete Invitation', }, deletionConfirm: { - id: 'course.userInvitations.PendingInvitationsButton.deletionConfirm', + id: 'course.userInvitations.InvitationActionButtons.deletionConfirm', defaultMessage: 'Are you sure you wish to delete invitation to {name} ({email})?', }, deletionSuccess: { - id: 'course.userInvitations.PendingInvitationsButton.deletionSuccess', + id: 'course.userInvitations.InvitationActionButtons.deletionSuccess', defaultMessage: 'Invitation for {name} was deleted.', }, deletionFailure: { - id: 'course.userInvitations.PendingInvitationsButton.deletionFailure', + id: 'course.userInvitations.InvitationActionButtons.deletionFailure', defaultMessage: 'Failed to delete user - {error}', }, }); -const PendingInvitationsButtons: FC = (props) => { - const { intl, invitation } = props; +const InvitationActionButtons: FC = (props) => { + const { invitation, isRetryable } = props; const dispatch = useAppDispatch(); + const { t } = useTranslation(); const [isResending, setIsResending] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -62,7 +60,7 @@ const PendingInvitationsButtons: FC = (props) => { return dispatch(resendInvitationEmail(invitation.id)) .then(() => { toast.success( - intl.formatMessage(translations.resendSuccess, { + t(translations.resendSuccess, { email: invitation.email, }), ); @@ -72,7 +70,7 @@ const PendingInvitationsButtons: FC = (props) => { ? error.response.data.errors : ''; toast.error( - intl.formatMessage(translations.resendFailure, { + t(translations.resendFailure, { error: errorMessage, }), ); @@ -85,7 +83,7 @@ const PendingInvitationsButtons: FC = (props) => { return dispatch(deleteInvitation(invitation.id)) .then(() => { toast.success( - intl.formatMessage(translations.deletionSuccess, { + t(translations.deletionSuccess, { name: invitation.name, }), ); @@ -96,7 +94,7 @@ const PendingInvitationsButtons: FC = (props) => { ? error.response.data.errors : ''; toast.error( - intl.formatMessage(translations.deletionFailure, { + t(translations.deletionFailure, { error: errorMessage, }), ); @@ -105,33 +103,30 @@ const PendingInvitationsButtons: FC = (props) => { }; return ( -
- +
+ {isRetryable && ( + + )}
); }; -export default memo( - injectIntl(PendingInvitationsButtons), - (prevProps, nextProps) => { - return equal(prevProps.invitation, nextProps.invitation); - }, -); +export default memo(InvitationActionButtons, (prevProps, nextProps) => { + return equal(prevProps.invitation, nextProps.invitation); +}); diff --git a/client/app/bundles/course/user-invitations/components/misc/InvitationsBarChart.tsx b/client/app/bundles/course/user-invitations/components/misc/InvitationsBarChart.tsx index 390c65b1f8f..b99264367e2 100644 --- a/client/app/bundles/course/user-invitations/components/misc/InvitationsBarChart.tsx +++ b/client/app/bundles/course/user-invitations/components/misc/InvitationsBarChart.tsx @@ -1,36 +1,34 @@ -import { defineMessages, FormattedMessage } from 'react-intl'; import palette from 'theme/palette'; import BarChart from 'lib/components/core/BarChart'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; interface BarchartProps { accepted: number; pending: number; + failed: number; } -const translations = defineMessages({ - accepted: { - id: 'course.userInvitations.InvitationsBarChart.accepted', - defaultMessage: 'Accepted Invitations', - }, - pending: { - id: 'course.userInvitations.InvitationsBarChart.pending', - defaultMessage: 'Pending', - }, -}); - const InvitationsBarChart = (props: BarchartProps): JSX.Element => { - const { accepted, pending } = props; + const { accepted, pending, failed } = props; + const { t } = useTranslation(); const data = [ { count: pending, color: palette.invitationStatus.pending, - label: , + label: t(translations.pending), }, { count: accepted, color: palette.invitationStatus.accepted, - label: , + label: t(translations.accepted), + }, + { + count: failed, + color: palette.invitationStatus.failed, + label: t(translations.failed), }, ]; diff --git a/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx b/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx index 2171b44cb1c..fdae45b059e 100644 --- a/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx @@ -1,272 +1,132 @@ -import { FC, memo, ReactElement } from 'react'; -import { defineMessages, FormattedMessage } from 'react-intl'; -import { Typography } from '@mui/material'; -import equal from 'fast-deep-equal'; -import { TableColumns, TableOptions } from 'types/components/DataTable'; +import { FC } from 'react'; import { InvitationMiniEntity, - InvitationRowData, + InvitationType, } from 'types/course/userInvitations'; -import DataTable from 'lib/components/core/layouts/DataTable'; +import { getManageCourseUserPermissions } from 'course/users/selectors'; import Note from 'lib/components/core/Note'; +import GhostIcon from 'lib/components/icons/GhostIcon'; +import { ColumnTemplate } from 'lib/components/table'; +import Table from 'lib/components/table/Table'; import { TIMELINE_ALGORITHMS } from 'lib/constants/sharedConstants'; -import rebuildObjectFromRow from 'lib/helpers/mui-datatables-helpers'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; import { formatLongDateTime } from 'lib/moment'; import roleTranslations from 'lib/translations/course/users/roles'; import tableTranslations from 'lib/translations/table'; -import { getManageCourseUserPermissions } from '../../selectors'; -import ResendInvitationsButton from '../buttons/ResendAllInvitationsButton'; +import translations from '../../translations'; +import InvitationActionButtons from '../buttons/InvitationActionButtons'; +import ResendAllInvitationsButton from '../buttons/ResendAllInvitationsButton'; interface Props { - title: string; invitations: InvitationMiniEntity[]; - pendingInvitations?: boolean; - acceptedInvitations?: boolean; - renderRowActionComponent?: (invitation: InvitationRowData) => ReactElement; + selectedType: InvitationType; } -const translations = defineMessages({ - noInvitations: { - id: 'course.userInvitations.UserInvitationsTable.noInvitations', - defaultMessage: 'There are no {invitationType}', - }, - pending: { - id: 'course.userInvitations.UserInvitationsTable.pending', - defaultMessage: 'pending', - }, - accepted: { - id: 'course.userInvitations.UserInvitationsTable.accepted', - defaultMessage: 'accepted', - }, -}); - const UserInvitationsTable: FC = (props) => { - const { - title, - invitations, - pendingInvitations = false, - acceptedInvitations = false, - renderRowActionComponent = null, - } = props; + const { invitations, selectedType } = props; + const { t } = useTranslation(); const permissions = useAppSelector(getManageCourseUserPermissions); - if (invitations && invitations.length === 0) { - return ( - - } - /> - ); - } - - const invitationTypePrefix: string = pendingInvitations - ? t(translations.pending) - : t(translations.accepted); - - const options: TableOptions = { - download: false, - filter: false, - pagination: false, - print: false, - search: true, - selectableRows: 'none', - setTableProps: (): Record => { - return { size: 'small' }; + const columns: ColumnTemplate[] = [ + { + of: 'name', + title: t(tableTranslations.name), + sortable: true, + searchable: true, + cell: (datum) => ( +
+ {datum.name} + {datum.phantom && } +
+ ), }, - setRowProps: (_row, dataIndex, _rowIndex): Record => { - return { - key: `invitation_${invitations[dataIndex].id}`, - invitationid: `invitation_${invitations[dataIndex].id}`, - className: `invitation ${invitationTypePrefix}_invitation_${invitations[dataIndex].id}`, - }; + { + of: 'email', + title: t(tableTranslations.email), + sortable: true, + searchable: true, + cell: (datum) => datum.email, }, - viewColumns: false, - ...(pendingInvitations && { - customToolbar: () => , - }), - }; - - const columns: TableColumns[] = [ { - name: 'id', - label: t(tableTranslations.id), - options: { - display: false, - filter: false, - sort: false, - }, + of: 'role', + title: t(tableTranslations.role), + sortable: true, + cell: (datum) => t(roleTranslations[datum.role]), }, { - name: 'name', - label: t(tableTranslations.name), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {invitation.name} - - ); - }, - }, + of: 'invitationKey', + title: t(tableTranslations.invitationCode), + sortable: true, + cell: (datum) => datum.invitationKey, }, { - name: 'email', - label: t(tableTranslations.email), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {invitation.email} - - ); - }, - }, + of: 'sentAt', + title: t(tableTranslations.invitationSentAt), + cell: (datum) => formatLongDateTime(datum.sentAt), + unless: selectedType === 'accepted', }, { - name: 'role', - label: t(tableTranslations.role), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {t(roleTranslations[invitation.role])} - - ); - }, - }, + of: 'timelineAlgorithm', + title: t(tableTranslations.personalizedTimeline), + cell: (datum) => + TIMELINE_ALGORITHMS.find( + (timeline) => timeline.value === datum.timelineAlgorithm, + )?.label ?? '-', + unless: !permissions.canManagePersonalTimes, }, { - name: 'phantom', - label: t(tableTranslations.phantom), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {invitation.phantom ? 'Yes' : 'No'} - - ); - }, - }, + of: 'confirmedAt', + title: t(tableTranslations.invitationAcceptedAt), + cell: (datum) => formatLongDateTime(datum.confirmedAt), + unless: selectedType !== 'accepted', }, { - name: 'invitationKey', - label: t(tableTranslations.invitationCode), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {invitation.invitationKey} - - ); - }, - }, + id: 'actions', + title: t(tableTranslations.actions), + cell: (datum) => ( + + ), + className: 'text-center', + unless: selectedType === 'accepted', }, ]; - if (pendingInvitations) { - columns.push({ - name: 'sentAt', - label: t(tableTranslations.invitationSentAt), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {formatLongDateTime(invitation.sentAt)} - - ); - }, - }, - }); - } - - if (permissions.canManagePersonalTimes) { - columns.push({ - name: 'timelineAlgorithm', - label: t(tableTranslations.personalizedTimeline), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {TIMELINE_ALGORITHMS.find( - (timeline) => timeline.value === invitation.timelineAlgorithm, - )?.label ?? '-'} - - ); - }, - }, - }); - } + const buttons: JSX.Element[] = []; - if (acceptedInvitations) { - columns.push({ - name: 'confirmedAt', - label: t(tableTranslations.invitationAcceptedAt), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {formatLongDateTime(invitation.confirmedAt)} - - ); - }, - }, - }); + if (selectedType === 'pending') { + buttons.push(); } - if (renderRowActionComponent) { - columns.push({ - name: 'actions', - label: t(tableTranslations.actions), - options: { - empty: true, - sort: false, - alignCenter: true, - customBodyRender: (_value, tableMeta): JSX.Element => { - const rowData = tableMeta.rowData; - const invitation = rebuildObjectFromRow(columns, rowData); - return renderRowActionComponent(invitation as InvitationRowData); - }, - }, - }); + if (invitations.length === 0) { + return ( + + ); } return ( - datum.id.toString()} + indexing={{ indices: true }} + toolbar={{ + show: true, + buttons, + }} /> ); }; -export default memo(UserInvitationsTable, (prevProps, nextProps) => { - return equal(prevProps.invitations, nextProps.invitations); -}); +export default UserInvitationsTable; diff --git a/client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx b/client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx index cf38ee63ad0..3fd621fe349 100644 --- a/client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx +++ b/client/app/bundles/course/user-invitations/pages/InvitationsIndex/index.tsx @@ -1,14 +1,20 @@ -import { FC, useEffect, useState } from 'react'; -import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; -import { Box, Typography } from '@mui/material'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Typography, +} from '@mui/material'; +import { InvitationType } from 'types/course/userInvitations'; import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; import UserManagementTabs from '../../../users/components/navigation/UserManagementTabs'; -import PendingInvitationsButtons from '../../components/buttons/PendingInvitationsButtons'; import InvitationsBarChart from '../../components/misc/InvitationsBarChart'; import UserInvitationsTable from '../../components/tables/UserInvitationsTable'; import { fetchInvitations } from '../../operations'; @@ -17,45 +23,41 @@ import { getManageCourseUserPermissions, getManageCourseUsersSharedData, } from '../../selectors'; +import translations from '../../translations'; -type Props = WrappedComponentProps; - -const translations = defineMessages({ - manageUsersHeader: { - id: 'course.userInvitations.InvitationsIndex.manageUsersHeader', - defaultMessage: 'Manage Users', - }, - pending: { - id: 'course.userInvitations.InvitationsIndex.pending', - defaultMessage: 'Pending Invitations', - }, - accepted: { - id: 'course.userInvitations.InvitationsIndex.accepted', - defaultMessage: 'Accepted Invitations', - }, - failure: { - id: 'course.userInvitations.InvitationsIndex.failure', - defaultMessage: 'Failed to fetch all invitations', - }, - invitationsInfo: { - id: 'course.userInvitations.InvitationsIndex.invitationsInfo', - defaultMessage: - 'The page lists all invitations which have been sent out to date.{br}Users can key in their invitation code into the course registration page to manually register into this course.', - }, -}); - -const InviteUsers: FC = (props) => { - const { intl } = props; +const InvitationsIndex: FC = () => { const [isLoading, setIsLoading] = useState(true); + const [selectedType, setSelectedType] = useState('pending'); const invitations = useAppSelector(getAllInvitationsMiniEntities); const permissions = useAppSelector(getManageCourseUserPermissions); const sharedData = useAppSelector(getManageCourseUsersSharedData); - const pendingInvitations = invitations.filter( - (invitation) => !invitation.confirmed, - ); - const acceptedInvitations = invitations.filter( - (invitation) => invitation.confirmed, + const { t } = useTranslation(); + + // Filter invitations based on selected type + const filteredInvitations = useMemo(() => { + switch (selectedType) { + case 'pending': + return invitations.filter((inv) => !inv.confirmed && inv.isRetryable); + case 'accepted': + return invitations.filter((inv) => inv.confirmed); + case 'failed': + return invitations.filter((inv) => !inv.confirmed && !inv.isRetryable); + default: + return invitations; + } + }, [invitations, selectedType]); + + // Count invitations for each type + const counts = useMemo( + () => ({ + pending: invitations.filter((inv) => !inv.confirmed && inv.isRetryable) + .length, + accepted: invitations.filter((inv) => inv.confirmed).length, + failed: invitations.filter((inv) => !inv.confirmed && !inv.isRetryable) + .length, + }), + [invitations], ); const dispatch = useAppDispatch(); @@ -65,11 +67,11 @@ const InviteUsers: FC = (props) => { .finally(() => { setIsLoading(false); }) - .catch(() => toast.error(intl.formatMessage(translations.failure))); + .catch(() => toast.error(t(translations.failure))); }, [dispatch]); return ( - + {isLoading ? ( ) : ( @@ -79,41 +81,54 @@ const InviteUsers: FC = (props) => { sharedData={sharedData} /> - - - - + + + {t(translations.invitationsHeader)} + + - - {intl.formatMessage(translations.invitationsInfo, { br:
})} + + {t(translations.invitationsInfo, { br:
})}
- {pendingInvitations.length > 0 && ( - ( - - )} - title={intl.formatMessage(translations.pending)} - /> - )} - - {acceptedInvitations.length > 0 && ( - - )} + + + setSelectedType(e.target.value as InvitationType) + } + row + value={selectedType} + > + } + label={`${t(translations.pending)} (${counts.pending})`} + value="pending" + /> + } + label={`${t(translations.accepted)} (${counts.accepted})`} + value="accepted" + /> + } + label={`${t(translations.failed)} (${counts.failed})`} + value="failed" + /> + + + )}
); }; -export default injectIntl(InviteUsers); +export default InvitationsIndex; diff --git a/client/app/bundles/course/user-invitations/translations.ts b/client/app/bundles/course/user-invitations/translations.ts new file mode 100644 index 00000000000..368820a220d --- /dev/null +++ b/client/app/bundles/course/user-invitations/translations.ts @@ -0,0 +1,39 @@ +import { defineMessages } from 'react-intl'; + +const translations = defineMessages({ + manageUsersHeader: { + id: 'course.userInvitations.InvitationsIndex.manageUsersHeader', + defaultMessage: 'Manage Users', + }, + failure: { + id: 'course.userInvitations.InvitationsIndex.failure', + defaultMessage: 'Failed to fetch all invitations', + }, + invitationsInfo: { + id: 'course.userInvitations.InvitationsIndex.invitationsInfo', + defaultMessage: + 'The page lists all invitations which have been sent out to date.{br}Users can key in their invitation code into the course registration page to manually register into this course.', + }, + invitationsHeader: { + id: 'course.userInvitations.InvitationsIndex.invitationsHeader', + defaultMessage: 'Invitations', + }, + noInvitations: { + id: 'course.userInvitations.UserInvitationsTable.noInvitations', + defaultMessage: 'There are no {invitationType} invitations.', + }, + pending: { + id: 'course.userInvitations.UserInvitationsTable.pending', + defaultMessage: 'Pending', + }, + accepted: { + id: 'course.userInvitations.UserInvitationsTable.accepted', + defaultMessage: 'Accepted', + }, + failed: { + id: 'course.userInvitations.UserInvitationsTable.failed', + defaultMessage: 'Failed', + }, +}); + +export default translations; diff --git a/client/app/theme/palette.js b/client/app/theme/palette.js index 1711c3f7f07..d515afad3ce 100644 --- a/client/app/theme/palette.js +++ b/client/app/theme/palette.js @@ -115,6 +115,7 @@ const palette = { invitationStatus: { pending: colors.grey[100], accepted: colors.green[100], + failed: colors.red[100], }, links: colors.blue[800], }; diff --git a/client/app/types/course/userInvitations.ts b/client/app/types/course/userInvitations.ts index f87d2ea37ca..8318f94e1c3 100644 --- a/client/app/types/course/userInvitations.ts +++ b/client/app/types/course/userInvitations.ts @@ -1,4 +1,4 @@ -import { CourseUserData } from './courseUsers'; +import { CourseUserData, CourseUserRole } from './courseUsers'; import { TimelineAlgorithm } from './personalTimes'; export interface InvitationFileEntity { @@ -54,32 +54,28 @@ export interface InvitationMiniEntity { id: number; name: string; email: string; - role: string; + role: CourseUserRole; phantom: boolean; timelineAlgorithm?: TimelineAlgorithm; invitationKey: string; confirmed: boolean; sentAt: string | null; confirmedAt: string | null; + isRetryable: boolean; } export interface InvitationListData { id: number; name: string; email: string; - role: string; + role: CourseUserRole; phantom: boolean; timelineAlgorithm?: TimelineAlgorithm; invitationKey: string; confirmed: boolean; sentAt: string | null; confirmedAt: string | null; + isRetryable: boolean; } -/** - * Row data from UserInvitationsTable Datatable - */ -export interface InvitationRowData extends InvitationMiniEntity { - 'S/N'?: number; - actions?: undefined; -} +export type InvitationType = 'pending' | 'accepted' | 'failed'; diff --git a/client/locales/en.json b/client/locales/en.json index 4678e037958..091463233fc 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6852,24 +6852,18 @@ "course.userInvitations.InvitationsBarChart.accepted": { "defaultMessage": "Accepted Invitations" }, - "course.userInvitations.InvitationsBarChart.pending": { - "defaultMessage": "Pending" - }, - "course.userInvitations.InvitationsIndex.accepted": { - "defaultMessage": "Accepted Invitations" - }, "course.userInvitations.InvitationsIndex.failure": { "defaultMessage": "Failed to fetch all invitations" }, + "course.userInvitations.InvitationsIndex.invitationsHeader": { + "defaultMessage": "Invitations" + }, "course.userInvitations.InvitationsIndex.invitationsInfo": { "defaultMessage": "The page lists all invitations which have been sent out to date.{br}Users can key in their invitation code into the course registration page to manually register into this course." }, "course.userInvitations.InvitationsIndex.manageUsersHeader": { "defaultMessage": "Manage Users" }, - "course.userInvitations.InvitationsIndex.pending": { - "defaultMessage": "Pending Invitations" - }, "course.userInvitations.InviteUsers.inviteUsersHeader": { "defaultMessage": "Invite Users" }, @@ -6915,25 +6909,25 @@ "course.userInvitations.InviteUsersfileUploadForm.invite": { "defaultMessage": "Invite Users from File" }, - "course.userInvitations.PendingInvitationsButton.deletionConfirm": { + "course.userInvitations.InvitationActionButtons.deletionConfirm": { "defaultMessage": "Are you sure you wish to delete invitation to {name} ({email})?" }, - "course.userInvitations.PendingInvitationsButton.deletionFailure": { + "course.userInvitations.InvitationActionButtons.deletionFailure": { "defaultMessage": "Failed to delete user - {error}" }, - "course.userInvitations.PendingInvitationsButton.deletionSuccess": { + "course.userInvitations.InvitationActionButtons.deletionSuccess": { "defaultMessage": "Invitation for {name} was deleted." }, - "course.userInvitations.PendingInvitationsButton.deletionTooltip": { + "course.userInvitations.InvitationActionButtons.deletionTooltip": { "defaultMessage": "Delete Invitation" }, - "course.userInvitations.PendingInvitationsButton.resendFailure": { + "course.userInvitations.InvitationActionButtons.resendFailure": { "defaultMessage": "Failed to resend invitation - {error}" }, - "course.userInvitations.PendingInvitationsButton.resendSuccess": { + "course.userInvitations.InvitationActionButtons.resendSuccess": { "defaultMessage": "Resent email invitation to {email}!" }, - "course.userInvitations.PendingInvitationsButton.resendTooltip": { + "course.userInvitations.InvitationActionButtons.resendTooltip": { "defaultMessage": "Resend Invitation" }, "course.userInvitations.RegistrationCodeButton.registrationCode": { @@ -6952,13 +6946,16 @@ "defaultMessage": "Invite from file" }, "course.userInvitations.UserInvitationsTable.accepted": { - "defaultMessage": "accepted" + "defaultMessage": "Accepted" + }, + "course.userInvitations.UserInvitationsTable.failed": { + "defaultMessage": "Failed" }, "course.userInvitations.UserInvitationsTable.noInvitations": { - "defaultMessage": "There are no {invitationType}" + "defaultMessage": "There are no {invitationType} invitations." }, "course.userInvitations.UserInvitationsTable.pending": { - "defaultMessage": "pending" + "defaultMessage": "Pending" }, "course.userNotification.AchievementGainedPopup.unlocked": { "defaultMessage": "Achievement Unlocked!" @@ -8403,25 +8400,25 @@ "system.admin.instance.instance.InvitationResultDialog.newInvitations": { "defaultMessage": "New Invitations ({count})" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionConfirm": { + "system.admin.instance.instance.InvitationActionButtons.deletionConfirm": { "defaultMessage": "Are you sure you wish to delete invitation to {name} ({email})?" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionFailure": { + "system.admin.instance.instance.InvitationActionButtons.deletionFailure": { "defaultMessage": "Failed to delete user - {error}" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionSuccess": { + "system.admin.instance.instance.InvitationActionButtons.deletionSuccess": { "defaultMessage": "Invitation for {name} was deleted." }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionTooltip": { + "system.admin.instance.instance.InvitationActionButtons.deletionTooltip": { "defaultMessage": "Delete Invitation" }, - "system.admin.instance.instance.PendingInvitationsButtons.resendFailure": { + "system.admin.instance.instance.InvitationActionButtons.resendFailure": { "defaultMessage": "Failed to resend invitation." }, - "system.admin.instance.instance.PendingInvitationsButtons.resendSuccess": { + "system.admin.instance.instance.InvitationActionButtons.resendSuccess": { "defaultMessage": "Resent email invitation to {email}!" }, - "system.admin.instance.instance.PendingInvitationsButtons.resendTooltip": { + "system.admin.instance.instance.InvitationActionButtons.resendTooltip": { "defaultMessage": "Resend Invitation" }, "system.admin.instance.instance.PendingRoleRequestsButton.approveFailure": { diff --git a/client/locales/ko.json b/client/locales/ko.json index 5e73a3047ce..d2b80891fb6 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6842,21 +6842,18 @@ "course.userInvitations.InvitationsBarChart.pending": { "defaultMessage": "대기 중" }, - "course.userInvitations.InvitationsIndex.accepted": { - "defaultMessage": "수락된 초대" - }, "course.userInvitations.InvitationsIndex.failure": { "defaultMessage": "모든 초대를 가져오지 못했습니다." }, + "course.userInvitations.InvitationsIndex.invitationsHeader": { + "defaultMessage": "초대" + }, "course.userInvitations.InvitationsIndex.invitationsInfo": { "defaultMessage": "이 페이지는 지금까지 발송된 모든 초대장을 나열합니다.{br}사용자는 초대 코드를 코스 등록 페이지에 입력하여 이 과정에 수동으로 등록할 수 있습니다." }, "course.userInvitations.InvitationsIndex.manageUsersHeader": { "defaultMessage": "사용자 관리" }, - "course.userInvitations.InvitationsIndex.pending": { - "defaultMessage": "대기 중인 초대" - }, "course.userInvitations.InviteUsers.inviteUsersHeader": { "defaultMessage": "사용자 초대" }, @@ -6902,25 +6899,25 @@ "course.userInvitations.InviteUsersfileUploadForm.invite": { "defaultMessage": "파일에서 사용자 초대" }, - "course.userInvitations.PendingInvitationsButton.deletionConfirm": { + "course.userInvitations.InvitationActionButtons.deletionConfirm": { "defaultMessage": "{name} ({email})에 대한 초대를 삭제하시겠습니까?" }, - "course.userInvitations.PendingInvitationsButton.deletionFailure": { + "course.userInvitations.InvitationActionButtons.deletionFailure": { "defaultMessage": "사용자 삭제에 실패했습니다 - {error}" }, - "course.userInvitations.PendingInvitationsButton.deletionSuccess": { + "course.userInvitations.InvitationActionButtons.deletionSuccess": { "defaultMessage": "{name}에 대한 초대가 삭제되었습니다." }, - "course.userInvitations.PendingInvitationsButton.deletionTooltip": { + "course.userInvitations.InvitationActionButtons.deletionTooltip": { "defaultMessage": "초대 삭제" }, - "course.userInvitations.PendingInvitationsButton.resendFailure": { + "course.userInvitations.InvitationActionButtons.resendFailure": { "defaultMessage": "초대 재전송에 실패했습니다 - {error}" }, - "course.userInvitations.PendingInvitationsButton.resendSuccess": { + "course.userInvitations.InvitationActionButtons.resendSuccess": { "defaultMessage": "{email}로 초대 이메일을 다시 보냈습니다!" }, - "course.userInvitations.PendingInvitationsButton.resendTooltip": { + "course.userInvitations.InvitationActionButtons.resendTooltip": { "defaultMessage": "초대 재전송" }, "course.userInvitations.RegistrationCodeButton.registrationCode": { @@ -6941,8 +6938,11 @@ "course.userInvitations.UserInvitationsTable.accepted": { "defaultMessage": "수락됨" }, + "course.userInvitations.UserInvitationsTable.failed": { + "defaultMessage": "실패" + }, "course.userInvitations.UserInvitationsTable.noInvitations": { - "defaultMessage": "{invitationType}이(가) 없습니다." + "defaultMessage": "{invitationType} 초대가 없습니다." }, "course.userInvitations.UserInvitationsTable.pending": { "defaultMessage": "대기 중" @@ -8402,25 +8402,25 @@ "system.admin.instance.instance.InvitationResultDialog.newInvitations": { "defaultMessage": "새 초대 ({count})" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionConfirm": { + "system.admin.instance.instance.InvitationActionButtons.deletionConfirm": { "defaultMessage": "{name} ({email})에 대한 초대를 삭제하시겠습니까?" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionFailure": { + "system.admin.instance.instance.InvitationActionButtons.deletionFailure": { "defaultMessage": "사용자를 삭제하지 못했습니다 - {error}" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionSuccess": { + "system.admin.instance.instance.InvitationActionButtons.deletionSuccess": { "defaultMessage": "{name}의 초대가 삭제되었습니다." }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionTooltip": { + "system.admin.instance.instance.InvitationActionButtons.deletionTooltip": { "defaultMessage": "초대 삭제" }, - "system.admin.instance.instance.PendingInvitationsButtons.resendFailure": { + "system.admin.instance.instance.InvitationActionButtons.resendFailure": { "defaultMessage": "초대를 다시 보내지 못했습니다." }, - "system.admin.instance.instance.PendingInvitationsButtons.resendSuccess": { + "system.admin.instance.instance.InvitationActionButtons.resendSuccess": { "defaultMessage": "{email}로 초대 이메일을 다시 보냈습니다!" }, - "system.admin.instance.instance.PendingInvitationsButtons.resendTooltip": { + "system.admin.instance.instance.InvitationActionButtons.resendTooltip": { "defaultMessage": "초대 다시 보내기" }, "system.admin.instance.instance.PendingRoleRequestsButton.approveFailure": { diff --git a/client/locales/zh.json b/client/locales/zh.json index ab005dbc724..aa13db6e555 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6836,21 +6836,18 @@ "course.userInvitations.InvitationsBarChart.pending": { "defaultMessage": "待处理" }, - "course.userInvitations.InvitationsIndex.accepted": { - "defaultMessage": "已接受邀请" - }, "course.userInvitations.InvitationsIndex.failure": { "defaultMessage": "无法获取所有邀请" }, + "course.userInvitations.InvitationsIndex.invitationsHeader": { + "defaultMessage": "邀请" + }, "course.userInvitations.InvitationsIndex.invitationsInfo": { "defaultMessage": "至今发出的所有邀请已列出。用户可以在课程注册页面输入邀请码,手动注册该课程。" }, "course.userInvitations.InvitationsIndex.manageUsersHeader": { "defaultMessage": "管理用户" }, - "course.userInvitations.InvitationsIndex.pending": { - "defaultMessage": "待处理的邀请" - }, "course.userInvitations.InviteUsers.inviteUsersHeader": { "defaultMessage": "邀请用户" }, @@ -6896,25 +6893,25 @@ "course.userInvitations.InviteUsersfileUploadForm.invite": { "defaultMessage": "从文件邀请用户" }, - "course.userInvitations.PendingInvitationsButton.deletionConfirm": { + "course.userInvitations.InvitationActionButtons.deletionConfirm": { "defaultMessage": "你确定要删除对{name} ({email}) 的邀请吗?" }, - "course.userInvitations.PendingInvitationsButton.deletionFailure": { + "course.userInvitations.InvitationActionButtons.deletionFailure": { "defaultMessage": "无法删除用户 - {error}" }, - "course.userInvitations.PendingInvitationsButton.deletionSuccess": { + "course.userInvitations.InvitationActionButtons.deletionSuccess": { "defaultMessage": "对 {name} 的邀请已删除。" }, - "course.userInvitations.PendingInvitationsButton.deletionTooltip": { + "course.userInvitations.InvitationActionButtons.deletionTooltip": { "defaultMessage": "删除邀请" }, - "course.userInvitations.PendingInvitationsButton.resendFailure": { + "course.userInvitations.InvitationActionButtons.resendFailure": { "defaultMessage": "无法重新发送邀请 - {error}" }, - "course.userInvitations.PendingInvitationsButton.resendSuccess": { + "course.userInvitations.InvitationActionButtons.resendSuccess": { "defaultMessage": "向 {email} 重新发送电子邮件邀请!" }, - "course.userInvitations.PendingInvitationsButton.resendTooltip": { + "course.userInvitations.InvitationActionButtons.resendTooltip": { "defaultMessage": "重新发送邀请" }, "course.userInvitations.RegistrationCodeButton.registrationCode": { @@ -6935,8 +6932,11 @@ "course.userInvitations.UserInvitationsTable.accepted": { "defaultMessage": "已接受" }, + "course.userInvitations.UserInvitationsTable.failed": { + "defaultMessage": "失败" + }, "course.userInvitations.UserInvitationsTable.noInvitations": { - "defaultMessage": "没有{invitationType}" + "defaultMessage": "没有{invitationType}邀请。" }, "course.userInvitations.UserInvitationsTable.pending": { "defaultMessage": "待处理" @@ -8396,25 +8396,25 @@ "system.admin.instance.instance.InvitationResultDialog.newInvitations": { "defaultMessage": "新邀请({count})" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionConfirm": { + "system.admin.instance.instance.InvitationActionButtons.deletionConfirm": { "defaultMessage": "你确定要删除对{name} ({email}) 的邀请吗?" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionFailure": { + "system.admin.instance.instance.InvitationActionButtons.deletionFailure": { "defaultMessage": "无法删除用户 - {error}" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionSuccess": { + "system.admin.instance.instance.InvitationActionButtons.deletionSuccess": { "defaultMessage": "{name} 的邀请已删除。" }, - "system.admin.instance.instance.PendingInvitationsButtons.deletionTooltip": { + "system.admin.instance.instance.InvitationActionButtons.deletionTooltip": { "defaultMessage": "删除邀请" }, - "system.admin.instance.instance.PendingInvitationsButtons.resendFailure": { + "system.admin.instance.instance.InvitationActionButtons.resendFailure": { "defaultMessage": "未能重新发送邀请。" }, - "system.admin.instance.instance.PendingInvitationsButtons.resendSuccess": { + "system.admin.instance.instance.InvitationActionButtons.resendSuccess": { "defaultMessage": "向 {email} 重新发送电子邮件邀请!" }, - "system.admin.instance.instance.PendingInvitationsButtons.resendTooltip": { + "system.admin.instance.instance.InvitationActionButtons.resendTooltip": { "defaultMessage": "重新发送邀请" }, "system.admin.instance.instance.PendingRoleRequestsButton.approveFailure": { From 03ea5664578bbdd3d6acee365b81efdc71d2cbbf Mon Sep 17 00:00:00 2001 From: adi-herwana-nus Date: Fri, 6 Feb 2026 19:39:48 +0800 Subject: [PATCH 4/4] feat(instance_user_invitations): revamp invitations tab --- ...ce_user_invitation_list_data.json.jbuilder | 1 + ...uttons.tsx => InvitationActionButtons.tsx} | 40 +-- .../tables/UserInvitationsTable.tsx | 252 +++++------------- .../pages/InstanceUsersInvitations.tsx | 80 ++++-- .../app/types/system/instance/invitations.ts | 2 + client/locales/en.json | 12 +- client/locales/ko.json | 8 +- client/locales/zh.json | 8 +- 8 files changed, 181 insertions(+), 222 deletions(-) rename client/app/bundles/system/admin/instance/instance/components/buttons/{PendingInvitationsButtons.tsx => InvitationActionButtons.tsx} (67%) diff --git a/app/views/system/admin/instance/user_invitations/_instance_user_invitation_list_data.json.jbuilder b/app/views/system/admin/instance/user_invitations/_instance_user_invitation_list_data.json.jbuilder index 28146e44724..befa531365f 100644 --- a/app/views/system/admin/instance/user_invitations/_instance_user_invitation_list_data.json.jbuilder +++ b/app/views/system/admin/instance/user_invitations/_instance_user_invitation_list_data.json.jbuilder @@ -4,6 +4,7 @@ json.name invitation.name json.email invitation.email json.role invitation.role json.invitationKey invitation.invitation_key +json.isRetryable invitation.is_retryable json.confirmed invitation.confirmed? json.sentAt invitation.sent_at json.confirmedAt invitation.confirmed_at diff --git a/client/app/bundles/system/admin/instance/instance/components/buttons/PendingInvitationsButtons.tsx b/client/app/bundles/system/admin/instance/instance/components/buttons/InvitationActionButtons.tsx similarity index 67% rename from client/app/bundles/system/admin/instance/instance/components/buttons/PendingInvitationsButtons.tsx rename to client/app/bundles/system/admin/instance/instance/components/buttons/InvitationActionButtons.tsx index bf5374c7f2a..bfeac0fba33 100644 --- a/client/app/bundles/system/admin/instance/instance/components/buttons/PendingInvitationsButtons.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/buttons/InvitationActionButtons.tsx @@ -1,5 +1,5 @@ import { FC, memo, useState } from 'react'; -import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { defineMessages } from 'react-intl'; import equal from 'fast-deep-equal'; import { InvitationRowData } from 'types/system/instance/invitations'; @@ -9,45 +9,47 @@ import { useAppDispatch } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import { deleteInvitation, resendInvitationEmail } from '../../operations'; +import useTranslation from 'lib/hooks/useTranslation'; -interface Props extends WrappedComponentProps { +interface Props { invitation: InvitationRowData; } const translations = defineMessages({ resendTooltip: { - id: 'system.admin.instance.instance.PendingInvitationsButtons.resendTooltip', + id: 'system.admin.instance.instance.InvitationActionButtons.resendTooltip', defaultMessage: 'Resend Invitation', }, resendSuccess: { - id: 'system.admin.instance.instance.PendingInvitationsButtons.resendSuccess', + id: 'system.admin.instance.instance.InvitationActionButtons.resendSuccess', defaultMessage: 'Resent email invitation to {email}!', }, resendFailure: { - id: 'system.admin.instance.instance.PendingInvitationsButtons.resendFailure', + id: 'system.admin.instance.instance.InvitationActionButtons.resendFailure', defaultMessage: 'Failed to resend invitation.', }, deletionTooltip: { - id: 'system.admin.instance.instance.PendingInvitationsButtons.deletionTooltip', + id: 'system.admin.instance.instance.InvitationActionButtons.deletionTooltip', defaultMessage: 'Delete Invitation', }, deletionConfirm: { - id: 'system.admin.instance.instance.PendingInvitationsButtons.deletionConfirm', + id: 'system.admin.instance.instance.InvitationActionButtons.deletionConfirm', defaultMessage: 'Are you sure you wish to delete invitation to {name} ({email})?', }, deletionSuccess: { - id: 'system.admin.instance.instance.PendingInvitationsButtons.deletionSuccess', + id: 'system.admin.instance.instance.InvitationActionButtons.deletionSuccess', defaultMessage: 'Invitation for {name} was deleted.', }, deletionFailure: { - id: 'system.admin.instance.instance.PendingInvitationsButtons.deletionFailure', + id: 'system.admin.instance.instance.InvitationActionButtons.deletionFailure', defaultMessage: 'Failed to delete user - {error}', }, }); -const PendingInvitationsButtons: FC = (props) => { - const { intl, invitation } = props; +const InvitationActionButtons: FC = (props) => { + const { invitation } = props; + const { t } = useTranslation(); const dispatch = useAppDispatch(); const [isResending, setIsResending] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -56,13 +58,13 @@ const PendingInvitationsButtons: FC = (props) => { return dispatch(resendInvitationEmail(invitation.id)) .then(() => { toast.success( - intl.formatMessage(translations.resendSuccess, { + t(translations.resendSuccess, { email: invitation.email, }), ); }) .catch(() => { - toast.error(intl.formatMessage(translations.resendFailure)); + toast.error(t(translations.resendFailure)); }) .finally(() => setIsResending(false)); }; @@ -72,7 +74,7 @@ const PendingInvitationsButtons: FC = (props) => { return dispatch(deleteInvitation(invitation.id)) .then(() => { toast.success( - intl.formatMessage(translations.deletionSuccess, { + t(translations.deletionSuccess, { name: invitation.name, }), ); @@ -83,7 +85,7 @@ const PendingInvitationsButtons: FC = (props) => { ? error.response.data.errors : ''; toast.error( - intl.formatMessage(translations.deletionFailure, { + t(translations.deletionFailure, { error: errorMessage, }), ); @@ -97,25 +99,25 @@ const PendingInvitationsButtons: FC = (props) => { className={`invitation-resend-${invitation.id} p-0`} disabled={isResending || isDeleting} onClick={onResend} - tooltip={intl.formatMessage(translations.resendTooltip)} + tooltip={t(translations.resendTooltip)} />
); }; export default memo( - injectIntl(PendingInvitationsButtons), + InvitationActionButtons, (prevProps, nextProps) => { return equal(prevProps.invitation, nextProps.invitation); }, diff --git a/client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx b/client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx index 17e9295777b..ffe9a045bd9 100644 --- a/client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx +++ b/client/app/bundles/system/admin/instance/instance/components/tables/UserInvitationsTable.tsx @@ -1,231 +1,125 @@ -import { FC, memo, ReactElement } from 'react'; -import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; -import { Typography } from '@mui/material'; -import equal from 'fast-deep-equal'; -import { TableColumns, TableOptions } from 'types/components/DataTable'; -import { - InvitationMiniEntity, - InvitationRowData, -} from 'types/system/instance/invitations'; +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { InvitationMiniEntity } from 'types/system/instance/invitations'; +import { InvitationType } from 'types/course/userInvitations'; -import DataTable from 'lib/components/core/layouts/DataTable'; import Note from 'lib/components/core/Note'; +import { ColumnTemplate } from 'lib/components/table'; +import Table from 'lib/components/table/Table'; import { INSTANCE_USER_ROLES } from 'lib/constants/sharedConstants'; -import rebuildObjectFromRow from 'lib/helpers/mui-datatables-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; import { formatLongDateTime } from 'lib/moment'; import tableTranslations from 'lib/translations/table'; +import InvitationActionButtons from '../buttons/InvitationActionButtons'; import ResendAllInvitationsButton from '../buttons/ResendAllInvitationsButton'; -interface Props extends WrappedComponentProps { - title: string; +interface Props { invitations: InvitationMiniEntity[]; - pendingInvitations?: boolean; - acceptedInvitations?: boolean; - renderRowActionComponent?: (invitation: InvitationRowData) => ReactElement; + selectedType: InvitationType; } const translations = defineMessages({ noInvitations: { id: 'system.admin.instance.instance.UserInvitationsTable.noInvitations', - defaultMessage: 'There are no {invitationType}', + defaultMessage: 'There are no {invitationType} invitations.', }, pending: { id: 'system.admin.instance.instance.UserInvitationsTable.pending', - defaultMessage: 'pending', + defaultMessage: 'Pending', }, accepted: { id: 'system.admin.instance.instance.UserInvitationsTable.accepted', - defaultMessage: 'accepted', + defaultMessage: 'Accepted', + }, + failed: { + id: 'system.admin.instance.instance.UserInvitationsTable.failed', + defaultMessage: 'Failed', }, }); const UserInvitationsTable: FC = (props) => { - const { - title, - invitations, - pendingInvitations = false, - acceptedInvitations = false, - renderRowActionComponent = null, - intl, - } = props; + const { invitations, selectedType } = props; - if (invitations && invitations.length === 0) { - return ( - - ); - } + const { t } = useTranslation(); - const invitationTypePrefix: string = pendingInvitations - ? intl.formatMessage(translations.pending) - : intl.formatMessage(translations.accepted); - - const options: TableOptions = { - download: false, - filter: false, - pagination: true, - print: false, - rowsPerPage: 50, - rowsPerPageOptions: [15, 30, 50, 100], - search: true, - selectableRows: 'none', - setTableProps: (): Record => { - return { size: 'small' }; - }, - setRowProps: (_row, dataIndex, _rowIndex): Record => { - return { - key: `invitation_${invitations[dataIndex].id}`, - invitationid: `invitation_${invitations[dataIndex].id}`, - className: `invitation ${invitationTypePrefix}_invitation_${invitations[dataIndex].id}`, - }; + const columns: ColumnTemplate[] = [ + { + of: 'name', + title: t(tableTranslations.name), + sortable: true, + searchable: true, + cell: (datum) => datum.name, }, - viewColumns: false, - ...(pendingInvitations && { - customToolbar: () => , - }), - }; - - const columns: TableColumns[] = [ { - name: 'id', - label: intl.formatMessage(tableTranslations.id), - options: { - display: false, - filter: false, - sort: false, - }, + of: 'email', + title: t(tableTranslations.email), + sortable: true, + searchable: true, + cell: (datum) => datum.email, }, { - name: 'name', - label: intl.formatMessage(tableTranslations.name), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {invitation.name} - - ); - }, - }, + of: 'role', + title: t(tableTranslations.role), + sortable: true, + cell: (datum) => INSTANCE_USER_ROLES[datum.role], }, { - name: 'email', - label: intl.formatMessage(tableTranslations.email), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {invitation.email} - - ); - }, - }, + of: 'invitationKey', + title: t(tableTranslations.invitationCode), + sortable: true, + cell: (datum) => datum.invitationKey, }, { - name: 'role', - label: intl.formatMessage(tableTranslations.role), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {INSTANCE_USER_ROLES[invitation.role]} - - ); - }, - }, + of: 'sentAt', + title: t(tableTranslations.invitationSentAt), + cell: (datum) => formatLongDateTime(datum.sentAt), + unless: selectedType === 'accepted', }, { - name: 'invitationCode', - label: intl.formatMessage(tableTranslations.invitationCode), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {invitation.invitationKey} - - ); - }, - }, + of: 'confirmedAt', + title: t(tableTranslations.invitationAcceptedAt), + cell: (datum) => formatLongDateTime(datum.confirmedAt), + unless: selectedType !== 'accepted', }, { - name: 'sentAt', - label: intl.formatMessage(tableTranslations.invitationSentAt), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {formatLongDateTime(invitation.sentAt)} - - ); - }, - }, + id: 'actions', + title: t(tableTranslations.actions), + cell: (datum) => , + className: 'text-center', + unless: selectedType === 'accepted', }, ]; - if (acceptedInvitations) { - columns.push({ - name: 'acceptedAt', - label: intl.formatMessage(tableTranslations.invitationAcceptedAt), - options: { - alignCenter: false, - customBodyRenderLite: (dataIndex): JSX.Element => { - const invitation = invitations[dataIndex]; - return ( - - {formatLongDateTime(invitation.confirmedAt)} - - ); - }, - }, - }); + const buttons: JSX.Element[] = []; + + if (selectedType === 'pending') { + buttons.push(); } - if (renderRowActionComponent) { - columns.push({ - name: 'actions', - label: intl.formatMessage(tableTranslations.actions), - options: { - empty: true, - sort: false, - alignCenter: true, - customBodyRender: (_value, tableMeta): JSX.Element => { - const rowData = tableMeta.rowData; - const invitation = rebuildObjectFromRow(columns, rowData); - return renderRowActionComponent(invitation as InvitationRowData); - }, - }, - }); + if (invitations.length === 0) { + return ( + + ); } return ( - datum.id.toString()} + indexing={{ indices: true }} + toolbar={{ + show: true, + buttons, + }} /> ); }; -export default memo( - injectIntl(UserInvitationsTable), - (prevProps, nextProps) => { - return equal(prevProps.invitations, nextProps.invitations); - }, -); +export default UserInvitationsTable; diff --git a/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx b/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx index 9b1f092009e..5c98599f797 100644 --- a/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx +++ b/client/app/bundles/system/admin/instance/instance/pages/InstanceUsersInvitations.tsx @@ -1,11 +1,17 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useState, useMemo } from 'react'; import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { + FormControl, + FormControlLabel, + Radio, + RadioGroup, +} from '@mui/material'; +import { InvitationType } from 'types/course/userInvitations'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; -import PendingInvitationsButtons from '../components/buttons/PendingInvitationsButtons'; import InstanceUsersTabs from '../components/navigation/InstanceUsersTabs'; import UserInvitationsTable from '../components/tables/UserInvitationsTable'; import { fetchInvitations } from '../operations'; @@ -34,19 +40,39 @@ const translations = defineMessages({ id: 'system.admin.instance.instance.InstanceUsersInvitations.accepted', defaultMessage: 'Accepted Invitations', }, + failed: { + id: 'system.admin.instance.instance.InstanceUsersInvitations.failed', + defaultMessage: 'Failed Invitations', + }, }); const InstanceUsersInvitations: FC = (props) => { const { intl } = props; const [isLoading, setIsLoading] = useState(false); + const [selectedType, setSelectedType] = useState('pending'); const invitations = useAppSelector(getAllInvitationMiniEntities); - const pendingInvitations = invitations.filter( - (invitation) => !invitation.confirmed, - ); - const acceptedInvitations = invitations.filter( - (invitation) => invitation.confirmed, - ); + // Filter invitations based on selected type + const filteredInvitations = useMemo(() => { + switch (selectedType) { + case 'pending': + return invitations.filter(inv => !inv.confirmed && inv.isRetryable); + case 'accepted': + return invitations.filter(inv => inv.confirmed); + case 'failed': + return invitations.filter(inv => !inv.confirmed && !inv.isRetryable); + default: + return invitations; + } + }, [invitations, selectedType]); + + // Count invitations for each type + const counts = useMemo(() => ({ + pending: invitations.filter(inv => !inv.confirmed && inv.isRetryable).length, + accepted: invitations.filter(inv => inv.confirmed).length, + failed: invitations.filter(inv => !inv.confirmed && !inv.isRetryable).length, + }), [invitations]); + const dispatch = useAppDispatch(); useEffect(() => { @@ -62,18 +88,34 @@ const InstanceUsersInvitations: FC = (props) => { return ( <> + + + setSelectedType(e.target.value as InvitationType)} + row + value={selectedType} + > + } + label={`${intl.formatMessage(translations.pending)} (${counts.pending})`} + value="pending" + /> + } + label={`${intl.formatMessage(translations.accepted)} (${counts.accepted})`} + value="accepted" + /> + } + label={`${intl.formatMessage(translations.failed)} (${counts.failed})`} + value="failed" + /> + + + ( - - )} - title={intl.formatMessage(translations.pending)} - /> - ); diff --git a/client/app/types/system/instance/invitations.ts b/client/app/types/system/instance/invitations.ts index 0efd29f4abd..a8d6e42b565 100644 --- a/client/app/types/system/instance/invitations.ts +++ b/client/app/types/system/instance/invitations.ts @@ -27,6 +27,7 @@ export interface InvitationMiniEntity { invitationKey: string; sentAt: string | null; confirmedAt: string | null; + isRetryable: boolean; } export interface InvitationListData { @@ -38,6 +39,7 @@ export interface InvitationListData { invitationKey: string; sentAt: string | null; confirmedAt: string | null; + isRetryable: boolean; } /** diff --git a/client/locales/en.json b/client/locales/en.json index 091463233fc..419efcbb2fa 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -8349,6 +8349,9 @@ "system.admin.instance.instance.InstanceUsersInvitations.accepted": { "defaultMessage": "Accepted Invitations" }, + "system.admin.instance.instance.InstanceUsersInvitations.failed": { + "defaultMessage": "Failed Invitations" + }, "system.admin.instance.instance.InstanceUsersInvitations.fetch.failure": { "defaultMessage": "Failed to fetch invitations." }, @@ -8464,13 +8467,16 @@ "defaultMessage": "Email invitations were successfully resent." }, "system.admin.instance.instance.UserInvitationsTable.accepted": { - "defaultMessage": "accepted" + "defaultMessage": "Accepted" + }, + "system.admin.instance.instance.UserInvitationsTable.failed": { + "defaultMessage": "Failed" }, "system.admin.instance.instance.UserInvitationsTable.noInvitations": { - "defaultMessage": "There are no {invitationType}" + "defaultMessage": "There are no {invitationType} invitations." }, "system.admin.instance.instance.UserInvitationsTable.pending": { - "defaultMessage": "pending" + "defaultMessage": "Pending" }, "system.admin.instance.instance.UsersButton.deleteTooltip": { "defaultMessage": "Remove User" diff --git a/client/locales/ko.json b/client/locales/ko.json index d2b80891fb6..d7858c2343c 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -8351,6 +8351,9 @@ "system.admin.instance.instance.InstanceUsersInvitations.accepted": { "defaultMessage": "수락된 초대" }, + "system.admin.instance.instance.InstanceUsersInvitations.failed": { + "defaultMessage": "실패한 초대" + }, "system.admin.instance.instance.InstanceUsersInvitations.fetch.failure": { "defaultMessage": "초대를 가져오지 못했습니다." }, @@ -8468,8 +8471,11 @@ "system.admin.instance.instance.UserInvitationsTable.accepted": { "defaultMessage": "수락됨" }, + "system.admin.instance.instance.UserInvitationsTable.failed": { + "defaultMessage": "실패" + }, "system.admin.instance.instance.UserInvitationsTable.noInvitations": { - "defaultMessage": "{invitationType}이(가) 없습니다" + "defaultMessage": "{invitationType} 초대가 없습니다." }, "system.admin.instance.instance.UserInvitationsTable.pending": { "defaultMessage": "대기 중" diff --git a/client/locales/zh.json b/client/locales/zh.json index aa13db6e555..56fbfd1a41b 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -8345,6 +8345,9 @@ "system.admin.instance.instance.InstanceUsersInvitations.accepted": { "defaultMessage": "接受邀请" }, + "system.admin.instance.instance.InstanceUsersInvitations.failed": { + "defaultMessage": "失败的邀请" + }, "system.admin.instance.instance.InstanceUsersInvitations.fetch.failure": { "defaultMessage": "无法获取邀请。" }, @@ -8462,8 +8465,11 @@ "system.admin.instance.instance.UserInvitationsTable.accepted": { "defaultMessage": "已接受" }, + "system.admin.instance.instance.UserInvitationsTable.failed": { + "defaultMessage": "失败" + }, "system.admin.instance.instance.UserInvitationsTable.noInvitations": { - "defaultMessage": "没有{invitationType}" + "defaultMessage": "没有{invitationType}邀请。" }, "system.admin.instance.instance.UserInvitationsTable.pending": { "defaultMessage": "待处理"