Many-to-many checkboxes revisited, part 11: Attaching to an ABC browse

by Unknown user

Read Part 10

I originally chose the School.app as my test bed for applying my many-to-many checkbox classes to an ABC app. I had a browse that looked like this:

After looking at the data dictionary a bit more closely I discovered a number of things I didn't like, chief among them the lack of an unexposed autonumbered primary key in each table. I  changed the primary keys to use new autonumbered fields. I made a few other changes to make the dictionary suit my purposes better, but I won't bother going into them here because this is an article about managing many-to-many links not about writing course registration software. 

The second time it's a template

Eventually I'll want a template I can throw at this procedure, but my first step in writing almost any template is to write the source I want generated and verify that it works. Then all the template has to do is reproduce the code I wrote, which makes writing the template much simpler since I'm not also debugging the generated code.

An ABC persister

I previously created a persister class that can write the linking data to a TPS file, and looking at that code I can see that it contains much of the code that I'll need if I want to write to any file managed by the ABC classes. But I don't want to derive my ABC persister from that class because CML_Data_ManyToManyLinksPersisterForTPS contains an actual definition of a TPS file which I won't use and which will only confuse the situation. 

In a case like this where I have two classes that will share a lot of code, the logical solution is to remove that common code to a class that can be the parent to both classes. 

My first thought is to call this new parent class CML_Data_ManyToManyLinksPersisterForFile since it contains the standard file handling code. 

And before I get it working for ABC classes I really should make sure that the refactored CML_Data_ManyToManyLinksPersisterForTPS class still does its job after it is derived from CML_Data_ManyToManyLinksPersisterForFile. 

But I have another problem. When I look at CML_Data_ManyToManyLinksPersisterForTPS I see that it's derived from CML_Data_ManyToManyLinksPersister, and that class implements code for three methods:

 

CML_Data_ManyToManyLinksPersister.CloseDataFile procedure
    code
    if not self.DataFile &= null
        close(self.DataFile)
    end
 
CML_Data_ManyToManyLinksPersister.OpenDataFile  procedure!,bool
    code
    dbg.write('CML_Data_ManyToManyLinksPersister.OpenDataFile')
    self.CloseDataFile()
    if not self.DataFile &= null
        share(self.DataFile)
        if errorcode()
            create(self.DataFile)
            if errorcode()
                message('Unable to create data file ' & self.DataFile{prop:name} & ' ' & error())
                return false
            end
            share(self.DataFile)
            if errorcode()
                message('Unable to open data file ' & self.DataFile{prop:name} & ' ' & error())
                return false
            end
        end
    end
    dbg.write('returning true')
    return true


CML_Data_ManyToManyLinksPersister.Save          procedure(long leftRecordID,CML_Data_ManyToManyLinksDataQ linksDataQ)
x                                                   long
    code
    if self.OpenDataFile()
        dbg.write('CML_Data_ManyToManyLinksPersister.Save')
        loop x = 1 to records(linksDataQ)
            get(LinksDataQ,x)
            if LinksDataQ.IsLinked and not LinksDataQ.IsPersisted
                self.AddLinkRecord(LinksDataQ.LeftRecordID,LinksDataQ.RightRecordID)
                LinksDataQ.IsPersisted = true
                put(linksDataQ)
            elsif not LinksDataQ.IsLinked and LinksDataQ.IsPersisted
                self.RemoveLinkRecord(LinksDataQ.LeftRecordID,LinksDataQ.RightRecordID)
                LinksDataQ.IsPersisted = false
                put(linksDataQ)
            end
        end
        self.CloseDataFile()
    end

The third method is no problem, but the first two methods will absolutely not work for ABC (or for that matter Legacy) files as they will do an end-run around the standard open counting. So that code needs to be moved up to the TPS persister class; once I've done that I can use CML_Data_ManyToManyLinksPersister as the base class for CML_Data_ManyToManyLinksPersisterForABC and I will not need CML_Data_ManyToManyLinksPersisterForFile (at least not right now). 

The CML_Data_ManyToManyLinksPersister methods now look like this:

