Merge pull request #6830 from concourse/issue/6617

Re ordering instanced pipelines
This commit is contained in:
Esteban Foronda Sierra 2021-05-03 11:09:31 -05:00 committed by GitHub
commit 5c311157aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1494 additions and 471 deletions

View File

@ -60,6 +60,7 @@ var DefaultRoles = map[string]string{
atc.GetPipeline: ViewerRole,
atc.DeletePipeline: MemberRole,
atc.OrderPipelines: MemberRole,
atc.OrderPipelinesWithinGroup: MemberRole,
atc.PausePipeline: OperatorRole,
atc.ArchivePipeline: OwnerRole,
atc.UnpausePipeline: OperatorRole,

View File

@ -139,21 +139,22 @@ func NewHandler(
atc.ClearTaskCache: pipelineHandlerFactory.HandlerFor(jobServer.ClearTaskCache),
atc.ListAllPipelines: http.HandlerFunc(pipelineServer.ListAllPipelines),
atc.ListPipelines: http.HandlerFunc(pipelineServer.ListPipelines),
atc.GetPipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.GetPipeline),
atc.DeletePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.DeletePipeline),
atc.OrderPipelines: http.HandlerFunc(pipelineServer.OrderPipelines),
atc.PausePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.PausePipeline),
atc.ArchivePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.ArchivePipeline),
atc.UnpausePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.UnpausePipeline),
atc.ExposePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.ExposePipeline),
atc.HidePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.HidePipeline),
atc.GetVersionsDB: pipelineHandlerFactory.HandlerFor(pipelineServer.GetVersionsDB),
atc.RenamePipeline: teamHandlerFactory.HandlerFor(pipelineServer.RenamePipeline),
atc.ListPipelineBuilds: pipelineHandlerFactory.HandlerFor(pipelineServer.ListPipelineBuilds),
atc.CreatePipelineBuild: pipelineHandlerFactory.HandlerFor(pipelineServer.CreateBuild),
atc.PipelineBadge: pipelineHandlerFactory.HandlerFor(pipelineServer.PipelineBadge),
atc.ListAllPipelines: http.HandlerFunc(pipelineServer.ListAllPipelines),
atc.ListPipelines: http.HandlerFunc(pipelineServer.ListPipelines),
atc.GetPipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.GetPipeline),
atc.DeletePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.DeletePipeline),
atc.OrderPipelines: teamHandlerFactory.HandlerFor(pipelineServer.OrderPipelines),
atc.OrderPipelinesWithinGroup: teamHandlerFactory.HandlerFor(pipelineServer.OrderPipelinesWithinGroup),
atc.PausePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.PausePipeline),
atc.ArchivePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.ArchivePipeline),
atc.UnpausePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.UnpausePipeline),
atc.ExposePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.ExposePipeline),
atc.HidePipeline: pipelineHandlerFactory.HandlerFor(pipelineServer.HidePipeline),
atc.GetVersionsDB: pipelineHandlerFactory.HandlerFor(pipelineServer.GetVersionsDB),
atc.RenamePipeline: teamHandlerFactory.HandlerFor(pipelineServer.RenamePipeline),
atc.ListPipelineBuilds: pipelineHandlerFactory.HandlerFor(pipelineServer.ListPipelineBuilds),
atc.CreatePipelineBuild: pipelineHandlerFactory.HandlerFor(pipelineServer.CreateBuild),
atc.PipelineBadge: pipelineHandlerFactory.HandlerFor(pipelineServer.PipelineBadge),
atc.ListAllResources: http.HandlerFunc(resourceServer.ListAllResources),
atc.ListResources: pipelineHandlerFactory.HandlerFor(resourceServer.ListResources),

View File

@ -1346,6 +1346,106 @@ var _ = Describe("Pipelines API", func() {
})
})
Describe("PUT /api/v1/teams/:team_name/pipelines/:pipeline_name/ordering", func() {
var response *http.Response
var instanceVars []atc.InstanceVars
BeforeEach(func() {
instanceVars = []atc.InstanceVars{
{"branch": "test"},
{},
{"branch": "test-2"},
}
})
JustBeforeEach(func() {
requestPayload, err := json.Marshal(instanceVars)
Expect(err).NotTo(HaveOccurred())
request, err := http.NewRequest("PUT", server.URL+"/api/v1/teams/a-team/pipelines/a-pipeline/ordering", bytes.NewBuffer(requestPayload))
Expect(err).NotTo(HaveOccurred())
response, err = client.Do(request)
Expect(err).NotTo(HaveOccurred())
})
Context("when authenticated", func() {
BeforeEach(func() {
fakeAccess.IsAuthenticatedReturns(true)
})
Context("when requester belongs to the team", func() {
BeforeEach(func() {
fakeAccess.IsAuthorizedReturns(true)
dbTeamFactory.FindTeamReturns(fakeTeam, true, nil)
})
It("constructs team with provided team name", func() {
Expect(dbTeamFactory.FindTeamCallCount()).To(Equal(1))
Expect(dbTeamFactory.FindTeamArgsForCall(0)).To(Equal("a-team"))
})
Context("when ordering the pipelines succeeds", func() {
BeforeEach(func() {
fakeTeam.OrderPipelinesWithinGroupReturns(nil)
})
It("orders the pipelines", func() {
Expect(fakeTeam.OrderPipelinesWithinGroupCallCount()).To(Equal(1))
groupName, actualInstanceVars := fakeTeam.OrderPipelinesWithinGroupArgsForCall(0)
Expect(groupName).To(Equal("a-pipeline"))
Expect(actualInstanceVars).To(Equal(instanceVars))
})
It("returns 200", func() {
Expect(response.StatusCode).To(Equal(http.StatusOK))
})
})
Context("when a pipeline does not exist", func() {
BeforeEach(func() {
fakeTeam.OrderPipelinesWithinGroupReturns(db.ErrPipelineNotFound{Name: "a-pipeline"})
})
It("returns 400", func() {
Expect(response.StatusCode).To(Equal(http.StatusBadRequest))
Expect(ioutil.ReadAll(response.Body)).To(ContainSubstring("pipeline 'a-pipeline' not found"))
})
})
Context("when ordering the pipelines fails", func() {
BeforeEach(func() {
fakeTeam.OrderPipelinesWithinGroupReturns(errors.New("welp"))
})
It("returns 500", func() {
Expect(response.StatusCode).To(Equal(http.StatusInternalServerError))
})
})
})
Context("when requester does not belong to the team", func() {
BeforeEach(func() {
fakeAccess.IsAuthorizedReturns(false)
})
It("returns 403", func() {
Expect(response.StatusCode).To(Equal(http.StatusForbidden))
})
})
})
Context("when not authenticated", func() {
BeforeEach(func() {
fakeAccess.IsAuthenticatedReturns(false)
})
It("returns 401 Unauthorized", func() {
Expect(response.StatusCode).To(Equal(http.StatusUnauthorized))
})
})
})
Describe("GET /api/v1/teams/:team_name/pipelines/:pipeline_name/versions-db", func() {
var response *http.Response

View File

@ -10,44 +10,32 @@ import (
"github.com/concourse/concourse/atc/db"
)
func (s *Server) OrderPipelines(w http.ResponseWriter, r *http.Request) {
logger := s.logger.Session("order-pipelines")
func (s *Server) OrderPipelines(team db.Team) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := s.logger.Session("order-pipelines")
var pipelinesNames []string
if err := json.NewDecoder(r.Body).Decode(&pipelinesNames); err != nil {
logger.Error("invalid-json", err)
w.WriteHeader(http.StatusBadRequest)
return
}
teamName := r.FormValue(":team_name")
team, found, err := s.teamFactory.FindTeam(teamName)
if err != nil {
logger.Error("failed-to-get-team", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if !found {
logger.Info("team-not-found")
w.WriteHeader(http.StatusNotFound)
return
}
err = team.OrderPipelines(pipelinesNames)
if err != nil {
logger.Error("failed-to-order-pipelines", err, lager.Data{
"pipeline_names": pipelinesNames,
})
var errNotFound db.ErrPipelineNotFound
if errors.As(err, &errNotFound) {
var pipelinesNames []string
if err := json.NewDecoder(r.Body).Decode(&pipelinesNames); err != nil {
logger.Error("invalid-json", err)
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, err.Error())
} else {
w.WriteHeader(http.StatusInternalServerError)
return
}
return
}
w.WriteHeader(http.StatusOK)
err := team.OrderPipelines(pipelinesNames)
if err != nil {
logger.Error("failed-to-order-pipelines", err, lager.Data{
"pipeline_names": pipelinesNames,
})
var errNotFound db.ErrPipelineNotFound
if errors.As(err, &errNotFound) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, err.Error())
} else {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusOK)
})
}

View File

@ -0,0 +1,46 @@
package pipelineserver
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"code.cloudfoundry.org/lager"
"github.com/concourse/concourse/atc"
"github.com/concourse/concourse/atc/db"
)
func (s *Server) OrderPipelinesWithinGroup(team db.Team) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := s.logger.Session("order-pipelines-within-group")
var instanceVars []atc.InstanceVars
if err := json.NewDecoder(r.Body).Decode(&instanceVars); err != nil {
logger.Error("invalid-json", err)
w.WriteHeader(http.StatusBadRequest)
return
}
groupName := r.FormValue(":pipeline_name")
err := team.OrderPipelinesWithinGroup(groupName, instanceVars)
if err != nil {
logger.Error("failed-to-order-pipelines", err, lager.Data{
"team_name": team.Name(),
"pipeline_name": groupName,
"instance_vars": instanceVars,
})
var errNotFound db.ErrPipelineNotFound
if errors.As(err, &errNotFound) {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintln(w, err.Error())
} else {
w.WriteHeader(http.StatusInternalServerError)
}
return
}
w.WriteHeader(http.StatusOK)
})
}

View File

