diff --git a/core/lib/src/models/models.dart b/core/lib/src/models/models.dart index dd2bde896..4740fac30 100644 --- a/core/lib/src/models/models.dart +++ b/core/lib/src/models/models.dart @@ -16,6 +16,8 @@ export 'tables/repo.dart'; export 'tables/study.dart'; export 'tables/study_invite.dart'; export 'tables/study_subject.dart'; +export 'tables/study_tag.dart'; export 'tables/subject_progress.dart'; +export 'tables/tag.dart'; export 'tables/user.dart'; export 'tasks/tasks.dart'; diff --git a/core/lib/src/models/tables/study.dart b/core/lib/src/models/tables/study.dart index 1b516330e..a40d327bb 100644 --- a/core/lib/src/models/tables/study.dart +++ b/core/lib/src/models/tables/study.dart @@ -69,6 +69,8 @@ class Study extends SupabaseObjectFunctions { @JsonKey(name: 'registry_published') late bool registryPublished = false; + @JsonKey(includeToJson: false, includeFromJson: false) + List studyTags = []; @JsonKey(includeToJson: false, includeFromJson: false) int participantCount = 0; @JsonKey(includeToJson: false, includeFromJson: false) @@ -99,6 +101,17 @@ class Study extends SupabaseObjectFunctions { factory Study.fromJson(Map json) { final study = _$StudyFromJson(json); + final List? studyTags = json['study_tags'] as List?; + if (studyTags != null) { + study.studyTags = studyTags + .map( + (json) => StudyTag.fromTag( + studyId: study.id, + tag: Tag.fromJson(json as Map), + ), + ) + .toList(); + } final List? repo = json['repo'] as List?; if (repo != null && repo.isNotEmpty) { @@ -244,6 +257,6 @@ class Study extends SupabaseObjectFunctions { @override String toString() { - return 'Study{id: $id, title: $title, description: $description, userId: $userId, participation: $participation, resultSharing: $resultSharing, contact: $contact, iconName: $iconName, published: $published, questionnaire: $questionnaire, eligibilityCriteria: $eligibilityCriteria, consent: $consent, interventions: $interventions, observations: $observations, schedule: $schedule, reportSpecification: $reportSpecification, results: $results, collaboratorEmails: $collaboratorEmails, registryPublished: $registryPublished, participantCount: $participantCount, endedCount: $endedCount, activeSubjectCount: $activeSubjectCount, missedDays: $missedDays, repo: $repo, invites: $invites, participants: $participants, participantsProgress: $participantsProgress, createdAt: $createdAt}'; + return 'Study{id: $id, title: $title, description: $description, studyTags: $studyTags, userId: $userId, participation: $participation, resultSharing: $resultSharing, contact: $contact, iconName: $iconName, published: $published, questionnaire: $questionnaire, eligibilityCriteria: $eligibilityCriteria, consent: $consent, interventions: $interventions, observations: $observations, schedule: $schedule, reportSpecification: $reportSpecification, results: $results, collaboratorEmails: $collaboratorEmails, registryPublished: $registryPublished, participantCount: $participantCount, endedCount: $endedCount, activeSubjectCount: $activeSubjectCount, missedDays: $missedDays, repo: $repo, invites: $invites, participants: $participants, participantsProgress: $participantsProgress, createdAt: $createdAt}'; } } diff --git a/core/lib/src/models/tables/study_tag.dart b/core/lib/src/models/tables/study_tag.dart new file mode 100644 index 000000000..1877ad21d --- /dev/null +++ b/core/lib/src/models/tables/study_tag.dart @@ -0,0 +1,70 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:studyu_core/core.dart'; + +part 'study_tag.g.dart'; + +@JsonSerializable() +class StudyTag extends SupabaseObjectFunctions { + static const String tableName = 'study_tag'; + + @override + Map get primaryKeys => {'tag_id': tagId, 'study_id': studyId}; + + @JsonKey(name: 'study_id') + final String studyId; + @JsonKey(name: 'tag_id') + final String tagId; + + @JsonKey(includeToJson: false, includeFromJson: false) + late Tag tag; + + /*@JsonKey(includeToJson: false, includeFromJson: false) + late Study study;*/ + + StudyTag({ + required this.studyId, + required this.tagId, + }); + + StudyTag.fromTag({ + required this.tag, + required this.studyId, + }) : tagId = tag.id; + + factory StudyTag.fromJson(Map json) { + final studyTag = _$StudyTagFromJson(json); + + /*final Map? study = json['study'] as Map?; + if (study != null) { + studyTag.study = Study.fromJson(study); + }*/ + + final Map? tag = json['tag'] as Map?; + if (tag != null) { + studyTag.tag = Tag.fromJson(tag); + } + + return studyTag; + } + + String get name => tag.name; + + String get id => tag.id; + + @override + Map toJson() => _$StudyTagToJson(this); + + @override + String toString() => toJson().toString(); + + @override + bool operator ==(Object other) => + identical(this, other) || other is StudyTag && studyId == other.studyId && tag == other.tag; + + @override + int get hashCode => id.hashCode ^ name.hashCode ^ studyId.hashCode ^ tag.hashCode; +} + +extension StudyTagListToTagList on List { + List toTagList() => map((studyTag) => studyTag.tag).toList(); +} diff --git a/core/lib/src/models/tables/study_tag.g.dart b/core/lib/src/models/tables/study_tag.g.dart new file mode 100644 index 000000000..49f853b20 --- /dev/null +++ b/core/lib/src/models/tables/study_tag.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'study_tag.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StudyTag _$StudyTagFromJson(Map json) => StudyTag( + studyId: json['study_id'] as String, + tagId: json['tag_id'] as String, + ); + +Map _$StudyTagToJson(StudyTag instance) => { + 'study_id': instance.studyId, + 'tag_id': instance.tagId, + }; diff --git a/core/lib/src/models/tables/tag.dart b/core/lib/src/models/tables/tag.dart new file mode 100644 index 000000000..fef312415 --- /dev/null +++ b/core/lib/src/models/tables/tag.dart @@ -0,0 +1,33 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:studyu_core/core.dart'; +import 'package:studyu_core/src/util/supabase_object.dart'; + +part 'tag.g.dart'; + +@JsonSerializable() +class Tag extends SupabaseObjectFunctions { + static const String tableName = 'tag'; + + @override + Map get primaryKeys => {'id': id}; + + @JsonKey(name: 'id') + String id; + @JsonKey(name: 'name') + String name; + + Tag({required this.id, required this.name}); + + factory Tag.fromJson(Map json) => _$TagFromJson(json); + + @override + Map toJson() => _$TagToJson(this); + + @override + bool operator ==(Object other) => + identical(this, other) || other is Tag && id == other.id && name == other.name; + + @override + int get hashCode => id.hashCode ^ name.hashCode; +} diff --git a/core/lib/src/models/tables/tag.g.dart b/core/lib/src/models/tables/tag.g.dart new file mode 100644 index 000000000..18b97e39d --- /dev/null +++ b/core/lib/src/models/tables/tag.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tag.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Tag _$TagFromJson(Map json) => Tag( + id: json['id'] as String, + name: json['name'] as String, + ); + +Map _$TagToJson(Tag instance) => { + 'id': instance.id, + 'name': instance.name, + }; diff --git a/core/lib/src/util/supabase_object.dart b/core/lib/src/util/supabase_object.dart index e67a66125..a1e0793df 100644 --- a/core/lib/src/util/supabase_object.dart +++ b/core/lib/src/util/supabase_object.dart @@ -26,6 +26,10 @@ String tableName(Type cls) { return StudyInvite.tableName; case StudyUUser: return StudyUUser.tableName; + case StudyTag: + return StudyTag.tableName; + case Tag: + return Tag.tableName; default: print('$cls is not a supported Supabase type'); throw TypeError(); @@ -49,6 +53,10 @@ abstract class SupabaseObjectFunctions implements Supa return StudyInvite.fromJson(json) as T; case StudyUUser: return StudyUUser.fromJson(json) as T; + case StudyTag: + return StudyTag.fromJson(json) as T; + case Tag: + return Tag.fromJson(json) as T; default: print('$T is not a supported Supabase type'); throw TypeError(); diff --git a/database/migrate_tags.sql b/database/migrate_tags.sql new file mode 100644 index 000000000..3b0b25be3 --- /dev/null +++ b/database/migrate_tags.sql @@ -0,0 +1,129 @@ +-- +-- Name: tag; Type: TABLE; Schema: public; Owner: supabase_admin +-- + +CREATE TABLE public.tag ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name text NOT NULL, + color text, + parent_id uuid + -- rename result_sharing to visibility + --visibility public.result_sharing NOT NULL DEFAULT 'private'::public.result_sharing, +); + +ALTER TABLE public.tag OWNER TO supabase_admin; + + +-- +-- Name: tag tag_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin +-- + +ALTER TABLE ONLY public.tag + ADD CONSTRAINT tag_pkey PRIMARY KEY (id); + + +-- +-- Name: study_tag; Type: TABLE; Schema: public; Owner: supabase_admin +-- + +CREATE TABLE public.study_tag ( + study_id uuid NOT NULL, + tag_id uuid NOT NULL +); + +ALTER TABLE public.study_tag OWNER TO supabase_admin; + + +-- +-- Name: study_tag study_tag_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin +-- + +ALTER TABLE ONLY public.study_tag + ADD CONSTRAINT "study_tag_pkey" PRIMARY KEY (study_id, tag_id); + + +-- +-- Name: tag tag_parentId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: supabase_admin +-- + +ALTER TABLE ONLY public.tag + ADD CONSTRAINT "tag_parentId_fkey" FOREIGN KEY (parent_id) REFERENCES public.tag(id) ON DELETE CASCADE; + + +-- +-- Name: tag study_tag_studyId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: supabase_admin +-- + +ALTER TABLE ONLY public.study_tag + ADD CONSTRAINT "study_tag_studyId_fkey" FOREIGN KEY (study_id) REFERENCES public.study(id) ON DELETE CASCADE; + + +-- +-- Name: tag study_tag_tagId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: supabase_admin +-- + +ALTER TABLE ONLY public.study_tag + ADD CONSTRAINT "study_tag_tagId_fkey" FOREIGN KEY (tag_id) REFERENCES public.tag(id) ON DELETE CASCADE; + + +-- TODO VERIFY all policies regarding anonymous select, update, insert, delete and authenticated behavior regarding auth.uid() + +-- +-- Name: study_tag Allow read access but deny write access for tags; Type: POLICY; Schema: public; Owner: supabase_admin +-- + +CREATE POLICY "Allow read access, deny write access" + ON public.tag + FOR SELECT + USING (true); + + +-- +-- Name: Allow study creators to manage tags; Type: POLICY; Schema: public; Owner: supabase_admin +-- + +CREATE POLICY "Allow study creators to manage tags" + ON public.study_tag + FOR ALL + USING ( + EXISTS ( + SELECT 1 + FROM study + WHERE study.id = study_tag.study_id + AND study.user_id = auth.uid() + ) + ); + + +-- +-- Name: Allow subscribed users to select study tags; Type: POLICY; Schema: public; Owner: supabase_admin +-- + +CREATE POLICY "Allow subscribed users to select study tags" + ON public.study_tag + FOR SELECT + USING ( + EXISTS ( + SELECT 1 + FROM public.study_subject + WHERE study_subject.study_id = study_tag.study_id + AND study_subject.user_id = auth.uid() + ) + ); + + +-- todo deny insert, delete, update for everyone else +-- todo deny select for everyone except study creators and users subscribed to the study + + +-- +-- Name: tag; Type: ROW SECURITY; Schema: public; Owner: supabase_admin +-- + +ALTER TABLE public.tag ENABLE ROW LEVEL SECURITY; + +-- +-- Name: study_tag; Type: ROW SECURITY; Schema: public; Owner: supabase_admin +-- + +ALTER TABLE public.study_tag ENABLE ROW LEVEL SECURITY; diff --git a/database/studyu-schema.sql b/database/studyu-schema.sql index b753c7445..ec40cf5f9 100644 --- a/database/studyu-schema.sql +++ b/database/studyu-schema.sql @@ -231,6 +231,64 @@ CREATE TABLE public.study_subject ( ALTER TABLE public.study_subject OWNER TO postgres; + +-- +-- Name: tag; Type: TABLE; Schema: public; Owner: +-- + +CREATE TABLE public.tag ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name text NOT NULL UNIQUE +); + + +ALTER TABLE public.tag OWNER TO postgres; + + +-- +-- Name: study_tag; Type: TABLE; Schema: public; Owner: +-- + +CREATE TABLE public.study_tag ( + study_id uuid NOT NULL, + tag_id uuid NOT NULL +); + +ALTER TABLE public.study_tag OWNER TO postgres; + + +-- +-- Name: tag tag_pkey; Type: CONSTRAINT; Schema: public; Owner: +-- + +ALTER TABLE ONLY public.tag + ADD CONSTRAINT tag_pkey PRIMARY KEY (id); + + +-- +-- Name: study_tag study_tag_pkey; Type: CONSTRAINT; Schema: public; Owner: +-- + +ALTER TABLE ONLY public.study_tag + ADD CONSTRAINT "study_tag_pkey" PRIMARY KEY (study_id, tag_id); + + +-- +-- Name: tag study_tag_studyId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: +-- + +ALTER TABLE ONLY public.study_tag + ADD CONSTRAINT "study_tag_studyId_fkey" FOREIGN KEY (study_id) REFERENCES public.study(id) ON DELETE CASCADE; + + +-- +-- Name: tag study_tag_tagId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: +-- + +ALTER TABLE ONLY public.study_tag + ADD CONSTRAINT "study_tag_tagId_fkey" FOREIGN KEY (tag_id) REFERENCES public.tag(id) ON DELETE CASCADE; + + -- -- Name: has_study_ended(public.study_subject); Type: FUNCTION; Schema: public; Owner: postgres -- @@ -832,6 +890,56 @@ CREATE POLICY "Users can do everything with their progress" ON public.subject_pr WHERE (study_subject.id = subject_progress.subject_id)))); +-- +-- Name: tag Allow read access but deny write access for tags; Type: POLICY; Schema: public; Owner: +-- + +-- todo we need function to automatically delete a tag if it is not used in any study + +CREATE POLICY "Allow read access to tags" + ON public.tag + FOR SELECT + USING (true); + +CREATE POLICY "Allow add new tags" + ON public.tag + FOR INSERT + WITH CHECK (true); + +-- +-- Name: Allow study creators to manage tags; Type: POLICY; Schema: public; Owner: +-- + +CREATE POLICY "Allow study creators to manage tags" + ON public.study_tag + FOR ALL + USING ( + EXISTS ( + SELECT 1 + FROM public.study + WHERE study.id = study_tag.study_id + AND study.user_id = auth.uid() + ) + ); + + +-- +-- Name: Allow subscribed users to select study tags; Type: POLICY; Schema: public; Owner: +-- + +CREATE POLICY "Allow subscribed users to select study tags" + ON public.study_tag + FOR SELECT + USING ( + EXISTS ( + SELECT 1 + FROM public.study_subject + WHERE study_subject.study_id = study_tag.study_id + AND study_subject.user_id = auth.uid() + ) + ); + + -- -- Name: Allow users to manage their own user; Type: POLICY; Schema: public; Owner: -- @@ -872,6 +980,18 @@ ALTER TABLE public.study_invite ENABLE ROW LEVEL SECURITY; ALTER TABLE public.study_subject ENABLE ROW LEVEL SECURITY; + +-- Name: tag; Type: ROW SECURITY; Schema: public; Owner: +-- + +ALTER TABLE public.tag ENABLE ROW LEVEL SECURITY; + +-- +-- Name: study_tag; Type: ROW SECURITY; Schema: public; Owner: +-- + +ALTER TABLE public.study_tag ENABLE ROW LEVEL SECURITY; + -- -- Name: subject_progress; Type: ROW SECURITY; Schema: public; Owner: postgres -- diff --git a/designer_v2/lib/common_views/reactive_textfield_tags.dart b/designer_v2/lib/common_views/reactive_textfield_tags.dart new file mode 100644 index 000000000..8f9ec2c93 --- /dev/null +++ b/designer_v2/lib/common_views/reactive_textfield_tags.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:reactive_forms/reactive_forms.dart'; +import 'package:studyu_designer_v2/features/forms/form_validation.dart'; +import 'package:textfield_tags/textfield_tags.dart'; + +class ReactiveTextfieldTags extends ReactiveFormField> { + ReactiveTextfieldTags({ + Key? key, + FormControl? formControl, + String? formControlName, + Map? validationMessages, + required List availableTags, + Function(List value)? onSubmittedCb, + Function(String tag)? validator, + String helperText = '', + String hintText = '', + }) : super( + key: key, + formControl: formControl, + formControlName: formControlName, + validationMessages: validationMessages, + builder: (ReactiveFormFieldState> field) { + TextField? tf; + final controller = TextfieldTagsController(); + return Autocomplete( + optionsViewBuilder: (context, onSelected, options) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 4.0), + child: Align( + alignment: Alignment.topCenter, + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final dynamic option = options.elementAt(index); + return TextButton( + onPressed: () { + onSelected(option); + tf!.onSubmitted!(option); + print("$option selected1"); + }, + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 15.0), + child: Text( + option, + textAlign: TextAlign.left, + style: const TextStyle( + color: Color.fromARGB(255, 74, 137, 92), + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + ); + }, + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text == '') { + return const Iterable.empty(); + } + return availableTags.where((String option) { + return option.contains(textEditingValue.text.toLowerCase()); + }); + }, + onSelected: (String selectedTag) { + final t = List.from(field.value!); + t.add(selectedTag); + field.didChange(t); + if (field.control.valid) { + onSubmittedCb?.call(t); + } + print("$selectedTag selected2"); + }, + fieldViewBuilder: (context, ttec, tfn, onFieldSubmitted) { + return TextFieldTags( + textEditingController: ttec, + focusNode: tfn, + textfieldTagsController: controller, + initialTags: field.value, + textSeparators: const [' ', ','], + letterCase: LetterCase.normal, + validator: (String tag) { + print("validate jetzt"); + return field.control.validationErrorMessages.isEmpty ? null : field.control.validationErrorMessages.first.second; + }, + inputfieldBuilder: + (context, tec, fn, error, onChanged, onSubmitted) { + return ((context, sc, tags, onTagDelete) { + if (field.control.valid) { + onSubmittedCb?.call(tags); + } + print("$tags inputfieldBuilder"); + tf = TextField( + controller: tec, + focusNode: fn, + decoration: InputDecoration( + border: const UnderlineInputBorder( + borderSide: BorderSide( + color: Color.fromARGB(255, 74, 137, 92), + width: 3.0), + ), + focusedBorder: const UnderlineInputBorder( + borderSide: BorderSide( + color: Color.fromARGB(255, 74, 137, 92), + width: 3.0), + ), + helperText: helperText, + helperStyle: const TextStyle( + color: Color.fromARGB(255, 74, 137, 92), + ), + hintText: hintText, + errorText: error, + prefixIcon: tags.isNotEmpty + ? SingleChildScrollView( + controller: sc, + scrollDirection: Axis.horizontal, + child: Row( + children: tags.map((String tag) { + return Container( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all( + Radius.circular(20.0), + ), + color: Color.fromARGB( + 255, 74, 137, 92), + ), + margin: const EdgeInsets.only( + right: 10.0), + padding: const EdgeInsets.symmetric( + horizontal: 10.0, vertical: 4.0), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + InkWell( + child: Text( + tag, + style: const TextStyle( + color: Colors.white), + ), + onTap: () { + print("$tag selected9"); + }, + ), + const SizedBox(width: 4.0), + InkWell( + child: const Icon( + Icons.cancel, + size: 14.0, + color: Color.fromARGB( + 255, 233, 233, 233), + ), + onTap: () { + final t = List.from(tags); + t.remove(tag); + field.didChange(t); + onTagDelete(tag); + }, + ) + ], + ), + ); + }).toList()), + ) + : null, + ), + onChanged: (value) { + onChanged!(value); + }, + onSubmitted: (value) { + final t = List.from(tags); + t.add(value); + field.didChange(t); + onSubmitted!(value); + print("$value submitted3"); + //tf!.onSubmitted!(value); + }, + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: tf, + ); + }); + }, + ); + }, + ); + } + ); +} diff --git a/designer_v2/lib/common_views/study_tag_badge.dart b/designer_v2/lib/common_views/study_tag_badge.dart new file mode 100644 index 000000000..db3b94bef --- /dev/null +++ b/designer_v2/lib/common_views/study_tag_badge.dart @@ -0,0 +1 @@ +// delme \ No newline at end of file diff --git a/designer_v2/lib/domain/study.dart b/designer_v2/lib/domain/study.dart index 9edfb23bb..a0bcd708c 100644 --- a/designer_v2/lib/domain/study.dart +++ b/designer_v2/lib/domain/study.dart @@ -132,6 +132,7 @@ extension StudyDuplicateX on Study { } Study copyJsonIgnoredAttributes({required Study from, createdAt = false}) { + studyTags = from.studyTags; participantCount = from.participantCount; activeSubjectCount = from.activeSubjectCount; endedCount = from.endedCount; @@ -147,6 +148,7 @@ extension StudyDuplicateX on Study { } Study resetJsonIgnoredAttributes() { + studyTags = []; participantCount = 0; activeSubjectCount = 0; endedCount = 0; diff --git a/designer_v2/lib/features/dashboard/studies_table.dart b/designer_v2/lib/features/dashboard/studies_table.dart index b2cb9a4e9..d653a287e 100644 --- a/designer_v2/lib/features/dashboard/studies_table.dart +++ b/designer_v2/lib/features/dashboard/studies_table.dart @@ -4,6 +4,7 @@ import 'package:studyu_core/core.dart'; import 'package:studyu_designer_v2/common_views/action_popup_menu.dart'; import 'package:studyu_designer_v2/common_views/mouse_events.dart'; import 'package:studyu_designer_v2/common_views/standard_table.dart'; +import 'package:studyu_designer_v2/common_views/badge.dart' as studybadge; import 'package:studyu_designer_v2/features/dashboard/dashboard_controller.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; import 'package:studyu_designer_v2/theme.dart'; @@ -157,7 +158,34 @@ class StudiesTable extends StatelessWidget { ); }, ), - Text(item.title ?? '[Missing study title]'), + item.studyTags.isEmpty + ? Text(item.title ?? '[Missing study title]') + : Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(item.title ?? '[Missing study title]'), + const SizedBox(height: 8.0), + Wrap( + spacing: 8.0, + children: List.generate(item.studyTags.length, (index) { + // todo either use [StudyTagBadge] here or use [studybadge.Badge] at [StudyDesignInfoFormView] + // try to style [StudyTagBadge] the same as Badge here because of delete function. + // then replace this badge here with [StudyTagBadge] + return MouseEventsRegion( + //onTap: () => dashboardController.filterTag.add(item.studyTags.elementAt(index)), + onTap: () { + dashboardController.setSearchText(item.studyTags.elementAt(index).name); + dashboardController.filterStudies(item.studyTags.elementAt(index).name); + }, + builder: (context, mouseEventState) { + return studybadge.Badge( + label: item.studyTags.elementAt(index).name, + type: studybadge.BadgeType.outlineFill, + icon: null, + ); + }, + ); + }), + ), + ]), StudyStatusBadge( status: item.status, showPrefixIcon: false, diff --git a/designer_v2/lib/features/design/info/study_info_form_controller.dart b/designer_v2/lib/features/design/info/study_info_form_controller.dart index 5b67da35f..7065761be 100644 --- a/designer_v2/lib/features/design/info/study_info_form_controller.dart +++ b/designer_v2/lib/features/design/info/study_info_form_controller.dart @@ -6,11 +6,13 @@ import 'package:studyu_designer_v2/features/design/study_form_validation.dart'; import 'package:studyu_designer_v2/features/forms/form_validation.dart'; import 'package:studyu_designer_v2/features/forms/form_view_model.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; +import 'package:studyu_designer_v2/repositories/study_tag_repository.dart'; import 'package:studyu_designer_v2/utils/validation.dart'; class StudyInfoFormViewModel extends FormViewModel { StudyInfoFormViewModel({ required this.study, + required this.studyTagsRepository, super.delegate, super.formData, super.autosave = true, @@ -18,12 +20,18 @@ class StudyInfoFormViewModel extends FormViewModel { }); final Study study; + final IStudyTagRepository studyTagsRepository; // - Form fields final FormControl titleControl = FormControl(); final FormControl iconControl = FormControl(); final FormControl descriptionControl = FormControl(); + final FormControl> studyTagsControl = FormControl( + validators: [ + const TagsValidator(), + ], + ); final FormControl organizationControl = FormControl(); final FormControl reviewBoardControl = FormControl(); final FormControl reviewBoardNumberControl = FormControl(); @@ -38,6 +46,7 @@ class StudyInfoFormViewModel extends FormViewModel { 'title': titleControl, 'icon': iconControl, 'description': descriptionControl, + 'studyTags': studyTagsControl, 'organization': organizationControl, 'institutionalReviewBoard': reviewBoardControl, 'institutionalReviewBoardNumber': reviewBoardNumberControl, @@ -53,6 +62,7 @@ class StudyInfoFormViewModel extends FormViewModel { titleControl.value = data.title; iconControl.value = IconOption(data.iconName); descriptionControl.value = data.description; + studyTagsControl.value = data.studyTags.map((e) => e.name).toList(); organizationControl.value = data.contactInfoFormData.organization; reviewBoardControl.value = data.contactInfoFormData.institutionalReviewBoard; reviewBoardNumberControl.value = data.contactInfoFormData.institutionalReviewBoardNumber; @@ -69,6 +79,7 @@ class StudyInfoFormViewModel extends FormViewModel { title: titleControl.value!, // required iconName: iconControl.value?.name ?? '', description: descriptionControl.value, + studyTags: studyTagsControl.value!.map((e) => study.studyTags.firstWhere((element) => element.name == e)).toList(), contactInfoFormData: StudyContactInfoFormData( organization: organizationControl.value, institutionalReviewBoard: reviewBoardControl.value, @@ -94,7 +105,7 @@ class StudyInfoFormViewModel extends FormViewModel { @override FormValidationConfigSet get sharedValidationConfig => { // TODO phoneFormat - StudyFormValidationSet.draft: [titleRequired, emailFormat, websiteFormat], + StudyFormValidationSet.draft: [titleRequired, emailFormat, websiteFormat, tagsFormat], StudyFormValidationSet.publish: [ titleRequired, descriptionRequired, @@ -107,6 +118,7 @@ class StudyInfoFormViewModel extends FormViewModel { phoneRequired, emailFormat, websiteFormat, + tagsFormat, ], StudyFormValidationSet.test: [titleRequired], }; @@ -166,4 +178,25 @@ class StudyInfoFormViewModel extends FormViewModel { ], validationMessages: { 'pattern': (error) => tr.form_field_website_pattern, }); + + get tagsFormat => FormControlValidation(control: studyTagsControl, validators: [ + const TagsValidator(), + ], validationMessages: { + 'validateTags': (error) => tr.form_field_study_tags_error_length(3), + }); +} + +class TagsValidator extends Validator { + const TagsValidator() : super(); + + @override + Map? validate(AbstractControl control) { + print("accessing validator"); + return control.isNotNull && + control.value is List && + control.value.length <= 3 + // todo check if studyTagsControl already contains the new tag + ? null + : {'validateTags': true}; + } } diff --git a/designer_v2/lib/features/design/info/study_info_form_data.dart b/designer_v2/lib/features/design/info/study_info_form_data.dart index a3169b94c..69a827bea 100644 --- a/designer_v2/lib/features/design/info/study_info_form_data.dart +++ b/designer_v2/lib/features/design/info/study_info_form_data.dart @@ -5,12 +5,14 @@ class StudyInfoFormData implements IStudyFormData { StudyInfoFormData({ required this.title, this.description, + required this.studyTags, required this.contactInfoFormData, required this.iconName, }); final String title; final String? description; + final List studyTags; final String iconName; final StudyContactInfoFormData contactInfoFormData; @@ -18,6 +20,7 @@ class StudyInfoFormData implements IStudyFormData { return StudyInfoFormData( title: study.title ?? '', description: study.description ?? '', + studyTags: study.studyTags, iconName: study.iconName, contactInfoFormData: StudyContactInfoFormData.fromStudy(study), ); @@ -27,6 +30,7 @@ class StudyInfoFormData implements IStudyFormData { Study apply(Study study) { study.title = title; study.description = description; + study.studyTags = studyTags; study.iconName = iconName; contactInfoFormData.apply(study); return study; diff --git a/designer_v2/lib/features/design/info/study_info_form_view.dart b/designer_v2/lib/features/design/info/study_info_form_view.dart index 53b22b3ec..04d9d39fb 100644 --- a/designer_v2/lib/features/design/info/study_info_form_view.dart +++ b/designer_v2/lib/features/design/info/study_info_form_view.dart @@ -6,12 +6,15 @@ import 'package:studyu_core/core.dart'; import 'package:studyu_designer_v2/common_views/async_value_widget.dart'; import 'package:studyu_designer_v2/common_views/form_table_layout.dart'; import 'package:studyu_designer_v2/common_views/icon_picker.dart'; +import 'package:studyu_designer_v2/common_views/reactive_textfield_tags.dart'; import 'package:studyu_designer_v2/common_views/text_paragraph.dart'; import 'package:studyu_designer_v2/features/design/study_design_page_view.dart'; import 'package:studyu_designer_v2/features/design/study_form_providers.dart'; import 'package:studyu_designer_v2/features/forms/form_validation.dart'; import 'package:studyu_designer_v2/features/study/study_controller.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; +import 'package:studyu_designer_v2/repositories/study_tag_repository.dart'; +import 'package:studyu_designer_v2/repositories/tag_repository.dart'; class StudyDesignInfoFormView extends StudyDesignPageWidget { const StudyDesignInfoFormView(super.studyId, {super.key}); @@ -19,11 +22,12 @@ class StudyDesignInfoFormView extends StudyDesignPageWidget { @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(studyControllerProvider(studyId)); - return AsyncValueWidget( value: state.study, data: (study) { final formViewModel = ref.read(studyInfoFormViewModelProvider(studyId)); + final tags = ref.watch(tagRepositoryProvider); + final studyTags = ref.watch(studyTagRepositoryProvider(studyId)); return ReactiveForm( formGroup: formViewModel.form, child: Column( @@ -79,6 +83,52 @@ class StudyDesignInfoFormView extends StudyDesignPageWidget { decoration: InputDecoration(hintText: tr.form_field_study_description_hint), ), ), + FormTableRow( + control: formViewModel.studyTagsControl, + label: tr.form_field_study_tags, + labelHelpText: tr.form_field_study_tags_tooltip, + input: FutureBuilder>( + future: tags.fetchAll(), + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.hasData) { + return Row(children: [ + Expanded( + child: ReactiveTextfieldTags( + formControl: formViewModel.studyTagsControl, + availableTags: snapshot.data!.map((e) => e.name).toList(), + validationMessages: formViewModel.studyTagsControl.validationMessages, + onSubmittedCb: (tags) { + studyTags.updateStudyTags(tags); + }, + validator: (tag) { + + }, + helperText: tr.form_field_study_tags_helper(3), + hintText: tr.form_field_study_tags_hint, + ) + ) + ]); + /*ReactiveTextfieldTags( + formControl: formViewModel.studyTagsControl, + availableTags: snapshot.data!.map((e) => e.name).toList(), + ),*/ + /*Expanded( + child: ReactiveMul#tiSelectDialogField( + formControl: formViewModel.studyTagsControl, + items: snapshot.data! + .map((e) => + MultiSelectItem(StudyTag.fromTag(tag: e, studyId: studyId), e.name)) + .toList(), + dialogHeight: 200, + dialogWidth: 300, + searchable: true, + chipDisplay: display, + ))*/ + } else { + return const SizedBox(); + } + }), + ), ], columnWidths: const { 0: FixedColumnWidth(185.0), 1: FlexColumnWidth(), diff --git a/designer_v2/lib/features/design/study_form_controller.dart b/designer_v2/lib/features/design/study_form_controller.dart index 1817a1d02..67f9dee42 100644 --- a/designer_v2/lib/features/design/study_form_controller.dart +++ b/designer_v2/lib/features/design/study_form_controller.dart @@ -22,12 +22,14 @@ import 'package:studyu_designer_v2/features/forms/form_view_model.dart'; import 'package:studyu_designer_v2/features/study/study_controller.dart'; import 'package:studyu_designer_v2/repositories/auth_repository.dart'; import 'package:studyu_designer_v2/repositories/study_repository.dart'; +import 'package:studyu_designer_v2/repositories/study_tag_repository.dart'; import 'package:studyu_designer_v2/routing/router.dart'; class StudyFormViewModel extends FormViewModel implements IFormViewModelDelegate { StudyFormViewModel({ required this.router, required this.studyRepository, + required this.studyTagsRepository, required this.authRepository, required super.formData, // Study super.validationSet = StudyFormValidationSet.draft, @@ -41,6 +43,7 @@ class StudyFormViewModel extends FormViewModel implements IFormViewModelD Study? studyDirtyCopy; final IStudyRepository studyRepository; + final IStudyTagRepository studyTagsRepository; final IAuthRepository authRepository; final GoRouter router; @@ -48,6 +51,7 @@ class StudyFormViewModel extends FormViewModel implements IFormViewModelD late final StudyInfoFormViewModel studyInfoFormViewModel = StudyInfoFormViewModel( formData: StudyInfoFormData.fromStudy(formData!), + studyTagsRepository: studyTagsRepository, delegate: this, study: formData!, validationSet: validationSet, @@ -167,6 +171,7 @@ final studyFormViewModelProvider = Provider.autoDispose.family saveStudyInvite(StudyInvite invite); Future fetchStudyInvite(String code); Future deleteStudyInvite(StudyInvite invite); + Future> fetchAllTags(); + Future> fetchTag(); + Future saveTag(Tag tag); + Future deleteTag(Tag tag); + Future saveStudyTag(StudyTag studyTag); + Future deleteStudyTag(StudyTag studyTag); Future> deleteParticipants(Study study, List participants); /* Future> deleteStudyProgress( @@ -63,6 +69,7 @@ class StudyUApiClient extends SupabaseClientDependant with SupabaseQueryMixin im static final studyColumns = [ '*', + 'study_tags:tag(*)', 'repo(*)', 'study_invite!study_invite_studyId_fkey(*)', 'study_participant_count', @@ -168,6 +175,50 @@ class StudyUApiClient extends SupabaseClientDependant with SupabaseQueryMixin im return _awaitGuarded(request); } + @override + Future> fetchAllTags() async { + await _testDelay(); + final request = getAll(); + return _awaitGuarded(request); + } + + @override + Future> fetchTag() async { + await _testDelay(); + final request = getAll(); + return _awaitGuarded(request); + } + + @override + Future saveTag(Tag tag) async { + await _testDelay(); + final Future request = tag.save(); + return _awaitGuarded(request); + } + + @override + Future deleteTag(Tag tag) async { + await _testDelay(); + // todo do not delete if tag is part of another study + final request = tag.delete(); + return _awaitGuarded(request); + } + + @override + Future saveStudyTag(StudyTag studyTag) async { + await _testDelay(); + final Future request = studyTag.save(); + return _awaitGuarded(request); + } + + @override + Future deleteStudyTag(StudyTag studyTag) async { + await _testDelay(); + // Delegate to [SupabaseObjectMethods] + final request = studyTag.delete(); + return _awaitGuarded(request); + } + @override Future fetchAppConfig() async { final request = AppConfig.getAppConfig(); diff --git a/designer_v2/lib/repositories/study_tag_repository.dart b/designer_v2/lib/repositories/study_tag_repository.dart new file mode 100644 index 000000000..657315a3d --- /dev/null +++ b/designer_v2/lib/repositories/study_tag_repository.dart @@ -0,0 +1,181 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:studyu_core/core.dart'; +import 'package:studyu_designer_v2/domain/study.dart'; +import 'package:studyu_designer_v2/repositories/api_client.dart'; +import 'package:studyu_designer_v2/repositories/auth_repository.dart'; +import 'package:studyu_designer_v2/repositories/model_repository.dart'; +import 'package:studyu_designer_v2/repositories/study_repository.dart'; +import 'package:studyu_designer_v2/repositories/tag_repository.dart'; +import 'package:studyu_designer_v2/utils/extensions.dart'; +import 'package:studyu_designer_v2/utils/optimistic_update.dart'; + +abstract class IStudyTagRepository implements ModelRepository { + void updateStudyTags(List tagsToUpdate); +} + +class StudyTagRepository extends ModelRepository implements IStudyTagRepository { + StudyTagRepository({ + required this.studyId, + required this.apiClient, + required this.authRepository, + required this.studyRepository, + required this.ref, + required this.tagRepository, + }) : super(StudyTagRepositoryDelegate( + study: studyRepository.get(studyId)!.model, apiClient: apiClient, studyRepository: studyRepository)); + + /// The [Study] this repository operates on + final StudyID studyId; + + Study get study => studyRepository.get(studyId)!.model; + + /// Reference to Riverpod's context to resolve dependencies in callbacks + final ProviderRef ref; + + final StudyUApi apiClient; + final IAuthRepository authRepository; + final IStudyRepository studyRepository; + final ITagRepository tagRepository; + + @override + ModelID getKey(StudyTag model) { + return model.id; + } + + @override + void updateStudyTags(List studyTagStringsToUpdate) async { + final currentStudyTags = study.studyTags.toSet(); + final allTags = (await tagRepository.fetchAll()); + final futureTags = []; + print("updateStudyTags $studyTagStringsToUpdate"); + for (String tagNameToUpdate in studyTagStringsToUpdate) { + final maybeTag = allTags.firstWhereOrNull((element) => + element.name == tagNameToUpdate); + if (maybeTag == null) { + print("create new tag $tagNameToUpdate"); + final newTag = await tagRepository.createTagIfNotExists(tagNameToUpdate); + futureTags.add(StudyTag.fromTag(tag: newTag, studyId: studyId)); + } else { + futureTags.add(StudyTag.fromTag(tag: maybeTag, studyId: studyId)); + } + } + for (StudyTag toSave in futureTags.toSet().difference(currentStudyTags)) { + await delegate.save(toSave); + } + for (StudyTag toDelete in currentStudyTags.difference(futureTags.toSet())) { + await delegate.delete(toDelete); + } + } + + @override + emitUpdate() { + print("StudyTagRepository.emitUpdate"); + super.emitUpdate(); + } +} + +class StudyTagRepositoryDelegate extends IModelRepositoryDelegate { + StudyTagRepositoryDelegate({ + required this.study, + required this.apiClient, + required this.studyRepository, + }); + + final Study study; + final StudyUApi apiClient; + final IStudyRepository studyRepository; + + @override + Future fetch(ModelID modelId) { + return Future.value(study.studyTags.firstWhere((element) => element.id == modelId)); + } + + @override + Future> fetchAll() { + return Future.value(study.studyTags); + } + + @override + Future save(StudyTag model) async { + print("save"); + final saveOperation = OptimisticUpdate( + applyOptimistic: () { + final idx = study.studyTags.indexWhere((i) => i.id == model.id); + if (idx == -1) { + study.studyTags.add(model); + } else { + study.studyTags[idx] = model; + } + studyRepository.upsertLocally(study); + }, + apply: () async { + await apiClient.saveStudyTag(model); + }, + rollback: () { + study.studyTags.remove(model); + studyRepository.upsertLocally(study); + }, + onUpdate: () { + print("saveOperation: studyRepository.emitUpdate()"); + studyRepository.emitUpdate(); + }, + rethrowErrors: true, + ); + + return saveOperation.execute().then((_) => model); + } + + @override + Future delete(StudyTag model) { + final prevStudyTags = [...study.studyTags]; + final deleteOperation = OptimisticUpdate( + applyOptimistic: () { + study.studyTags.remove(model); + studyRepository.upsertLocally(study); + }, + apply: () => apiClient.deleteStudyTag(model), + rollback: () { + study.studyTags = prevStudyTags; + studyRepository.upsertLocally(study); + }, + onUpdate: studyRepository.emitUpdate, + rethrowErrors: true, + ); + + return deleteOperation.execute(); + } + + @override + onError(Object error, StackTrace? stackTrace) { + return; // TODO + } + + @override + StudyTag createDuplicate(StudyTag model) { + throw UnimplementedError(); // not available + } + + @override + StudyTag createNewInstance() { + throw UnimplementedError(); // not available + } +} + +final studyTagRepositoryProvider = Provider.autoDispose.family((ref, studyId) { + print("studyTagRepositoryProvider"); + // Initialize repository for a given study + final repository = StudyTagRepository( + studyId: studyId, + apiClient: ref.watch(apiClientProvider), + authRepository: ref.watch(authRepositoryProvider), + studyRepository: ref.watch(studyRepositoryProvider), + tagRepository: ref.watch(tagRepositoryProvider), + ref: ref, + ); + // Bind lifecycle to Riverpod + ref.onDispose(() { + print("studyTagRepositoryProvider.DISPOSE"); + repository.dispose(); + }); + return repository; +}); diff --git a/designer_v2/lib/repositories/tag_repository.dart b/designer_v2/lib/repositories/tag_repository.dart new file mode 100644 index 000000000..c9f5a2cc3 --- /dev/null +++ b/designer_v2/lib/repositories/tag_repository.dart @@ -0,0 +1,48 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:studyu_core/core.dart'; +import 'package:studyu_designer_v2/repositories/api_client.dart'; +import 'package:studyu_designer_v2/utils/extensions.dart'; +import 'package:uuid/uuid.dart'; + +abstract class ITagRepository { + Future> fetchAll(); + Future createTagIfNotExists(String tagName); + Future save(Tag tag); + Future delete(Tag tag); +} + +class TagRepository implements ITagRepository { + TagRepository(this.apiClient); + + final StudyUApi apiClient; + + @override + Future> fetchAll() { + return apiClient.fetchAllTags(); + } + + @override + Future createTagIfNotExists(String tagName) async { + final maybeTag = (await fetchAll()).firstWhereOrNull((element) => element.name == tagName); + if (maybeTag == null) { + return await save(Tag(name: tagName, id: const Uuid().v4())); + } + return Future.value(maybeTag); + } + + @override + Future save(Tag tag) { + print("try to save tag ${tag.name}"); + return apiClient.saveTag(tag); + } + + @override + Future delete(Tag tag) { + return apiClient.deleteTag(tag); + } +} + +final tagRepositoryProvider = Provider((ref) { + final apiClient = ref.watch(apiClientProvider); + return TagRepository(apiClient); +}); diff --git a/designer_v2/lib/repositories/user_repository.dart b/designer_v2/lib/repositories/user_repository.dart index 21293958e..ac0aafc8a 100644 --- a/designer_v2/lib/repositories/user_repository.dart +++ b/designer_v2/lib/repositories/user_repository.dart @@ -36,6 +36,32 @@ class UserRepository implements IUserRepository { Future saveUser() async { user = await apiClient.saveUser(user); return user; + /*final saveOperation = OptimisticUpdate( + applyOptimistic: () { + final idx = study.studyTags.indexWhere((i) => i.id == model.id); + if (idx == -1) { + study.studyTags.add(model); + } else { + study.studyTags[idx] = model; + } + studyRepository.upsertLocally(study); + }, + apply: () async { + await studyRepository.ensurePersisted(model.id); + await apiClient.saveStudyTag(model); + }, + rollback: () { + study.studyTags.remove(model); + studyRepository.upsertLocally(study); + }, + onUpdate: () { + print("saveOperation: studyRepository.emitUpdate()"); + studyRepository.emitUpdate(); + }, + rethrowErrors: true, + ); + + return saveOperation.execute().then((_) => model);*/ } @override @@ -54,6 +80,74 @@ class UserRepository implements IUserRepository { } } +/*class UserRepositoryDelegate extends IModelRepositoryDelegate { + UserRepositoryDelegate({ required this.apiClient }) { + throw UnimplementedError(); + } + + final StudyUApi apiClient; + + @override + Future fetch(ModelID modelId) { + return Future.value(study.studyTags.firstWhere((element) => element.id == modelId)); + } + + @override + Future save(StudyUUser model) async { + final saveOperation = OptimisticUpdate( + applyOptimistic: () { + final idx = study.studyTags.indexWhere((i) => i.id == model.id); + if (idx == -1) { + study.studyTags.add(model); + } else { + study.studyTags[idx] = model; + } + studyRepository.upsertLocally(study); + }, + apply: () async { + //await studyRepository.ensurePersisted(model.id); + await apiClient.saveStudyTag(model); + }, + rollback: () { + study.studyTags.remove(model); + studyRepository.upsertLocally(study); + }, + onUpdate: () { + print("saveOperation: studyRepository.emitUpdate()"); + studyRepository.emitUpdate(); + }, + rethrowErrors: true, + ); + + return saveOperation.execute().then((_) => model); + } + + @override + Future delete(StudyUUser model) { + throw UnimplementedError(); + } + + @override + onError(Object error, StackTrace? stackTrace) { + return; + } + + @override + StudyUUser createDuplicate(StudyUUser model) { + throw UnimplementedError(); // not available + } + + @override + StudyUUser createNewInstance() { + throw UnimplementedError(); // not available + } + + @override + Future> fetchAll() { + throw UnimplementedError(); // not available + } +}*/ + final userRepositoryProvider = Provider.autoDispose((ref) { print("userRepositoryProvider"); final apiClient = ref.watch(apiClientProvider); diff --git a/designer_v2/pubspec.lock b/designer_v2/pubspec.lock index a765b4d13..2d6eab0f1 100644 --- a/designer_v2/pubspec.lock +++ b/designer_v2/pubspec.lock @@ -688,6 +688,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + textfield_tags: + dependency: "direct main" + description: + name: textfield_tags + sha256: c1d215f481e7e8da5c79719825e595db4f829bf1ad3fce4c7ce43d340aa72683 + url: "https://pub.dev" + source: hosted + version: "2.0.2" typed_data: dependency: transitive description: diff --git a/designer_v2/pubspec.yaml b/designer_v2/pubspec.yaml index b6dde446b..072b933c2 100644 --- a/designer_v2/pubspec.yaml +++ b/designer_v2/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: studyu_flutter_common: ^1.6.2-dev.8 supabase: ^1.9.5 supabase_flutter: ^1.10.6 + textfield_tags: ^2.0.2 url_launcher: ^6.1.11 uuid: ^3.0.7