CML_Data_ManyToManyLinksPersister.Construct             Procedure()
    code
CML_Data_ManyToManyLinksPersister.Destruct              Procedure()
    code
CML_Data_ManyToManyLinksPersister.AddLinkRecord         procedure(long leftRecordID,long rightRecordID)!,bool,proc,virtual
    code
    return false
    
CML_Data_ManyToManyLinksPersister.CloseDataFile         procedure!,bool,proc,virtual
    code
    return false
    
CML_Data_ManyToManyLinksPersister.LoadAllLinkingData    procedure(long leftRecordID,CML_Data_ManyToManyLinksDataQ linksDataQ)!,bool,proc,virtual
    code
    return false
    
CML_Data_ManyToManyLinksPersister.OpenDataFile          procedure!,bool,proc,virtual
    code
    return false
CML_Data_ManyToManyLinksPersister.RemoveLinkRecord      procedure(long leftRecordID,long rightRecordID)!,bool,proc,virtual
    code
    return false
CML_Data_ManyToManyLinksPersister.Save                  procedure(long leftRecordID,CML_Data_ManyToManyLinksDataQ linksDataQ)!,bool,proc,virtual
x                                                   long
    code
    if self.OpenDataFile()
        !dbg.write('CML_Data_ManyToManyLinksPersister.Save')
        loop x = 1 to records(linksDataQ)
            get(LinksDataQ,x)
            !dbg.write('Got LinksDataQ record ' & x)
            !dbg.write('LinksDataQ.IsLinked ' & LinksDataQ.IsLinked)
            !dbg.write('LinksDataQ.IsPersisted ' & LinksDataQ.IsPersisted)
            if LinksDataQ.IsLinked and not LinksDataQ.IsPersisted
                self.AddLinkRecord(LinksDataQ.LeftRecordID,LinksDataQ.RightRecordID)
                LinksDataQ.IsPersisted = true
                put(linksDataQ)
            elsif not LinksDataQ.IsLinked and LinksDataQ.IsPersisted
                self.RemoveLinkRecord(LinksDataQ.LeftRecordID,LinksDataQ.RightRecordID)
                LinksDataQ.IsPersisted = false
                put(linksDataQ)
            end
        end
        return self.CloseDataFile()
    end
    return false
    