@ -95,6 +95,7 @@ func (a *auditor) ValidateAction(action string) bool {
atc.GetPipeline,
atc.DeletePipeline,
atc.OrderPipelines,
atc.OrderPipelinesWithinGroup,
atc.PausePipeline,
atc.ArchivePipeline,
atc.UnpausePipeline,

View File

@ -274,6 +274,18 @@ type FakeTeam struct {
orderPipelinesReturnsOnCall map[int]struct {
result1 error
}
OrderPipelinesWithinGroupStub func(string, []atc.InstanceVars) error
orderPipelinesWithinGroupMutex sync.RWMutex
orderPipelinesWithinGroupArgsForCall []struct {
arg1 string
arg2 []atc.InstanceVars
}
orderPipelinesWithinGroupReturns struct {
result1 error
}
orderPipelinesWithinGroupReturnsOnCall map[int]struct {
result1 error
}
PipelineStub func(atc.PipelineRef) (db.Pipeline, bool, error)
pipelineMutex sync.RWMutex
pipelineArgsForCall []struct {
@ -1652,6 +1664,73 @@ func (fake *FakeTeam) OrderPipelinesReturnsOnCall(i int, result1 error) {
}{result1}
}
func (fake *FakeTeam) OrderPipelinesWithinGroup(arg1 string, arg2 []atc.InstanceVars) error {
var arg2Copy []atc.InstanceVars
if arg2 != nil {
arg2Copy = make([]atc.InstanceVars, len(arg2))
copy(arg2Copy, arg2)
}
fake.orderPipelinesWithinGroupMutex.Lock()
ret, specificReturn := fake.orderPipelinesWithinGroupReturnsOnCall[len(fake.orderPipelinesWithinGroupArgsForCall)]
fake.orderPipelinesWithinGroupArgsForCall = append(fake.orderPipelinesWithinGroupArgsForCall, struct {
arg1 string
arg2 []atc.InstanceVars
}{arg1, arg2Copy})
stub := fake.OrderPipelinesWithinGroupStub
fakeReturns := fake.orderPipelinesWithinGroupReturns
fake.recordInvocation("OrderPipelinesWithinGroup", []interface{}{arg1, arg2Copy})
fake.orderPipelinesWithinGroupMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeTeam) OrderPipelinesWithinGroupCallCount() int {
fake.orderPipelinesWithinGroupMutex.RLock()
defer fake.orderPipelinesWithinGroupMutex.RUnlock()
return len(fake.orderPipelinesWithinGroupArgsForCall)
}
func (fake *FakeTeam) OrderPipelinesWithinGroupCalls(stub func(string, []atc.InstanceVars) error) {
fake.orderPipelinesWithinGroupMutex.Lock()
defer fake.orderPipelinesWithinGroupMutex.Unlock()
fake.OrderPipelinesWithinGroupStub = stub
}
func (fake *FakeTeam) OrderPipelinesWithinGroupArgsForCall(i int) (string, []atc.InstanceVars) {
fake.orderPipelinesWithinGroupMutex.RLock()
defer fake.orderPipelinesWithinGroupMutex.RUnlock()
argsForCall := fake.orderPipelinesWithinGroupArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeTeam) OrderPipelinesWithinGroupReturns(result1 error) {
fake.orderPipelinesWithinGroupMutex.Lock()
defer fake.orderPipelinesWithinGroupMutex.Unlock()
fake.OrderPipelinesWithinGroupStub = nil
fake.orderPipelinesWithinGroupReturns = struct {
result1 error
}{result1}
}
func (fake *FakeTeam) OrderPipelinesWithinGroupReturnsOnCall(i int, result1 error) {
fake.orderPipelinesWithinGroupMutex.Lock()
defer fake.orderPipelinesWithinGroupMutex.Unlock()
fake.OrderPipelinesWithinGroupStub = nil
if fake.orderPipelinesWithinGroupReturnsOnCall == nil {
fake.orderPipelinesWithinGroupReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.orderPipelinesWithinGroupReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakeTeam) Pipeline(arg1 atc.PipelineRef) (db.Pipeline, bool, error) {
fake.pipelineMutex.Lock()
ret, specificReturn := fake.pipelineReturnsOnCall[len(fake.pipelineArgsForCall)]
@ -2319,6 +2398,8 @@ func (fake *FakeTeam) Invocations() map[string][][]interface{} {
defer fake.nameMutex.RUnlock()
fake.orderPipelinesMutex.RLock()
defer fake.orderPipelinesMutex.RUnlock()
fake.orderPipelinesWithinGroupMutex.RLock()
defer fake.orderPipelinesWithinGroupMutex.RUnlock()
fake.pipelineMutex.RLock()
defer fake.pipelineMutex.RUnlock()
fake.pipelinesMutex.RLock()

View File

@ -0,0 +1,74 @@
package migration_test
import (
"database/sql"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
var _ = Describe("Add secondary ordering column", func() {
const preMigrationVersion = 1616768782
const postMigrationVersion = 1618347807
var (
db *sql.DB
)
Context("Up", func() {
It("migrates populates secondary ordering", func() {
db = postgresRunner.OpenDBAtVersion(preMigrationVersion)
_, err := db.Exec(`
INSERT INTO teams(id, name) VALUES
(1, 'team1'),
(2, 'team2')
`)
Expect(err).NotTo(HaveOccurred())
_, err = db.Exec(`
INSERT INTO pipelines(id, team_id, name, instance_vars) VALUES
(1, 1, 'group1', '{"version": 1}'::jsonb),
(2, 1, 'group1', '{"version": 2}'::jsonb),
(3, 1, 'group2', '{"version": 1}'::jsonb),
(4, 1, 'group1', NULL),
(5, 1, 'pipeline', NULL),
(6, 2, 'group1', '{"version": 3}'::jsonb)
`)
Expect(err).NotTo(HaveOccurred())
db.Close()
db = postgresRunner.OpenDBAtVersion(postMigrationVersion)
rows, err := db.Query(`SELECT id, secondary_ordering FROM pipelines ORDER BY id ASC`)
Expect(err).NotTo(HaveOccurred())
type pipelineOrdering struct {
pipelineID int
secondaryOrdering int
}
ordering := []pipelineOrdering{}
for rows.Next() {
var o pipelineOrdering
err := rows.Scan(&o.pipelineID, &o.secondaryOrdering)
Expect(err).NotTo(HaveOccurred())
ordering = append(ordering, o)
}
_ = db.Close()
Expect(ordering).To(Equal([]pipelineOrdering{
{1, 1},
{2, 2},
{3, 1},
{4, 3},
{5, 1},
{6, 1},
}))
})
})
})

View File

@ -0,0 +1 @@
ALTER TABLE pipelines DROP COLUMN secondary_ordering;

View File

@ -0,0 +1,12 @@
ALTER TABLE pipelines ADD COLUMN secondary_ordering integer;
WITH s AS (
SELECT id, row_number() OVER (PARTITION BY team_id, name ORDER BY id) as secondary_ordering
FROM pipelines
)
UPDATE pipelines p
SET secondary_ordering = s.secondary_ordering
FROM s
WHERE p.id = s.id;
ALTER TABLE pipelines ALTER COLUMN secondary_ordering SET NOT NULL;

View File

@ -34,7 +34,7 @@ func (f *pipelineFactory) VisiblePipelines(teamNames []string) ([]Pipeline, erro
rows, err := pipelinesQuery.
Where(sq.Eq{"t.name": teamNames}).
OrderBy("t.name ASC", "p.ordering ASC", "p.id ASC").
OrderBy("t.name ASC", "p.ordering ASC", "p.secondary_ordering ASC").
RunWith(tx).
Query()
if err != nil {
@ -71,7 +71,7 @@ func (f *pipelineFactory) VisiblePipelines(teamNames []string) ([]Pipeline, erro
func (f *pipelineFactory) AllPipelines() ([]Pipeline, error) {
rows, err := pipelinesQuery.
OrderBy("t.name ASC", "p.ordering ASC", "p.id ASC").
OrderBy("t.name ASC", "p.ordering ASC", "p.secondary_ordering ASC").
RunWith(f.conn).
Query()
if err != nil {

View File

@ -20,13 +20,14 @@ var _ = Describe("Pipeline Factory", func() {
pipeline2 db.Pipeline
pipeline3 db.Pipeline
pipeline4 db.Pipeline
team db.Team
)
BeforeEach(func() {
err := defaultPipeline.Destroy()
Expect(err).ToNot(HaveOccurred())
team, err := teamFactory.CreateTeam(atc.Team{Name: "some-team"})
team, err = teamFactory.CreateTeam(atc.Team{Name: "some-team"})
Expect(err).ToNot(HaveOccurred())
pipeline1, _, err = team.SavePipeline(atc.PipelineRef{Name: "fake-pipeline"}, atc.Config{
@ -66,36 +67,61 @@ var _ = Describe("Pipeline Factory", func() {
It("returns all pipelines visible for the given teams", func() {
pipelines, err := pipelineFactory.VisiblePipelines([]string{"some-team"})
Expect(err).ToNot(HaveOccurred())
Expect(len(pipelines)).To(Equal(3))
Expect(pipelines[0].Name()).To(Equal(pipeline1.Name()))
Expect(pipelines[1].Name()).To(Equal(pipeline4.Name()))
Expect(pipelines[2].Name()).To(Equal(pipeline3.Name()))
Expect(pipelineRefs(pipelines)).To(Equal([]atc.PipelineRef{
pipelineRef(pipeline1),
pipelineRef(pipeline4),
pipelineRef(pipeline3),
}))
})
It("returns all pipelines visible when empty team name provided", func() {
pipelines, err := pipelineFactory.VisiblePipelines([]string{""})
Expect(err).ToNot(HaveOccurred())
Expect(len(pipelines)).To(Equal(1))
Expect(pipelines[0].Name()).To(Equal(pipeline3.Name()))
Expect(pipelineRefs(pipelines)).To(Equal([]atc.PipelineRef{
pipelineRef(pipeline3),
}))
})
It("returns all pipelines visible when empty teams provided", func() {
pipelines, err := pipelineFactory.VisiblePipelines([]string{})
Expect(err).ToNot(HaveOccurred())
Expect(len(pipelines)).To(Equal(1))
Expect(pipelines[0].Name()).To(Equal(pipeline3.Name()))
Expect(pipelineRefs(pipelines)).To(Equal([]atc.PipelineRef{
pipelineRef(pipeline3),
}))
})
It("returns all pipelines visible when nil teams provided", func() {
pipelines, err := pipelineFactory.VisiblePipelines(nil)
Expect(err).ToNot(HaveOccurred())
Expect(len(pipelines)).To(Equal(1))
Expect(pipelines[0].Name()).To(Equal(pipeline3.Name()))
Expect(pipelineRefs(pipelines)).To(Equal([]atc.PipelineRef{
pipelineRef(pipeline3),
}))
})
Describe("When instance pipeline ordered is change", func() {
BeforeEach(func() {
err := team.OrderPipelinesWithinGroup("fake-pipeline", []atc.InstanceVars{
{"branch": "master"},
{},
})
Expect(err).ToNot(HaveOccurred())
})
It("Should keep the right order", func() {
pipelines, err := pipelineFactory.VisiblePipelines([]string{"some-team"})
Expect(err).ToNot(HaveOccurred())
Expect(pipelineRefs(pipelines)).To(Equal([]atc.PipelineRef{
pipelineRef(pipeline4),
pipelineRef(pipeline1),
pipelineRef(pipeline3),
}))
})
})
})
Describe("AllPipelines", func() {
var (
team db.Team
pipeline1 db.Pipeline
pipeline2 db.Pipeline
pipeline3 db.Pipeline
@ -106,7 +132,7 @@ var _ = Describe("Pipeline Factory", func() {
err := defaultPipeline.Destroy()
Expect(err).ToNot(HaveOccurred())
team, err := teamFactory.CreateTeam(atc.Team{Name: "some-team"})
team, err = teamFactory.CreateTeam(atc.Team{Name: "some-team"})
Expect(err).ToNot(HaveOccurred())
pipeline2, _, err = team.SavePipeline(atc.PipelineRef{Name: "fake-pipeline-two"}, atc.Config{
@ -145,14 +171,48 @@ var _ = Describe("Pipeline Factory", func() {
})
It("returns all pipelines ordered by team id -> ordering -> pipeline id", func() {
It("returns all pipelines ordered by team id -> ordering -> secondary_ordering", func() {
pipelines, err := pipelineFactory.AllPipelines()
Expect(err).ToNot(HaveOccurred())
Expect(len(pipelines)).To(Equal(4))
Expect(pipelines[0].Name()).To(Equal(pipeline1.Name()))
Expect(pipelines[1].Name()).To(Equal(pipeline2.Name()))
Expect(pipelines[2].Name()).To(Equal(pipeline4.Name()))
Expect(pipelines[3].Name()).To(Equal(pipeline3.Name()))
Expect(pipelineRefs(pipelines)).To(Equal([]atc.PipelineRef{
pipelineRef(pipeline1),
pipelineRef(pipeline2),
pipelineRef(pipeline4),
pipelineRef(pipeline3),
}))
})
Describe("When instance pipeline ordered is change", func() {
BeforeEach(func() {
err := team.OrderPipelinesWithinGroup("fake-pipeline-two", []atc.InstanceVars{
{"branch": "master"},
{},
})
Expect(err).ToNot(HaveOccurred())
})
It("Should keep the right order", func() {
pipelines, err := pipelineFactory.AllPipelines()
Expect(err).ToNot(HaveOccurred())
Expect(pipelineRefs(pipelines)).To(Equal([]atc.PipelineRef{
pipelineRef(pipeline1),
pipelineRef(pipeline4),
pipelineRef(pipeline2),
pipelineRef(pipeline3),
}))
})
})
})
})
func pipelineRef(pipeline db.Pipeline) atc.PipelineRef {
return atc.PipelineRef{Name: pipeline.Name(), InstanceVars: pipeline.InstanceVars()}
}
func pipelineRefs(pipelines []db.Pipeline) []atc.PipelineRef {
refs := make([]atc.PipelineRef, len(pipelines))
for i, p := range pipelines {
refs[i] = pipelineRef(p)
}
return refs
}

View File

@ -21,12 +21,10 @@ import (
var ErrConfigComparisonFailed = errors.New("comparison with existing config failed during save")
type ErrPipelineNotFound struct {
Name string
}
type ErrPipelineNotFound atc.PipelineRef
func (e ErrPipelineNotFound) Error() string {
return fmt.Sprintf("pipeline '%s' not found", e.Name)
return fmt.Sprintf("pipeline '%s' not found", atc.PipelineRef(e))
}
//counterfeiter:generate . Team
@ -52,6 +50,7 @@ type Team interface {
Pipelines() ([]Pipeline, error)
PublicPipelines() ([]Pipeline, error)
OrderPipelines([]string) error
OrderPipelinesWithinGroup(string, []atc.InstanceVars) error
CreateOneOffBuild() (Build, error)
CreateStartedBuild(plan atc.Plan) (Build, error)
@ -430,7 +429,8 @@ func savePipeline(
"instance_vars": instanceVars,
}
var ordering sql.NullInt64
err := psql.Select("max(ordering)").
var secondaryOrdering sql.NullInt64
err := psql.Select("max(ordering), max(secondary_ordering)").
From("pipelines").
Where(sq.Eq{
"team_id": teamID,
@ -438,14 +438,16 @@ func savePipeline(
}).
RunWith(tx).
QueryRow().
Scan(&ordering)
Scan(&ordering, &secondaryOrdering)
if err != nil {
return 0, false, err
}
if ordering.Valid {
values["ordering"] = ordering.Int64
values["secondary_ordering"] = secondaryOrdering.Int64 + 1
} else {
values["ordering"] = sq.Expr("currval('pipelines_id_seq')")
values["secondary_ordering"] = 1
}
err = psql.Insert("pipelines").
SetMap(values).
@ -660,7 +662,7 @@ func (t *team) Pipelines() ([]Pipeline, error) {
Where(sq.Eq{
"team_id": t.id,
}).
OrderBy("p.ordering", "p.id").
OrderBy("p.ordering", "p.secondary_ordering").
RunWith(t.conn).
Query()
if err != nil {
@ -681,7 +683,7 @@ func (t *team) PublicPipelines() ([]Pipeline, error) {
"team_id": t.id,
"public": true,
}).
OrderBy("t.name ASC", "ordering ASC").
OrderBy("p.ordering ASC", "p.secondary_ordering ASC").
RunWith(t.conn).
Query()
if err != nil {
@ -731,6 +733,54 @@ func (t *team) OrderPipelines(names []string) error {
return nil
}
func (t *team) OrderPipelinesWithinGroup(groupName string, instanceVars []atc.InstanceVars) error {
tx, err := t.conn.Begin()
if err != nil {
return err
}
defer Rollback(tx)
for i, vars := range instanceVars {
filter := sq.Eq{
"team_id": t.id,
"name": groupName,
}
if len(vars) == 0 {
filter["instance_vars"] = nil
} else {
varsJson, err := json.Marshal(vars)
if err != nil {
return err
}
filter["instance_vars"] = varsJson
}
pipelineUpdate, err := psql.Update("pipelines").
Set("secondary_ordering", i+1).
Where(filter).
RunWith(tx).
Exec()
if err != nil {
return err
}
updatedPipelines, err := pipelineUpdate.RowsAffected()
if err != nil {
return err
}
if updatedPipelines == 0 {
return ErrPipelineNotFound{Name: groupName, InstanceVars: vars}
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
// XXX: This is only begin used by tests, replace all tests to CreateBuild on a job
func (t *team) CreateOneOffBuild() (Build, error) {
tx, err := t.conn.Begin()

View File

@ -1107,7 +1107,7 @@ var _ = Describe("Team", func() {
Context("when pipeline does not exist", func() {
It("returns not found error", func() {
err := otherTeam.OrderPipelines([]string{"pipeline1", "invalid-pipeline"})
Expect(err).To(MatchError(db.ErrPipelineNotFound{"invalid-pipeline"}))
Expect(err).To(MatchError(db.ErrPipelineNotFound{Name: "invalid-pipeline"}))
})
})
})

View File

@ -53,20 +53,21 @@ const (
GetCC = "GetCC"
ListAllPipelines = "ListAllPipelines"
ListPipelines = "ListPipelines"
GetPipeline = "GetPipeline"
DeletePipeline = "DeletePipeline"
OrderPipelines = "OrderPipelines"
PausePipeline = "PausePipeline"
ArchivePipeline = "ArchivePipeline"
UnpausePipeline = "UnpausePipeline"
ExposePipeline = "ExposePipeline"
HidePipeline = "HidePipeline"
RenamePipeline = "RenamePipeline"
ListPipelineBuilds = "ListPipelineBuilds"
CreatePipelineBuild = "CreatePipelineBuild"
PipelineBadge = "PipelineBadge"
ListAllPipelines = "ListAllPipelines"
ListPipelines = "ListPipelines"
GetPipeline = "GetPipeline"
DeletePipeline = "DeletePipeline"
OrderPipelines = "OrderPipelines"
OrderPipelinesWithinGroup = "OrderPipelinesWithinGroup"
PausePipeline = "PausePipeline"
ArchivePipeline = "ArchivePipeline"
UnpausePipeline = "UnpausePipeline"
ExposePipeline = "ExposePipeline"
HidePipeline = "HidePipeline"
RenamePipeline = "RenamePipeline"
ListPipelineBuilds = "ListPipelineBuilds"
CreatePipelineBuild = "CreatePipelineBuild"
PipelineBadge = "PipelineBadge"
RegisterWorker = "RegisterWorker"
LandWorker = "LandWorker"
@ -153,6 +154,7 @@ var Routes = rata.Routes([]rata.Route{
{Path: "/api/v1/teams/:team_name/pipelines/:pipeline_name", Method: "GET", Name: GetPipeline},
{Path: "/api/v1/teams/:team_name/pipelines/:pipeline_name", Method: "DELETE", Name: DeletePipeline},
{Path: "/api/v1/teams/:team_name/pipelines/ordering", Method: "PUT", Name: OrderPipelines},
{Path: "/api/v1/teams/:team_name/pipelines/:pipeline_name/ordering", Method: "PUT", Name: OrderPipelinesWithinGroup},
{Path: "/api/v1/teams/:team_name/pipelines/:pipeline_name/pause", Method: "PUT", Name: PausePipeline},
{Path: "/api/v1/teams/:team_name/pipelines/:pipeline_name/archive", Method: "PUT", Name: ArchivePipeline},
{Path: "/api/v1/teams/:team_name/pipelines/:pipeline_name/unpause", Method: "PUT", Name: UnpausePipeline},

View File

@ -141,6 +141,7 @@ func (wrappa *APIAuthWrappa) Wrap(handlers rata.Handlers) rata.Handlers {
atc.GetVersionsDB,
atc.ListJobInputs,
atc.OrderPipelines,
atc.OrderPipelinesWithinGroup,
atc.PauseJob,
atc.PausePipeline,
atc.RenamePipeline,

View File

@ -109,6 +109,7 @@ func (rw *RejectArchivedWrappa) Wrap(handlers rata.Handlers) rata.Handlers {
atc.GetVersionsDB,
atc.ListJobInputs,
atc.OrderPipelines,
atc.OrderPipelinesWithinGroup,
atc.PauseJob,
atc.ArchivePipeline,
atc.RenamePipeline,

View File

@ -45,19 +45,20 @@ type FlyCommand struct {
UnpauseJob UnpauseJobCommand `command:"unpause-job" alias:"uj" description:"Unpause a job"`
ScheduleJob ScheduleJobCommand `command:"schedule-job" alias:"sj" description:"Request the scheduler to run for a job. Introduced as a recovery command for the v6.0 scheduler."`
Pipelines PipelinesCommand `command:"pipelines" alias:"ps" description:"List the configured pipelines"`
DestroyPipeline DestroyPipelineCommand `command:"destroy-pipeline" alias:"dp" description:"Destroy a pipeline"`
GetPipeline GetPipelineCommand `command:"get-pipeline" alias:"gp" description:"Get a pipeline's current configuration"`
SetPipeline SetPipelineCommand `command:"set-pipeline" alias:"sp" description:"Create or update a pipeline's configuration"`
PausePipeline PausePipelineCommand `command:"pause-pipeline" alias:"pp" description:"Pause a pipeline"`
ArchivePipeline ArchivePipelineCommand `command:"archive-pipeline" alias:"ap" description:"Archive a pipeline"`
UnpausePipeline UnpausePipelineCommand `command:"unpause-pipeline" alias:"up" description:"Un-pause a pipeline"`
ExposePipeline ExposePipelineCommand `command:"expose-pipeline" alias:"ep" description:"Make a pipeline publicly viewable"`
HidePipeline HidePipelineCommand `command:"hide-pipeline" alias:"hp" description:"Hide a pipeline from the public"`
RenamePipeline RenamePipelineCommand `command:"rename-pipeline" alias:"rp" description:"Rename a pipeline"`
ValidatePipeline ValidatePipelineCommand `command:"validate-pipeline" alias:"vp" description:"Validate a pipeline config"`
FormatPipeline FormatPipelineCommand `command:"format-pipeline" alias:"fp" description:"Format a pipeline config"`
OrderPipelines OrderPipelinesCommand `command:"order-pipelines" alias:"op" description:"Orders pipelines"`
Pipelines PipelinesCommand `command:"pipelines" alias:"ps" description:"List the configured pipelines"`
DestroyPipeline DestroyPipelineCommand `command:"destroy-pipeline" alias:"dp" description:"Destroy a pipeline"`
GetPipeline GetPipelineCommand `command:"get-pipeline" alias:"gp" description:"Get a pipeline's current configuration"`
SetPipeline SetPipelineCommand `command:"set-pipeline" alias:"sp" description:"Create or update a pipeline's configuration"`
PausePipeline PausePipelineCommand `command:"pause-pipeline" alias:"pp" description:"Pause a pipeline"`
ArchivePipeline ArchivePipelineCommand `command:"archive-pipeline" alias:"ap" description:"Archive a pipeline"`
UnpausePipeline UnpausePipelineCommand `command:"unpause-pipeline" alias:"up" description:"Un-pause a pipeline"`
ExposePipeline ExposePipelineCommand `command:"expose-pipeline" alias:"ep" description:"Make a pipeline publicly viewable"`
HidePipeline HidePipelineCommand `command:"hide-pipeline" alias:"hp" description:"Hide a pipeline from the public"`
RenamePipeline RenamePipelineCommand `command:"rename-pipeline" alias:"rp" description:"Rename a pipeline"`
ValidatePipeline ValidatePipelineCommand `command:"validate-pipeline" alias:"vp" description:"Validate a pipeline config"`
FormatPipeline FormatPipelineCommand `command:"format-pipeline" alias:"fp" description:"Format a pipeline config"`
OrderPipelines OrderPipelinesCommand `command:"order-pipelines" alias:"op" description:"Orders pipelines"`
OrderPipelinesWithinGroup OrderInstancedPipelinesCommand `command:"order-instanced-pipelines" alias:"oip" description:"Orders instanced pipelines within an instance group"`
Resources ResourcesCommand `command:"resources" alias:"rs" description:"List the resources in the pipeline"`
ResourceVersions ResourceVersionsCommand `command:"resource-versions" alias:"rvs" description:"List the versions of a resource"`

View File

@ -0,0 +1,108 @@
package flaghelpers
import (
"errors"
"fmt"
"strings"
"github.com/concourse/concourse/atc"
"github.com/concourse/concourse/vars"
"sigs.k8s.io/yaml"
)
type InstanceVarsFlag struct {
InstanceVars atc.InstanceVars
}
func (flag *InstanceVarsFlag) UnmarshalFlag(value string) error {
var err error
flag.InstanceVars, err = unmarshalInstanceVars(value)
if err != nil {
return err
}
return nil
}
func unmarshalInstanceVars(s string) (atc.InstanceVars, error) {
var kvPairs vars.KVPairs
for {
colonIndex, ok := findUnquoted(s, `"`, nextOccurrenceOf(':'))
if !ok {
break
}
rawKey := s[:colonIndex]
var kvPair vars.KVPair
var err error
kvPair.Ref, err = vars.ParseReference(rawKey)
if err != nil {
return nil, err
}
s = s[colonIndex+1:]
rawValue := []byte(s)
commaIndex, hasComma := findUnquoted(s, `"'`, nextOccurrenceOfOutsideOfYAML(','))
if hasComma {
rawValue = rawValue[:commaIndex]
s = s[commaIndex+1:]
}
if err := yaml.Unmarshal(rawValue, &kvPair.Value, useNumber); err != nil {
return nil, fmt.Errorf("invalid value for key '%s': %w", rawKey, err)
}
kvPairs = append(kvPairs, kvPair)
if !hasComma {
break
}
}
if len(kvPairs) == 0 {
return nil, errors.New("instance vars should be formatted as <key1:value1>(,<key2:value2>)")
}
return atc.InstanceVars(kvPairs.Expand()), nil
}
func findUnquoted(s string, quoteset string, stop func(c rune) bool) (int, bool) {
var quoteChar rune
for i, c := range s {
if quoteChar == 0 {
if stop(c) {
return i, true
}
if strings.ContainsRune(quoteset, c) {
quoteChar = c
}
} else if c == quoteChar {
quoteChar = 0
}
}
return 0, false
}
func nextOccurrenceOf(r rune) func(rune) bool {
return func(c rune) bool {
return c == r
}
}
func nextOccurrenceOfOutsideOfYAML(r rune) func(rune) bool {
braceCount := 0
bracketCount := 0
return func(c rune) bool {
switch c {
case r:
if braceCount == 0 && bracketCount == 0 {
return true
}
case '{':
braceCount++
case '}':
braceCount--
case '[':
bracketCount++
case ']':
bracketCount--
}
return false
}
}

View File

@ -0,0 +1,154 @@
package flaghelpers_test
import (
"encoding/json"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/concourse/concourse/atc"
"github.com/concourse/concourse/fly/commands/internal/flaghelpers"
)
var _ = Describe("InstanceVarsFlag", func() {
Describe("UnmarshalFlag", func() {
var instanceVarsFlag *flaghelpers.InstanceVarsFlag
BeforeEach(func() {
instanceVarsFlag = &flaghelpers.InstanceVarsFlag{}
})
for _, tt := range []struct {
desc string
flag string
instanceVars atc.InstanceVars
err string
}{
{
desc: "instance var",
flag: "branch:master",
instanceVars: atc.InstanceVars{"branch": "master"},
},
{
desc: "multiple instance vars",
flag: `branch:master,list:[1, "2"],other:{foo:bar: 123}`,
instanceVars: atc.InstanceVars{
"branch": "master",
"list": []interface{}{json.Number("1"), "2"},
"other": map[string]interface{}{"foo:bar": json.Number("123")},
},
},
{
desc: "quoted yaml brackets/braces with \"",
flag: `field1:{a: "{", b: "["},field2:hello`,
instanceVars: atc.InstanceVars{
"field1": map[string]interface{}{
"a": "{",
"b": "[",
},
"field2": "hello",
},
},
{
desc: "quoted yaml brackets/braces with '",
flag: `field1:{a: '{', b: '['},field2:hello`,
instanceVars: atc.InstanceVars{
"field1": map[string]interface{}{
"a": "{",
"b": "[",
},
"field2": "hello",
},
},
{
desc: "empty values",
flag: `field1:,field2:"",field3:null`,
instanceVars: atc.InstanceVars{
"field1": nil,
"field2": "",
"field3": nil,
},
},
{
desc: "yaml list",
flag: `field:[{a: '{', b: '['}, 1]`,
instanceVars: atc.InstanceVars{
"field": []interface{}{
map[string]interface{}{
"a": "{",
"b": "[",
},
json.Number("1"),
},
},
},
{
desc: "indexing by numerical field still uses map",
flag: `field.0:0,field.1:1`,
instanceVars: atc.InstanceVars{
"field": map[string]interface{}{
"0": json.Number("0"),
"1": json.Number("1"),
},
},
},
{
desc: "whitespace trimmed from path/values",
flag: `branch: master, other: 123`,
instanceVars: atc.InstanceVars{
"branch": "master",
"other": json.Number("123"),
},
},
{
desc: "quoted fields can contain special characters",
flag: `"some.field:here":abc`,
instanceVars: atc.InstanceVars{
"some.field:here": "abc",
},
},
{
desc: "special characters in quoted yaml",
flag: `field1:'foo,bar',field2:"value1:value2"`,
instanceVars: atc.InstanceVars{
"field1": "foo,bar",
"field2": "value1:value2",
},
},
{
desc: "supports dot notation",
flag: `"my.field".subkey1."subkey:2":"my-value","my.field".other:'other-value'`,
instanceVars: atc.InstanceVars{
"my.field": map[string]interface{}{
"subkey1": map[string]interface{}{
"subkey:2": "my-value",
},
"other": "other-value",
},
},
},
{
desc: "errors if invalid ref is passed",
flag: `"my.field".:bad`,
err: `invalid var '"my.field".': empty field`,
},
{
desc: "errors if invalid YAML is passed as the value",
flag: `hello:{bad: yaml`,
err: `invalid value for key 'hello': error converting YAML to JSON: yaml: line 1: did not find expected ',' or '}'`,
},
} {
tt := tt
It(tt.desc, func() {
err := instanceVarsFlag.UnmarshalFlag(tt.flag)
if tt.err == "" {
Expect(err).ToNot(HaveOccurred())
Expect(instanceVarsFlag.InstanceVars).To(Equal(tt.instanceVars))
} else {
Expect(err).To(MatchError(tt.err))
}
})
}
})
})

View File

@ -39,7 +39,7 @@ func (flag *JobFlag) UnmarshalFlag(value string) error {
var err error
flag.PipelineRef.InstanceVars, err = unmarshalInstanceVars(vs[1])
if err != nil {
return errors.New(err.Error() + "/<job>")
return err
}
}

View File

@ -74,7 +74,7 @@ var _ = Describe("JobFlag", func() {
{
desc: "malformed instance var",
flag: "some-pipeline/branch=master/some-job",
err: "argument format should be <pipeline>/<key:value>/<job>",
err: "instance vars should be formatted as <key1:value1>(,<key2:value2>)",
},
} {
tt := tt

View File

@ -2,12 +2,9 @@ package flaghelpers
import (
"errors"
"fmt"
"strings"
"github.com/concourse/concourse/vars"
"github.com/jessevdk/go-flags"
"sigs.k8s.io/yaml"
"github.com/concourse/concourse/atc"
"github.com/concourse/concourse/fly/rc"
@ -62,90 +59,6 @@ func (flag *PipelineFlag) UnmarshalFlag(value string) error {
return nil
}
func unmarshalInstanceVars(s string) (atc.InstanceVars, error) {
var kvPairs vars.KVPairs
for {
colonIndex, ok := findUnquoted(s, `"`, nextOccurrenceOf(':'))
if !ok {
break
}
rawKey := s[:colonIndex]
var kvPair vars.KVPair
var err error
kvPair.Ref, err = vars.ParseReference(rawKey)
if err != nil {
return nil, err
}
s = s[colonIndex+1:]
rawValue := []byte(s)
commaIndex, hasComma := findUnquoted(s, `"'`, nextOccurrenceOfOutsideOfYAML(','))
if hasComma {
rawValue = rawValue[:commaIndex]
s = s[commaIndex+1:]
}
if err := yaml.Unmarshal(rawValue, &kvPair.Value, useNumber); err != nil {
return nil, fmt.Errorf("invalid value for key '%s': %w", rawKey, err)
}
kvPairs = append(kvPairs, kvPair)
if !hasComma {
break
}
}
if len(kvPairs) == 0 {
return nil, errors.New("argument format should be <pipeline>/<key:value>")
}
return atc.InstanceVars(kvPairs.Expand()), nil
}
func findUnquoted(s string, quoteset string, stop func(c rune) bool) (int, bool) {
var quoteChar rune
for i, c := range s {
if quoteChar == 0 {
if stop(c) {
return i, true
}
if strings.ContainsRune(quoteset, c) {
quoteChar = c
}
} else if c == quoteChar {
quoteChar = 0
}
}
return 0, false
}
func nextOccurrenceOf(r rune) func(rune) bool {
return func(c rune) bool {
return c == r
}
}
func nextOccurrenceOfOutsideOfYAML(r rune) func(rune) bool {
braceCount := 0
bracketCount := 0
return func(c rune) bool {
switch c {
case r:
if braceCount == 0 && bracketCount == 0 {
return true
}
case '{':
braceCount++
case '}':
braceCount--
case '[':
bracketCount++
case ']':
bracketCount--
}
return false
}
}
func (flag *PipelineFlag) Complete(match string) []flags.Completion {
fly := parseFlags()

View File

@ -1,8 +1,6 @@
package flaghelpers_test
import (
"encoding/json"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
@ -24,7 +22,6 @@ var _ = Describe("PipelineFlag", func() {
flag string
name string
instanceVars atc.InstanceVars
err string
}{
{
desc: "name",
@ -37,135 +34,13 @@ var _ = Describe("PipelineFlag", func() {
name: "some-pipeline",
instanceVars: atc.InstanceVars{"branch": "master"},
},
{
desc: "multiple instance vars",
flag: `some-pipeline/branch:master,list:[1, "2"],other:{foo:bar: 123}`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"branch": "master",
"list": []interface{}{json.Number("1"), "2"},
"other": map[string]interface{}{"foo:bar": json.Number("123")},
},
},
{
desc: "quoted yaml brackets/braces with \"",
flag: `some-pipeline/field1:{a: "{", b: "["},field2:hello`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"field1": map[string]interface{}{
"a": "{",
"b": "[",
},
"field2": "hello",
},
},
{
desc: "quoted yaml brackets/braces with '",
flag: `some-pipeline/field1:{a: '{', b: '['},field2:hello`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"field1": map[string]interface{}{
"a": "{",
"b": "[",
},
"field2": "hello",
},
},
{
desc: "empty values",
flag: `some-pipeline/field1:,field2:"",field3:null`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"field1": nil,
"field2": "",
"field3": nil,
},
},
{
desc: "yaml list",
flag: `some-pipeline/field:[{a: '{', b: '['}, 1]`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"field": []interface{}{
map[string]interface{}{
"a": "{",
"b": "[",
},
json.Number("1"),
},
},
},
{
desc: "indexing by numerical field still uses map",
flag: `some-pipeline/field.0:0,field.1:1`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"field": map[string]interface{}{
"0": json.Number("0"),
"1": json.Number("1"),
},
},
},
{
desc: "whitespace trimmed from path/values",
flag: `some-pipeline/branch: master, other: 123`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"branch": "master",
"other": json.Number("123"),
},
},
{
desc: "quoted fields can contain special characters",
flag: `some-pipeline/"some.field:here":abc`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"some.field:here": "abc",
},
},
{
desc: "special characters in quoted yaml",
flag: `some-pipeline/field1:'foo,bar',field2:"value1:value2"`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"field1": "foo,bar",
"field2": "value1:value2",
},
},
{
desc: "supports dot notation",
flag: `some-pipeline/"my.field".subkey1."subkey:2":"my-value","my.field".other:'other-value'`,
name: "some-pipeline",
instanceVars: atc.InstanceVars{
"my.field": map[string]interface{}{
"subkey1": map[string]interface{}{
"subkey:2": "my-value",
},
"other": "other-value",
},
},
},
{
desc: "errors if invalid ref is passed",
flag: `some-pipeline/"my.field".:bad`,
err: `invalid var '"my.field".': empty field`,
},
{
desc: "errors if invalid YAML is passed as the value",
flag: `some-pipeline/hello:{bad: yaml`,
err: `invalid value for key 'hello': error converting YAML to JSON: yaml: line 1: did not find expected ',' or '}'`,
},
} {
tt := tt
It(tt.desc, func() {
err := pipelineFlag.UnmarshalFlag(tt.flag)
if tt.err == "" {
Expect(err).ToNot(HaveOccurred())
Expect(pipelineFlag.Name).To(Equal(tt.name))
Expect(pipelineFlag.InstanceVars).To(Equal(tt.instanceVars))
} else {
Expect(err).To(MatchError(tt.err))
}
Expect(err).ToNot(HaveOccurred())
Expect(pipelineFlag.Name).To(Equal(tt.name))
Expect(pipelineFlag.InstanceVars).To(Equal(tt.instanceVars))
})
}
})

View File

@ -36,7 +36,7 @@ func (flag *ResourceFlag) UnmarshalFlag(value string) error {
var err error
flag.PipelineRef.InstanceVars, err = unmarshalInstanceVars(vs[1])
if err != nil {
return errors.New(err.Error() + "/<resource>")
return err
}
}

View File

@ -72,7 +72,7 @@ var _ = Describe("ResourceFlag", func() {
{
desc: "malformed instance var",
flag: "some-pipeline/branch=master/some-resource",
err: "argument format should be <pipeline>/<key:value>/<resource>",
err: "instance vars should be formatted as <key1:value1>(,<key2:value2>)",
},
} {
tt := tt

View File

@ -0,0 +1,58 @@
package commands
import (
"fmt"
"github.com/concourse/concourse/atc"
"github.com/concourse/concourse/go-concourse/concourse"
"github.com/concourse/concourse/fly/commands/internal/displayhelpers"
"github.com/concourse/concourse/fly/commands/internal/flaghelpers"
"github.com/concourse/concourse/fly/rc"
)
type OrderInstancedPipelinesCommand struct {
Group string `short:"g" long:"group" required:"true" description:"Name of the instance group"`
InstanceVars []flaghelpers.InstanceVarsFlag `short:"p" long:"pipeline" required:"true" description:"Instance vars identifying pipeline (can be specified multiple times to provide relative ordering)"`
Team string `long:"team" description:"Name of the team to which the pipelines belong, if different from the target default"`
}
func (command *OrderInstancedPipelinesCommand) Execute(args []string) error {
target, err := rc.LoadTarget(Fly.Target, Fly.Verbose)
if err != nil {
return err
}
err = target.Validate()
if err != nil {
return err
}
var team concourse.Team
if command.Team != "" {
team, err = target.FindTeam(command.Team)
if err != nil {
return err
}
} else {
team = target.Team()
}
var instanceVars []atc.InstanceVars
for _, instanceVar := range command.InstanceVars {
instanceVars = append(instanceVars, instanceVar.InstanceVars)
}
err = team.OrderingPipelinesWithinGroup(command.Group, instanceVars)
if err != nil {
displayhelpers.FailWithErrorf("failed to order instanced pipelines", err)
}
fmt.Printf("ordered instanced pipelines \n")
for _, iv := range instanceVars {
fmt.Printf(" - %s \n", iv)
}
return nil
}

View File

@ -3,19 +3,19 @@ package commands
import (
"errors"
"fmt"
"github.com/concourse/concourse/go-concourse/concourse"
"sort"
"strings"
"github.com/concourse/concourse/fly/commands/internal/displayhelpers"
"github.com/concourse/concourse/fly/rc"
"github.com/concourse/concourse/go-concourse/concourse"
)
var ErrMissingPipelineName = errors.New("Need to specify at least one pipeline name")
type OrderPipelinesCommand struct {
Alphabetical bool `short:"a" long:"alphabetical" description:"Order all pipelines alphabetically"`
Pipelines []string `short:"p" long:"pipeline" description:"Name of pipelines to order"`
Pipelines []string `short:"p" long:"pipeline" description:"Name of pipeline (can be specified multiple times to provide relative ordering)"`
Team string `long:"team" description:"Name of the team to which the pipelines belong, if different from the target default"`
}

View File

@ -1054,7 +1054,7 @@ run:
sess, err := gexec.Start(flyCmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
Eventually(sess.Err).Should(gbytes.Say("argument format should be <pipeline>/<key:value>/<job>"))
Eventually(sess.Err).Should(gbytes.Say("instance vars should be formatted as <key1:value1>\\(,<key2:value2>\\)"))
<-sess.Exited
Expect(sess.ExitCode()).To(Equal(1))

View File

@ -0,0 +1,131 @@
package integration_test
import (
"net/http"
"os/exec"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/concourse/concourse/atc"
"github.com/onsi/gomega/gbytes"
"github.com/onsi/gomega/gexec"
"github.com/onsi/gomega/ghttp"
"github.com/tedsuo/rata"
)
var _ = Describe("Fly CLI", func() {
Describe("order-instanced-pipelines", func() {
Context("when pipelines are specified", func() {
var (
path string
err error
)
BeforeEach(func() {
path, err = atc.Routes.CreatePathForRoute(atc.OrderPipelinesWithinGroup, rata.Params{"team_name": "main", "pipeline_name": "awesome-pipeline"})
Expect(err).NotTo(HaveOccurred())
})
Context("when the pipeline exists", func() {
var instanceVars []atc.InstanceVars
BeforeEach(func() {
instanceVars = []atc.InstanceVars{
{"branch": "main"},
{"branch": "test"},
}
})
JustBeforeEach(func() {
atcServer.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyJSONRepresenting(instanceVars),
ghttp.VerifyRequest("PUT", path),
ghttp.RespondWith(http.StatusOK, nil),
),
)
})
It("orders the instance pipelines", func() {
Expect(func() {
flyCmd := exec.Command(flyPath, "-t", targetName, "order-instanced-pipelines", "-g", "awesome-pipeline", "-p", "branch:main", "-p", "branch:test")
sess, err := gexec.Start(flyCmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
<-sess.Exited
Expect(sess.ExitCode()).To(Equal(0))
Eventually(sess).Should(gbytes.Say(`ordered instanced pipelines`))
Eventually(sess).Should(gbytes.Say(` - branch:main`))
Eventually(sess).Should(gbytes.Say(` - branch:test`))
}).To(Change(func() int {
return len(atcServer.ReceivedRequests())
}).By(2))
})
It("orders the instance pipeline with alias", func() {
Expect(func() {
flyCmd := exec.Command(flyPath, "-t", targetName, "oip", "-g", "awesome-pipeline", "-p", "branch:main", "-p", "branch:test")
sess, err := gexec.Start(flyCmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
<-sess.Exited
Expect(sess.ExitCode()).To(Equal(0))
Eventually(sess).Should(gbytes.Say(`ordered instanced pipelines`))
Eventually(sess).Should(gbytes.Say(` - branch:main`))
Eventually(sess).Should(gbytes.Say(` - branch:test`))
}).To(Change(func() int {
return len(atcServer.ReceivedRequests())
}).By(2))
})
})
Context("when ordering fails", func() {
BeforeEach(func() {
atcServer.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest("PUT", path),
ghttp.RespondWith(http.StatusBadRequest, "pipeline 'awesome-pipeline/branch:main' not found"),
),
)
})
It("prints error message", func() {
Expect(func() {
flyCmd := exec.Command(flyPath, "-t", targetName, "order-instanced-pipelines", "-g", "awesome-pipeline", "-p", "branch:main")
sess, err := gexec.Start(flyCmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
<-sess.Exited
Expect(sess.ExitCode()).To(Equal(1))
Eventually(sess.Err).Should(gbytes.Say(`failed to order instanced pipelines`))
Consistently(sess.Err).ShouldNot(gbytes.Say(`Unexpected Response`))
}).To(Change(func() int {
return len(atcServer.ReceivedRequests())
}).By(2))
})
})
})
Context("when the pipeline name is not specified", func() {
It("errors", func() {
Expect(func() {
flyCmd := exec.Command(flyPath, "-t", targetName, "order-instanced-pipelines")
sess, err := gexec.Start(flyCmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
<-sess.Exited
Expect(sess.ExitCode()).To(Equal(1))
Expect(sess.Err).Should(gbytes.Say("error: the required flags `-g, --group' and `-p, --pipeline' were not specified"))
}).To(Change(func() int {
return len(atcServer.ReceivedRequests())
}).By(0))
})
})
})
})

View File

@ -131,7 +131,7 @@ var _ = Describe("Fly CLI", func() {
atcServer.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest("PUT", path),
ghttp.RespondWith(http.StatusInternalServerError, nil),
ghttp.RespondWith(http.StatusBadRequest, "pipeline 'awsome-pipeline' not found"),
),
)
})
@ -147,6 +147,8 @@ var _ = Describe("Fly CLI", func() {
Expect(sess.ExitCode()).To(Equal(1))
Eventually(sess.Err).Should(gbytes.Say(`failed to order pipelines`))
Consistently(sess.Err).ShouldNot(gbytes.Say(`Unexpected Response`))
}).To(Change(func() int {
return len(atcServer.ReceivedRequests())
}).By(2))

View File

@ -505,6 +505,18 @@ type FakeTeam struct {
orderingPipelinesReturnsOnCall map[int]struct {
result1 error
}
OrderingPipelinesWithinGroupStub func(string, []atc.InstanceVars) error
orderingPipelinesWithinGroupMutex sync.RWMutex
orderingPipelinesWithinGroupArgsForCall []struct {
arg1 string
arg2 []atc.InstanceVars
}
orderingPipelinesWithinGroupReturns struct {
result1 error
}
orderingPipelinesWithinGroupReturnsOnCall map[int]struct {
result1 error
}
PauseJobStub func(atc.PipelineRef, string) (bool, error)
pauseJobMutex sync.RWMutex
pauseJobArgsForCall []struct {
@ -3031,6 +3043,73 @@ func (fake *FakeTeam) OrderingPipelinesReturnsOnCall(i int, result1 error) {
}{result1}
}
func (fake *FakeTeam) OrderingPipelinesWithinGroup(arg1 string, arg2 []atc.InstanceVars) error {
var arg2Copy []atc.InstanceVars
if arg2 != nil {
arg2Copy = make([]atc.InstanceVars, len(arg2))
copy(arg2Copy, arg2)
}
fake.orderingPipelinesWithinGroupMutex.Lock()
ret, specificReturn := fake.orderingPipelinesWithinGroupReturnsOnCall[len(fake.orderingPipelinesWithinGroupArgsForCall)]
fake.orderingPipelinesWithinGroupArgsForCall = append(fake.orderingPipelinesWithinGroupArgsForCall, struct {
arg1 string
arg2 []atc.InstanceVars
}{arg1, arg2Copy})
stub := fake.OrderingPipelinesWithinGroupStub
fakeReturns := fake.orderingPipelinesWithinGroupReturns
fake.recordInvocation("OrderingPipelinesWithinGroup", []interface{}{arg1, arg2Copy})
fake.orderingPipelinesWithinGroupMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeTeam) OrderingPipelinesWithinGroupCallCount() int {
fake.orderingPipelinesWithinGroupMutex.RLock()
defer fake.orderingPipelinesWithinGroupMutex.RUnlock()
return len(fake.orderingPipelinesWithinGroupArgsForCall)
}
func (fake *FakeTeam) OrderingPipelinesWithinGroupCalls(stub func(string, []atc.InstanceVars) error) {
fake.orderingPipelinesWithinGroupMutex.Lock()
defer fake.orderingPipelinesWithinGroupMutex.Unlock()
fake.OrderingPipelinesWithinGroupStub = stub
}
func (fake *FakeTeam) OrderingPipelinesWithinGroupArgsForCall(i int) (string, []atc.InstanceVars) {
fake.orderingPipelinesWithinGroupMutex.RLock()
defer fake.orderingPipelinesWithinGroupMutex.RUnlock()
argsForCall := fake.orderingPipelinesWithinGroupArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeTeam) OrderingPipelinesWithinGroupReturns(result1 error) {
fake.orderingPipelinesWithinGroupMutex.Lock()
defer fake.orderingPipelinesWithinGroupMutex.Unlock()
fake.OrderingPipelinesWithinGroupStub = nil
fake.orderingPipelinesWithinGroupReturns = struct {
result1 error
}{result1}
}
func (fake *FakeTeam) OrderingPipelinesWithinGroupReturnsOnCall(i int, result1 error) {
fake.orderingPipelinesWithinGroupMutex.Lock()
defer fake.orderingPipelinesWithinGroupMutex.Unlock()
fake.OrderingPipelinesWithinGroupStub = nil
if fake.orderingPipelinesWithinGroupReturnsOnCall == nil {
fake.orderingPipelinesWithinGroupReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.orderingPipelinesWithinGroupReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakeTeam) PauseJob(arg1 atc.PipelineRef, arg2 string) (bool, error) {
fake.pauseJobMutex.Lock()
ret, specificReturn := fake.pauseJobReturnsOnCall[len(fake.pauseJobArgsForCall)]
@ -4242,6 +4321,8 @@ func (fake *FakeTeam) Invocations() map[string][][]interface{} {
defer fake.nameMutex.RUnlock()
fake.orderingPipelinesMutex.RLock()
defer fake.orderingPipelinesMutex.RUnlock()
fake.orderingPipelinesWithinGroupMutex.RLock()
defer fake.orderingPipelinesWithinGroupMutex.RUnlock()
fake.pauseJobMutex.RLock()
defer fake.pauseJobMutex.RUnlock()
fake.pausePipelineMutex.RLock()

View File

@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/concourse/concourse/atc"
@ -47,14 +48,80 @@ func (team *team) OrderingPipelines(pipelineNames []string) error {
return fmt.Errorf("Unable to marshal pipeline names: %s", err)
}
return team.connection.Send(internal.Request{
resp, err := team.httpAgent.Send(internal.Request{
RequestName: atc.OrderPipelines,
Params: params,
Body: buffer,
Header: http.Header{
"Content-Type": {"application/json"},
},
}, &internal.Response{})
})
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusForbidden:
return fmt.Errorf("you do not have a role on team '%s'", team.Name())
case http.StatusBadRequest:
body, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf(string(body))
default:
body, _ := ioutil.ReadAll(resp.Body)
return internal.UnexpectedResponseError{
StatusCode: resp.StatusCode,
Status: resp.Status,
Body: string(body),
}
}
}
func (team *team) OrderingPipelinesWithinGroup(groupName string, instanceVars []atc.InstanceVars) error {
params := rata.Params{
"team_name": team.Name(),
"pipeline_name": groupName,
}
buffer := &bytes.Buffer{}
err := json.NewEncoder(buffer).Encode(instanceVars)
if err != nil {
return fmt.Errorf("Unable to marshal pipeline instance vars: %s", err)
}
resp, err := team.httpAgent.Send(internal.Request{
RequestName: atc.OrderPipelinesWithinGroup,
Params: params,
Body: buffer,
Header: http.Header{
"Content-Type": {"application/json"},
},
})
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return nil
case http.StatusForbidden:
return fmt.Errorf("you do not have a role on team '%s'", team.Name())
case http.StatusBadRequest:
body, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf(string(body))
default:
body, _ := ioutil.ReadAll(resp.Body)
return internal.UnexpectedResponseError{
StatusCode: resp.StatusCode,
Status: resp.Status,
Body: string(body),
}
}
}
func (team *team) ListPipelines() ([]atc.Pipeline, error) {

View File

@ -257,6 +257,48 @@ var _ = Describe("ATC Handler Pipelines", func() {
})
})
Describe("OrderingInstancePipelines", func() {
instanceVars := []atc.InstanceVars{
{"branch": "main"},
{"branch": "test"},
}
Context("when the API call succeeds", func() {
BeforeEach(func() {
expectedURL := "/api/v1/teams/some-team/pipelines/my-pipeline/ordering"
atcServer.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest("PUT", expectedURL),
ghttp.VerifyJSONRepresenting(instanceVars),
ghttp.RespondWith(http.StatusOK, ""),
),
)
})
It("return no error", func() {
err := team.OrderingPipelinesWithinGroup("my-pipeline", instanceVars)
Expect(err).NotTo(HaveOccurred())
})
})
Context("when the API call errors", func() {
BeforeEach(func() {
expectedURL := "/api/v1/teams/some-team/pipelines/my-pipeline/ordering"
atcServer.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest("PUT", expectedURL),
ghttp.RespondWithJSONEncoded(http.StatusNotFound, ""),
),
)
})
It("returns error", func() {
err := team.OrderingPipelinesWithinGroup("my-pipeline", instanceVars)
Expect(err).To(HaveOccurred())
})
})
})
Describe("Pipeline", func() {
var expectedPipeline atc.Pipeline
expectedURL := "/api/v1/teams/some-team/pipelines/mypipeline"

View File

@ -70,6 +70,7 @@ type Team interface {
CreateBuild(plan atc.Plan) (atc.Build, error)
Builds(page Page) ([]atc.Build, Pagination, error)
OrderingPipelines(pipelineNames []string) error
OrderingPipelinesWithinGroup(groupName string, instanceVars []atc.InstanceVars) error
CreateArtifact(io.Reader, string, []string) (atc.WorkerArtifact, error)
GetArtifact(int) (io.ReadCloser, error)

View File

@ -30,8 +30,8 @@ var _ = Describe("Reference", func() {
},
{
desc: "segments contain special chars",
ref: vars.Reference{Path: "hello.world", Fields: []string{"a", "foo:bar", "other field"}},
result: `"hello.world".a."foo:bar"."other field"`,
ref: vars.Reference{Path: "hello.world", Fields: []string{"a", "foo:bar", "other field", "another/field"}},
result: `"hello.world".a."foo:bar"."other field"."another/field"`,
},
{
desc: "var source",

View File

@ -105,7 +105,7 @@ func (r Reference) String() string {
}
func refSegmentString(seg string) string {
if strings.ContainsAny(seg, ",.: ") {
if strings.ContainsAny(seg, ",.:/ ") {
return fmt.Sprintf("%q", seg)
}
return seg

View File

@ -1,6 +1,7 @@
module Api.Endpoints exposing
( BuildEndpoint(..)
, Endpoint(..)
, InstanceGroupEndpoint(..)
, JobEndpoint(..)
, PipelineEndpoint(..)
, ResourceEndpoint(..)
@ -30,6 +31,7 @@ type Endpoint
| Cli
| UserInfo
| Logout
| InstanceGroup Concourse.InstanceGroupIdentifier InstanceGroupEndpoint
type PipelineEndpoint
@ -79,6 +81,10 @@ type TeamEndpoint
| OrderTeamPipelines
type InstanceGroupEndpoint
= OrderInstanceGroupPipelines
base : RouteBuilder
base =
( [ "api", "v1" ], [] )
@ -175,6 +181,12 @@ builder endpoint =
Logout ->
baseSky |> appendPath [ "logout" ]
InstanceGroup { teamName, name } subEndpoint ->
base
|> appendPath [ "teams", teamName ]
|> appendPath [ "pipelines", name ]
|> append (instanceGroupEndpoint subEndpoint)
pipelineEndpoint : PipelineEndpoint -> RouteBuilder
pipelineEndpoint endpoint =
@ -297,3 +309,12 @@ teamEndpoint endpoint =
[ "pipelines", "ordering" ]
, []
)
instanceGroupEndpoint : InstanceGroupEndpoint -> RouteBuilder
instanceGroupEndpoint endpoint =
( case endpoint of
OrderInstanceGroupPipelines ->
[ "ordering" ]
, []
)

View File

@ -22,7 +22,15 @@ import Dashboard.Footer as Footer
import Dashboard.Grid as Grid
import Dashboard.Grid.Constants as GridConstants
import Dashboard.Group as Group
import Dashboard.Group.Models exposing (Card(..), Pipeline, cardName)
import Dashboard.Group.Models
exposing
( Card(..)
, Pipeline
, cardName
, cardTeamName
, groupCardsWithinTeam
, ungroupCards
)
import Dashboard.Models as Models
exposing
( DragState(..)
@ -675,24 +683,42 @@ update session msg =
updateBody : Session -> Message -> ET Model
updateBody session msg ( model, effects ) =
case msg of
DragStart teamName cardId ->
( { model | dragState = Models.Dragging teamName cardId }, effects )
DragStart card ->
( { model | dragState = Models.Dragging card }, effects )
DragOver target ->
( { model | dropState = Models.Dropping target }, effects )
DragEnd ->
case ( model.dragState, model.dropState ) of
( Dragging teamName identifier, Dropping target ) ->
( Dragging card, Dropping target ) ->
let
teamName =
cardTeamName card
viewingInstanceGroups =
case card of
InstancedPipelineCard _ ->
True
_ ->
False
cardGroupFunction =
if viewingInstanceGroups then
List.map InstancedPipelineCard
else
groupCardsWithinTeam
teamCards =
model.pipelines
|> Maybe.andThen (Dict.get teamName)
|> Maybe.withDefault []
|> groupCardsWithinTeam
|> cardGroupFunction
|> (\cards ->
cards
|> (case Drag.dragCardIndices identifier target cards of
|> (case Drag.dragCardIndices card target cards of
Just ( from, to ) ->
Drag.drag from to
@ -701,26 +727,29 @@ updateBody session msg ( model, effects ) =
)
)
teamPipelines =
ungroupCards teamCards
pipelines =
model.pipelines
|> Maybe.withDefault Dict.empty
|> Dict.update teamName
(always <|
Just <|
List.concatMap
(\card ->
case card of
PipelineCard p ->
[ p ]
|> Dict.update teamName (always <| Just teamPipelines)
InstancedPipelineCard p ->
[ p ]
request =
if viewingInstanceGroups then
let
instanceGroupName =
cardName card
in
teamPipelines
|> List.filter (.name >> (==) instanceGroupName)
|> List.map .instanceVars
|> SendOrderPipelinesWithinGroupRequest { teamName = teamName, name = instanceGroupName }
InstanceGroupCard p ps ->
p :: ps
)
teamCards
)
else
teamCards
|> List.map cardName
|> SendOrderPipelinesRequest teamName
in
( { model
| pipelines = Just pipelines
@ -728,9 +757,7 @@ updateBody session msg ( model, effects ) =
, dropState = DroppingWhileApiRequestInFlight teamName
}
, effects
++ [ teamCards
|> List.map cardName
|> SendOrderPipelinesRequest teamName
++ [ request
, pipelines
|> Dict.values
|> List.concat
@ -1298,28 +1325,15 @@ regularCardsView session params =
filteredPipelinesByTeam
|> List.map
(\( team, teamPipelines ) ->
( team
, groupCardsWithinTeam teamPipelines
)
{ header = team
, cards = groupCardsWithinTeam teamPipelines
, teamName = team
}
)
in
cardsView session params teamCards
groupCardsWithinTeam : List Pipeline -> List Card
groupCardsWithinTeam =
Concourse.groupPipelinesWithinTeam
>> List.map
(\g ->
case g of
Concourse.RegularPipeline p ->
PipelineCard p
Concourse.InstanceGroup p ps ->
InstanceGroupCard p ps
)
instanceGroupCardsView : Session -> Model -> List (Html Message)
instanceGroupCardsView session model =
let
@ -1329,7 +1343,7 @@ instanceGroupCardsView session model =
|> Dict.toList
|> List.sortWith (Ordering.byFieldWith (Group.ordering session) Tuple.first)
instanceGroups : List ( String, List Card )
instanceGroups : List (Group.Section Card)
instanceGroups =
filteredPipelines
|> List.concatMap
@ -1337,16 +1351,17 @@ instanceGroupCardsView session model =
List.Extra.gatherEqualsBy .name teamPipelines
|> List.map
(\( p, ps ) ->
( team ++ " / " ++ p.name
, p :: ps |> List.map InstancedPipelineCard
)
{ header = team ++ " / " ++ p.name
, cards = p :: ps |> List.map InstancedPipelineCard
, teamName = team
}
)
)
in
cardsView session model instanceGroups
cardsView : Session -> Model -> List ( String, List Card ) -> List (Html Message)
cardsView : Session -> Model -> List (Group.Section Card) -> List (Html Message)
cardsView session params teamCards =
let
jobs =
@ -1364,7 +1379,7 @@ cardsView session params teamCards =
let
favoritedCards =
teamCards
|> List.concatMap Tuple.second
|> List.concatMap .cards
|> List.concatMap
(\c ->
case c of
@ -1461,7 +1476,7 @@ cardsView session params teamCards =
else
List.foldl
(\( teamName, cards ) ( htmlList, totalOffset ) ->
(\{ header, teamName, cards } ( htmlList, totalOffset ) ->
let
startingOffset =
totalOffset
@ -1495,8 +1510,10 @@ cardsView session params teamCards =
, query = params.query
, viewingInstanceGroups = viewingInstanceGroups
}
teamName
layout.cards
{ header = header
, teamName = teamName
, cards = layout.cards
}
|> (\html ->
( html :: htmlList
, startingOffset + layout.height

View File

@ -15,14 +15,14 @@ insertAt idx x xs =
x :: xs
dragCardIndices : String -> DropTarget -> List Card -> Maybe ( Int, Int )
dragCardIndices cardId target cards =
dragCardIndices : Card -> DropTarget -> List Card -> Maybe ( Int, Int )
dragCardIndices card target cards =
let
cardIndex id =
cards |> List.Extra.findIndex (cardIdentifier >> (==) id)
cardIndex c =
List.Extra.findIndex (cardIdentifier >> (==) (cardIdentifier c)) cards
fromIndex =
cardIndex cardId
cardIndex card
toIndex =
(case target of

View File

@ -21,7 +21,6 @@ import Dashboard.Grid.Layout as Layout
import Dashboard.Group.Models as Models
exposing
( Card(..)
, cardIdentifier
, cardName
, cardTeamName
)
@ -87,9 +86,9 @@ computeLayout params teamName cards =
let
dragIndices =
case ( params.dragState, params.dropState ) of
( Dragging team cardId, Dropping target ) ->
if teamName == team then
Drag.dragCardIndices cardId target cards
( Dragging card, Dropping target ) ->
if teamName == cardTeamName card then
Drag.dragCardIndices card target cards
else
Nothing
@ -149,7 +148,7 @@ computeLayout params teamName cards =
dropAreaBounds bounds
curDropArea =
{ bounds = curBounds, target = Before <| cardIdentifier origCard }
{ bounds = curBounds, target = Before origCard }
in
( curDropArea :: dropAreas, Just curCard )
)

View File

@ -1,5 +1,6 @@
module Dashboard.Group exposing
( PipelineIndex
, Section
, hdView
, ordering
, pipelineNotSetView
@ -11,7 +12,7 @@ import Application.Models exposing (Session)
import Concourse
import Dashboard.Grid as Grid
import Dashboard.Grid.Constants as GridConstants
import Dashboard.Group.Models exposing (Card(..), Pipeline, cardIdentifier)
import Dashboard.Group.Models exposing (Card(..), Pipeline, cardIdentifier, cardTeamName)
import Dashboard.Group.Tag as Tag
import Dashboard.InstanceGroup as InstanceGroup
import Dashboard.Models exposing (DragState(..), DropState(..))
@ -45,6 +46,13 @@ type alias PipelineIndex =
Int
type alias Section card =
{ teamName : String
, header : String
, cards : List card
}
view :
Session
->
@ -61,10 +69,9 @@ view :
, query : String
, viewingInstanceGroups : Bool
}
-> Concourse.TeamName
-> List Grid.Card
-> Section Grid.Card
-> Html Message
view session params teamName cards =
view session params { header, teamName, cards } =
let
cardViews =
if List.isEmpty cards then
@ -117,9 +124,14 @@ view session params teamName cards =
(\{ bounds, target } ->
pipelineDropAreaView params.dragState teamName bounds target
)
-- we use the header as the ID so that instance groups have unique
-- IDs despite being in the same team
groupId =
header
in
Html.div
[ id <| Effects.toHtmlID <| DashboardGroup teamName
[ id <| Effects.toHtmlID <| DashboardGroup groupId
, class "dashboard-team-group"
, attribute "data-team-name" teamName
]
@ -133,7 +145,7 @@ view session params teamName cards =
[ class "dashboard-team-name"
, style "font-weight" Views.Styles.fontWeightBold
]
[ Html.text teamName ]
[ Html.text header ]
:: (Maybe.Extra.toList <|
Maybe.map (Tag.view False) (tag session teamName)
)
@ -256,14 +268,14 @@ hdView :
, query : String
}
-> { a | userState : UserState, pipelineRunningKeyframes : String }
-> ( Concourse.TeamName, List Card )
-> Section Card
-> List (Html Message)
hdView { pipelinesWithResourceErrors, pipelineJobs, jobs, dashboardView, query } session ( teamName, cards ) =
hdView { pipelinesWithResourceErrors, pipelineJobs, jobs, dashboardView, query } session { teamName, cards, header } =
let
header =
headerElement =
Html.div
[ class "dashboard-team-name" ]
[ Html.text teamName ]
[ Html.text header ]
:: (Maybe.Extra.toList <| Maybe.map (Tag.view True) (tag session teamName))
teamPipelines =
@ -320,14 +332,14 @@ hdView { pipelinesWithResourceErrors, pipelineJobs, jobs, dashboardView, query }
in
case teamPipelines of
[] ->
header
headerElement
p :: ps ->
-- Wrap the team name and the first pipeline together so
-- the team name is not the last element in a column
Html.div
(class "dashboard-team-name-wrapper" :: Styles.teamNameHd)
(header ++ [ p ])
(headerElement ++ [ p ])
:: ps
@ -367,6 +379,14 @@ pipelineCardView :
-> String
-> Html Message
pipelineCardView session params section { bounds, headerHeight, pipeline, inInstanceGroup } teamName =
let
card =
if inInstanceGroup then
InstancedPipelineCard pipeline
else
PipelineCard pipeline
in
Html.div
([ class "card-wrapper"
, style "position" "absolute"
@ -400,27 +420,32 @@ pipelineCardView session params section { bounds, headerHeight, pipeline, inInst
, style "height" "100%"
, attribute "data-pipeline-name" pipeline.name
]
++ (if section == AllPipelinesSection && not pipeline.stale && not params.viewingInstanceGroups then
++ (if section == AllPipelinesSection && not pipeline.stale then
[ attribute
"ondragstart"
"event.dataTransfer.setData('text/plain', '');"
, draggable "true"
, on "dragstart"
(Json.Decode.succeed (DragStart pipeline.teamName <| cardIdentifier <| PipelineCard pipeline))
(Json.Decode.succeed (DragStart <| card))
, on "dragend" (Json.Decode.succeed DragEnd)
]
else
[]
)
++ (if params.dragState == Dragging pipeline.teamName (cardIdentifier <| PipelineCard pipeline) then
[ style "width" "0"
, style "margin" "0 12.5px"
, style "overflow" "hidden"
]
++ (case params.dragState of
Dragging currCard ->
if cardIdentifier currCard == cardIdentifier card then
[ style "width" "0"
, style "margin" "0 12.5px"
, style "overflow" "hidden"
]
else
[]
else
[]
_ ->
[]
)
++ (if params.dropState == DroppingWhileApiRequestInFlight teamName then
[ style "opacity" "0.45", style "pointer-events" "none" ]
@ -507,21 +532,26 @@ instanceGroupCardView session params section { bounds, headerHeight } p ps =
"event.dataTransfer.setData('text/plain', '');"
, draggable "true"
, on "dragstart"
(Json.Decode.succeed (DragStart p.teamName <| cardIdentifier <| InstanceGroupCard p ps))
(Json.Decode.succeed (DragStart <| InstanceGroupCard p ps))
, on "dragend" (Json.Decode.succeed DragEnd)
]
else
[]
)
++ (if params.dragState == Dragging p.teamName (cardIdentifier <| InstanceGroupCard p ps) then
[ style "width" "0"
, style "margin" "0 12.5px"
, style "overflow" "hidden"
]
++ (case params.dragState of
Dragging card ->
if cardIdentifier card == cardIdentifier (InstanceGroupCard p ps) then
[ style "width" "0"
, style "margin" "0 12.5px"
, style "overflow" "hidden"
]
else
[]
else
[]
_ ->
[]
)
++ (if params.dropState == DroppingWhileApiRequestInFlight p.teamName then
[ style "opacity" "0.45", style "pointer-events" "none" ]
@ -556,8 +586,8 @@ pipelineDropAreaView dragState name { x, y, width, height } target =
let
active =
case dragState of
Dragging team _ ->
team == name
Dragging card ->
cardTeamName card == name
_ ->
False

View File

@ -4,6 +4,8 @@ module Dashboard.Group.Models exposing
, cardIdentifier
, cardName
, cardTeamName
, groupCardsWithinTeam
, ungroupCards
)
import Concourse
@ -15,6 +17,36 @@ type Card
| InstanceGroupCard Pipeline (List Pipeline)
groupCardsWithinTeam : List Pipeline -> List Card
groupCardsWithinTeam =
Concourse.groupPipelinesWithinTeam
>> List.map
(\g ->
case g of
Concourse.RegularPipeline p ->
PipelineCard p
Concourse.InstanceGroup p ps ->
InstanceGroupCard p ps
)
ungroupCards : List Card -> List Pipeline
ungroupCards =
List.concatMap
(\c ->
case c of
PipelineCard p ->
[ p ]
InstancedPipelineCard p ->
[ p ]
InstanceGroupCard p ps ->
p :: ps
)
cardIdentifier : Card -> String
cardIdentifier c =
case c of

View File

@ -8,7 +8,7 @@ module Dashboard.Models exposing
)
import Concourse
import Dashboard.Group.Models
import Dashboard.Group.Models as GroupModels
import Dict exposing (Dict)
import FetchResult exposing (FetchResult)
import Login.Login as Login
@ -54,7 +54,7 @@ type FetchError
type DragState
= NotDragging
| Dragging Concourse.TeamName String
| Dragging GroupModels.Card
type DropState
@ -68,7 +68,7 @@ type alias FooterModel r =
| hideFooter : Bool
, hideFooterCounter : Int
, showHelp : Bool
, pipelines : Maybe (Dict String (List Dashboard.Group.Models.Pipeline))
, pipelines : Maybe (Dict String (List GroupModels.Pipeline))
, dropdown : Dropdown
, highDensity : Bool
, dashboardView : Routes.DashboardView

View File

@ -148,7 +148,7 @@ pipelineView session { now, pipeline, hovered, resourceError, existingJobs, laye
in
Html.div
(Styles.pipelineCard
++ (if section == AllPipelinesSection && not pipeline.stale && not viewingInstanceGroups then
++ (if section == AllPipelinesSection && not pipeline.stale then
[ style "cursor" "move" ]
else

View File

@ -29,7 +29,7 @@ type Callback
| PipelineFetched (Fetched Concourse.Pipeline)
| PipelinesFetched (Fetched (List Concourse.Pipeline))
| PipelineToggled Concourse.PipelineIdentifier (Fetched ())
| PipelinesOrdered String (Fetched ())
| PipelinesOrdered Concourse.TeamName (Fetched ())
| UserFetched (Fetched Concourse.User)
| ResourcesFetched (Fetched (List Concourse.Resource))
| BuildResourcesFetched (Fetched ( Int, Concourse.BuildResources ))

View File

@ -160,7 +160,8 @@ type Effect
| SetPinComment Concourse.ResourceIdentifier String
| SendTokenToFly String Int
| SendTogglePipelineRequest Concourse.PipelineIdentifier Bool
| SendOrderPipelinesRequest String (List String)
| SendOrderPipelinesRequest Concourse.TeamName (List Concourse.PipelineName)
| SendOrderPipelinesWithinGroupRequest Concourse.InstanceGroupIdentifier (List Concourse.InstanceVars)
| SendLogOutRequest
| GetScreenSize
| PinTeamNames StickyHeaderConfig
@ -473,6 +474,15 @@ runEffect effect key csrfToken =
|> Api.request
|> Task.attempt (PipelinesOrdered teamName)
SendOrderPipelinesWithinGroupRequest id instanceVars ->
Api.put
(Endpoints.OrderInstanceGroupPipelines |> Endpoints.InstanceGroup id)
csrfToken
|> Api.withJsonBody
(Json.Encode.list Concourse.encodeInstanceVars instanceVars)
|> Api.request
|> Task.attempt (PipelinesOrdered id.teamName)
SendLogOutRequest ->
Api.get Endpoints.Logout
|> Api.request

View File

@ -11,6 +11,7 @@ module Message.Message exposing
import Concourse
import Concourse.Cli as Cli
import Concourse.Pagination exposing (Page)
import Dashboard.Group.Models
import Routes exposing (StepID)
import StrictEvents
@ -24,7 +25,7 @@ type Message
| ToggleGroup Concourse.PipelineGroup
| SetGroups (List String)
-- Dashboard
| DragStart String String
| DragStart Dashboard.Group.Models.Card
| DragOver DropTarget
| DragEnd
-- Resource
@ -130,5 +131,5 @@ type alias VersionId =
type DropTarget
= Before String
= Before Dashboard.Group.Models.Card
| End

View File

@ -3,6 +3,7 @@ module DashboardCacheTests exposing (all)
import Application.Application as Application
import Common
import Concourse.BuildStatus exposing (BuildStatus(..))
import Dashboard.Group.Models exposing (Card(..))
import DashboardTests exposing (whenOnDashboard)
import Data
import Message.Callback exposing (Callback(..))
@ -240,7 +241,7 @@ all =
)
|> Tuple.first
|> Application.update
(TopLevelMessage.Update <| Message.DragStart "team" "1")
(TopLevelMessage.Update <| Message.DragStart <| PipelineCard (Data.dashboardPipeline "team" 1 |> Data.withName "pipeline"))
|> Tuple.first
|> Application.update
(TopLevelMessage.Update <| Message.DragOver End)

View File

@ -370,19 +370,6 @@ all =
Effects.toHtmlID <|
Msgs.PipelineCardInstanceVar Msgs.AllPipelinesSection 1 "a" "foo"
]
, test "is not draggable" <|
\_ ->
whenOnDashboardViewingInstanceGroup { dashboardView = ViewNonArchivedPipelines }
|> gotPipelines
[ pipelineInstanceWithVars 1
[ ( "a", JsonString "foo" ) ]
]
|> Common.queryView
|> findCard
|> Expect.all
[ Query.hasNot [ attribute <| Attr.attribute "draggable" "true" ]
, Query.hasNot [ style "cursor" "move" ]
]
]
]

View File

@ -40,6 +40,7 @@ import Concourse
import Concourse.BuildStatus exposing (BuildStatus(..))
import Concourse.Cli as Cli
import Concourse.PipelineStatus exposing (PipelineStatus(..))
import Dashboard.Group.Models exposing (Card(..))
import Data
import Dict
import Expect exposing (Expectation)
@ -2000,7 +2001,11 @@ all =
(Callback.AllJobsFetched <| Ok [])
|> Tuple.first
|> Application.update
(ApplicationMsgs.Update <| Msgs.DragStart "team" "1")
(ApplicationMsgs.Update <|
Msgs.DragStart <|
PipelineCard <|
Data.dashboardPipeline "team" 1
)
|> Tuple.first
|> Application.handleDelivery
(ClockTicked FiveSeconds <|

View File

@ -158,7 +158,7 @@ resource pinnedVersion =
}
pipeline : String -> Int -> Concourse.Pipeline
pipeline : Concourse.TeamName -> Int -> Concourse.Pipeline
pipeline team id =
{ id = id
, name = "pipeline-" ++ String.fromInt id
@ -172,13 +172,13 @@ pipeline team id =
}
dashboardPipeline : Int -> Bool -> Dashboard.Group.Models.Pipeline
dashboardPipeline id public =
dashboardPipeline : Concourse.TeamName -> Int -> Dashboard.Group.Models.Pipeline
dashboardPipeline team id =
{ id = id
, name = pipelineName
, name = "pipeline-" ++ String.fromInt id
, instanceVars = Dict.empty
, teamName = teamName
, public = public
, teamName = team
, public = True
, isToggleLoading = False
, isVisibilityLoading = False
, paused = False

View File

@ -3,6 +3,7 @@ module DragAndDropTests exposing (all)
import Application.Application as Application
import Common exposing (given, then_, when)
import Concourse exposing (JsonValue(..))
import Dashboard.Group.Models exposing (Card(..))
import DashboardTests exposing (whenOnDashboard)
import Data
import Dict exposing (Dict)
@ -11,9 +12,9 @@ import Http
import Json.Encode as Encode
import Message.Callback as Callback
import Message.Effects as Effects
import Message.Message as Message exposing (DropTarget(..))
import Message.Message as Message exposing (DropTarget(..), Message(..))
import Message.Subscription exposing (Delivery(..), Interval(..))
import Message.TopLevelMessage as TopLevelMessage exposing (TopLevelMessage)
import Message.TopLevelMessage as TopLevelMessage exposing (TopLevelMessage(..))
import Test exposing (Test, describe, test)
import Test.Html.Event as Event
import Test.Html.Query as Query
@ -28,18 +29,18 @@ all =
[ test "pipeline card has dragstart listener" <|
given iVisitedTheDashboard
>> given myBrowserFetchedOnePipeline
>> when iAmLookingAtTheFirstPipelineCard
>> then_ (itListensForDragStartWithId "1")
>> when iAmLookingAtTheFirstCard
>> then_ (itListensForDragStartWithCard firstPipelineCard)
, test "instance group card has drag start listener with id independent of the visible instances" <|
given iVisitedTheDashboard
>> given myBrowserFetchedPipelinesWithInstanceVars
>> when iAmLookingAtTheInstanceGroupCard
>> then_ (itListensForDragStartWithId "team/other-pipeline")
>> then_ (itListensForDragStartWithCard instanceGroupCard)
, test "pipeline card disappears when dragging starts" <|
given iVisitedTheDashboard
>> given myBrowserFetchedOnePipeline
>> given iAmDraggingTheFirstPipelineCard
>> when iAmLookingAtTheFirstPipelineCard
>> when iAmLookingAtTheFirstCard
>> then_ itIsInvisible
, test "pipeline cards wrappers transition their transform when dragging" <|
given iVisitedTheDashboard
@ -61,14 +62,14 @@ all =
given iVisitedTheDashboard
>> given myBrowserFetchedOnePipeline
>> given iAmDraggingTheFirstPipelineCard
>> when iAmLookingAtTheFirstPipelineCard
>> when iAmLookingAtTheFirstCard
>> then_ itListensForDragEnd
, test "pipeline card becomes visible when it is dropped" <|
given iVisitedTheDashboard
>> given myBrowserFetchedOnePipeline
>> given iAmDraggingTheFirstPipelineCard
>> given iDropThePipelineCard
>> when iAmLookingAtTheFirstPipelineCard
>> when iAmLookingAtTheFirstCard
>> then_ itIsVisible
, test "dropping first pipeline card on final drop area rearranges cards" <|
given iVisitedTheDashboard
@ -76,7 +77,7 @@ all =
>> given iAmDraggingTheFirstPipelineCard
>> given iAmDraggingOverTheThirdDropArea
>> given iDropThePipelineCard
>> when iAmLookingAtTheFirstPipelineCard
>> when iAmLookingAtTheFirstCard
>> then_ itIsTheOtherPipelineCard
, test "dropping first pipeline card on final drop area makes API call" <|
given iVisitedTheDashboard
@ -113,6 +114,20 @@ all =
>> given iAmDraggingOverTheFirstDropArea
>> when iDropTheCard
>> then_ myBrowserMakesTheOrderPipelinesAPICall
, test "instanced pipelines can be reordered" <|
given iVisitedTheDashboard
>> given myBrowserFetchedPipelinesWithInstanceVars
>> given iAmViewingTheInstanceGroup
>> given iAmDraggingTheFirstInstancedPipelineCard
>> given iAmDraggingOverTheThirdDropArea
>> when iDropTheCard
>> then_ myBrowserMakesTheOrderPipelinesWithinGroupAPICall
, test "instanced pipeline card has dragstart listener" <|
given iVisitedTheDashboard
>> given myBrowserFetchedPipelinesWithInstanceVars
>> given iAmViewingTheInstanceGroup
>> when iAmLookingAtTheFirstCard
>> then_ (itListensForDragStartWithCard firstInstancedPipelineCard)
, test "dashboard does not auto-refresh during dragging" <|
given iVisitedTheDashboard
>> given myBrowserFetchedPipelinesFromMultipleTeams
@ -209,6 +224,10 @@ myBrowserFetchedOnePipeline =
)
firstPipelineCard =
PipelineCard <| (Data.dashboardPipeline "team" 1 |> Data.withName "pipeline")
myBrowserFetchedTwoPipelines =
Application.handleCallback
(Callback.AllPipelinesFetched <|
@ -228,10 +247,42 @@ myBrowserFetchedPipelinesWithInstanceVars =
, Data.pipeline "team" 3
|> Data.withName "other-pipeline"
|> Data.withInstanceVars (Dict.fromList [ ( "hello", JsonString "world" ) ])
, Data.pipeline "team" 4
|> Data.withName "other-pipeline"
|> Data.withInstanceVars (Dict.fromList [ ( "brach", JsonString "world-1" ) ])
]
)
firstInstancedPipelineCard =
InstancedPipelineCard <|
(Data.dashboardPipeline "team" 3
|> Data.withName "other-pipeline"
|> Data.withInstanceVars (Dict.fromList [ ( "hello", JsonString "world" ) ])
)
instanceGroupCard =
-- pipeline 2 is not included because it's archived and we aren't viewing archived pipelines
InstanceGroupCard
(Data.dashboardPipeline "team" 3
|> Data.withName "other-pipeline"
|> Data.withInstanceVars (Dict.fromList [ ( "hello", JsonString "world" ) ])
)
[ Data.dashboardPipeline "team" 4
|> Data.withName "other-pipeline"
|> Data.withInstanceVars (Dict.fromList [ ( "brach", JsonString "world-1" ) ])
]
iAmViewingTheInstanceGroup =
Tuple.first
>> Application.update
(Update <|
FilterMsg "group:\"other-pipeline\""
)
myBrowserFetchedPipelinesFromMultipleTeams =
Application.handleCallback
(Callback.AllPipelinesFetched <|
@ -243,7 +294,7 @@ myBrowserFetchedPipelinesFromMultipleTeams =
)
iAmLookingAtTheFirstPipelineCard =
iAmLookingAtTheFirstCard =
Tuple.first
>> Common.queryView
>> Query.findAll [ class "card" ]
@ -277,23 +328,29 @@ iAmLookingAtAllPipelineCardsOfThatTeam =
>> Query.findAll [ class "card" ]
itListensForDragStartWithId : String -> Query.Single TopLevelMessage -> Expectation
itListensForDragStartWithId id =
itListensForDragStartWithCard : Card -> Query.Single TopLevelMessage -> Expectation
itListensForDragStartWithCard card =
Event.simulate (Event.custom "dragstart" (Encode.object []))
>> Event.expect
(TopLevelMessage.Update <| Message.DragStart "team" id)
(TopLevelMessage.Update <| Message.DragStart card)
iAmDraggingTheFirstPipelineCard =
Tuple.first
>> Application.update
(TopLevelMessage.Update <| Message.DragStart "team" "1")
(TopLevelMessage.Update <| Message.DragStart firstPipelineCard)
iAmDraggingTheFirstInstancedPipelineCard =
Tuple.first
>> Application.update
(TopLevelMessage.Update <| Message.DragStart firstInstancedPipelineCard)
iAmDraggingTheInstanceGroupCard =
Tuple.first
>> Application.update
(TopLevelMessage.Update <| Message.DragStart "team" "team/other-pipeline")
(TopLevelMessage.Update <| Message.DragStart instanceGroupCard)
itIsInvisible =
@ -354,7 +411,7 @@ itListensForDragOverPreventingDefault =
iAmDraggingOverTheFirstDropArea =
Tuple.first
>> Application.update
(TopLevelMessage.Update <| Message.DragOver <| Before "1")
(TopLevelMessage.Update <| Message.DragOver <| Before firstPipelineCard)
iAmDraggingOverTheThirdDropArea =
@ -426,6 +483,17 @@ myBrowserMakesTheOrderPipelinesAPICall =
)
myBrowserMakesTheOrderPipelinesWithinGroupAPICall =
Tuple.second
>> Common.contains
(Effects.SendOrderPipelinesWithinGroupRequest { teamName = "team", name = "other-pipeline" }
[ Dict.empty
, Dict.fromList [ ( "brach", JsonString "world-1" ) ]
, Dict.fromList [ ( "hello", JsonString "world" ) ]
]
)
myBrowserMakesTheFetchPipelinesAPICall =
Tuple.second
>> Common.contains