From 618aa158d546a8be566cdb8883b1e9f73d855e07 Mon Sep 17 00:00:00 2001 From: Luka Pajukanta Date: Sun, 14 Jun 2026 14:50:13 +0300 Subject: [PATCH 1/3] feat(involvement): manually override computed perks Let an Involvement admin override individual perks (dimension values and annotation values) on a person's COMBINED_PERKS involvement, and track which perks are overridden via a technical `manual-perks-override` dimension whose values name the overridden perks (`d-` / `a-`). Override preservation is implemented once in `Involvement.for_combined_perks` so event-specific Emperkelators need not be aware of it: overridden perks keep their manual value while the rest are recomputed. Overriding is logged to the event log (`involvement.perks.overridden`). The new `PerksForm` is a self-contained client form (one value control plus an Override checkbox per perk, single save button) rather than a patch into SchemaForm. Unticking a perk's Override recomputes it on save. Also make SchemaFormInput a shared (server-renderable) component again by splitting the pattern-validation custom-validity logic into a small client-only PatternTextInput, used only for fields that set patternDescription. Co-Authored-By: Claude Opus 4.8 (1M context) --- kompassi-v2-frontend/src/__generated__/gql.ts | 6 + .../src/__generated__/graphql.ts | 41 +++ .../[eventSlug]/people/[personId]/actions.ts | 36 +- .../[eventSlug]/people/[personId]/page.tsx | 75 ++-- .../src/components/forms/PatternTextInput.tsx | 56 +++ .../src/components/forms/SchemaFormInput.tsx | 60 ++-- .../src/components/involvement/PerksForm.tsx | 330 ++++++++++++++++++ .../src/components/involvement/perks.ts | 33 ++ kompassi-v2-frontend/src/translations/en.tsx | 4 + kompassi-v2-frontend/src/translations/fi.tsx | 5 + kompassi-v2-frontend/src/translations/sv.tsx | 5 + kompassi/graphql_api/schema.py | 2 + kompassi/involvement/event_log_entry_types.py | 5 + .../mutations/update_involvement_perks.py | 131 +++++++ kompassi/involvement/models/involvement.py | 56 ++- kompassi/involvement/models/meta.py | 7 +- kompassi/involvement/perks.py | 110 ++++++ kompassi/involvement/tests.py | 126 +++++++ 18 files changed, 990 insertions(+), 98 deletions(-) create mode 100644 kompassi-v2-frontend/src/components/forms/PatternTextInput.tsx create mode 100644 kompassi-v2-frontend/src/components/involvement/PerksForm.tsx create mode 100644 kompassi-v2-frontend/src/components/involvement/perks.ts create mode 100644 kompassi/involvement/graphql/mutations/update_involvement_perks.py create mode 100644 kompassi/involvement/perks.py create mode 100644 kompassi/involvement/tests.py diff --git a/kompassi-v2-frontend/src/__generated__/gql.ts b/kompassi-v2-frontend/src/__generated__/gql.ts index 3565a2fbc..f7f2a5e9d 100644 --- a/kompassi-v2-frontend/src/__generated__/gql.ts +++ b/kompassi-v2-frontend/src/__generated__/gql.ts @@ -46,6 +46,7 @@ type Documents = { "\n mutation CancelOwnOrder($input: CancelOwnUnpaidOrderInput!) {\n cancelOwnUnpaidOrder(input: $input) {\n order {\n id\n }\n }\n }\n": typeof types.CancelOwnOrderDocument, "\n mutation RequestOrderCancellation($input: RequestOrderCancellationInput!) {\n requestOrderCancellation(input: $input) {\n success\n }\n }\n": typeof types.RequestOrderCancellationDocument, "\n mutation ConfirmOrderCancellation($input: ConfirmOrderCancellationInput!) {\n confirmOrderCancellation(input: $input) {\n success\n }\n }\n": typeof types.ConfirmOrderCancellationDocument, + "\n mutation UpdateInvolvementPerks($input: UpdateInvolvementPerksInput!) {\n updateInvolvementPerks(input: $input) {\n involvement {\n id\n }\n }\n }\n": typeof types.UpdateInvolvementPerksDocument, "\n fragment InvolvedPersonDetailInvolvement on LimitedInvolvementType {\n id\n type\n title\n adminLink\n isActive\n cachedDimensions\n cachedAnnotations\n }\n": typeof types.InvolvedPersonDetailInvolvementFragmentDoc, "\n fragment InvolvedPersonDetail on ProfileWithInvolvementType {\n id\n firstName\n lastName\n nick\n email\n phoneNumber\n discordHandle\n fullName\n\n profileFieldSelector {\n ...FullProfileFieldSelector\n }\n\n isActive\n\n involvements {\n ...InvolvedPersonDetailInvolvement\n }\n }\n": typeof types.InvolvedPersonDetailFragmentDoc, "\n query PersonPage($eventSlug: String!, $locale: String, $personId: Int!) {\n event(slug: $eventSlug) {\n slug\n name\n timezone\n\n involvement {\n dimensions(publicOnly: false) {\n ...CachedDimensionsBadges\n ...DimensionValueSelect\n\n isKeyDimension\n isShownInDetail\n }\n\n annotations(publicOnly: false, perksOnly: true) {\n ...AnnotationsFormAnnotation\n }\n\n person(id: $personId) {\n ...InvolvedPersonDetail\n }\n }\n }\n }\n": typeof types.PersonPageDocument, @@ -249,6 +250,7 @@ const documents: Documents = { "\n mutation CancelOwnOrder($input: CancelOwnUnpaidOrderInput!) {\n cancelOwnUnpaidOrder(input: $input) {\n order {\n id\n }\n }\n }\n": types.CancelOwnOrderDocument, "\n mutation RequestOrderCancellation($input: RequestOrderCancellationInput!) {\n requestOrderCancellation(input: $input) {\n success\n }\n }\n": types.RequestOrderCancellationDocument, "\n mutation ConfirmOrderCancellation($input: ConfirmOrderCancellationInput!) {\n confirmOrderCancellation(input: $input) {\n success\n }\n }\n": types.ConfirmOrderCancellationDocument, + "\n mutation UpdateInvolvementPerks($input: UpdateInvolvementPerksInput!) {\n updateInvolvementPerks(input: $input) {\n involvement {\n id\n }\n }\n }\n": types.UpdateInvolvementPerksDocument, "\n fragment InvolvedPersonDetailInvolvement on LimitedInvolvementType {\n id\n type\n title\n adminLink\n isActive\n cachedDimensions\n cachedAnnotations\n }\n": types.InvolvedPersonDetailInvolvementFragmentDoc, "\n fragment InvolvedPersonDetail on ProfileWithInvolvementType {\n id\n firstName\n lastName\n nick\n email\n phoneNumber\n discordHandle\n fullName\n\n profileFieldSelector {\n ...FullProfileFieldSelector\n }\n\n isActive\n\n involvements {\n ...InvolvedPersonDetailInvolvement\n }\n }\n": types.InvolvedPersonDetailFragmentDoc, "\n query PersonPage($eventSlug: String!, $locale: String, $personId: Int!) {\n event(slug: $eventSlug) {\n slug\n name\n timezone\n\n involvement {\n dimensions(publicOnly: false) {\n ...CachedDimensionsBadges\n ...DimensionValueSelect\n\n isKeyDimension\n isShownInDetail\n }\n\n annotations(publicOnly: false, perksOnly: true) {\n ...AnnotationsFormAnnotation\n }\n\n person(id: $personId) {\n ...InvolvedPersonDetail\n }\n }\n }\n }\n": types.PersonPageDocument, @@ -562,6 +564,10 @@ export function graphql(source: "\n mutation RequestOrderCancellation($input: R * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation ConfirmOrderCancellation($input: ConfirmOrderCancellationInput!) {\n confirmOrderCancellation(input: $input) {\n success\n }\n }\n"): (typeof documents)["\n mutation ConfirmOrderCancellation($input: ConfirmOrderCancellationInput!) {\n confirmOrderCancellation(input: $input) {\n success\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation UpdateInvolvementPerks($input: UpdateInvolvementPerksInput!) {\n updateInvolvementPerks(input: $input) {\n involvement {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateInvolvementPerks($input: UpdateInvolvementPerksInput!) {\n updateInvolvementPerks(input: $input) {\n involvement {\n id\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/kompassi-v2-frontend/src/__generated__/graphql.ts b/kompassi-v2-frontend/src/__generated__/graphql.ts index 9ba405f7b..3c4bc42b7 100644 --- a/kompassi-v2-frontend/src/__generated__/graphql.ts +++ b/kompassi-v2-frontend/src/__generated__/graphql.ts @@ -1676,6 +1676,15 @@ export type Mutation = { updateForm?: Maybe; updateFormFields?: Maybe; updateInvolvementDimensions?: Maybe; + /** + * Manually override the automatically computed perks of a person's COMBINED_PERKS + * involvement, then recompute the non-overridden perks. + * + * ``form_data`` is ``{ overrides: string[], dimensions: {slug: string[]}, annotations: {slug: value} }`` + * where ``overrides`` is the set of ticked override keys (``d-`` / ``a-``), + * and ``dimensions``/``annotations`` carry the manually set values for the overridden perks. + */ + updateInvolvementPerks?: Maybe; updateInvolvementPreferences?: Maybe; updateOrder?: Maybe; updateProduct?: Maybe; @@ -1957,6 +1966,11 @@ export type MutationUpdateInvolvementDimensionsArgs = { }; +export type MutationUpdateInvolvementPerksArgs = { + input: UpdateInvolvementPerksInput; +}; + + export type MutationUpdateInvolvementPreferencesArgs = { input: UpdateInvolvementPreferencesInput; }; @@ -2905,6 +2919,25 @@ export type UpdateInvolvementDimensionsInput = { involvementId: Scalars['String']['input']; }; +/** + * Manually override the automatically computed perks of a person's COMBINED_PERKS + * involvement, then recompute the non-overridden perks. + * + * ``form_data`` is ``{ overrides: string[], dimensions: {slug: string[]}, annotations: {slug: value} }`` + * where ``overrides`` is the set of ticked override keys (``d-`` / ``a-``), + * and ``dimensions``/``annotations`` carry the manually set values for the overridden perks. + */ +export type UpdateInvolvementPerks = { + __typename?: 'UpdateInvolvementPerks'; + involvement?: Maybe; +}; + +export type UpdateInvolvementPerksInput = { + eventSlug: Scalars['String']['input']; + formData: Scalars['GenericScalar']['input']; + involvementId: Scalars['String']['input']; +}; + export type UpdateInvolvementPreferences = { __typename?: 'UpdateInvolvementPreferences'; preferences?: Maybe; @@ -3251,6 +3284,13 @@ export type ConfirmOrderCancellationMutationVariables = Exact<{ export type ConfirmOrderCancellationMutation = { __typename?: 'Mutation', confirmOrderCancellation?: { __typename?: 'ConfirmOrderCancellation', success: boolean } | null }; +export type UpdateInvolvementPerksMutationVariables = Exact<{ + input: UpdateInvolvementPerksInput; +}>; + + +export type UpdateInvolvementPerksMutation = { __typename?: 'Mutation', updateInvolvementPerks?: { __typename?: 'UpdateInvolvementPerks', involvement?: { __typename?: 'LimitedInvolvementType', id: string } | null } | null }; + export type InvolvedPersonDetailInvolvementFragment = { __typename?: 'LimitedInvolvementType', id: string, type: InvolvementType, title: string, adminLink?: string | null, isActive: boolean, cachedDimensions: unknown, cachedAnnotations: unknown }; export type InvolvedPersonDetailFragment = { __typename?: 'ProfileWithInvolvementType', id: number, firstName: string, lastName: string, nick: string, email: string, phoneNumber: string, discordHandle: string, fullName: string, isActive: boolean, profileFieldSelector: { __typename?: 'ProfileFieldSelectorType', firstName: boolean, lastName: boolean, nick: boolean, email: boolean, phoneNumber: boolean, discordHandle: boolean }, involvements: Array<{ __typename?: 'LimitedInvolvementType', id: string, type: InvolvementType, title: string, adminLink?: string | null, isActive: boolean, cachedDimensions: unknown, cachedAnnotations: unknown }> }; @@ -4313,6 +4353,7 @@ export const AdminOrderListWithOrdersDocument = {"kind":"Document","definitions" export const CancelOwnOrderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CancelOwnOrder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CancelOwnUnpaidOrderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cancelOwnUnpaidOrder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const RequestOrderCancellationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RequestOrderCancellation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RequestOrderCancellationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"requestOrderCancellation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; export const ConfirmOrderCancellationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ConfirmOrderCancellation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ConfirmOrderCancellationInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"confirmOrderCancellation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}}]}}]} as unknown as DocumentNode; +export const UpdateInvolvementPerksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateInvolvementPerks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateInvolvementPerksInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateInvolvementPerks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"involvement"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; export const PersonPageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PersonPage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"personId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"involvement"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dimensions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"publicOnly"},"value":{"kind":"BooleanValue","value":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CachedDimensionsBadges"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"DimensionValueSelect"}},{"kind":"Field","name":{"kind":"Name","value":"isKeyDimension"}},{"kind":"Field","name":{"kind":"Name","value":"isShownInDetail"}}]}},{"kind":"Field","name":{"kind":"Name","value":"annotations"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"publicOnly"},"value":{"kind":"BooleanValue","value":false}},{"kind":"Argument","name":{"kind":"Name","value":"perksOnly"},"value":{"kind":"BooleanValue","value":true}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AnnotationsFormAnnotation"}}]}},{"kind":"Field","name":{"kind":"Name","value":"person"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"personId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"InvolvedPersonDetail"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullProfileFieldSelector"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProfileFieldSelectorType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"nick"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"phoneNumber"}},{"kind":"Field","name":{"kind":"Name","value":"discordHandle"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"InvolvedPersonDetailInvolvement"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedInvolvementType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"adminLink"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"cachedDimensions"}},{"kind":"Field","name":{"kind":"Name","value":"cachedAnnotations"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CachedDimensionsBadges"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullDimensionType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"values"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DimensionValueSelect"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullDimensionType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"isTechnical"}},{"kind":"Field","name":{"kind":"Name","value":"isMultiValue"}},{"kind":"Field","name":{"kind":"Name","value":"values"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AnnotationsFormAnnotation"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AnnotationType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"description"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"isComputed"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"InvolvedPersonDetail"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProfileWithInvolvementType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"nick"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"phoneNumber"}},{"kind":"Field","name":{"kind":"Name","value":"discordHandle"}},{"kind":"Field","name":{"kind":"Name","value":"fullName"}},{"kind":"Field","name":{"kind":"Name","value":"profileFieldSelector"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullProfileFieldSelector"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"involvements"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"InvolvedPersonDetailInvolvement"}}]}}]}}]} as unknown as DocumentNode; export const PeoplePageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PeoplePage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DimensionFilterInput"}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"search"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnNone"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"event"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"slug"},"value":{"kind":"Variable","name":{"kind":"Name","value":"eventSlug"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"timezone"}},{"kind":"Field","name":{"kind":"Name","value":"involvement"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dimensions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"publicOnly"},"value":{"kind":"BooleanValue","value":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DimensionFilter"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"CachedDimensionsBadges"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"DimensionValueSelect"}}]}},{"kind":"Field","name":{"kind":"Name","value":"people"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"search"},"value":{"kind":"Variable","name":{"kind":"Name","value":"search"}}},{"kind":"Argument","name":{"kind":"Name","value":"returnNone"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnNone"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"InvolvedPerson"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DimensionFilterValue"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"DimensionValueType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"InvolvedPersonInvolvement"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"LimitedInvolvementType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"adminLink"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"cachedDimensions"}},{"kind":"Field","name":{"kind":"Name","value":"cachedAnnotations"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DimensionFilter"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullDimensionType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"isMultiValue"}},{"kind":"Field","name":{"kind":"Name","value":"isListFilter"}},{"kind":"Field","name":{"kind":"Name","value":"isKeyDimension"}},{"kind":"Field","name":{"kind":"Name","value":"values"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"DimensionFilterValue"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CachedDimensionsBadges"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullDimensionType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"values"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"color"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"DimensionValueSelect"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullDimensionType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]},{"kind":"Field","name":{"kind":"Name","value":"isTechnical"}},{"kind":"Field","name":{"kind":"Name","value":"isMultiValue"}},{"kind":"Field","name":{"kind":"Name","value":"values"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"title"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"lang"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"InvolvedPerson"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ProfileWithInvolvementType"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"nick"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"involvements"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"InvolvedPersonInvolvement"}}]}}]}}]} as unknown as DocumentNode; export const UpdateProductDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateProduct"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateProductInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateProduct"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"product"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode; diff --git a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/people/[personId]/actions.ts b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/people/[personId]/actions.ts index 672fe58cc..c899a692d 100644 --- a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/people/[personId]/actions.ts +++ b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/people/[personId]/actions.ts @@ -1,15 +1,37 @@ "use server"; -export async function updateCombinedPerks( +import { revalidatePath } from "next/cache"; +import { graphql } from "@/__generated__"; +import { getClient } from "@/apolloClient"; +import { PerksOverridePayload } from "@/components/involvement/perks"; + +const updateInvolvementPerksMutation = graphql(` + mutation UpdateInvolvementPerks($input: UpdateInvolvementPerksInput!) { + updateInvolvementPerks(input: $input) { + involvement { + id + } + } + } +`); + +export async function updateInvolvementPerks( locale: string, eventSlug: string, personId: number, - formData: FormData, + involvementId: string, + payload: PerksOverridePayload, ) { - console.log({ - locale, - eventSlug, - personId, - formData: Object.fromEntries(formData.entries()), + await getClient().mutate({ + mutation: updateInvolvementPerksMutation, + variables: { + input: { + eventSlug, + involvementId, + formData: payload, + }, + }, }); + + revalidatePath(`/${locale}/${eventSlug}/people/${personId}`); } diff --git a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/people/[personId]/page.tsx b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/people/[personId]/page.tsx index d605d6a74..fa6e017c5 100644 --- a/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/people/[personId]/page.tsx +++ b/kompassi-v2-frontend/src/app/[locale]/[eventSlug]/people/[personId]/page.tsx @@ -7,19 +7,22 @@ import { } from "@/__generated__/graphql"; import { getClient } from "@/apolloClient"; import { auth } from "@/auth"; -import AnnotationsForm from "@/components/annotations/AnnotationsForm"; import { validateCachedAnnotations } from "@/components/annotations/models"; -import DimensionValueSelectionForm from "@/components/dimensions/DimensionValueSelectionForm"; import { validateCachedDimensions } from "@/components/dimensions/models"; import SignInRequired from "@/components/errors/SignInRequired"; import InvolvementAdminView from "@/components/involvement/InvolvementAdminView"; +import PerksForm from "@/components/involvement/PerksForm"; +import { + MANUAL_PERKS_OVERRIDE_SLUG, + PerksOverridePayload, +} from "@/components/involvement/perks"; import { ProfileFields } from "@/components/profile/ProfileFields"; import getPageTitle from "@/helpers/getPageTitle"; import { getTranslations } from "@/translations"; import { Translations } from "@/translations/en"; import { notFound } from "next/navigation"; import { Card, CardBody, CardText, CardTitle } from "react-bootstrap"; -import { updateCombinedPerks } from "./actions"; +import { updateInvolvementPerks } from "./actions"; import "./page.css"; import { Column } from "@/components/ReorderableDataTable"; @@ -134,78 +137,42 @@ export async function generateMetadata(props: Props) { }; } -function CombinedPerksCardWIP({ +function CombinedPerksCard({ involvement, translations, dimensions, annotations, - locale, - onChange, + onSubmit, }: { involvement: InvolvedPersonDetailInvolvementFragment; translations: Translations; dimensions: DimensionValueSelectFragment[]; annotations: AnnotationsFormAnnotationFragment[]; - locale: string; - onChange: (formData: FormData) => Promise; + onSubmit: (payload: PerksOverridePayload) => Promise; }) { const t = translations.Involvement; validateCachedDimensions(involvement.cachedDimensions); validateCachedAnnotations(annotations, involvement.cachedAnnotations); - annotations = annotations.filter((annotation) => !annotation.isComputed); + const manualPerksOverride = + involvement.cachedDimensions[MANUAL_PERKS_OVERRIDE_SLUG] ?? []; return ( {t.attributes.combinedPerks.title} {t.attributes.combinedPerks.message} - - - - - ); -} - -// Readonly version for now -function CombinedPerksCard({ - involvement, - translations, - annotations, -}: { - involvement: InvolvedPersonDetailInvolvementFragment; - translations: Translations; - dimensions: DimensionValueSelectFragment[]; - annotations: AnnotationsFormAnnotationFragment[]; - onChange: (formData: FormData) => Promise; -}) { - const t = translations.Involvement; - - validateCachedAnnotations(annotations, involvement.cachedAnnotations); - - return ( - - - {t.attributes.combinedPerks.title} - -
-
{t.attributes.title.title}
-
{involvement.title}
- -
{t.attributes.combinedPerks.title}
-
{involvement.cachedAnnotations["internal:formattedPerks"]}
-
); @@ -293,17 +260,17 @@ export default async function PersonPage(props: Props) { {combinedPerksInvolvement && ( - )} diff --git a/kompassi-v2-frontend/src/components/forms/PatternTextInput.tsx b/kompassi-v2-frontend/src/components/forms/PatternTextInput.tsx new file mode 100644 index 000000000..42cf51f2e --- /dev/null +++ b/kompassi-v2-frontend/src/components/forms/PatternTextInput.tsx @@ -0,0 +1,56 @@ +"use client"; + +interface Props { + className?: string; + type: string; + defaultValue?: string; + required?: boolean; + readOnly?: boolean; + id: string; + name: string; + pattern?: string; + maxLength?: number; + /** Custom validity message shown when the value does not match `pattern`. */ + patternDescription: string; +} + +/** + * A single-line text input that reports a custom validity message on pattern mismatch. + * + * This is the only part of SchemaFormInput that needs client-side event handlers, so it + * is split out: SchemaFormInput stays a shared (server-renderable) component and only this + * small component is shipped to the client, and only for fields that set patternDescription. + */ +export default function PatternTextInput({ + className, + type, + defaultValue, + required, + readOnly, + id, + name, + pattern, + maxLength, + patternDescription, +}: Props) { + return ( + { + if (e.currentTarget.validity.patternMismatch) { + e.currentTarget.setCustomValidity(patternDescription); + } + }} + onChange={(e) => e.currentTarget.setCustomValidity("")} + /> + ); +} diff --git a/kompassi-v2-frontend/src/components/forms/SchemaFormInput.tsx b/kompassi-v2-frontend/src/components/forms/SchemaFormInput.tsx index 4f8bc5364..2ac4ed14a 100644 --- a/kompassi-v2-frontend/src/components/forms/SchemaFormInput.tsx +++ b/kompassi-v2-frontend/src/components/forms/SchemaFormInput.tsx @@ -1,9 +1,8 @@ -"use client"; - import Card from "react-bootstrap/Card"; import CardBody from "react-bootstrap/CardBody"; import makeInputId from "./makeInputId"; import type { Choice, Field, SingleSelectPresentation } from "./models"; +import PatternTextInput from "./PatternTextInput"; import { SchemaForm } from "./SchemaForm"; import UploadedFileCards from "./UploadedFileCards"; import DateTimeInput from "./DateTimeInput"; @@ -47,37 +46,32 @@ function SchemaFormInput({ case "Divider": case "StaticText": return null; - case "SingleLineText": - return ( - { - if (e.currentTarget.validity.patternMismatch) { - e.currentTarget.setCustomValidity( - field.patternDescription!, - ); - } - } - : undefined - } - onChange={ - field.patternDescription - ? (e) => e.currentTarget.setCustomValidity("") - : undefined - } - /> - ); + case "SingleLineText": { + const textInputProps = { + className: "form-control", + type: htmlType ?? "text", + defaultValue: value, + required, + readOnly, + id, + name: slug, + pattern: field.pattern, + maxLength: field.maxLength, + }; + + // Only the custom validity message for pattern validation needs client-side + // event handlers, so reach for the client component only when it is actually used. + if (field.patternDescription) { + return ( + + ); + } + + return ; + } case "MultiLineText": return (