As you can see the only method that is still implemented is Save(), because that's the only code that's truly common in all situations. Everything else can change depending on the kind of data store in use (which isn't necessarily a traditional database). 

I moved the open and close code up to CML_Data_ManyToManyLinksPersisterForTPS, but as that's pretty much a throwaway class I won't go into the details here. I did recompile my UITest application and the class does function as expected, so all's well there. 

Here's the declaration for CML_Data_ManyToManyLinksPersisterForABC. Note that it references ABFile.inc because the class contains a reference to the ABC FileManager object.  

    include('CML_IncludeInAllClassHeaderFiles.inc'),once
    Include('CML_Data_ManyToManyLinksPersister.inc'),Once
    include('ABFile.inc'),once

CML_Data_ManyToManyLinksPersisterForABC         Class(CML_Data_ManyToManyLinksPersister),Type,Module('CML_Data_ManyToManyLinksPersisterForABC.CLW'),Link('CML_Data_ManyToManyLinksPersisterForABC.CLW',_CML_Classes_LinkMode_),Dll(_CML_Classes_DllMode_)
Initialized                                         bool,private
LinkFileKey                                         &key,protected
LinkFileLeftIDField                                 any,protected
LinkFileManager                                     &FileManager,protected
LinkFileRightIDField                                any,protected
ManageFileOpenAndClose                              bool
Construct                                           Procedure()
Destruct                                            Procedure()
AddLinkRecord                                       procedure(long leftRecordID,long rightRecordID),bool,proc,derived
CloseDataFile                                       procedure,bool,proc,derived
Init                                                procedure(FileManager linkFileManager,*key LinkFileKey,*? LinkFileLeftIDField, *? LinkFileRightIDField)
LoadAllLinkingData                                  procedure(long leftRecordID,CML_Data_ManyToManyLinksDataQ linksDataQ),bool,proc,derived
OpenDataFile                                        procedure,bool,proc,derived
RemoveLinkRecord                                    procedure(long leftRecordID,long rightRecordID),bool,proc,derived
                                                End

And here is the method source:

                                            Member
                                            Map
                                            End

    Include('CML_Data_ManyToManyLinksPersisterForABC.inc'),Once
    include('CML_System_Diagnostics_Logger.inc'),once

CML_Data_ManyToManyLinksPersisterForABC.Construct                     Procedure()
    code
    self.Initialized = false
    self.ManageFileOpenAndClose = false

CML_Data_ManyToManyLinksPersisterForABC.Destruct                      Procedure()
    code

CML_Data_ManyToManyLinksPersisterForABC.AddLinkRecord        procedure(long leftIDField,long rightIDField)!,derived
    code
    if not self.Initialized then return false.
    clear(self.LinkFileManager.File)
    self.LinkFileLeftIDField = LeftIDField
    self.LinkFileRightIDField = rightIDField
    if self.LinkFileManager.Insert() = Level:Benign then return true.
    return false
    
CML_Data_ManyToManyLinksPersisterForABC.CloseDataFile procedure
    code
    if not self.Initialized then return false.
    if self.ManageFileOpenAndClose
        if self.LinkFileManager.Close() = level:benign then return true.
        return false
    end
    return true
    
CML_Data_ManyToManyLinksPersisterForABC.Init       procedure(FileManager linkFileManager,*key LinkFileKey,*? LinkFileLeftIDField, *? LinkFileRightIDField)
    code
    self.LinkFileKey          &= LinkFileKey             
    self.LinkFileLeftIDField  &= LinkFileLeftIDField     
    self.LinkFileManager      &= LinkFileManager         
    self.LinkFileRightIDField &= LinkFileRightIDField    
    if not self.LinkFileKey                &= null |
        and not self.LinkFileLeftIDField   &= null |
        and not self.LinkFileManager       &= null |
        and not self.LinkFileRightIDField  &= null  
        self.Initialized = true
    end
    
CML_Data_ManyToManyLinksPersisterForABC.LoadAllLinkingData    procedure(long leftIDField,CML_Data_ManyToManyLinksDataQ linksDataQ)
    code
    if not self.Initialized then return false.
    free(linksDataQ)
    if self.OpenDataFile()
        clear(self.LinkFileManager.File)
        self.LinkFileLeftIDField = LeftIDField
        set(self.LinkFileKey,self.LinkFileKey)
        loop
            next(self.LinkFileManager.File)
            if errorcode() or self.LinkFileLeftIDField <> LeftIDField then break.
            clear(linksDataQ)
            linksDataQ.LeftRecordID = self.LinkFileLeftIDField
            linksDataQ.RightRecordID = self.LinkFileRightIDField
            linksDataQ.IsPersisted = true
            linksDataQ.IsLinked = true
            add(linksDataQ)
        end
        return self.CloseDataFile()
    end
    return false
    
CML_Data_ManyToManyLinksPersisterForABC.OpenDataFile  procedure!,bool
    code
    if not self.Initialized then return false.
    if self.ManageFileOpenAndClose
        if self.LinkFileManager.Open() = Level:Benign 
            self.LinkFileManager.UseFile
            return true
        end
        return false
    end
    return true
    
CML_Data_ManyToManyLinksPersisterForABC.RemoveLinkRecord     procedure(long leftIDField,long rightIDField)!,derived
    code    
    if not self.Initialized then return false.
    clear(self.LinkFileManager.File)
    self.LinkFileLeftIDField  = LeftIDField
    self.LinkFileRightIDField = rightIDField
    get(self.LinkFileManager.File,self.LinkFileKey)
    if not errorcode()
        if self.LinkFileManager.DeleteRecord(0) = Level:Benign then return true.
    end
    return false

I originally passed in the file reference as well, but then I realized that there is already a reference as a property of the FileManager instance. I also initially wanted a way to do the equivalent of a Clear(prefix:Record) but there doesn't seem to be a way to keep a reference to a Record structure. That's not a problem because Clear(File) works just as well. The only downside is it might be marginally less efficient since it also clears any Blob or Memo fields, which are not included in a Record. 

Adding the code to an ABC app

After making the above-noted changes to the dictionary and fixing up the app, I had a browse that looked like this (students on the left, courses they're registered for on the right):

To begin with I needed a couple of global includes (the third one is just so I can add some debug statements if needed):

 

When I first threw the checkbox code into my test app I put together a couple of routines in place of some embed code. I didn't have a really good reason for doing that (too many legacy apps on my brain lately?), and when Mike Hanson looked at the code he quickly pointed out that the code should be in local class methods. 

Here's the procedure-level class that wraps up all of the functionality:

CheckboxList         class
ListCheckbox            &CML_UI_ListCheckbox
Persister               &CML_Data_ManyToManyLinksPersisterForABC
Links                   &CML_Data_ManyToManyLinks
Construct               procedure
Destruct                procedure
DisplayCheckboxData     procedure
Init                    procedure
LoadEnrollmentData      procedure
SaveEnrollmentData      procedure
                    end

And here are the methods, declared at the end of the procedure:

CheckboxList.Construct                           procedure
    code
    self.ListCheckbox &= new CML_UI_ListCheckbox
    self.Persister    &= new CML_Data_ManyToManyLinksPersisterForABC
    self.Links        &= new CML_Data_ManyToManyLinks
    
CheckboxList.Destruct                            procedure
    code
    dispose(self.ListCheckbox)
    dispose(self.Persister)
    dispose(self.Links)
    
CheckboxList.DisplayCheckboxData                 procedure
    code
    self.ListCheckbox.LoadDisplayableCheckboxData()      
    
CheckboxList.Init                                procedure
    code
    self.Persister.Init(Access:Enrollment,ENR:kStudentIDCourseInstanceID,|
        ENR:StudentID,ENR:CourseInstanceID)
    self.Links.SetPersister(self.Persister)
    self.ListCheckbox.Initialize(Queue:Browse,Queue:Browse.InClass_Icon,|
        Queue:Browse.CLA:CourseInstanceID,?List:Enrollment,,self.Links)
    
CheckboxList.LoadEnrollmentData                  procedure
    code
    log.write('CheckboxList.LoadEnrollmentData')
    self.SaveEnrollmentData()
    StudentBrowse.UpdateBuffer()
    self.links.LeftRecordID = STU:StudentID
    self.Links.LoadAllLinkingData()
    self.ListCheckbox.LoadDisplayableCheckboxData()    
    display(?List:Enrollment)
    
CheckboxList.SaveEnrollmentData                  procedure
    code
    self.Links.SaveAllLinkingData()

This is the mostly-working version of the code. There are a few wrinkles which I'll get to after I show the embed code. 

Right after the window is opened I call the Init method which gets all of the objects ready for use:

At the end of ThisWindow.Run I added code to save any changes on exiting the procedure:

I needed a call to the LoadEnrollmentData() method whenever the user selected a different student record, but I had problems. I initially put my code in the WindowManager.TakeNewSelection method for the Student list box. And I found that using the arrow keys to select a new record worked fine, but using the mouse keys did not update the record in memory. 

I asked Mike about this, and he replied:

The better place is inside the Browse method. You cannot access that embed from the Control's embed list. You have to go to the ABC objects tree in the procedure level embeds, or even better, the source view (a.k.a. embeditor). Search for ".TakeNewSelection " (note the period at the start and space at the end).

You cannot assume that PARENT.TakeNewSelection will load your record. Even though it does load the newly selected record, it calls ThisWindow.Reset, which may change things.

To be safe, call either MyBrowse.UpdateBuffer (which fetches the highlighted record from the queue and updated the corresponding fields in the record), or MyBrowse.UpdateViewRecord (which additionally fetches the record from the database). Avoid low level code like GET(Queue:Browse:1,CHOICE(?Browse:1)).

Also, I often want the user's "click" to fire some code, even if the record hasn't changed. I'll use the TakeAccepted handler for the control event, rather than (or in addition to) the browse class' TakeNewSelection method.

The bottom line is that your approach may vary from situation to situation.  If you want to stick with one approach that will work most of the time, then use the browse method's TakeNewSelection method (via the embeditor).  You may want your code to go before or after the PARENT call, depending on your situation. Regardless, do a call to UpdateBuffer or UpdateViewRecord, to be sure that the newly selected record is in memory.

I put my code in StudentBrowse.TakeNewSelection, and I had my LoadEnrollmentData method call StudentBrowse.UpdateBuffer, and that solved my update problem. Thanks Mike!

I thought I was all set, but my procedure still had some issues. I discovered that although my linking records were saved to the database, the display was more than a little funky especially when I tried scrolling through the page loaded list of courses. 

I fixed the checkbox display problem by re-applying the checkbox data every time the right hand list changes:

In hindsight this makes perfect sense. The right hand list, like the left hand one, is a page loaded browse, so if the user scrolls and brings up a new record or page of records, the internal list of which records are checked must be consulted and the icons applied accordingly. 

And I found a really silly coding error that only showed up once I started working with an actual database. Have a look at the LoadDisplayableCheckboxData and see if you can spot it:

CML_UI_ListCheckbox.LoadDisplayableCheckboxData procedure
x                                                   long
    code
    if not self.ManyToManyLinks &= null
        loop x = 1 to records(self.ListQ)
            get(self.ListQ,x)
            if self.ManyToManyLinks.IsLinkedTo(x)
                self.ListQIconField = CML_UI_ListCheckbox_TrueValue
                dbg.write(x & ' true')
            else
                self.ListQIconField = CML_UI_ListCheckbox_FalseValue
                dbg.write(x & ' false')
            end
            put(self.ListQ)
        end
    end

You'd have to be fairly well immersed in the code to notice this, but the problem is the line

if self.ManyToManyLinks.IsLinkedTo(x)

which needs to be

if self.ManyToManyLinks.IsLinkedTo(self.ListQRightRecordID)

With that final fix the sample app worked almost as expected.

This time the problem was with the scroll bar buttons. When you click a scroll bar button the list box doesn't fire a TakeNewSelection event until you release the mouse. Meanwhile any new list box items that scroll up don't have their list boxes drawn:

The first solution I tried was to add one line of code to the end of the CheckboxList.DisplayCheckboxData method:

select(?List:Enrollment,choice(?List:Enrollment))

That partially solved the problem. While the mouse was held down the icons were still blank, but as soon as I released the mouse the icons were drawn. I also tried a Display() on the list but that caused the entire control to flicker. 

I fixed the problem by adding a method call to the SetQueueRecord method, which is where you normally insert code to modify the displayed values.

Here's the code for SetCheckboxIcon:

CheckboxList.SetCheckboxIcon                    procedure
    code
    if self.links.IsLinkBetween(self.Links.LeftRecordID,self.ListCheckbox.ListQRightRecordID)
        self.ListCheckbox.ListQIconField = CML_UI_ListCheckbox_TrueValue
    else
        self.ListCheckbox.ListQIconField = CML_UI_ListCheckbox_FalseValue
    end

As all of the values specified here are already being tracked by the classes I think I can move the code down a level rather than have to write it out every time I use the classes. I'll look into that next time.

A nice side effect

The checkbox list has a benefit I hadn't thought through beforehand. Ordinarily if you have a child list box, any time you change the parent list box selection the child list box reloads completely. But because the list of linking records is managed internally by the class, and then applied to the right hand list box in whatever state it happens to be, you can go to whatever page you want on the right hand list box, then select any record on the left hand list box and the right hand list box will still display the same records. Only the checkboxes will change based on the underlying data.

For instance, I can go the the last page of courses, then scroll through all the students and see who is registered for any of those courses. It feels right - this is how it should work.

I often have this experience when writing classes - although there's more work up front, there are many little paybacks (and sometimes big paybacks) later on in the process. 

Next time: A template to make it all easy.

Download the source (the checkbox classes are also available on GitHub but are not yet exported from the library).